Compare commits

..
252 changed files with 3705 additions and 13489 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Qodana Configuration Upload
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
upload-qodana-config:
runs-on: [self-hosted, Linux, X64, default]
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Run Qodana Configuration Uploader
env:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: |
docker run --rm \
-v $(pwd):/workspace \
-w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader:latest \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
+3 -7
View File
@@ -7,13 +7,9 @@ on:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
upload-qodana-config:
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 10
steps:
- name: Checkout repository
@@ -24,9 +20,9 @@ jobs:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: |
docker run --rm \
-v "$(pwd):/workspace" \
-v $(pwd):/workspace \
-w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader:latest \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
+14 -83
View File
@@ -1,35 +1,30 @@
name: Frontend Release
on:
workflow_run:
workflows:
- Automated Tests
types:
- completed
push:
branches:
- master
workflow_dispatch:
permissions:
contents: read
actions: read
concurrency:
group: frontend-release-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
group: frontend-release-${{ github.ref }}
cancel-in-progress: false
jobs:
build-upload-and-verify:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
runs-on: [self-hosted, Linux, X64, default]
env:
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
RELEASE_WAIT_INITIAL_SECONDS: 45
RELEASE_WAIT_TIMEOUT_SECONDS: 600
RELEASE_POLL_INTERVAL_SECONDS: 10
@@ -38,114 +33,54 @@ jobs:
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}
- name: Check release commit is current
id: branch-head
run: |
latest_sha="$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; then
echo "Could not resolve origin/$RELEASE_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$RELEASE_EXPECTED_COMMIT" ]]; then
echo "current=false" >> "$GITHUB_OUTPUT"
echo "Skipping stale release for $RELEASE_EXPECTED_COMMIT; origin/$RELEASE_BRANCH is $latest_sha."
exit 0
fi
echo "current=true" >> "$GITHUB_OUTPUT"
echo "Release commit is current for $RELEASE_BRANCH."
env:
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
- name: Setup Node.js
if: steps.branch-head.outputs.current == 'true'
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
if: steps.branch-head.outputs.current == 'true'
run: npm ci --legacy-peer-deps
- name: Check AI workflow sync
if: steps.branch-head.outputs.current == 'true'
run: node scripts/sync-ai-workflow.mjs --check
- name: Source and i18n checks
if: steps.branch-head.outputs.current == 'true'
run: |
npm run text:check-encoding
npm run i18n:v2:source-check
- name: Unit tests
if: steps.branch-head.outputs.current == 'true'
run: npm run test:unit
env:
VITEST_BATCH_SIZE: 5
- name: Build release artifact
if: steps.branch-head.outputs.current == 'true'
run: npm run build
- name: Install Playwright Chromium
if: steps.branch-head.outputs.current == 'true'
run: node scripts/install-playwright-browsers.mjs chromium
run: npx playwright install --with-deps chromium
- name: Production Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:prod
- name: Upload dist artifact
if: steps.branch-head.outputs.current == 'true'
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
path: dist
retention-days: 3
- name: Request Release Manager auto sync
if: steps.branch-head.outputs.current == 'true'
run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
response_file="$(mktemp)"
status_code="$(curl --show-error --silent \
--output "$response_file" \
--write-out "%{http_code}" \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
if [[ "$status_code" =~ ^2 ]]; then
cat "$response_file"
elif [[ "$status_code" == "504" ]]; then
echo "Release Manager auto sync request reached the gateway timeout; continuing to artifact wait."
else
cat "$response_file" >&2
echo "Release Manager auto sync request failed with HTTP $status_code." >&2
exit 1
fi
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
retention-days: 14
- name: Wait for Coolify release artifact
if: steps.branch-head.outputs.current == 'true'
run: npm run release:verify-upload
- name: Public live Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:live:public
env:
NODE_OPTIONS: --use-system-ca
- name: Credentialed live Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:live:roles
env:
NODE_OPTIONS: --use-system-ca
@@ -158,35 +93,31 @@ jobs:
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
- name: Record Release Manager gate
if: steps.branch-head.outputs.current == 'true'
run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}"
curl --fail --show-error --silent \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RELEASE_BRANCH: ${{ github.ref_name }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Update server version after verification
if: steps.branch-head.outputs.current == 'true'
run: npm run release:update-server-version
env:
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
RELEASE_VERSION: ${{ github.sha }}
- name: Upload Playwright report
if: failure() && steps.branch-head.outputs.current == 'true'
continue-on-error: true
if: failure()
uses: actions/upload-artifact@v4
with:
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
path: output/playwright
if-no-files-found: ignore
retention-days: 3
retention-days: 14
+22 -118
View File
@@ -3,8 +3,6 @@ name: Automated Tests
on:
pull_request:
push:
branches:
- master
workflow_dispatch:
schedule:
- cron: "0 2 * * *"
@@ -13,14 +11,13 @@ permissions:
contents: read
concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
group: frontend-tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
format-tests:
# CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 15
runs-on: [self-hosted, Linux, X64, default]
steps:
- name: Checkout repository
uses: actions/checkout@v5
@@ -29,6 +26,7 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Check AI workflow sync
run: node scripts/sync-ai-workflow.mjs --check
@@ -41,8 +39,7 @@ jobs:
build-and-unit:
needs: format-tests
runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 30
runs-on: [self-hosted, Linux, X64, default]
steps:
- name: Checkout repository
uses: actions/checkout@v5
@@ -51,16 +48,11 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Lint
run: npm run lint
- name: Check i18n source consistency
run: npm run i18n:v2:check
- name: Build sanity check
run: npm run build
@@ -72,18 +64,7 @@ jobs:
e2e-pr:
if: github.event_name != 'schedule'
needs: build-and-unit
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 4
matrix:
suite: [core, changed]
project: [chromium-desktop, chromium-mobile]
env:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
PLAYWRIGHT_REPORTER_MODE: line-html
runs-on: [self-hosted, Linux, X64, default]
steps:
- name: Checkout repository
uses: actions/checkout@v5
@@ -117,77 +98,33 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Install Playwright browsers
run: node scripts/install-playwright-browsers.mjs chromium
run: npx playwright install --with-deps 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
if: matrix.suite == 'core'
run: |
ulimit -n 16384 || true
npx playwright test --grep @smoke --project="${{ matrix.project }}"
- name: Run Playwright PR core tests
if: matrix.suite == 'core'
run: |
ulimit -n 16384 || true
npm run test:e2e:pr -- --core-only --project="${{ matrix.project }}"
- name: Run Playwright changed-area tests
if: matrix.suite == 'changed'
run: |
ulimit -n 16384 || true
npm run test:e2e:pr -- --changed-only --project="${{ matrix.project }}" --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
- name: Run Playwright PR tests
run: npm run test:e2e:pr -- --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
- name: Upload Playwright report
if: failure() || cancelled()
continue-on-error: true
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
path: |
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
name: playwright-report-pr
path: output/playwright
if-no-files-found: ignore
retention-days: 3
retention-days: 7
e2e-full:
if: >
always() &&
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
needs.build-and-unit.result == 'success' &&
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
needs: [build-and-unit, e2e-pr]
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
needs: build-and-unit
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 60
runs-on: [self-hosted, Linux, X64, default]
strategy:
fail-fast: false
max-parallel: 4
matrix:
role: [customer, subuser, admin, superuser]
browser: [chromium, firefox, webkit]
@@ -213,52 +150,19 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Install Playwright browsers
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"
run: npx playwright install --with-deps ${{ matrix.browser_install }}
- name: Run full Playwright slice
run: |
ulimit -n 16384 || true
npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
run: npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
- name: Upload Playwright report
if: failure() || cancelled()
continue-on-error: true
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
@@ -267,4 +171,4 @@ jobs:
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt
if-no-files-found: ignore
retention-days: 3
retention-days: 14
-4
View File
@@ -1,4 +0,0 @@
{
"version": 1,
"setupCompletedAt": "2026-06-07T11:37:04.444Z"
}
+3 -3
View File
@@ -56,9 +56,9 @@ android {
defaultConfig {
applicationId "io.truckwash.twa"
minSdkVersion 21
targetSdkVersion 36
versionCode 7
versionName "7"
targetSdkVersion 35
versionCode 6
versionName "6"
// The name for the application
resValue "string", "appName", twaManifest.name
+1 -1
View File
@@ -1 +1 @@
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"/"}
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"pleno-pwa-test"}
@@ -1,33 +0,0 @@
# User wash start production-readiness QA matrix
This note documents deterministic coverage for the self-serve user wash start flow. The `@dynamic-image` Playwright case remains separated because it validates rendered image behavior in addition to deterministic state and API transitions.
## Unit matrix
| Area | Required scenario | Coverage |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useWashFlowState` | Step transitions for vehicle, questions, lane, tasks, in-progress, completed | `tests/unit/use-wash-flow-state-production.spec.js` validates step clickability, next-button gating, questions-to-lane updates, lane-to-start target selection, task completion transition, in-progress navigation, and completed-step non-clickability contract. |
| `useWashSessionActions` | START success and failure | `tests/unit/use-wash-session-actions-production.spec.js` covers successful manual START state mutation and failed START retryability without active-wash mutation. |
| `useWashSessionActions` | Relay enable success and relay enable failure with STOP rollback | `tests/unit/use-wash-session-actions-production.spec.js` covers machine relay success and relay failure rollback via STOP. |
| `useWashSessionActions` | STOP failure and already-stopped STOP recovery | `tests/unit/use-wash-session-actions-production.spec.js` covers failed STOP preserving active state and already-not-occupied STOP clearing local state. |
| `useSelfServeLogic` | Preview/summary merge | `tests/unit/use-self-serve-logic-production.spec.js` covers preview questions merging with summary questions, answer maps, visible question order, tasks, and allowed services. |
| `useSelfServeLogic` | Request race handling | `tests/unit/use-self-serve-logic-production.spec.js` covers stale preview/summary responses being ignored when a newer request wins. |
| `useSelfServeLogic` | Allowed services updates | `tests/unit/use-self-serve-logic-production.spec.js` covers lane allowed-service endpoint updates and first-failure fallback behavior. |
| `useSelfServeLogic` | Answer sync failures | `tests/unit/use-self-serve-logic-production.spec.js` covers error reporting while preserving the caller's optimistic local answer. |
| `MyWashStart.vue` | Local progress restore, server active-wash restore, recent-completion suppression, unmount cleanup, duplicate-fetch prevention | `tests/unit/my-wash-start-production.spec.js` locks the component contracts for restore ordering, authenticated active-wash application, recent-completion suppression across restore/polling, unmount cleanup, and duplicate-fetch/sync guards. |
## E2E mocked matrix
| Required scenario | Coverage |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Full manual flow | `tests/e2e/self-serve-wash.spec.js` covers direct manual wash start, guided completion, STOP, exit-gate open, completed state, and close/reset. |
| Full machine flow | `tests/e2e/self-serve-wash.spec.js` covers machine task rendering, dynamic image progress, required task completion, machine start path, and reload persistence. |
| Reload/resume active wash | `tests/e2e/self-serve-wash.spec.js` covers local progress reload and authenticated server active-wash resume from another device. |
| Backend says wash completed during polling | `tests/e2e/self-serve-wash.spec.js` covers server polling of active wash and local transition to completed when the backend no longer reports the matching in-progress wash. |
| Network failures for preview, answer sync, START, relay enable, STOP | `tests/e2e/self-serve-wash.spec.js` covers retryable START failure, STOP failure preservation, allowed-services gateway timeout/retry, answer sync background resilience, and unit-level relay rollback. Add mocked network route overrides when expanding browser-level failure assertions. |
## Optional live smoke
| Optional scenario | Coverage |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dynamic image smoke | The `@dynamic-image` Playwright test in `tests/e2e/self-serve-wash.spec.js` is tagged separately from deterministic CI coverage so it can be included or excluded explicitly with Playwright grep controls. |
-139
View File
@@ -1,139 +0,0 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import vue from "eslint-plugin-vue";
const commonGlobals = {
...globals.browser,
...globals.node,
...globals.es2024,
grecaptcha: "readonly",
};
const vitestGlobals = {
...globals.vitest,
};
const lintTargets = ["**/*.{js,mjs,cjs,ts,tsx,vue}"];
const tsTargets = ["**/*.{ts,tsx,mts,cts}"];
function warningRules(rules = {}) {
return Object.fromEntries(
Object.entries(rules).map(([name, value]) => {
if (value === "off" || value === 0) {
return [name, value];
}
if (Array.isArray(value)) {
return [name, ["warn", ...value.slice(1)]];
}
return [name, "warn"];
}),
);
}
function warningConfig(config, files) {
return {
...config,
files: config.files ?? files,
rules: warningRules(config.rules),
};
}
const jsRecommended = warningConfig(js.configs.recommended, lintTargets);
const tsRecommended = tseslint.configs.recommended.map((config) =>
warningConfig(config, tsTargets),
);
const vueEssential = vue.configs["flat/essential"].map((config) =>
warningConfig(config, ["**/*.vue"]),
);
const ignoredPaths = [
"app/build/**",
"coverage/**",
"dist/**",
"node_modules/**",
"node_modules.codex-backup/**",
"output/**",
"public/build/**",
"src/assets/test/**",
"test/**",
"vendor/**",
"*.timestamp-*.mjs",
"**/*.timestamp-*.mjs",
];
const commonUnusedOptions = {
argsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
varsIgnorePattern: "^_",
};
export default [
{
ignores: ignoredPaths,
},
jsRecommended,
...tsRecommended,
...vueEssential,
{
files: ["**/*.vue"],
languageOptions: {
parserOptions: {
parser: tseslint.parser,
extraFileExtensions: [".vue"],
ecmaVersion: "latest",
sourceType: "module",
},
},
},
{
files: lintTargets,
plugins: {
vue,
},
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: commonGlobals,
},
rules: {
"no-console": "off",
"no-debugger": "warn",
"no-empty": "warn",
"no-undef": "warn",
"no-unused-vars": ["warn", commonUnusedOptions],
"no-useless-assignment": "warn",
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": "warn",
"vue/no-unused-components": "warn",
"vue/no-unused-vars": "warn",
"vue/no-v-html": "off",
},
},
{
files: ["**/*.{ts,tsx}"],
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["warn", commonUnusedOptions],
},
},
{
files: ["tests/**/*.{js,ts}", "playwright*.ts", "scripts/**/*.{js,mjs}"],
languageOptions: {
globals: {
...commonGlobals,
...globals.node,
...vitestGlobals,
},
},
rules: {
"no-empty": "warn",
},
},
];
+4 -13
View File
@@ -2,19 +2,10 @@
<html lang="" class="theme-light" data-theme="light">
<head>
<meta charset="UTF-8">
<script>
(function () {
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/);
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/';
var base = document.createElement('base');
base.href = href;
document.currentScript.after(base);
})();
</script>
<link rel="icon" type="image/png" href="%BASE_URL%assets/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="%BASE_URL%assets/favicons/favicon.svg" />
<link rel="shortcut icon" href="%BASE_URL%assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="%BASE_URL%assets/favicons/apple-touch-icon.png" />
<link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
<link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
+1 -9
View File
@@ -8,14 +8,6 @@ server {
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
location ~ ^/(?:.+/)?(?<webmanifest_path>(?:assets/)?manifest\.webmanifest)$ {
types { application/manifest+json webmanifest; }
default_type application/manifest+json;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$webmanifest_path =404;
}
location ~ ^/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
@@ -37,7 +29,7 @@ server {
try_files /$static_asset_path =404;
}
location ~ ^/(?:.+/)?(?<static_file_path>index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
location ~ ^/(?:.+/)?(?<static_file_path>manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
try_files /$static_file_path =404;
}
+3 -3
View File
@@ -56,9 +56,9 @@ android {
defaultConfig {
applicationId "io.truckwash.twa.staging"
minSdkVersion 21
targetSdkVersion 36
versionCode 6
versionName "6"
targetSdkVersion 35
versionCode 5
versionName "5"
// The name for the application
resValue "string", "appName", twaManifest.name
@@ -1 +1 @@
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"/"}
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"pleno-pwa-test"}
+35 -1114
View File
File diff suppressed because it is too large Load Diff
+1 -9
View File
@@ -7,9 +7,6 @@
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint eslint.config.js src tests scripts vite.config.js vitest.config.js playwright*.ts --quiet",
"lint:strict": "npm run lint:report -- --max-warnings=0",
"lint:report": "eslint eslint.config.js src tests scripts vite.config.js vitest.config.js playwright*.ts",
"format:tests": "prettier --write \"tests/**/*.{js,ts}\"",
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
@@ -89,6 +86,7 @@
"dayspan-vuetify": "^0.4.0",
"flow": "^0.2.3",
"html2canvas": "^1.4.1",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"sass": "^1.81.1",
"sweetalert2": "11.22.4",
@@ -111,23 +109,17 @@
},
"devDependencies": {
"@creativebulma/bulma-divider": "^1.1.0",
"@eslint/js": "^10.0.1",
"@event-calendar/core": "^4.1.0",
"@playwright/test": "^1.58.2",
"@types/event-calendar__core": "^3.7.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/test-utils": "^2.4.6",
"eslint": "^10.4.1",
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.6.0",
"husky": "^9.1.7",
"jsdom": "^29.0.0",
"otpauth": "^9.5.0",
"prettier": "2.8.8",
"sass-embedded": "^1.81.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vite": "7.1.11",
"vite-plugin-pwa": "^1.0.2",
"vite-plugin-vue-devtools": "^7.5.4",
-4
View File
@@ -38,14 +38,10 @@ function buildProject(name: string, browserName: "chromium" | "firefox" | "webki
export default defineConfig({
testDir: "./tests/e2e",
testIgnore: ["**/release/**"],
snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}-win32{ext}",
timeout: 60_000,
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
expect: {
timeout: 15_000,
},
workers,
...(process.env.PLAYWRIGHT_BASE_URL
? {}
+4 -11
View File
@@ -19,16 +19,6 @@ const stderrLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}
const viteCliPath = path.resolve(process.cwd(), "node_modules/vite/bin/vite.js");
const serverOutputLimit = 80;
const activeOutputReaders = [];
const viteServerEnv = {
...process.env,
PLAYWRIGHT: "1",
...(process.env.CI
? {
CHOKIDAR_INTERVAL: process.env.CHOKIDAR_INTERVAL || "300",
CHOKIDAR_USEPOLLING: process.env.CHOKIDAR_USEPOLLING || "true",
}
: {}),
};
// Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot.
const viteDevArgs = [
...(process.env.PLAYWRIGHT_VITE_FORCE === "1" ? ["--force"] : []),
@@ -459,7 +449,10 @@ export default async function globalSetup() {
const serverProcess = spawn(process.execPath, [viteCliPath, ...viteDevArgs], {
cwd: process.cwd(),
detached: true,
env: viteServerEnv,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
+20 -51
View File
@@ -1,4 +1,3 @@
import fs from "node:fs";
import { defineConfig, devices } from "@playwright/test";
const baseURL = "http://127.0.0.1:4173";
@@ -6,55 +5,6 @@ const isCI = !!process.env.CI;
process.env.PLAYWRIGHT_BASE_URL = baseURL;
const osReleaseValue = (key: string) => {
try {
const body = fs.readFileSync("/etc/os-release", "utf8");
const match = body.match(new RegExp(`^${key}=(.*)$`, "m"));
return String(match?.[1] || "").replace(/^"|"$/g, "");
} catch {
return "";
}
};
const isUnsupportedWebKitHost = () => {
if (process.platform !== "linux") {
return false;
}
const id = osReleaseValue("ID").toLowerCase();
const version = Number.parseFloat(osReleaseValue("VERSION_ID"));
return id === "ubuntu" && Number.isFinite(version) && version >= 26.04;
};
const webKitOverride = String(process.env.PLAYWRIGHT_PROD_WEBKIT || "")
.trim()
.toLowerCase();
const includeWebKit =
webKitOverride === "1" || (webKitOverride !== "0" && webKitOverride !== "false" && !isUnsupportedWebKitHost());
const projects = [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 5"],
},
},
];
if (includeWebKit) {
projects.push({
name: "webkit-desktop",
use: {
...devices["Desktop Safari"],
},
});
}
export default defineConfig({
testDir: "./tests/e2e/release",
testMatch: /.*\.local-prod\.spec\.ts/,
@@ -78,5 +28,24 @@ export default defineConfig({
timeout: 240_000,
reuseExistingServer: !isCI,
},
projects,
projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 5"],
},
},
{
name: "webkit-desktop",
use: {
...devices["Desktop Safari"],
},
},
],
});
+4 -8
View File
@@ -2,10 +2,6 @@
Options -MultiViews
</IfModule>
<IfModule mod_mime.c>
AddType application/manifest+json .webmanifest
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
@@ -27,17 +23,17 @@
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteRule ^.+/((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
@@ -45,7 +41,7 @@
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteRule ^(?:.*?/)?(?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
-1
View File
@@ -2,7 +2,6 @@
"name": "Truck Wash Kundeportal",
"short_name": "Truck Wash",
"description": "Access your Truck Wash accounts and transactions from anywhere.",
"id": "/",
"icons": [
{
"src": "assets/favicons/web-app-manifest-192x192.png",
-90
View File
@@ -1,90 +0,0 @@
import { spawnSync } from "node:child_process";
const browsers = process.argv.slice(2);
const requestedBrowsers = browsers.length > 0 ? browsers : ["chromium"];
const fallbackHostPlatformByUnsupportedPlatform = (platform) => {
const ubuntuMatch = platform.match(/^ubuntu(\d+\.\d+)-(x64|arm64)$/);
if (ubuntuMatch && Number.parseInt(ubuntuMatch[1], 10) >= 26) {
return `ubuntu24.04-${ubuntuMatch[2]}`;
}
return null;
};
const outputText = (result) => `${result.stdout || ""}\n${result.stderr || ""}`;
const unsupportedHostPlatform = (result) => {
const output = outputText(result);
return (
output.match(/Cannot install dependencies for (?<platform>\S+) with Playwright/i)?.groups?.platform ||
output.match(/Playwright does not support \S+ on (?<platform>\S+)/i)?.groups?.platform ||
null
);
};
const runPlaywrightInstall = (args, env = {}) =>
spawnSync("npx", ["playwright", "install", ...args], {
cwd: process.cwd(),
env: {
...process.env,
...env,
},
encoding: "utf8",
});
const writeOutput = (result) => {
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
};
const hasUnsupportedHostPlatformFailure = (result) => {
const output = outputText(result);
return (
result.status !== 0 &&
/Playwright does not support .* on /i.test(output)
);
};
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
writeOutput(withDepsResult);
if (withDepsResult.status === 0) {
process.exit(0);
}
if (!hasUnsupportedHostPlatformFailure(withDepsResult)) {
process.exit(withDepsResult.status ?? 1);
}
const unsupportedPlatform = unsupportedHostPlatform(withDepsResult);
const fallbackHostPlatform = unsupportedPlatform
? fallbackHostPlatformByUnsupportedPlatform(unsupportedPlatform)
: null;
if (!fallbackHostPlatform) {
console.error(
`Playwright dependency install is unsupported for ${unsupportedPlatform || "this platform"}, ` +
"and this script does not have a safe browser archive fallback for it."
);
process.exit(withDepsResult.status ?? 1);
}
console.warn(
[
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
`Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
"The self-hosted runner image must provide the required browser system libraries.",
].join("\n")
);
const fallbackResult = runPlaywrightInstall(requestedBrowsers, {
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE: fallbackHostPlatform,
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: "1",
});
writeOutput(fallbackResult);
process.exit(fallbackResult.status ?? 1);
+1 -25
View File
@@ -1,5 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -8,24 +7,6 @@ function numberEnv(name, fallback) {
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function booleanEnv(name) {
return /^(1|true|yes)$/i.test(process.env[name] || "");
}
function appendGithubEnv(values) {
const envFile = process.env.GITHUB_ENV;
if (!envFile) {
return;
}
const lines = Object.entries(values)
.filter(([, value]) => value)
.map(([key, value]) => `${key}=${String(value).replace(/\r?\n/g, "")}`);
if (lines.length > 0) {
fs.appendFileSync(envFile, `${lines.join("\n")}\n`);
}
}
function requiredUrl() {
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
if (!value) {
@@ -140,7 +121,6 @@ async function verifyShell(baseUrl, shellPath) {
async function verifyRelease(baseUrl) {
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
const strictBuildId = booleanEnv("RELEASE_STRICT_BUILD_ID");
const manifest = await fetchJson(baseUrl, "release-manifest.json");
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
@@ -150,7 +130,7 @@ async function verifyRelease(baseUrl) {
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
}
if (strictBuildId && expectedBuildId && manifest.build_id !== expectedBuildId) {
if (expectedBuildId && manifest.build_id !== expectedBuildId) {
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
}
if (releaseEntry.entry !== manifest.entry) {
@@ -211,10 +191,6 @@ async function main() {
attempt += 1;
try {
const result = await verifyRelease(baseUrl);
appendGithubEnv({
RELEASE_VERIFIED_BUILD_ID: result.build_id,
RELEASE_VERIFIED_COMMIT: result.commit_sha,
});
console.log(
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
);
-2
View File
@@ -35,7 +35,6 @@ export const ownedFilesByRole = {
"userHome.spec.ts",
"userInvoices.spec.ts",
"userMyWashStart.spec.ts",
"userMyWashStartFlow.spec.ts",
"userProfileInvoicing.spec.ts",
"userProfileNotifications.spec.ts",
"userProfileSecurity.spec.ts",
@@ -80,7 +79,6 @@ export const ownedFilesByRole = {
"edge-gateways.visual.spec.js",
"errorReports.spec.ts",
"failover-config.source.spec.ts",
"collected-invoice-move-customer.spec.ts",
"invoice-distribution.smoke.spec.js",
"invoice-transfer-monitor.spec.ts",
"invoice-transfer-queue-history.spec.js",
-16
View File
@@ -22,7 +22,6 @@ for (const signal of ["SIGINT", "SIGTERM"]) {
function parseArgs(rawArgs) {
const parsed = {
help: false,
coreOnly: false,
changedOnly: false,
listOnly: false,
base: "",
@@ -44,11 +43,6 @@ function parseArgs(rawArgs) {
continue;
}
if (value === "--core-only") {
parsed.coreOnly = true;
continue;
}
if (value === "--changed-only") {
parsed.changedOnly = true;
continue;
@@ -109,14 +103,12 @@ Options:
--base <ref> Base ref for changed-area detection
--head <ref> Head ref for changed-area detection. Default: HEAD
--project <name> Restrict to one Chromium Playwright project. Can be repeated.
--core-only Run only the core @pr gate
--changed-only Run changed-area selection without the core @pr gate
--list-only List selected tests instead of running them
-h, --help Show help
Examples:
npm run test:e2e:pr
npm run test:e2e:pr -- --core-only --project=chromium-desktop
npm run test:e2e:changed -- --base=HEAD~1 --head=HEAD
`);
}
@@ -426,10 +418,6 @@ async function main() {
return;
}
if (args.coreOnly && args.changedOnly) {
throw new Error("--core-only and --changed-only cannot be used together.");
}
await fs.access(playwrightCliPath);
if (!args.changedOnly) {
@@ -440,10 +428,6 @@ async function main() {
}
}
if (args.coreOnly) {
return;
}
const changed = await getChangedFiles();
if (changed.unavailable) {
console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`);
-2
View File
@@ -20,7 +20,6 @@ const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/L
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
const ErrorReportLauncher = defineAsyncComponent(() => import("@/components/global/ErrorReportLauncher.vue"));
const FrontendMaintenanceMenu = defineAsyncComponent(() => import("@/components/global/FrontendMaintenanceMenu.vue"));
const ReleaseChannelUnavailable = defineAsyncComponent(() =>
import("@/components/release/ReleaseChannelUnavailable.vue")
);
@@ -129,7 +128,6 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
</template>
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<FrontendMaintenanceMenu />
</template>
<style scoped></style>
@@ -2535,13 +2535,11 @@ const syncDesktopFlyoutPosition = () => {
dropdownContentEl.style.top = "";
}
const viewportInsets = getViewportInsets();
const viewportTop = viewportInsets.top;
const viewportBottom = viewportHeight - viewportInsets.bottom;
const padding = 12;
let top = triggerRect.bottom;
if (top + contentHeight > viewportBottom) {
top = Math.max(viewportTop, triggerRect.top - contentHeight);
if (top + contentHeight > viewportHeight - padding) {
top = Math.max(padding, triggerRect.top - contentHeight);
}
const nextFixedStyles = {
@@ -275,10 +275,6 @@ const getInvoiceCollectionResponseOrders = (response) => {
return response.data.orders;
}
if (Array.isArray(response?.data?.data?.orders)) {
return response.data.data.orders;
}
if (Array.isArray(response?.includes?.orders)) {
return response.includes.orders;
}
@@ -653,41 +649,6 @@ const getInvoiceCollectionDetails = (invoiceCollectionId) => {
}
return null;
};
const getInvoiceCollectionDetailOrders = (invoiceCollectionId) => {
const details = getInvoiceCollectionDetails(invoiceCollectionId);
return details ? getInvoiceCollectionResponseOrders(details) : [];
};
const getHiddenOrdersByInvoiceCollection = (invoiceCollectionId) => {
const visibleOrderIds = new Set(getOrdersByInvoiceCollection(invoiceCollectionId).map((order) => Number(order?.id)));
return getInvoiceCollectionDetailOrders(invoiceCollectionId).filter((order) => !visibleOrderIds.has(Number(order?.id)));
};
const getHiddenOrderIdsByInvoiceCollection = (invoiceCollectionId) => (
getHiddenOrdersByInvoiceCollection(invoiceCollectionId).map((order) => Number(order?.id))
);
const getHiddenInvoiceCollectionFlags = (invoiceCollectionId) => {
const hiddenOrderIds = new Set(getHiddenOrderIdsByInvoiceCollection(invoiceCollectionId));
if (hiddenOrderIds.size === 0) {
return [];
}
return sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
if (!isActiveInvoicePeriodFlag(flag)) {
return false;
}
const targetType = String(flag?.target_type || "");
if (!["order", "order_field", "order_item", "order_item_field"].includes(targetType)) {
return false;
}
const flagOrderId = Number(
flag?.order_id
|| flag?.context?.order_id
|| (["order", "order_field"].includes(targetType) ? flag?.target_id : 0)
);
return flagOrderId > 0 && hiddenOrderIds.has(flagOrderId);
}));
};
watch(
[() => props.orders, () => props.excludedOrderIds, () => props.groupInvoiceCollection],
() => {
@@ -1038,13 +999,6 @@ const formatCashierName = (order) => {
}}
er ikke vist, men vil muligvis blive faktureret alligevel.</span
>
<InvoicingPeriodFlagList
v-if="getHiddenInvoiceCollectionFlags(order.invoice_collection_id).length > 0"
class="mt-3"
compact
:flags="getHiddenInvoiceCollectionFlags(order.invoice_collection_id)"
@statusChanged="emitFlagStatusChanged"
/>
</div>
</div>
</td>
@@ -21,7 +21,6 @@ import {
step,
setDesktopStep1PreflightHandler,
clearDesktopStep1PreflightHandler,
pushPosRouteState,
} from "@/components/shop/POSDepartmentProcess.vue";
import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue";
import { POS_STEP_1_VERSION } from "@/config.js";
@@ -646,7 +645,7 @@ const pushDesktopStepTwoRoute = () => {
return;
}
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
};
const handleDesktopLastWashCopy = async (payload = {}) => {
@@ -761,7 +760,7 @@ watch(
</div>
<div class="pos-shell-actions">
<ButtonsBox class="pos-actions pos-actions--stacked">
<Cancel tabindex="5" class="is-fullwidth" />
<Cancel tabindex="2" class="is-fullwidth" />
</ButtonsBox>
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail">
<PosDesktopDuplicateWarning
@@ -53,9 +53,6 @@ import PosDepartmentStepMobile2AdditionalItems from "@/components/displays/depar
import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
onMounted(() => {
// Set the header to be transparent
@@ -480,8 +477,6 @@ const updateLastOrderItemPrices = (items: PosOrderItem[]) => {
const layout = {
classes: <string[]>[],
};
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
const onCopyLastOrder = (vehicleIndex: number) => {
lastOrders.select(vehicleIndex);
@@ -782,18 +777,15 @@ const buildDesiredOrderItemShapes = () => {
const addonShapes = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) => {
const addonProduct = addon?.product ?? addon;
return {
kind: "addon",
relatedKey: "primary",
product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
quantity: Number(addon?.quantity ?? 0),
related_item_id: "__PRIMARY__",
price: Number(addonProduct?.price ?? addon?.price ?? 0),
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
};
});
.map((addon: any) => ({
kind: "addon",
relatedKey: "primary",
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
quantity: Number(addon?.quantity ?? 0),
related_item_id: "__PRIMARY__",
price: Number(addon?.product?.price ?? addon?.price ?? 0),
notes: String(addon?.product?.notes ?? ""),
}));
const additionalShapes = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
@@ -960,17 +952,16 @@ const syncCurrentTransactionToOrder = async () => {
const addonPromises = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) => {
const addonProduct = addon?.product ?? addon;
return createOrderItem(
.map((addon: any) =>
createOrderItem(
normalizedOrderId,
addonProduct.id,
addon.product.id,
Number(addon.quantity),
createdPrimaryItemId,
addonProduct?.notes || addon?.notes || "",
addonProduct.price ?? addon.price
);
});
addon.product?.notes || "",
addon.product.price
)
);
const additionalPromises = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
@@ -982,146 +973,11 @@ const syncCurrentTransactionToOrder = async () => {
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 () => {
if (!transactionItems.primaryItem.value) {
throw new Error("No primary item selected");
}
if (!(await ensureRequiredOrderItemNotes())) {
return false;
}
await syncCurrentTransactionToOrder();
return true;
};
@@ -10,7 +10,6 @@ import {
reg_1,
reset_all_values,
setStep,
pushPosRouteState,
} from "@/components/shop/POSDepartmentProcess.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { StripeModule } from "@/components/stripe/StripeModule.vue";
@@ -71,10 +70,10 @@ const formatCurrency = (amount) => {
const navigateToItems = () => {
setStep(2);
if (normalizedOrderId.value) {
pushPosRouteState(`id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
window.history.pushState({}, '', `?id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
return;
}
pushPosRouteState("step=2");
window.history.pushState({}, '', `?step=2`);
};
const clearSuccessfulMobileCardFlow = () => {
@@ -2,43 +2,25 @@
import { useGeolocation } from "@vueuse/core";
import { watch } from "vue";
import { locations } from "../objects/PosDepartmentStepMobileFlow.vue";
const props = withDefaults(defineProps<{
enableHighAccuracy?: boolean;
maximumAge?: number;
timeout?: number;
}>(), {
const emits = defineEmits<{
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
}>();
const { coords, locatedAt, error, resume, pause } = useGeolocation({
enableHighAccuracy: true,
maximumAge: 30000,
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 normalizedCoords = locations.normalizeCoordinatePair(newCoords);
if (normalizedCoords) {
const timestamp = getLocationTimestamp();
if (newCoords.latitude && newCoords.longitude) {
locations.set({
coords: normalizedCoords,
timestamp: new Date(timestamp),
locatedAt: timestamp,
coords: {
latitude: newCoords.latitude,
longitude: newCoords.longitude,
},
timestamp: new Date(),
errorMessage: error.value ? error.value.message : null,
})
emits('location-updated', normalizedCoords);
emits('location-updated', newCoords);
}
};
watch(coords, (newCoords) => {
@@ -405,10 +405,7 @@ const onClick = async () => {
isProcessingClick.value = true;
try {
const beforeStepResult = await props.onBeforeStep();
if (beforeStepResult === false) {
return;
}
await props.onBeforeStep();
// Proceed to the next step
switch (step.value) {
case 1:
@@ -28,7 +28,6 @@ watch(note, (newNote) => {
class="input is-searched"
v-model="note"
type="text"
data-testid="pos-mobile-product-note-input"
placeholder="Indtast note"
/>
</div>
@@ -100,57 +100,18 @@ const getLocation = () => {
const clearLocation = () => {
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
const getDistance = (
from: CoordinatePair,
to: CoordinatePair
from: { latitude: number; longitude: number },
to: { latitude: number; longitude: 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 R = 6371; // Radius of the Earth in kilometers
const dLat = toRad(normalizedTo.latitude - normalizedFrom.latitude);
const dLon = toRad(normalizedTo.longitude - normalizedFrom.longitude);
const lat1 = toRad(normalizedFrom.latitude);
const lat2 = toRad(normalizedTo.latitude);
const dLat = toRad(to.latitude - from.latitude);
const dLon = toRad(to.longitude - from.longitude);
const lat1 = toRad(from.latitude);
const lat2 = toRad(to.latitude);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
@@ -163,8 +124,6 @@ const locations = {
set: setLocation,
get: getLocation,
clear: clearLocation,
normalizeCoordinatePair,
hasValidCoordinatePair,
getDistance,
defaultTimeout: locationTimeout,
};
@@ -18,9 +18,7 @@ export type PosLocation = {
coords?: PosLocationCoords | null;
/** Timestamp */
timestamp?: Date | null;
/** Browser geolocation timestamp in milliseconds */
locatedAt?: number | null;
/** Error message */
errorMessage?: string | null;
};
</script>
</script>
@@ -1,70 +0,0 @@
<!--
ErrorBanner.vue reusable self-serve error notification bar.
Props:
message error text to display (blank hidden)
type Bulma tint: is-danger | is-warning (default is-danger)
iconLeft Font Awesome icon name
:loading whether retry button is in loading state
:showRetry whether to show the retry button
Emit:
retry fired when retry button is clicked
-->
<script setup lang="ts">
interface Props {
message: string | null;
type?: "is-danger" | "is-warning";
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
retryTestId?: string | null;
}
withDefaults(
defineProps<{
message: string | null;
type?: "is-danger" | "is-warning";
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
retryTestId?: string | null;
}>(),
{
type: "is-danger",
iconLeft: "sync-alt",
loading: false,
showRetry: true,
retryTestId: null,
}
);
const emit = defineEmits<{
retry: [];
}>();
</script>
<template>
<b-message v-if="message" :type="type" has-icon :closable="false" class="self-serve-error-banner">
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
<span class="mr-3">{{ message }}</span>
<b-button
v-if="showRetry"
size="is-small"
:type="`${type} is-light`"
icon-pack="fas"
:icon-left="iconLeft"
:loading="loading"
:data-testid="retryTestId || undefined"
@click="emit('retry')"
>
{{ $t("common.try_again") }}
</b-button>
</div>
</b-message>
</template>
<style scoped>
.self-serve-error-banner {
margin-bottom: 1rem;
}
</style>
@@ -1,59 +0,0 @@
<!--
LaneSelectionSection.vue bane-valg radio buttons and lane status display.
Props:
lanes array of lane objects from the department
selectedLaneId currently selected lane id
washType "Manual" | "Machine"
departmentName human-readable department name
isLaneAvailableFn function(lane) => boolean
isMachineAvailableFn function(laneId) => boolean
Emit:
update:selectedLaneId new lane id selected
update:washType "Manual" | "Machine"
-->
<script setup lang="ts">
import SelfServeLaneStep from "@/components/displays/selfServe/SelfServeLaneStep.vue";
interface Props {
lanes: any[];
selectedLaneId: number | string | null;
washType: string;
departmentName: string | null;
isLaneAvailableFn: (lane: any) => boolean;
isMachineAvailableFn: (laneId: number | string | null) => boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:selectedLaneId": [id: number];
"update:washType": [type: string];
}>();
function handleLaneUpdate(id: number) {
emit("update:selectedLaneId", id);
}
function handleWashTypeUpdate(type: string) {
emit("update:washType", type);
}
</script>
<template>
<SelfServeLaneStep
:lanes="lanes"
:selected-lane-id="selectedLaneId"
:wash-type="washType"
:department-name="departmentName"
:is-lane-available="isLaneAvailableFn"
:is-machine-available="isMachineAvailableFn"
@update:selected-lane-id="handleLaneUpdate"
@update:wash-type="handleWashTypeUpdate"
/>
</template>
<style scoped>
/* Lane selection styling is inherited from SelfServeLaneStep */
</style>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { BSkeleton } from "buefy";
const props = defineProps<{
src: string | null;
alt?: string;
}>();
const emit = defineEmits<{
(e: "error"): void;
(e: "load"): void;
}>();
const isLoading = ref(false);
watch(
() => props.src,
(src) => {
isLoading.value = !!src;
},
{ immediate: true }
);
const onLoad = () => {
isLoading.value = false;
emit("load");
};
const onError = () => {
isLoading.value = false;
emit("error");
};
</script>
<template>
<div
v-if="src"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isLoading }"
data-testid="self-serve-dynamic-image-frame"
>
<b-skeleton
v-if="isLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
<img
:key="src"
:src="src"
:alt="alt || 'Machine status'"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
:class="{ 'is-loading': isLoading }"
@load="onLoad"
@error="onError"
/>
</div>
</template>
<style scoped>
.self-serve-dynamic-image-frame {
position: relative;
width: 100%;
max-width: 100%;
aspect-ratio: 16 / 9;
margin-left: auto;
margin-right: auto;
overflow: hidden;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
}
.self-serve-dynamic-image-skeleton {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 4px;
}
.self-serve-dynamic-image {
width: 100%;
height: 100%;
border-radius: 4px;
display: block;
object-fit: contain;
transition: opacity 120ms ease;
}
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</style>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from "vue";
import { BIcon, BMessage, BRadioButton } from "buefy";
defineProps<{
const props = defineProps<{
lanes: Array<any>;
selectedLaneId: number | string | null;
washType: string;
@@ -14,6 +15,8 @@ const emit = defineEmits<{
(e: "update:selectedLaneId", value: number): void;
(e: "update:washType", value: string): void;
}>();
const isSelectedMachineAvailable = computed(() => props.isMachineAvailable(props.selectedLaneId));
</script>
<template>
@@ -92,14 +95,16 @@ const emit = defineEmits<{
:model-value="washType"
native-value="Machine"
type="is-link"
:disabled="!isMachineAvailable(selectedLaneId)"
:disabled="!isSelectedMachineAvailable"
:title="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
:aria-label="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:washType', 'Machine')"
@input="emit('update:washType', 'Machine')"
>
<span>
<span>{{ $t("self_wash.machine") }}<br /></span>
<span v-if="!isMachineAvailable(selectedLaneId)">
<span v-if="!isSelectedMachineAvailable">
<small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.unavailable") }}
@@ -21,6 +21,16 @@ const isImageAttachment = (attachment: any) => (
&& attachment.content.other.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)
);
const hasTaskDescription = (task: any) => {
const description = task?.description;
if (typeof description !== "string") {
return !!description;
}
const trimmedDescription = description.trim();
return trimmedDescription.length > 0 && trimmedDescription !== "-";
};
const getImageAttachments = (task: any) => {
if (!Array.isArray(task?.attachments)) {
return [];
@@ -73,7 +83,7 @@ const formatTaskTitle = (task: any) => {
const title = String(task?.task || "");
const programWheelSelection = getProgramWheelSelection(task);
if (programWheelSelection === null || /\bprogram\s*#\d+\b/i.test(title)) {
if (programWheelSelection === null) {
return title;
}
@@ -117,11 +127,13 @@ const formatTaskTitle = (task: any) => {
type="is-success"
:data-testid="`self-serve-task-${task.id}-toggle`"
@update:model-value="emit('toggle-task', task.id, $event)"
@input="emit('toggle-task', task.id, $event)"
/>
</b-field>
</div>
<div class="self-serve-task-image-overlay-text">
<p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p>
<p v-if="hasTaskDescription(task)" class="self-serve-task-description">{{ task.description }}</p>
</div>
</div>
</div>
@@ -144,21 +156,22 @@ const formatTaskTitle = (task: any) => {
</div>
</div>
</template>
<div v-else class="self-serve-task-row">
<div v-if="props.showCheckboxes" class="self-serve-task-checkbox">
<div v-else class="columns is-mobile is-vcentered">
<div v-if="props.showCheckboxes" class="column is-narrow">
<b-field>
<b-checkbox
class="mr-0 pr-0"
size="is-large"
:model-value="completedTasks[task.id] === true"
type="is-success"
:data-testid="`self-serve-task-${task.id}-toggle`"
@update:model-value="emit('toggle-task', task.id, $event)"
@input="emit('toggle-task', task.id, $event)"
/>
</b-field>
</div>
<div class="self-serve-task-content">
<div class="column">
<p><strong>{{ formatTaskTitle(task) }}</strong></p>
<p v-if="hasTaskDescription(task)" class="is-size-7">{{ task.description }}</p>
<div v-if="getVisibleServices(task).length > 0" class="tags mt-2">
<span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span>
</div>
@@ -226,7 +239,7 @@ const formatTaskTitle = (task: any) => {
padding: 0.65rem 0.75rem;
display: flex;
align-items: flex-start;
gap: 0.35rem;
gap: 0.6rem;
pointer-events: auto;
}
@@ -234,30 +247,12 @@ const formatTaskTitle = (task: any) => {
margin-bottom: 0;
}
.self-serve-task-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.self-serve-task-checkbox {
flex: 0 0 auto;
}
.self-serve-task-checkbox :deep(.field) {
margin-bottom: 0;
}
.self-serve-task-content {
flex: 1 1 auto;
min-width: 0;
}
.self-serve-task-content p {
margin-bottom: 0;
}
.self-serve-task-title {
margin: 0;
}
.self-serve-task-description {
margin: 0.25rem 0 0;
font-size: 0.85rem;
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { BIcon } from "buefy";
import SelfServeDynamicImageFrame from "@/components/displays/selfServe/SelfServeDynamicImageFrame.vue";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
const props = defineProps<{
@@ -18,23 +18,6 @@ const emit = defineEmits<{
(e: "clear-dynamic-image"): void;
}>();
const isDynamicImageLoading = ref(false);
const failedDynamicImageUrl = ref<string | null>(null);
const hasRenderableDynamicImageUrl = computed(
() => !!props.dynamicImageUrl && failedDynamicImageUrl.value !== props.dynamicImageUrl
);
const showDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value && !isDynamicImageLoading.value);
const shouldRenderDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value);
watch(
() => props.dynamicImageUrl,
(dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
failedDynamicImageUrl.value = null;
},
{ immediate: true }
);
const emitToggleTask = (taskId: number, value: boolean) => {
emit("toggle-task", taskId, value);
};
@@ -43,13 +26,7 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
emit("download-attachment", taskId, attachmentId, attachment);
};
const onDynamicImageLoad = () => {
isDynamicImageLoading.value = false;
};
const onDynamicImageError = () => {
isDynamicImageLoading.value = false;
failedDynamicImageUrl.value = props.dynamicImageUrl;
emit("clear-dynamic-image");
};
</script>
@@ -70,41 +47,11 @@ const onDynamicImageError = () => {
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<img
v-if="dynamicImageUrl && isDynamicImageLoading"
:key="`${dynamicImageUrl}:preload`"
<SelfServeDynamicImageFrame
:src="dynamicImageUrl"
alt=""
data-testid="self-serve-dynamic-image-preload"
class="self-serve-dynamic-image-preload"
@load="onDynamicImageLoad"
alt="Machine status"
@error="onDynamicImageError"
/>
<div
v-if="shouldRenderDynamicImageFrame"
class="self-serve-dynamic-image-frame mb-4"
data-testid="self-serve-dynamic-image-frame"
>
<div
v-if="isDynamicImageLoading"
class="self-serve-dynamic-image-skeleton"
data-testid="self-serve-dynamic-image-skeleton"
>
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-large" />
<span class="is-sr-only">{{ $t("self_wash.loading_data") }}</span>
</div>
<img
v-if="showDynamicImageFrame"
:key="dynamicImageUrl"
:src="dynamicImageUrl"
alt="Machine status"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
</div>
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
<h1 class="title has-text-centered mb-2" v-if="activeTasks.length > 0">{{ $t("self_wash.start_machine") }}</h1>
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
@@ -118,49 +65,3 @@ const onDynamicImageError = () => {
</div>
</div>
</template>
<style scoped>
.self-serve-dynamic-image-frame {
position: relative;
width: 100%;
max-width: 100%;
aspect-ratio: 16 / 9;
margin-left: auto;
margin-right: auto;
overflow: hidden;
}
.self-serve-dynamic-image-skeleton {
align-items: center;
aspect-ratio: 16 / 9;
background: #edf2f7;
color: #112f5f;
display: flex;
height: 100%;
justify-content: center;
width: 100%;
}
.self-serve-dynamic-image-preload {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
}
.self-serve-dynamic-image {
width: 100%;
height: 100%;
border-radius: 4px;
display: block;
object-fit: contain;
transition: opacity 120ms ease;
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ref, watch } from "vue";
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
@@ -21,11 +21,8 @@ const props = defineProps<{
const emit = defineEmits<{
(e: "update:customerNumber", value: string): void;
(e: "update:customer-number", value: string): void;
(e: "update:registrationNumber", value: string): void;
(e: "update:registration-number", value: string): void;
(e: "select-vehicle-type", selection: any): void;
(e: "selected", selection: any): void;
}>();
const extractRegistrationValue = (value: unknown): string => {
@@ -52,19 +49,6 @@ const normalizeRegistration = (value: unknown) => extractRegistrationValue(value
const typedRegistration = ref(normalizeRegistration(props.registrationNumber));
const filteredRegistrationOptions = computed(() => {
const query = normalizeRegistration(typedRegistration.value);
const options = props.registrationOptions || [];
if (!query) {
return options;
}
return options.filter((option) => {
const normalizedOption = normalizeRegistration(option);
return normalizedOption !== query && normalizedOption.includes(query);
});
});
watch(() => props.registrationNumber, (newValue) => {
typedRegistration.value = normalizeRegistration(newValue);
});
@@ -73,18 +57,6 @@ const emitRegistration = (value: unknown) => {
const normalized = normalizeRegistration(value);
typedRegistration.value = normalized;
emit("update:registrationNumber", normalized);
emit("update:registration-number", normalized);
};
const emitCustomerNumber = (value: unknown) => {
const normalized = String(value || "");
emit("update:customerNumber", normalized);
emit("update:customer-number", normalized);
};
const emitVehicleTypeSelection = (selection: any) => {
emit("select-vehicle-type", selection);
emit("selected", selection);
};
</script>
@@ -96,41 +68,38 @@ const emitVehicleTypeSelection = (selection: any) => {
:model-value="customerNumber || ''"
data-testid="self-serve-customer-number"
:placeholder="$t('self_wash.enter_customer_number')"
@input="emitCustomerNumber($event)"
@update:modelValue="emitCustomerNumber($event)"
@input="emit('update:customerNumber', String($event || ''))"
/>
</b-field>
<b-field :label="$t('self_wash.registration_number')" class="self-serve-registration-field">
<div class="self-serve-registration-control">
<b-autocomplete
:model-value="typedRegistration"
data-testid="self-serve-registration"
:data="filteredRegistrationOptions"
:placeholder="$t('self_wash.enter_registration_number')"
:debounce="300"
:min-length="1"
icon-pack="fas"
icon="car-side"
clearable
:loading="filteredRegistrationOptions.length === 0 && isCustomerVehiclesLoading"
@select="emitRegistration($event)"
@typing="emitRegistration($event)"
@update:modelValue="emitRegistration($event)"
>
</b-autocomplete>
<p
v-if="typedRegistration"
class="help self-serve-registration-guidance"
data-testid="self-serve-registration-guidance"
>
<b-field :label="$t('self_wash.registration_number')">
<b-autocomplete
:model-value="typedRegistration"
data-testid="self-serve-registration"
:data="registrationOptions"
:placeholder="$t('self_wash.enter_registration_number')"
:debounce="300"
:min-length="1"
:filter="(option, query) => option.toLowerCase().includes(query.toLowerCase())"
icon-pack="fas"
icon="car-side"
clearable
:loading="registrationOptions.length === 0 && isCustomerVehiclesLoading"
:selectable-header="true"
@select="emitRegistration($event)"
@typing="emitRegistration($event)"
@update:modelValue="emitRegistration($event)"
@select-header="emitRegistration(typedRegistration)"
>
<template #header>
<template v-if="hasMatchingVehicle">
{{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }}
</template>
<template v-else>
{{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }}
</template>
</p>
</div>
</template>
<template #empty>{{ $t("self_wash.no_vehicles_found") }}</template>
</b-autocomplete>
</b-field>
<b-message
v-if="props.vehicleStepError"
@@ -155,7 +124,7 @@ const emitVehicleTypeSelection = (selection: any) => {
<SelfServeVehicleTypeSelector
:selectedVehicleTypeId="selectedVehicleTypeId"
:restrictToProductIds="availableProductIds"
@selected="emitVehicleTypeSelection"
@selected="emit('select-vehicle-type', $event)"
/>
</template>
<template v-else-if="vehicleTypes.length === 0">
@@ -171,34 +140,3 @@ const emitVehicleTypeSelection = (selection: any) => {
</b-field>
</div>
</template>
<style scoped>
.self-serve-registration-control {
display: block;
width: 100%;
min-width: 0;
}
.self-serve-registration-guidance {
display: block;
margin-top: 0.5rem;
line-height: 1.35;
white-space: normal;
}
.self-serve-registration-field :deep(.dropdown),
.self-serve-registration-field :deep(.autocomplete) {
width: 100%;
}
.self-serve-registration-field :deep(.dropdown-menu) {
left: 0;
right: 0;
width: 100%;
max-width: 100%;
}
.self-serve-registration-field :deep(.dropdown-item) {
white-space: normal;
}
</style>
@@ -1,81 +0,0 @@
<!--
VehicleInputSection.vue customer number, license plate, and vehicle type inputs.
Props:
showCustomerNumberInput whether to render the customer number field
customerNumber current customer number string
registrationNumber current license plate string
registrationOptions autocomplete options for existing vehicles
selectedVehicleTypeId currently selected vehicle type id
selectedVehicleName human-readable name of selected vehicle type
availableProductIds allowed product ids for the department
vehicleTypes list of vehicle type options
Emit:
update:customerNumber new customer number string
update:registrationNumber new license plate string
selectVehicleType selected VehicleTypeTemplate object
-->
<script setup lang="ts">
import { computed } from "vue";
import SelfServeVehicleStep from "@/components/displays/selfServe/SelfServeVehicleStep.vue";
interface Props {
showCustomerNumberInput: boolean;
customerNumber: string | null;
registrationNumber: string | null;
registrationOptions: string[];
selectedVehicleTypeId: number | null;
selectedVehicleName: string | null;
availableProductIds: (number | string)[];
vehicleTypes: any[];
}
defineProps<Props>();
const emit = defineEmits<{
"update:customerNumber": [value: string];
"update:registrationNumber": [value: string];
selectVehicleType: [type: { id: number }];
}>();
function handleCustomerNumberUpdate(value: string) {
emit("update:customerNumber", value);
}
function handleRegistrationNumberUpdate(value: string) {
emit("update:registrationNumber", value);
}
function handleSelectVehicleType(selection: any) {
emit("selectVehicleType", selection);
}
</script>
<template>
<SelfServeVehicleStep
:customer-number="customerNumber"
:show-customer-number-input="showCustomerNumberInput"
:registration-number="registrationNumber"
:registration-options="registrationOptions"
:is-customer-vehicles-loading="false"
:has-matching-vehicle="false"
:selected-vehicle-type-id="selectedVehicleTypeId"
:selected-vehicle-name="selectedVehicleName"
:selected-vehicle-description="null"
:available-product-ids="availableProductIds"
:vehicle-types="vehicleTypes"
:vehicle-step-error="null"
:vehicle-step-guidance="null"
@update:customer-number="handleCustomerNumberUpdate"
@update:customerNumber="handleCustomerNumberUpdate"
@update:registration-number="handleRegistrationNumberUpdate"
@update:registrationNumber="handleRegistrationNumberUpdate"
@select-vehicle-type="handleSelectVehicleType"
@selected="handleSelectVehicleType"
/>
</template>
<style scoped>
/* Vehicle input section styling is inherited from SelfServeVehicleStep */
</style>
@@ -1,260 +0,0 @@
<!--
WashProgressCard.vue timer, guided instructions, and bottom actions during active wash.
Renders when a wash session is in progress (WASH_IN_PROGRESS step).
Props:
currentGuidedWashStep index into guidedWashFlowSteps
guidedWashFlowSteps array of instruction step objects
formattedElapsed human-readable elapsed time string
isCompletingWash whether the wash is finishing right now
openingPropertyAccessGate loading state for access gate button
openingPropertyExitGate loading state for exit gate button
Emit:
update:currentGuidedWashStep new step index
goPreviousGuidedWashStep navigate to previous instruction
goNextGuidedWashStep navigate to next instruction
completeWash finish the wash session
openPropertyAccessGate open the property access gate (laneId)
openPropertyExitGate open the property exit gate (laneId)
requestAssistance call for assistance
-->
<script setup lang="ts">
import { computed } from "vue";
import { BButton } from "buefy";
interface Props {
currentGuidedWashStep: number;
guidedWashFlowSteps: any[];
formattedElapsed: string;
isCompletingWash: boolean;
openingPropertyAccessGate: boolean;
openingPropertyExitGate: boolean;
showProgressActions?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showProgressActions: true,
});
const emit = defineEmits<{
"update:currentGuidedWashStep": [step: number];
goPreviousGuidedWashStep: [];
goNextGuidedWashStep: [];
completeWash: [];
openPropertyAccessGate: [];
openPropertyExitGate: [];
requestAssistance: [];
}>();
const isLastGuidedWashStep = computed(
() =>
Array.isArray(props.guidedWashFlowSteps) &&
props.guidedWashFlowSteps.length > 0 &&
props.currentGuidedWashStep >= props.guidedWashFlowSteps.length - 1
);
function handleGoPrevious() {
emit("goPreviousGuidedWashStep");
}
function handleGoNext() {
emit("goNextGuidedWashStep");
}
function handleComplete() {
emit("completeWash");
}
function handleOpenAccessGate() {
emit("openPropertyAccessGate");
}
function handleOpenExitGate() {
emit("openPropertyExitGate");
}
function handleRequestAssistance() {
emit("requestAssistance");
}
</script>
<template>
<div data-testid="self-serve-wash-progress">
<!-- Finishing screen -->
<div
v-if="isCompletingWash && showProgressActions"
class="notification is-info is-light self-serve-finishing-screen"
data-testid="self-serve-finishing-wash"
>
<p class="title is-5 mb-0">{{ $t("self_wash.finishing_wash_exit_opening") }}</p>
</div>
<!-- Guided wash instructions (stepped walkthrough) -->
<template v-if="!isCompletingWash">
<!--
Accept a callback slot for the guided-instructions component so the
parent can plug in SelfServeGuidedInstructions or its own implementation.
-->
<slot
v-if="showProgressActions"
name="guided-instructions"
:current-step="currentGuidedWashStep"
:steps="guidedWashFlowSteps"
:is-last-step="isLastGuidedWashStep"
@update:current-step="emit('update:currentGuidedWashStep', $event)"
>
<!-- Default: render nothing (parent provides via slot) -->
</slot>
<!-- Bottom action bar while wash is in progress -->
<div
v-show="showProgressActions"
class="self-serve-bottom-actions"
data-testid="self-serve-bottom-actions"
>
<!-- Help / assistance -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-session-bottom-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-right="info-circle"
data-testid="self-serve-nav-help"
@click.prevent="handleRequestAssistance"
>
{{ $t("self_wash.assistance") }}
</b-button>
</div>
<!-- Property gate controls -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-property-gate-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="sign-in-alt"
data-testid="self-serve-nav-open-property-access-gate"
:loading="openingPropertyAccessGate"
:disabled="openingPropertyAccessGate"
@click.prevent="handleOpenAccessGate"
>
{{ $t("self_wash.open_property_access_gate") }}
</b-button>
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="sign-out-alt"
data-testid="self-serve-nav-open-property-exit-gate"
:loading="openingPropertyExitGate"
:disabled="openingPropertyExitGate"
@click.prevent="handleOpenExitGate"
>
{{ $t("self_wash.open_property_exit_gate") }}
</b-button>
</div>
<!-- Guided step navigation -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-guided-bottom-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="arrow-left"
data-testid="self-serve-guided-prev"
:disabled="currentGuidedWashStep === 0"
@click.prevent="handleGoPrevious"
>
{{ $t("common.previous") }}
</b-button>
<b-button
v-if="!isLastGuidedWashStep"
class="self-serve-bottom-actions__button"
type="is-link"
icon-pack="fas"
icon-right="arrow-right"
data-testid="self-serve-guided-next"
@click.prevent="handleGoNext"
>
{{ $t("common.next") }}
</b-button>
<b-button
v-else
class="self-serve-bottom-actions__button"
type="is-link"
icon-pack="fas"
icon-right="check"
data-testid="self-serve-nav-complete"
:loading="isCompletingWash"
:disabled="isCompletingWash"
@click.prevent="handleComplete"
>
{{ $t("common.done") }}
</b-button>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.self-serve-finishing-screen {
margin: 0 auto;
max-width: 32rem;
text-align: left;
}
.self-serve-bottom-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
justify-content: center;
margin: 0.75rem auto 0;
max-width: 30rem;
}
.self-serve-bottom-actions__row {
display: flex;
gap: 0.75rem;
justify-content: center;
width: 100%;
}
.self-serve-bottom-actions__button {
flex: 1 1 0;
font-weight: 700;
line-height: 1.15;
max-width: 14rem;
min-height: 2.75rem;
min-width: 0;
white-space: normal;
}
@media screen and (max-width: 768px) {
.self-serve-bottom-actions {
background: #ffffff;
border-top: 1px solid #dfe5f0;
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -0.25rem 0.75rem rgba(17, 47, 95, 0.08);
left: 0;
margin: 0;
max-width: none;
padding: 0.5rem 0.75rem;
position: fixed;
right: 0;
z-index: 41;
}
.self-serve-bottom-actions__button {
font-size: 0.88rem;
max-width: none;
min-height: 2.65rem;
padding-left: 0.55rem;
padding-right: 0.55rem;
}
}
</style>
@@ -1,159 +0,0 @@
<!--
WashTypeSelector.vue manual vs machine wash toggle.
Renders two radio-style cards: "Manual" (always available) and
"Machine" (available when the lane supports it).
Props:
modelValue current wash type ("Manual" | "Machine")
isMachineAvailable whether machine wash is available for this lane
Emit:
update:modelValue new wash type string
-->
<script setup lang="ts">
import { BRadioButton } from "buefy";
interface Props {
modelValue: string;
isMachineAvailable: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:modelValue": [type: string];
}>();
</script>
<template>
<section class="self-serve-choice-group" data-testid="self-serve-wash-type-group">
<h2 class="self-serve-choice-label">{{ $t("self_wash.wash_type") }}</h2>
<div
class="self-serve-choice-grid self-serve-choice-grid--centered"
data-testid="self-serve-wash-type-options"
>
<!-- Manual option (always available) -->
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="modelValue"
native-value="Manual"
type="is-link"
data-testid="self-serve-wash-type-manual"
@update:model-value="emit('update:modelValue', 'Manual')"
@input="emit('update:modelValue', 'Manual')"
>
<span>
<span>{{ $t("self_wash.manual") }}<br /></span>
<small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
{{ $t("self_wash.available") }}
</small>
</span>
</b-radio-button>
</div>
<!-- Machine option (conditioned) -->
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="modelValue"
native-value="Machine"
type="is-link"
:disabled="!isMachineAvailable"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:modelValue', 'Machine')"
@input="emit('update:modelValue', 'Machine')"
>
<span>
<span>{{ $t("self_wash.machine") }}<br /></span>
<span v-if="!isMachineAvailable">
<small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.unavailable") }}
</small>
</span>
<span v-else>
<small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
{{ $t("self_wash.available") }}
</small>
</span>
</span>
</b-radio-button>
</div>
</div>
</section>
</template>
<style scoped>
.self-serve-choice-group {
margin-bottom: 1.25rem;
}
.self-serve-choice-label {
color: #303440;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.2;
margin: 0 0 0.75rem;
}
.self-serve-choice-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 0.75rem;
width: 100%;
}
.self-serve-choice-grid--centered {
justify-content: center;
}
.self-serve-choice-grid__item {
min-width: 0;
}
.self-serve-choice-card {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button) {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button),
.self-serve-choice-card span,
.self-serve-choice-card :deep(.button span) {
min-width: 0;
}
.self-serve-choice-card small,
.self-serve-choice-card :deep(.button small) {
display: inline-flex;
align-items: center;
line-height: 1.25;
}
@media screen and (min-width: 769px) {
.self-serve-choice-grid {
grid-template-columns: repeat(auto-fit, minmax(11rem, 15rem));
}
}
</style>
@@ -129,19 +129,6 @@ const getOrderItemFlags = (orderItem) => sortInvoicePeriodFlags((props.invoicePe
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
}));
const getOrderItemFlagIconClass = (orderItem) => (
getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : ""
);
const getOrderItemFlagColorClass = (orderItem) => {
const flags = getOrderItemFlags(orderItem);
if (flags.length === 0) {
return "";
}
return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning";
};
const displayColumnCount = computed(() => (
2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
));
@@ -175,17 +162,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
<template v-if="!isOrderItemAddon(item)">
<!-- If the item is included in the invoice, show it normally -->
<tr v-if="item.include_in_invoice">
<td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
{{ item.product.name }}
</td>
<td>{{ item.product.name }}</td>
<td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
@@ -193,17 +170,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
</tr>
<!-- If the item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning-light">
<td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
<span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td><span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
<td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
@@ -226,17 +193,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
<!-- If the add-on item is included in the invoice, show it normally -->
<tr v-if="addon_item.include_in_invoice">
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }}
</td>
<td>+ {{ addon_item.product.name }}</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
@@ -244,17 +201,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
</tr>
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning">
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td>+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
@@ -290,14 +237,6 @@ watch(() => props.orderId, (newValue, oldValue) => {
<!-- If the item is a primary item, show it -->
<template v-if="!isOrderItemAddon(item)">
<div>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
<strong>{{ item.product.name }}</strong> x {{ item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span>
</div>
@@ -309,14 +248,6 @@ watch(() => props.orderId, (newValue, oldValue) => {
<!-- Show the add-on items -->
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
<small>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }} x {{ addon_item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
</small>
@@ -334,7 +265,5 @@ watch(() => props.orderId, (newValue, oldValue) => {
</template>
<style scoped>
.invoice-period-item-flag-indicator {
vertical-align: middle;
}
</style>
@@ -251,8 +251,8 @@ const onResendInvite = async (subuser) => {
<td>{{ formatDateTime(subuser.updated_at) }}</td>
<td>
<div class="buttons is-justify-content-flex-end action-buttons" :data-testid="`subuser-actions-${subuser.id}`">
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
<div class="buttons is-justify-content-flex-end action-buttons">
<ActionSettingsWheelButton v-if="hasRowActions(subuser)" display-actions-directly>
<template #actions>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
@@ -255,16 +255,6 @@ const getBookingMatchesStateKey = (matches) => {
return matches.map((booking) => getBookingStateKey(booking)).join("||");
};
const hasBookingDetailsChanged = (currentBooking, nextBooking) => {
const currentKeys = Object.keys(currentBooking || {});
const nextKeys = Object.keys(nextBooking || {});
if (currentKeys.length !== nextKeys.length) {
return true;
}
return nextKeys.some((key) => currentBooking?.[key] !== nextBooking?.[key]);
};
const setVehicleObject = (emittedVehicleObject, options = {}) => {
const normalizedOptions = {
preserveManualReference: true,
@@ -360,23 +350,17 @@ const mergeBookingMatchDetails = (booking) => {
}
let didMergeBooking = false;
let didChangeBooking = false;
const nextBookingMatches = bookingMatches.value.map((entry) => {
bookingMatches.value = bookingMatches.value.map((entry) => {
if (normalizeBookingId(entry?.id) !== normalizedBookingId) {
return entry;
}
didMergeBooking = true;
const mergedBooking = { ...entry, ...booking };
const hasChangedEntry = hasBookingDetailsChanged(entry, mergedBooking);
didChangeBooking = didChangeBooking || hasChangedEntry;
return hasChangedEntry ? mergedBooking : entry;
return { ...entry, ...booking };
});
if (!didMergeBooking) {
bookingMatches.value = [...bookingMatches.value, booking];
} else if (didChangeBooking) {
bookingMatches.value = nextBookingMatches;
}
};
@@ -1037,7 +1037,6 @@ const getCurrentIconColor = () => {
@blur="lostfocus"
@keydown="arrowKeyHandler"
id="reg_1"
tabindex="1"
autocomplete="off"
/>
</template>
@@ -1060,7 +1059,6 @@ const getCurrentIconColor = () => {
@blur="lostfocus"
@keydown="arrowKeyHandler"
id="reg_1"
tabindex="1"
autocomplete="off"
/>
</div>
@@ -394,16 +394,12 @@ onBeforeUnmount(() => {
v-model="typedReference"
:data="groupedSuggestions"
field="reference"
input-id="reference"
data-testid="pos-desktop-step-1-reference-input"
group-field="group"
group-options="items"
:keep-first="true"
open-on-focus
expanded
:loading="isFetching"
:tabindex="props.tabindex"
:aria-invalid="props.requiredWarning ? 'true' : undefined"
custom-class="has-sharp-edges"
icon-pack="fas"
max-height="320"
@@ -1,217 +0,0 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { releaseUpdateState, shortReleaseCommit } from "@/services/releaseUpdate.js";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
const SHIFT_PRESS_WINDOW_MS = 1200;
const CLOSE_EVENT = "frontend-maintenance-menu:close";
const { t } = useI18n({ useScope: "global" });
const isOpen = ref(false);
const isBusy = ref(false);
const shiftPresses = ref([]);
const currentVersion = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
const latestVersion = computed(() => shortReleaseCommit(releaseUpdateState.latestCommit || releaseUpdateState.currentCommit));
const closeMenu = () => {
if (!isBusy.value) {
isOpen.value = false;
}
};
const handleKeydown = (event) => {
if (event.key !== "Shift" || event.repeat) {
return;
}
const now = Date.now();
shiftPresses.value = [...shiftPresses.value.filter((pressedAt) => now - pressedAt <= SHIFT_PRESS_WINDOW_MS), now];
if (shiftPresses.value.length >= 3) {
shiftPresses.value = [];
isOpen.value = true;
}
};
const forceUpdateAndClearLocal = async () => {
if (isBusy.value || !window.confirm(t("maintenance_menu.confirm_clear_local"))) {
return;
}
isBusy.value = true;
try {
await forceFrontendUpdateAndClearLocal();
} catch (error) {
isBusy.value = false;
console.error("Failed to force frontend update and clear local data:", error);
}
};
onMounted(() => {
window.addEventListener("keydown", handleKeydown);
window.addEventListener(CLOSE_EVENT, closeMenu);
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", handleKeydown);
window.removeEventListener(CLOSE_EVENT, closeMenu);
});
</script>
<template>
<Teleport to="body">
<div v-if="isOpen" class="frontend-maintenance-menu" data-testid="frontend-maintenance-menu">
<button
type="button"
class="frontend-maintenance-menu__backdrop"
:aria-label="t('common.close')"
@click="closeMenu"
></button>
<section class="frontend-maintenance-menu__panel" role="dialog" aria-modal="true" :aria-label="t('maintenance_menu.title')">
<header class="frontend-maintenance-menu__header">
<div>
<p>{{ t("maintenance_menu.title") }}</p>
<span>{{ t("maintenance_menu.version", { current: currentVersion, latest: latestVersion }) }}</span>
</div>
<button type="button" class="frontend-maintenance-menu__close" :aria-label="t('common.close')" @click="closeMenu">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</header>
<button
type="button"
class="frontend-maintenance-menu__danger"
data-testid="frontend-maintenance-force-clear"
:disabled="isBusy"
@click="forceUpdateAndClearLocal"
>
<i class="fas fa-sync-alt" aria-hidden="true"></i>
<span>
<strong>{{ t("maintenance_menu.force_update_clear") }}</strong>
<small>{{ t("maintenance_menu.force_update_clear_hint") }}</small>
</span>
</button>
</section>
</div>
</Teleport>
</template>
<style scoped>
.frontend-maintenance-menu {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: end center;
padding: 16px;
pointer-events: none;
}
.frontend-maintenance-menu__backdrop {
position: fixed;
inset: 0;
border: 0;
background: rgba(12, 22, 34, 0.32);
pointer-events: auto;
}
.frontend-maintenance-menu__panel {
position: relative;
width: min(420px, calc(100vw - 32px));
border: 1px solid #d6dde6;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
color: #111827;
pointer-events: auto;
}
.frontend-maintenance-menu__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 14px 10px;
border-bottom: 1px solid #edf1f5;
}
.frontend-maintenance-menu__header p {
margin: 0;
color: #172033;
font-size: 0.96rem;
font-weight: 800;
}
.frontend-maintenance-menu__header span {
display: block;
margin-top: 3px;
color: #667085;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.72rem;
font-weight: 700;
}
.frontend-maintenance-menu__close {
width: 34px;
height: 34px;
flex: 0 0 auto;
border: 1px solid #d9e0e8;
border-radius: 6px;
background: #ffffff;
color: #334155;
cursor: pointer;
}
.frontend-maintenance-menu__danger {
width: calc(100% - 28px);
min-height: 58px;
display: flex;
align-items: center;
gap: 10px;
margin: 14px;
padding: 10px 12px;
border: 1px solid #f2b8b5;
border-radius: 6px;
background: #fff7f6;
color: #981b1b;
cursor: pointer;
text-align: left;
}
.frontend-maintenance-menu__danger:disabled {
cursor: wait;
opacity: 0.7;
}
.frontend-maintenance-menu__danger i {
width: 20px;
flex: 0 0 auto;
text-align: center;
}
.frontend-maintenance-menu__danger strong,
.frontend-maintenance-menu__danger small {
display: block;
}
.frontend-maintenance-menu__danger strong {
font-size: 0.86rem;
font-weight: 800;
}
.frontend-maintenance-menu__danger small {
margin-top: 2px;
color: #9f1c1c;
font-size: 0.72rem;
font-weight: 650;
line-height: 1.25;
}
@media (min-width: 720px) {
.frontend-maintenance-menu {
place-items: end end;
padding: 22px;
}
}
</style>
@@ -44,7 +44,6 @@ const REQUEST_INSIGHT_DEFINITIONS = Object.freeze([
]);
const SHIFT_MULTI_PRESS_WINDOW_MS = 700;
const SYSTEM_SEARCH_CLOSE_EVENT = "system-search:close";
const FRONTEND_MAINTENANCE_MENU_CLOSE_EVENT = "frontend-maintenance-menu:close";
const isExpanded = ref(REQUEST_QUEUE_CONFIG.inspector.expandedByDefault);
const isShortcutActivated = ref(false);
@@ -367,9 +366,6 @@ const handleWindowKeydown = (event) => {
}
if (shiftKeyPressCount.value >= 3) {
event.preventDefault?.();
event.stopImmediatePropagation?.();
window.dispatchEvent(new CustomEvent(FRONTEND_MAINTENANCE_MENU_CLOSE_EVENT));
closeSystemSearchForSuperuser();
isShortcutActivated.value = true;
isExpanded.value = true;
@@ -1500,3 +1496,4 @@ onBeforeUnmount(() => {
opacity: 0;
}
</style>
+2 -2
View File
@@ -3,7 +3,7 @@ import MenuDefault from "@/components/menus/MenuDefault.vue";
import { ref, watch, onMounted } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { isAccessibleVisibleNamedDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
import { isAccessibleVisibleDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
// Get the department ID from the URL
const route = useRoute();
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
@@ -26,7 +26,7 @@ const loadDepartments = () => {
value: department.id,
icon: "fas fa-building",
children: [],
hidden: !isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment),
hidden: !isAccessibleVisibleDepartment(department, SessionUser.canAccessAssignedDepartment),
});
});
menu_items.value[1].options = options;
@@ -13,9 +13,7 @@ import {
} from "@/composables/useDraftTransactionCustomer.js";
import { fetchDepartmentOrderBookingCounts } from "@/components/models/navigation/items/adminBookingCount.js";
import { fetchDepartmentDraftCount } from "@/components/models/navigation/items/adminDraftCount.js";
import { hasExplicitBookingCountPermission } from "@/components/models/navigation/items/bookingCountGuards.js";
import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js";
import { isAccessibleVisibleNamedDepartment } from "@/services/departmentVisibility.js";
const t = (key: string) => i18n.global.t(key);
@@ -72,8 +70,6 @@ let queuedBookingLoadingIndicator = false;
let queuedDraftLoadingIndicator = false;
const getDepartmentIdNumber = () => Number.parseInt(String(department_id.value), 10);
const canUseAdminNavigationCounts = () => SessionUser.canAccessAdmin();
const canUseAdminBookingNavigationCounts = () =>
canUseAdminNavigationCounts() && hasExplicitBookingCountPermission(SessionUser);
const getDepartmentById = (id: number) => {
return departments_cache.value?.find((department: any) => Number(department?.id) === Number(id)) || null;
};
@@ -152,19 +148,6 @@ const getDepartmentDraftBadge = () => {
};
const fetchDepartmentBookingCount = async ({ showLoadingIndicator = false } = {}) => {
if (!canUseAdminBookingNavigationCounts()) {
department_booking_counts.value = {
past: 0,
current: 0,
future: 0,
};
department_booking_counts_loading.value = false;
bookingCountFetchInFlight = false;
bookingCountRefreshQueued = false;
queuedBookingLoadingIndicator = false;
return;
}
if (!hasValidDepartmentId.value) {
department_booking_counts.value = {
past: 0,
@@ -393,26 +376,17 @@ const items = computed<NavigationItemProps[]>(() => [
hidden: isDepartmentSet.value,
classes: [ADMIN_DEPARTMENT_SELECTION_CLASS],
permissions: ["admin", "list_departments"],
children: SessionUser.functions
.getAccessibleDepartments()
.map((departmentId: any) => {
const label = getDepartmentName(departmentId);
const department = getDepartmentById(departmentId);
if (!isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment)) {
return null;
}
return {
label,
to: `/admin/${department.id}`,
type: "department",
permissions: ["admin", "view_department"],
order_priority: department?.order_priority ?? department?.priority_order ?? null,
priority_order: department?.priority_order ?? department?.order_priority ?? null,
};
})
.filter(Boolean) as NavigationItemProps[],
children: SessionUser.functions.getAccessibleDepartments().map((departmentId: any) => {
const department = getDepartmentById(departmentId);
return {
label: getDepartmentName(departmentId),
to: `/admin/${departmentId}`,
type: "department",
permissions: ["admin", "view_department"],
order_priority: department?.order_priority ?? department?.priority_order ?? null,
priority_order: department?.priority_order ?? department?.order_priority ?? null,
};
}),
},
// Kassesystem
{
@@ -9,7 +9,6 @@ import {
} from "@/composables/useDraftTransactionCustomer.js";
import { fetchSuperUserDraftCount } from "@/components/models/navigation/items/superUserDraftCount.js";
import { fetchSuperUserBookingCounts } from "@/components/models/navigation/items/superUserBookingCount.js";
import { hasExplicitBookingCountPermission } from "@/components/models/navigation/items/bookingCountGuards.js";
import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js";
const firstToUpperCase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
@@ -38,29 +37,23 @@ let queuedSuperUserBookingLoadingIndicator = false;
let queuedSuperUserDraftLoadingIndicator = false;
const canUseSuperUserNavigationCounts = () => SessionUser.canAccessSuperUser();
const canUseSuperUserBookingNavigationCounts = () =>
canUseSuperUserNavigationCounts() && hasExplicitBookingCountPermission(SessionUser);
const resetSuperUserBookingCounts = () => {
const resetSuperUserNavigationCounts = () => {
bookingCountRequestId += 1;
draftCountRequestId += 1;
superuser_booking_counts.value = {
past: 0,
current: 0,
future: 0,
};
superuser_booking_count_loading.value = false;
superUserBookingCountFetchInFlight = false;
superUserBookingCountRefreshQueued = false;
queuedSuperUserBookingLoadingIndicator = false;
};
const resetSuperUserNavigationCounts = () => {
resetSuperUserBookingCounts();
draftCountRequestId += 1;
superuser_draft_count.value = 0;
superuser_draft_count_loading.value = false;
superUserBookingCountFetchInFlight = false;
superUserDraftCountFetchInFlight = false;
superUserBookingCountRefreshQueued = false;
superUserDraftCountRefreshQueued = false;
queuedSuperUserBookingLoadingIndicator = false;
queuedSuperUserDraftLoadingIndicator = false;
};
@@ -132,8 +125,8 @@ const getSuperUserDraftsBadge = () => {
};
const fetchCurrentSuperUserBookingCount = async ({ showLoadingIndicator = false } = {}) => {
if (!canUseSuperUserBookingNavigationCounts()) {
resetSuperUserBookingCounts();
if (!canUseSuperUserNavigationCounts()) {
resetSuperUserNavigationCounts();
return;
}
@@ -1,8 +1,4 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {
emptyBookingCounts,
isExpectedBookingCountRequestFailure,
} from "@/components/models/navigation/items/bookingCountGuards.js";
const normalizePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -28,7 +24,11 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
if (!normalizedDepartmentId) {
return emptyBookingCounts();
return {
past: 0,
current: 0,
future: 0,
};
}
try {
@@ -38,11 +38,12 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
return normalizeBookingCounts(response);
} catch (error) {
if (!isExpectedBookingCountRequestFailure(error)) {
console.error("Error fetching department order-booking counts:", error);
}
return emptyBookingCounts();
console.error("Error fetching department order-booking counts:", error);
return {
past: 0,
current: 0,
future: 0,
};
}
};
@@ -1,24 +0,0 @@
export const BOOKING_COUNT_PERMISSIONS = ["list_bookings", "list_own_bookings"];
export const emptyBookingCounts = () => ({
past: 0,
current: 0,
future: 0,
});
export const hasExplicitBookingCountPermission = (sessionUser) => {
const permissions = sessionUser?.permissions?.value;
if (!Array.isArray(permissions)) {
return false;
}
return BOOKING_COUNT_PERMISSIONS.some((permission) => permissions.includes(permission));
};
export const getRequestErrorStatus = (error) =>
Number.parseInt(String(error?.response?.status ?? error?.status ?? ""), 10);
export const isExpectedBookingCountRequestFailure = (error) => {
return [401, 403, 404].includes(getRequestErrorStatus(error));
};
@@ -1,8 +1,4 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {
emptyBookingCounts,
isExpectedBookingCountRequestFailure,
} from "@/components/models/navigation/items/bookingCountGuards.js";
const toNonNegativeInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -24,11 +20,12 @@ export const fetchSuperUserBookingCounts = async () => {
const response = await authenticatedRequest("/order-bookings/counts", "GET", {});
return normalizeBookingCounts(response);
} catch (error) {
if (!isExpectedBookingCountRequestFailure(error)) {
console.error("Error fetching superuser booking counts:", error);
}
return emptyBookingCounts();
console.error("Error fetching superuser booking counts:", error);
return {
past: 0,
current: 0,
future: 0,
};
}
};
@@ -1,8 +1,9 @@
<script setup>
import { useRouter } from "vue-router";
import { useRouter } from 'vue-router';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { pingApiServer } from "@/services/apiHealth.js";
const props = defineProps({
@@ -41,12 +42,12 @@ const handleSlowSessionBootstrap = async () => {
}
const result = await Swal.fire({
title: "Error",
text: "The user session could not be initiated.",
icon: "error",
confirmButtonText: "Clear session, and try again",
title: 'Error',
text: 'The user session could not be initiated.',
icon: 'error',
confirmButtonText: 'Clear session, and try again',
showCancelButton: true,
cancelButtonText: "Retry",
cancelButtonText: 'Retry',
});
if (result.isConfirmed) {
@@ -59,16 +60,10 @@ const handleSlowSessionBootstrap = async () => {
}
};
watch(isSessionInitiated, (initiated) => {
if (initiated) {
clearSessionTimeout();
}
});
onMounted(() => {
sessionTimeout = setTimeout(() => {
void handleSlowSessionBootstrap();
}, 15000);
}, 5000);
});
onBeforeUnmount(clearSessionTimeout);
@@ -94,18 +89,9 @@ onBeforeUnmount(clearSessionTimeout);
</div>
</div>
<!-- Simple checks -->
<p v-if="router.currentRoute.value.path === '/user'">
You are on the /user page. <br />You do {{ SessionUser.hasPermission("user") ? "" : "not" }} have permission (
user ) to view this page.
</p>
<p v-if="router.currentRoute.value.path === '/admin'">
You are on the /admin page. <br />You do {{ SessionUser.hasPermission("admin") ? "" : "not" }} have permission (
admin ) to view this page.
</p>
<p v-if="router.currentRoute.value.path === '/superuser'">
You are on the /superuser <br />page. You do {{ SessionUser.hasPermission("superuser") ? "" : "not" }} have
permission ( superuser ) to view this page.
</p>
<p v-if="router.currentRoute.value.path === '/user'">You are on the /user page. <br>You do {{ SessionUser.hasPermission('user') ? '' : 'not' }} have permission ( user ) to view this page.</p>
<p v-if="router.currentRoute.value.path === '/admin'">You are on the /admin page. <br>You do {{ SessionUser.hasPermission('admin') ? '' : 'not' }} have permission ( admin ) to view this page.</p>
<p v-if="router.currentRoute.value.path === '/superuser'">You are on the /superuser <br>page. You do {{ SessionUser.hasPermission('superuser') ? '' : 'not' }} have permission ( superuser ) to view this page.</p>
<div v-if="showDebugInfo">
<div class="message mb-6">
<div class="message-header">
@@ -122,9 +108,7 @@ onBeforeUnmount(clearSessionTimeout);
<!-- Button to go back to the previous page -->
<button class="button is-dark" @click="router.go(-1)">Go back</button>
<!-- Button to see the debug information -->
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-if="!showDebugInfo">
Show debug information
</button>
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-if="!showDebugInfo">Show debug information</button>
<!-- Button to hide the debug information -->
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-else>Hide debug information</button>
</div>
@@ -132,4 +116,6 @@ onBeforeUnmount(clearSessionTimeout);
</div>
</template>
<style scoped></style>
<style scoped>
</style>
@@ -1,21 +1,42 @@
<script setup>
import { ref } from "vue";
import { computed, inject, ref } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue";
import ReleaseUpdateWidget from "@/components/release/ReleaseUpdateWidget.vue";
import { releaseChannelSelectorVisible, switchSelectedReleaseChannel } from "@/services/releaseChannelAvailability.js";
import {
clearSelectedReleaseChannel,
releaseChannelSelectorVisible,
switchSelectedReleaseChannel,
} from "@/services/releaseChannelAvailability.js";
import {
isReleaseSourceOverrideAvailable,
RELEASE_SOURCE_MODES,
setReleaseSourceOverride,
} from "@/services/releaseBootstrap.js";
import { inspectReleaseRuntimeForUpdate } from "@/services/releaseUpdate.js";
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
const switchingSlug = ref("");
const switchError = ref("");
const { t, te } = useI18n({ useScope: "global" });
const reloadWindow = inject("releaseSourceReload", () => {
if (typeof window !== "undefined") {
window.location.reload();
}
});
const tr = (key, fallback) => {
const path = `configuration.release_manager.channel_selector.${key}`;
return te(path) ? t(path) : fallback;
};
const showUseLocalFrontend = computed(
() =>
releaseChannelSelectorVisible.value &&
isReleaseSourceOverrideAvailable() &&
String(releaseRuntimeState.source || "").toLowerCase() !== RELEASE_SOURCE_MODES.LOCAL
);
const switchChannel = async (option) => {
if (switchingSlug.value) {
return;
@@ -27,14 +48,21 @@ const switchChannel = async (option) => {
await switchSelectedReleaseChannel(option.channel, SessionUser.refreshReleaseRuntime);
void inspectReleaseRuntimeForUpdate(releaseRuntimeState, { autoDownload: true });
} catch (error) {
switchError.value = tr(
"switch_error",
"Release channel could not be switched. The previous channel is still active."
);
switchError.value = tr("switch_error", "Release channel could not be switched. The previous channel is still active.");
} finally {
switchingSlug.value = "";
}
};
const useLocalFrontend = () => {
if (switchingSlug.value) {
return;
}
setReleaseSourceOverride(RELEASE_SOURCE_MODES.LOCAL);
clearSelectedReleaseChannel();
reloadWindow();
};
</script>
<template>
@@ -46,6 +74,17 @@ const switchChannel = async (option) => {
:disabled="Boolean(switchingSlug)"
@select="switchChannel"
/>
<button
v-if="showUseLocalFrontend"
type="button"
class="release-channel-sidebar-selector__local-button"
data-testid="release-channel-use-local-frontend"
:disabled="Boolean(switchingSlug)"
@click="useLocalFrontend"
>
<i class="fas fa-code" aria-hidden="true"></i>
<span>{{ tr("use_local_frontend", "Use local frontend") }}</span>
</button>
<p v-if="switchError" class="release-channel-sidebar-selector__error" role="alert">
{{ switchError }}
</p>
@@ -54,6 +93,37 @@ const switchChannel = async (option) => {
</template>
<style scoped>
.release-channel-sidebar-selector__local-button {
width: calc(100% - 32px);
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: -2px 16px 14px;
padding: 9px 10px;
border: 1px solid #b8c7d9;
border-radius: 5px;
background: #f8fbff;
color: #153554;
cursor: pointer;
font-size: 0.82rem;
font-weight: 800;
line-height: 1.2;
text-align: center;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.release-channel-sidebar-selector__local-button:hover:not(:disabled) {
border-color: #6f8fb4;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.release-channel-sidebar-selector__local-button:disabled {
cursor: default;
opacity: 0.7;
}
.release-channel-sidebar-selector__error {
margin: 0 16px 14px;
color: #b42318;
+1 -14
View File
@@ -295,23 +295,10 @@ const branchWarning = computed(() =>
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 {
color: #9f1f17;
flex: 1 1 12rem;
font-size: 0.82rem;
line-height: 1.25;
min-width: min(12rem, 100%);
overflow-wrap: break-word;
word-break: normal;
overflow-wrap: anywhere;
}
.release-context-bar__endpoints {
@@ -13,59 +13,7 @@ const getSelectedCustomerNumber = () => {
return stored ? parseInt(stored) : null;
};
const MY_ACTIVE_WASH_ENDPOINT = '/modules/self-serve/lane/wash/my-active-wash';
const ACTIVE_WASH_STARTED_STATUSES = new Set(['MACHINE_RELAY_ENABLED', 'MACHINE_STARTED']);
const normalizeStatus = (status) => String(status || '').trim().toUpperCase();
const parseDateTimeMs = (value) => {
if (!value) {
return null;
}
const timestamp = Date.parse(String(value));
return Number.isFinite(timestamp) ? timestamp : null;
};
const hasActiveWashStartEvidence = (details) => {
const session = details?.session || {};
const status = normalizeStatus(session?.status ?? details?.status);
return (
ACTIVE_WASH_STARTED_STATUSES.has(status) ||
session?.machine_relay_enabled === true ||
session?.machine_start_triggered === true ||
details?.machine_relay_enabled === true ||
details?.machine_start_triggered === true ||
parseDateTimeMs(session?.wash_started_at) !== null ||
parseDateTimeMs(session?.machine_start_triggered_at) !== null ||
parseDateTimeMs(session?.machine_relay_enabled_at) !== null
);
};
const normalizeActiveWashResponse = (url, method, response) => {
if (String(method || '').toUpperCase() !== 'GET' || !String(url || '').includes(MY_ACTIVE_WASH_ENDPOINT)) {
return response;
}
const details = response?.data?.data;
if (!details?.in_progress || hasActiveWashStartEvidence(details)) {
return response;
}
return {
...response,
data: {
...response.data,
data: {
...details,
in_progress: false,
},
},
};
};
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null, options = {}) => {
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null) => {
const token = localStorage.getItem('token');
if (!token) {
//throw new Error('No token was found, unable to make authenticated request');
@@ -94,7 +42,6 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
method,
url: requestUrl,
...(method === 'GET' ? { params: data } : { data }),
...(options?.signal ? { signal: options.signal } : {}),
__skipRequestQueue: true,
headers
}),
@@ -116,7 +63,6 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
throw error;
})
.then((response) => {
response = normalizeActiveWashResponse(url, method, response);
if (thenCallable) {
// Call the then callable
thenCallable(response);
@@ -184,7 +184,6 @@ const resetSessionState = () => {
SessionUser.user.notifications.wash_certificate_email.value = null;
SessionUser.user.notifications.email_notifications_enabled.value = null;
SessionUser.user.notifications.sms_notifications_enabled.value = null;
SessionUser.user.notifications.superuser_new_customer_email_notifications_enabled.value = null;
SessionUser.user.created_at.value = null;
SessionUser.user.updated_at.value = null;
SessionUser.user.cached_at.value = null;
@@ -529,8 +528,6 @@ export const getSessionData = async () => {
SessionUser.user.notifications.email_notifications_enabled.value =
session.notifications.email_notifications_enabled;
SessionUser.user.notifications.sms_notifications_enabled.value = session.notifications.sms_notifications_enabled;
SessionUser.user.notifications.superuser_new_customer_email_notifications_enabled.value =
session.notifications.superuser_new_customer_email_notifications_enabled;
SessionUser.user.created_at.value = session.created_at;
SessionUser.user.updated_at.value = session.updated_at;
SessionUser.user.display_name.value = session.display_name;
@@ -626,7 +623,6 @@ export const SessionUser = {
wash_certificate_email: ref(null),
email_notifications_enabled: ref(null),
sms_notifications_enabled: ref(null),
superuser_new_customer_email_notifications_enabled: ref(null),
setEmailNotificationsEnabled: (enabled) => {
return SessionUser.request("/account/notifications", "PUT", {
email_notifications_enabled: enabled,
@@ -651,18 +647,6 @@ export const SessionUser = {
console.error(error);
});
},
setSuperuserNewCustomerEmailNotificationsEnabled: (enabled) => {
return SessionUser.request("/account/notifications", "PUT", {
superuser_new_customer_email_notifications_enabled: enabled,
})
.then(() => {
SessionUser.user.notifications.superuser_new_customer_email_notifications_enabled.value = enabled;
})
.catch((error) => {
parseError(error, "user_notifications");
console.error(error);
});
},
setWashCertificateEmail: (email) => {
return SessionUser.request("/account/notifications", "PUT", {
wash_certificate_email: email,
@@ -456,29 +456,6 @@ export const CollectedOrderInvoices = {
throw error;
});
},
move_to_customer: async (id, customerNumber) => {
const invoiceCollectionId = parseInt(id);
const targetCustomerNumber = parseInt(customerNumber);
if (!Number.isInteger(invoiceCollectionId) || invoiceCollectionId <= 0) {
throw new Error('Invalid invoice collection id');
}
if (!Number.isInteger(targetCustomerNumber) || targetCustomerNumber <= 0) {
throw new Error('Invalid customer number');
}
return authenticatedRequest('/collected-invoices/move-to-customer', 'POST', {
id: invoiceCollectionId,
customer_number: targetCustomerNumber,
}).then((response) => {
console.log(response);
return response;
}).catch((error) => {
console.log(error);
throw error;
});
},
showCreateNewCustom: showCreateCustomInvoiceCollectionForm,
showInvoiceCollectionPickerForm: showInvoiceCollectionPickerModal,
add_vehicle_subscriptions: async (id) => {
@@ -843,3 +820,4 @@ export const CollectedOrderInvoices = {
}
};
</script>
@@ -173,7 +173,7 @@ export const SelfServeVehicleConditions = {
single: async (id) => {
return ObjectsGlobal.get.object(SelfServeVehicleConditions.meta.endpoint, id);
},
previewAllowed: async (laneId, reg, vehicleTypeId, options = {}) => {
previewAllowed: async (laneId, reg, vehicleTypeId = null) => {
const params = {
lane_id: parseInt(laneId),
reg: reg,
@@ -184,11 +184,11 @@ export const SelfServeVehicleConditions = {
params.vehicle_type = normalizedVehicleTypeId;
}
return authenticatedRequest("/department/selfserve/vehicle/allowed", "GET", params, null, null, options)
return authenticatedRequest("/department/selfserve/vehicle/allowed", "GET", params)
.then((response) => response.data.data || response.data);
},
washSummary: async (params, options) => {
return authenticatedRequest("/department/selfserve/washes/summary", "GET", params || {}, null, null, options || {})
washSummary: async (params = {}) => {
return authenticatedRequest("/department/selfserve/washes/summary", "GET", params)
.then((response) => response.data.data || response.data);
}
},
@@ -61,14 +61,6 @@ export const Config = {
});
},
keys: {
dynamic_image_size: {
get: async () => {
return Config.get("dynamic_image_size");
},
set: async (value) => {
return Config.set("dynamic_image_size", value);
},
},
minute_product: {
get: async () => {
return Config.get("minute_product");
@@ -1,34 +0,0 @@
<script>
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
/**
* The Slack -> Config object
*/
export const Config = {
get: async (variable) => {
return authenticatedRequest("/slack/config?variable=" + variable, "GET");
},
get_all: async () => {
return authenticatedRequest("/slack/config", "GET");
},
set: async (variable, value) => {
return authenticatedRequest("/slack/config", "POST", {
variable: variable,
value: value,
});
},
test_customer_registration_webhook: async () => {
return authenticatedRequest("/slack/config/test", "POST", {});
},
keys: {
customer_registration_webhook_url: {
get: async () => {
return Config.get("customer_registration_webhook_url");
},
set: async (value) => {
return Config.set("customer_registration_webhook_url", value);
},
},
},
};
</script>
@@ -1,21 +0,0 @@
<script>
import { Config } from "@/components/session/token/superUser/modules/slack/Config.vue";
/**
* The Slack object
*/
export const Slack = {
meta: {
title: "Slack",
icon: "fab fa-slack",
description: "Slack notifications",
endpoint: "/slack/config",
config_endpoint: "/configuration/slack",
labels: {
single: "slack",
multiple: "slack",
},
},
config: Config,
};
</script>
@@ -28,7 +28,6 @@ import { EdgeGateway } from "@/components/session/token/superUser/modules/edgega
import { Failover } from "@/components/session/token/superUser/modules/failover/Failover.vue";
import { Coolify } from "@/components/session/token/superUser/modules/coolify/Coolify.vue";
import { ReleaseManager } from "@/components/session/token/superUser/modules/releasemanager/ReleaseManager.vue";
import { Slack } from "@/components/session/token/superUser/modules/slack/Slack.vue";
let qrCodeModulePromise;
@@ -73,7 +72,6 @@ export const SuperUserObject = {
get failover() { return Failover; },
get coolify() { return Coolify; },
get releasemanager() { return ReleaseManager; },
get slack() { return Slack; },
},
/** Intimidate a user */
intimidate: {
+11 -21
View File
@@ -3,6 +3,7 @@ import { computed, ref, watch } from "vue";
import axios from "axios";
import { API_URL } from "@/config.js";
import { getScansDepartmentPagination } from "@/components/numberplatescanners/Scans.vue";
import { useRouter } from "vue-router";
import { parseError, clearErrors } from "@/components/request/HandleGlobalError.vue";
import { getNotes, createNote, deleteNote } from "@/components/shop/CustomerNotes.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
@@ -130,12 +131,12 @@ export const nextStep = async (options = { isMobile: false, orderCreation: true
// If the current step is 2, set the product category to addons
setProductsCategory(4);
// Set the step to 3 in the query parameters
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=3`);
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=3`);
}
if (step.value === 3) {
if (normalizedOptions.isMobile === false && doesOrderContainMaterial()) {
step.value = 4;
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=4`);
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=4`);
return;
}
@@ -230,7 +231,11 @@ const toPositiveInteger = (value) => {
};
const getRouteDepartmentId = () => {
return getWindowPathDepartmentId();
try {
return toPositiveInteger(useRouter().currentRoute.value.params.departmentId);
} catch (error) {
return null;
}
};
const getSessionUrlDepartmentId = () => {
@@ -752,7 +757,7 @@ export const reset_all_values = () => {
// Reset all values to their initial state
clearActivePosOrderContext({ clearStep: true });
// Clear the query parameters
pushPosRouteState("step=1");
window.history.pushState({}, "", `?step=1`);
};
/** Whenever the reg_1, reg_2 or reg_3 changes, remove any spaces and make the string uppercase */
@@ -763,21 +768,6 @@ const normalizeRegistrationValue = (value) =>
export const isBlankPosMetadataValue = (value) => String(value ?? "").trim().length === 0;
const getPosRoutePath = () => {
const normalizedDepartmentId = Number.parseInt(String(department_id.value || ""), 10);
if (Number.isInteger(normalizedDepartmentId) && normalizedDepartmentId > 0) {
return `/admin/${normalizedDepartmentId}/modules/pos`;
}
return window.location.pathname && window.location.pathname !== "/" ? window.location.pathname : "/admin/modules/pos";
};
export const pushPosRouteState = (queryString) => {
const normalizedQueryString = String(queryString || "").replace(/^\?/, "");
window.history.pushState({}, "", `${getPosRoutePath()}?${normalizedQueryString}`);
};
watch([reg_1, reg_2, reg_3], () => {
reg_1.value = normalizeRegistrationValue(reg_1.value);
reg_2.value = normalizeRegistrationValue(reg_2.value);
@@ -924,7 +914,7 @@ export const createOrder = async (options = { isMobile: false }) => {
// Set the order id in the local storage (To be make F5 safe)
localStorage.setItem("pos_order_id", order_id.value);
// Set the query parameters
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
return true;
})
.catch((error) => {
@@ -1641,7 +1631,7 @@ export const showDeleteEverythingDialog = () => {
/** Clear cache */
export const clearCache = () => {
// Reset the query parameters
pushPosRouteState("step=1");
window.history.pushState({}, "", `?step=1`);
// Clear the customer data
clearCustomerSelection();
// Clear the order data
@@ -13,7 +13,8 @@ import {
getDepartmentSelectionVisibility,
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import {
isAccessibleVisibleNamedDepartment,
isAccessibleVisibleDepartment,
isDepartmentLabelValid,
sortByDepartmentPriorityOrder,
} from "@/services/departmentVisibility.js";
@@ -34,7 +35,7 @@ const options = computed(() => {
// Filter out departments the user does not have access to
return departments.value
.filter((department: department) => {
return isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment);
return isAccessibleVisibleDepartment(department, SessionUser.canAccessAssignedDepartment);
})
.map((department: department) => {
return {
@@ -55,7 +56,7 @@ const mobileOptions = computed(() => {
});
const desktopOptions = computed(() => {
return sortByDepartmentPriorityOrder(options.value);
return sortByDepartmentPriorityOrder(options.value.filter((option) => isDepartmentLabelValid(option.label)));
});
const selectDepartment = (departmentId: number | string) => {
@@ -91,17 +92,11 @@ const getDistanceToDepartment = (departmentId: number | null, optionList = mobil
return 0;
}
const department = optionList.find((dept) => dept.value === departmentId);
const currentCoords = locations.normalizeCoordinatePair(locations.location.value?.coords);
const departmentCoords = locations.normalizeCoordinatePair(
{ latitude: department?.latitude, longitude: department?.longitude },
{ allowZeroPair: false }
);
if (department && currentCoords && departmentCoords) {
const distance = locations.getDistance(
currentCoords,
departmentCoords
if (department && locations.location.value?.coords) {
return locations.getDistance(
{ latitude: locations.location.value.coords.latitude, longitude: locations.location.value.coords.longitude },
{ latitude: department.latitude, longitude: department.longitude }
);
return Number.isFinite(distance) ? distance : 0;
}
return 0;
};
@@ -1,26 +1,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const emits = defineEmits(['camera-toggled', 'scanner-toggled', 'update:frame']);
const { t } = useI18n();
const videoRef = ref<HTMLVideoElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const cameraStream = ref<MediaStream | null>(null);
const isCameraActive = ref(false);
const cameraErrorKey = ref('pos.camera_permission_denied');
import { isCameraMounted, camera } from '@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue';
const getCameraErrorKey = (err: unknown) => {
const errorName = err instanceof DOMException ? err.name : '';
if (errorName === 'NotFoundError' || errorName === 'DevicesNotFoundError') {
return 'pos.no_camera_found';
}
return 'pos.camera_permission_denied';
};
function startCamera() {
const constraints = {
video: {
@@ -58,7 +45,6 @@ function startCamera() {
})
.catch((err) => {
isCameraActive.value = false;
cameraErrorKey.value = getCameraErrorKey(err);
console.error('Camera access error:', err);
});
}
@@ -185,7 +171,8 @@ watch(() => isCameraActive.value, (newVal) => {
></canvas>
<div v-if="!isCameraActive" class="camera-inactive">
<p>{{ t(cameraErrorKey) }}</p>
<p>Camera is not active.</p>
<p>Please enable camera access.</p>
</div>
</div>
</template>
+105 -242
View File
@@ -1,4 +1,5 @@
import { computed, getCurrentInstance, onUnmounted, ref } from "vue";
import { computed, ref } from "vue";
import { createPinia, defineStore, getActivePinia, setActivePinia, storeToRefs } from "pinia";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
@@ -271,56 +272,26 @@ const mergeByNumericId = (existingItems, incomingItems) => {
return merged;
};
const taskStableKey = (task) =>
String(task?.task ?? "")
.trim()
.toLowerCase();
let selfServeLogicStoreInstance = 0;
let fallbackSelfServePinia = null;
const mergeTaskSets = (existingTasks, incomingTasks) => {
if (!Array.isArray(existingTasks) || existingTasks.length === 0) {
return Array.isArray(incomingTasks) ? [...incomingTasks] : [];
const ensureSelfServePinia = () => {
if (typeof document === "undefined") {
fallbackSelfServePinia ||= createPinia();
setActivePinia(fallbackSelfServePinia);
return fallbackSelfServePinia;
}
if (!Array.isArray(incomingTasks) || incomingTasks.length === 0) {
return [...existingTasks];
const activePinia = getActivePinia();
if (activePinia) {
return activePinia;
}
const usedExistingIndexes = new Set();
return incomingTasks.map((incomingTask) => {
const incomingId = extractTaskId(incomingTask);
let existingIndex = -1;
if (incomingId) {
existingIndex = existingTasks.findIndex((task) => extractTaskId(task) === incomingId);
} else {
const incomingKey = taskStableKey(incomingTask);
existingIndex = existingTasks.findIndex(
(task, index) => !usedExistingIndexes.has(index) && taskStableKey(task) === incomingKey
);
}
if (existingIndex === -1) {
return incomingTask;
}
usedExistingIndexes.add(existingIndex);
const existingTask = existingTasks[existingIndex];
return {
...existingTask,
...incomingTask,
id: incomingTask.id || existingTask.id,
task_id: incomingTask.task_id || existingTask.task_id,
attachments: Array.isArray(incomingTask.attachments) && incomingTask.attachments.length > 0
? incomingTask.attachments
: existingTask.attachments,
_attachmentsLoaded: incomingTask._attachmentsLoaded || existingTask._attachmentsLoaded,
};
});
fallbackSelfServePinia ||= createPinia();
setActivePinia(fallbackSelfServePinia);
return fallbackSelfServePinia;
};
export function useSelfServeLogic() {
const createSelfServeLogicStore = (storeId) => defineStore(storeId, () => {
const loading = ref(false);
const requestError = ref(null);
const preview = ref(null);
@@ -345,94 +316,19 @@ export function useSelfServeLogic() {
const lastResolvedVehicleTypeId = ref(null);
const summaryVisibleQuestionIds = ref([]);
const summaryQuestionOrder = ref({});
const activeFetchAbortController = ref(null);
const latestMutationRequestId = ref(0);
const latestSuccessfulFetchKey = ref(null);
const inFlightFetchKey = ref(null);
const latestFetchRequestId = ref(0);
const createFetchKey = (departmentId, vehicleTypeId, laneId, reg) => {
const normalizedDepartmentId = parseInt(departmentId);
const normalizedLaneId = parseInt(laneId);
const normalizedReg = String(reg || "").trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(vehicleTypeId);
const vehicleTypeKey = !Number.isNaN(normalizedVehicleTypeId) && normalizedVehicleTypeId > 0
? normalizedVehicleTypeId
: "";
if (
Number.isNaN(normalizedDepartmentId)
|| normalizedDepartmentId <= 0
|| Number.isNaN(normalizedLaneId)
|| normalizedLaneId <= 0
|| normalizedReg.length < 2
) {
return null;
}
return [normalizedDepartmentId, normalizedLaneId, normalizedReg, vehicleTypeKey].join("|");
const beginFetchRequest = () => {
latestFetchRequestId.value += 1;
return latestFetchRequestId.value;
};
const isCanceledRequest = (error) => (
error?.code === "ERR_CANCELED"
|| error?.name === "CanceledError"
|| error?.name === "AbortError"
const isFetchRequestActive = (requestId) => (
requestId === null
|| requestId === undefined
|| requestId === latestFetchRequestId.value
);
const abortActiveFetchRequest = () => {
// Abort the previous self-serve read before a newer one starts; only the live signal may write state.
activeFetchAbortController.value?.abort();
activeFetchAbortController.value = null;
};
const beginFetchRequest = (externalSignal = null) => {
abortActiveFetchRequest();
if (typeof AbortController !== "function") {
return null;
}
const abortController = new AbortController();
if (externalSignal) {
if (externalSignal.aborted) {
abortController.abort();
} else {
externalSignal.addEventListener("abort", () => abortController.abort(), { once: true });
}
}
activeFetchAbortController.value = abortController;
return abortController;
};
const isFetchSignalActive = (signal = null) => (
!signal || (!signal.aborted && activeFetchAbortController.value?.signal === signal)
);
const requestPreviewAllowed = (laneId, reg, vehicleTypeId = null, signal = null) => {
const previewAllowed = SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed;
if (!signal || previewAllowed.length < 3) {
return vehicleTypeId
? previewAllowed(laneId, reg, vehicleTypeId)
: previewAllowed(laneId, reg);
}
return previewAllowed(laneId, reg, vehicleTypeId, { signal });
};
const requestWashSummary = (params = {}, signal = null) => {
const washSummary = SessionUser.objects.self_serve_vehicle_conditions.get.washSummary;
if (!signal || washSummary.length < 2) {
return washSummary(params);
}
return washSummary(params, { signal });
};
const beginMutationRequest = () => {
latestMutationRequestId.value += 1;
return latestMutationRequestId.value;
};
const isMutationRequestActive = (requestId) => requestId === latestMutationRequestId.value;
const setSummaryVisibleQuestions = (questionList = []) => {
const normalizedIds = (Array.isArray(questionList) ? questionList : [])
.map((question) => parseInt(question?.id ?? 0))
@@ -539,8 +435,8 @@ export function useSelfServeLogic() {
};
const applyPreviewData = async (previewData, options = {}) => {
const { mergeQuestions = false, signal = null } = options;
if (!isFetchSignalActive(signal)) {
const { mergeQuestions = false, requestId = null } = options;
if (!isFetchRequestActive(requestId)) {
return false;
}
@@ -549,10 +445,6 @@ export function useSelfServeLogic() {
machineType.value = previewData?.machine_type || machineType.value;
vehicle.value = previewData?.vehicle || null;
session.value = previewData?.session || session.value;
if (!previewData?.session?.id) {
summary.value = null;
events.value = [];
}
if (Object.prototype.hasOwnProperty.call(previewData || {}, "config_version_id")) {
configVersionId.value = previewData?.config_version_id ?? null;
}
@@ -571,7 +463,6 @@ export function useSelfServeLogic() {
questions.value = nextQuestions;
answers.value = buildAnswersMap(nextQuestions);
setSummaryVisibleQuestions(nextQuestions);
const normalizedConditions = Array.isArray(previewData?.conditions)
? previewData.conditions.map(normalizeCondition)
@@ -591,21 +482,19 @@ export function useSelfServeLogic() {
? previewData.tasks.map(normalizeTask).sort((a, b) => a.order_priority - b.order_priority)
: [];
tasks.value = normalizedTasks;
applyAllowedServices(previewData);
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
if (!isFetchSignalActive(signal)) {
if (!isFetchRequestActive(requestId)) {
return false;
}
tasks.value = hydratedTasks;
applyAllowedServices(previewData);
return true;
};
const applySummaryData = async (summaryData, options = {}) => {
const { signal = null } = options;
if (!isFetchSignalActive(signal)) {
const { requestId = null } = options;
if (!isFetchRequestActive(requestId)) {
return false;
}
@@ -644,10 +533,9 @@ export function useSelfServeLogic() {
const normalizedTasks = summaryData.tasks
.map(normalizeTask)
.sort((a, b) => a.order_priority - b.order_priority);
const mergedTasks = mergeTaskSets(tasks.value, normalizedTasks);
const hydratedTasks = await hydrateTaskAttachments(mergedTasks);
if (!isFetchSignalActive(signal)) {
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
if (!isFetchRequestActive(requestId)) {
return false;
}
@@ -677,7 +565,7 @@ export function useSelfServeLogic() {
};
const fetchWashSummary = async (params = {}, manageLoading = true, options = {}) => {
const { signal = null } = options;
const { requestId = null } = options;
if (!params.session_id && !(params.lane_id && params.reg)) {
return null;
}
@@ -687,39 +575,30 @@ export function useSelfServeLogic() {
}
try {
const summaryData = await requestWashSummary(params, signal);
const didApply = await applySummaryData(summaryData, { signal });
const summaryData = await SessionUser.objects.self_serve_vehicle_conditions.get.washSummary(params);
const didApply = await applySummaryData(summaryData, { requestId });
if (!didApply) {
return null;
}
requestError.value = null;
return summaryData;
} catch (error) {
if (isCanceledRequest(error)) {
return null;
}
console.error("Error fetching self-serve summary:", error);
if (isFetchSignalActive(signal)) {
if (isFetchRequestActive(requestId)) {
requestError.value = extractErrorMessage(error, "Kunne ikke hente vaskestatus. Prøv igen.");
}
return null;
} finally {
if (manageLoading && isFetchSignalActive(signal)) {
if (manageLoading) {
loading.value = false;
}
}
};
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null, options = {}) => {
const fetchKey = createFetchKey(_departmentId, _vehicleTypeId, laneId, reg);
if (!options.force && fetchKey && (fetchKey === latestSuccessfulFetchKey.value || fetchKey === inFlightFetchKey.value)) {
return null;
}
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null) => {
const requestId = beginFetchRequest();
if (!laneId || !reg || reg.trim().length < 2) {
abortActiveFetchRequest();
latestSuccessfulFetchKey.value = null;
inFlightFetchKey.value = null;
preview.value = null;
summary.value = null;
session.value = null;
@@ -745,11 +624,8 @@ export function useSelfServeLogic() {
return null;
}
const abortController = beginFetchRequest(options.signal || null);
const signal = abortController?.signal || null;
loading.value = true;
requestError.value = null;
inFlightFetchKey.value = fetchKey;
try {
const normalizedReg = reg.trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
@@ -764,18 +640,18 @@ export function useSelfServeLogic() {
const contextKey = `${parseInt(laneId)}:${normalizedReg}:${contextVehicleTypeKey}`;
const shouldMergeQuestions = lastPreviewContextKey.value === contextKey;
const previewData = hasVehicleTypeOverride
? await requestPreviewAllowed(laneId, normalizedReg, normalizedVehicleTypeId, signal)
: await requestPreviewAllowed(laneId, normalizedReg, null, signal);
? await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg, normalizedVehicleTypeId)
: await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg);
if (!isFetchSignalActive(signal)) {
if (!isFetchRequestActive(requestId)) {
return null;
}
const didApplyPreview = await applyPreviewData(previewData, {
mergeQuestions: shouldMergeQuestions,
signal,
requestId,
});
if (!didApplyPreview || !isFetchSignalActive(signal)) {
if (!didApplyPreview || !isFetchRequestActive(requestId)) {
return null;
}
@@ -790,11 +666,11 @@ export function useSelfServeLogic() {
summaryData = await fetchWashSummary(
{ session_id: previewData.session.id },
false,
{ signal }
{ requestId }
);
}
if (!isFetchSignalActive(signal)) {
if (!isFetchRequestActive(requestId)) {
return null;
}
@@ -816,9 +692,9 @@ export function useSelfServeLogic() {
const fallbackSummary = await fetchWashSummary(
fallbackSummaryParams,
false,
{ signal }
{ requestId }
);
if (!isFetchSignalActive(signal)) {
if (!isFetchRequestActive(requestId)) {
return null;
}
@@ -827,31 +703,19 @@ export function useSelfServeLogic() {
}
// If summary is not available yet, keep the preview list visible so the Questions step is not empty.
if (!hasSummaryQuestions && isFetchSignalActive(signal)) {
if (!hasSummaryQuestions && isFetchRequestActive(requestId)) {
setSummaryVisibleQuestions(questions.value);
}
if (isFetchSignalActive(signal)) {
latestSuccessfulFetchKey.value = fetchKey;
}
return previewData;
} catch (error) {
if (isCanceledRequest(error)) {
return null;
}
console.error("Error fetching self-serve preview:", error);
if (isFetchSignalActive(signal)) {
if (isFetchRequestActive(requestId)) {
requestError.value = extractErrorMessage(error);
}
return null;
} finally {
const isCurrentFetch = !abortController || activeFetchAbortController.value === abortController;
if (isCurrentFetch) {
if (inFlightFetchKey.value === fetchKey) {
inFlightFetchKey.value = null;
}
activeFetchAbortController.value = null;
if (isFetchRequestActive(requestId)) {
loading.value = false;
}
}
@@ -862,7 +726,6 @@ export function useSelfServeLogic() {
return null;
}
const mutationRequestId = beginMutationRequest();
loading.value = true;
try {
const normalizedReg = reg.trim().toUpperCase();
@@ -890,10 +753,6 @@ export function useSelfServeLogic() {
const payload = response?.data?.data || response?.data || response || {};
const responseSummary = payload?.selfserve || null;
if (!isMutationRequestActive(mutationRequestId)) {
return payload;
}
answers.value = {
...answers.value,
[parseInt(questionId)]: value,
@@ -910,10 +769,7 @@ export function useSelfServeLogic() {
updateResolvedVehicleTypeId(responseSummary, responseSummary?.session);
}
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg, { force: true });
if (!isMutationRequestActive(mutationRequestId)) {
return payload;
}
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg);
answers.value = {
...answers.value,
[parseInt(questionId)]: value,
@@ -921,14 +777,10 @@ export function useSelfServeLogic() {
return payload;
} catch (error) {
console.error("Error synchronizing vehicle answer:", error);
if (isMutationRequestActive(mutationRequestId)) {
requestError.value = extractErrorMessage(error, "Kunne ikke gemme svaret. Prøv igen.");
}
requestError.value = extractErrorMessage(error, "Kunne ikke gemme svaret. Prøv igen.");
throw error;
} finally {
if (isMutationRequestActive(mutationRequestId)) {
loading.value = false;
}
loading.value = false;
}
};
@@ -937,7 +789,6 @@ export function useSelfServeLogic() {
return { deletedCount: 0 };
}
const mutationRequestId = beginMutationRequest();
loading.value = true;
try {
const normalizedReg = reg.trim().toUpperCase();
@@ -973,10 +824,6 @@ export function useSelfServeLogic() {
SessionUser.objects.self_serve_vehicle_conditions.delete(conditionId)
)));
if (!isMutationRequestActive(mutationRequestId)) {
return { deletedCount: conditionIdsToDelete.length };
}
// Force a non-merge refresh so cleared answers are not kept from local state.
lastPreviewContextKey.value = null;
const refreshVehicleTypeCandidate = vehicleTypeId !== null && vehicleTypeId !== undefined
@@ -987,19 +834,15 @@ export function useSelfServeLogic() {
? normalizedRefreshVehicleTypeId
: null;
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg, { force: true });
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg);
return { deletedCount: conditionIdsToDelete.length };
} catch (error) {
console.error("Error clearing self-serve answers:", error);
if (isMutationRequestActive(mutationRequestId)) {
requestError.value = extractErrorMessage(error, "Kunne ikke nulstille svar. Prøv igen.");
}
requestError.value = extractErrorMessage(error, "Kunne ikke nulstille svar. Prøv igen.");
throw error;
} finally {
if (isMutationRequestActive(mutationRequestId)) {
loading.value = false;
}
loading.value = false;
}
};
@@ -1161,27 +1004,9 @@ export function useSelfServeLogic() {
return activeTaskServices.value.includes(serviceName);
};
const updateLaneAllowedServices = async (laneId, options = {}) => {
const updateLaneAllowedServices = async (laneId) => {
if (!laneId) return;
const hasTaskIdsOverride = Array.isArray(options.taskIds);
const activeTaskIds = hasTaskIdsOverride
? options.taskIds.map((taskId) => parseInt(taskId)).filter(Boolean)
: activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
const taskIds = hasTaskIdsOverride || activeTaskIds.length > 0
? activeTaskIds
: tasks.value.map((task) => extractTaskId(task)).filter(Boolean);
if (taskIds.length === 0) {
requestError.value = null;
return {
success: true,
data: {
lane_id: parseInt(laneId),
allowed_services: allowedServices.value,
skipped_empty_task_sync: true,
},
};
}
const taskIds = activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
try {
const response = await SessionUser.request('/modules/self-serve/lane/services/allowed', 'post', {
@@ -1227,12 +1052,6 @@ export function useSelfServeLogic() {
}
};
if (getCurrentInstance()) {
onUnmounted(() => {
abortActiveFetchRequest();
});
}
return {
loading,
error: requestError,
@@ -1250,7 +1069,6 @@ export function useSelfServeLogic() {
answers,
completedTasks,
allowedServices,
resolvedVehicleTypeId: lastResolvedVehicleTypeId,
configVersionId,
evaluationTrace,
visibleQuestions,
@@ -1262,7 +1080,52 @@ export function useSelfServeLogic() {
allowed,
fetchSelfServeData,
fetchWashSummary,
cancelSelfServeFetch: abortActiveFetchRequest,
syncVehicleAnswer,
clearVehicleAnswers,
evaluateRule,
evaluateCondition,
isQuestionVisible,
isTaskActive,
isServiceAllowed,
updateLaneAllowedServices,
enableMachineRelay,
isImage,
downloadAttachment,
reset,
answerQuestion,
removeAnswer,
};
});
export function useSelfServeLogic(options = {}) {
const pinia = ensureSelfServePinia();
const explicitStoreId = typeof options === "string" ? options : options?.storeId;
const storeId = explicitStoreId || `selfServeLogic:${++selfServeLogicStoreInstance}`;
const store = createSelfServeLogicStore(storeId)(pinia);
const refs = storeToRefs(store);
const {
fetchSelfServeData,
fetchWashSummary,
syncVehicleAnswer,
clearVehicleAnswers,
evaluateRule,
evaluateCondition,
isQuestionVisible,
isTaskActive,
isServiceAllowed,
updateLaneAllowedServices,
enableMachineRelay,
isImage,
downloadAttachment,
reset,
answerQuestion,
removeAnswer,
} = store;
return {
...refs,
fetchSelfServeData,
fetchWashSummary,
syncVehicleAnswer,
clearVehicleAnswers,
evaluateRule,
+21 -80
View File
@@ -14,21 +14,6 @@ const isLaneSelfServeEnabled = (lane) =>
)
);
const hasSelfServeEnabledLane = (department) =>
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) => ({
id: department.id,
distance,
@@ -62,7 +47,6 @@ export function useWashDepartments(options = {}) {
const isSearchingDepartments = ref(false);
const lastDepartmentFetchTime = ref(null);
const departmentFetchError = ref(null);
const departmentSelectionStrategy = ref(null);
let refreshInterval = null;
@@ -89,13 +73,6 @@ export function useWashDepartments(options = {}) {
return nearestDepartment.value.self_serve_enabled === true;
});
const isDepartmentSelectionDistanceBased = computed(() => departmentSelectionStrategy.value === "distance");
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
const hasLocationCoordinates = (locationValue = locations.location.value) =>
normalizeLocationCoordinates(locationValue) !== null;
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
const fetchDepartments = async () => {
@@ -133,97 +110,64 @@ export function useWashDepartments(options = {}) {
const forcedDepartment = getForcedDepartment();
if (forcedDepartment) {
nearestDepartment.value = toDepartmentViewModel(forcedDepartment);
departmentSelectionStrategy.value = "forced";
return nearestDepartment.value;
}
}
if (guestDepartments.value.length === 0) {
nearestDepartment.value = null;
departmentSelectionStrategy.value = null;
return nearestDepartment.value;
}
if (!hasLocationCoordinates(locationValue)) {
const fallbackDepartment = guestDepartments.value.find(hasSelfServeEnabledLane);
nearestDepartment.value = fallbackDepartment ? toDepartmentViewModel(fallbackDepartment) : null;
departmentSelectionStrategy.value = fallbackDepartment ? "fallback" : null;
return nearestDepartment.value;
}
const from = normalizeLocationCoordinates(locationValue);
let currentNearestDepartment = {
id: null,
distance: Infinity,
};
guestDepartments.value.forEach((department) => {
const to = normalizeDepartmentCoordinates(department);
if (!from || !to) {
if (!locationValue?.coords) {
return;
}
const from = {
latitude: locationValue.coords.latitude,
longitude: locationValue.coords.longitude,
};
const to = {
latitude: department.latitude,
longitude: department.longitude,
};
const distance = locations.getDistance(from, to);
if (Number.isFinite(distance) && distance < currentNearestDepartment.distance) {
if (distance < currentNearestDepartment.distance) {
currentNearestDepartment = toDepartmentViewModel(department, distance);
}
});
if (currentNearestDepartment.id) {
nearestDepartment.value = currentNearestDepartment;
departmentSelectionStrategy.value = "distance";
return nearestDepartment.value;
}
const fallbackDepartment =
guestDepartments.value.find((department) => department.self_serve_enabled === true) || guestDepartments.value[0];
if (fallbackDepartment) {
nearestDepartment.value = toDepartmentViewModel(fallbackDepartment);
departmentSelectionStrategy.value = "fallback";
} else {
nearestDepartment.value = null;
departmentSelectionStrategy.value = null;
}
return nearestDepartment.value;
};
const orderDepartmentsByDistance = (departmentsList = guestDepartments.value) => {
const currentCoords = normalizeLocationCoordinates(locations.location.value);
if (!currentCoords) {
if (!locations.location.value?.coords) {
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) => {
const distanceA = distanceFromCurrentLocation(departmentA);
const distanceB = distanceFromCurrentLocation(departmentB);
if (!Number.isFinite(distanceA) && !Number.isFinite(distanceB)) {
return 0;
}
if (!Number.isFinite(distanceA)) {
return 1;
}
if (!Number.isFinite(distanceB)) {
return -1;
}
const currentCoords = locations.location.value.coords;
const distanceA = locations.getDistance(
{ latitude: currentCoords.latitude, longitude: currentCoords.longitude },
{ latitude: departmentA.latitude, longitude: departmentA.longitude }
);
const distanceB = locations.getDistance(
{ latitude: currentCoords.latitude, longitude: currentCoords.longitude },
{ latitude: departmentB.latitude, longitude: departmentB.longitude }
);
return distanceA - distanceB;
});
};
const startDepartmentSearch = () => {
if (!canAccessSuperUser() && hasLocationCoordinates()) {
if (!canAccessSuperUser()) {
return;
}
@@ -301,9 +245,6 @@ export function useWashDepartments(options = {}) {
isSearchingDepartments,
lastDepartmentFetchTime,
departmentFetchError,
departmentSelectionStrategy,
isDepartmentSelectionDistanceBased,
isDepartmentSelectionFallbackBased,
availableProductIds,
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments,
+11 -28
View File
@@ -7,11 +7,6 @@ export const WASH_STEPS = {
COMPLETED: 5,
};
const normalizeLaneId = (value) => {
const normalized = parseInt(String(value ?? ""), 10);
return Number.isNaN(normalized) || normalized <= 0 ? null : normalized;
};
export function useWashFlowState(options) {
const {
currentStep,
@@ -19,7 +14,7 @@ export function useWashFlowState(options) {
customerNumberInput,
licensePlateInput,
vehicleTypeSelect,
isCustomerNumberRequired,
availableProductIds,
radioLaneOption,
nearestDepartment,
allVisibleQuestionsAnswered,
@@ -39,17 +34,6 @@ export function useWashFlowState(options) {
activeTasks.value.length > 0 ? steps.TASKS : steps.WASH_IN_PROGRESS
);
const getSelectedLaneId = () => normalizeLaneId(radioLaneOption.value);
const getSelectedLane = () => {
const selectedLaneId = getSelectedLaneId();
if (selectedLaneId === null) {
return null;
}
return nearestDepartment.value?.lanes.find((lane) => normalizeLaneId(lane.id) === selectedLaneId) || null;
};
const clickableSteps = {
[steps.VEHICLE]: () => {
if (washInProgress.value) {
@@ -57,15 +41,16 @@ export function useWashFlowState(options) {
}
const selectedVehicleTypeId = vehicleTypeSelect.value;
const hasSelectedVehicleType = selectedVehicleTypeId !== null && selectedVehicleTypeId !== undefined;
const allowedProductIds = new Set((availableProductIds.value || []).map((productId) => String(productId)));
const hasAllowedVehicleType = selectedVehicleTypeId !== null
&& selectedVehicleTypeId !== undefined
&& allowedProductIds.has(String(selectedVehicleTypeId));
const customerNumberValue = customerNumberInput.value;
const requiresCustomerNumber = isCustomerNumberRequired?.value ?? false;
const customerNumberValid = !requiresCustomerNumber
|| customerNumberValue === null
const customerNumberValid = customerNumberValue === null
|| customerNumberValue === undefined
|| String(customerNumberValue).trim() !== "";
const licensePlateValid = !!(licensePlateInput.value && licensePlateInput.value.trim() !== "");
if (!hasSelectedVehicleType) {
if (!hasAllowedVehicleType) {
return false;
}
@@ -118,7 +103,7 @@ export function useWashFlowState(options) {
}
if (currentStep.value === steps.SELECT_LANE) {
const selectedLane = getSelectedLane();
const selectedLane = nearestDepartment.value?.lanes.find((lane) => lane.id === radioLaneOption.value);
if (!selectedLane || !isLaneAvailable(selectedLane)) {
return true;
}
@@ -148,10 +133,9 @@ export function useWashFlowState(options) {
const handleConfirmNext = async () => {
if (currentStep.value === steps.QUESTIONS) {
editAnswers.value = false;
const selectedLaneId = getSelectedLaneId();
if (selectedLaneId !== null) {
if (radioLaneOption.value && radioLaneOption.value !== "Any") {
try {
await updateLaneAllowedServices(selectedLaneId);
await updateLaneAllowedServices(radioLaneOption.value);
} catch {
return;
}
@@ -166,9 +150,8 @@ export function useWashFlowState(options) {
return;
}
const selectedLaneId = getSelectedLaneId() ?? radioLaneOption.value;
await onStartWash(
selectedLaneId,
radioLaneOption.value,
licensePlateInput.value,
customerNumberInput.value,
targetStepForStart()
+3 -8
View File
@@ -70,10 +70,7 @@ export function useWashSessionActions(options) {
const isAlreadyStoppedStopError = (message) => {
const normalized = String(message || "").toLowerCase();
return (
normalized.includes("not occupied") &&
(normalized.includes("cannot stop lane") || normalized.includes("cannot invoice"))
);
return normalized.includes("cannot stop lane") && normalized.includes("not occupied");
};
const clearActiveWashState = () => {
@@ -250,18 +247,16 @@ export function useWashSessionActions(options) {
return false;
}
const selectedWashType = radioWashType.value === "Machine" ? "Machine" : "Manual";
const startResponse = await executeSelfServeCommand(laneId, "START", {
customer_number: parseInt(customerNumber),
license_plate: licensePlate.trim().toUpperCase(),
wash_type: selectedWashType,
defer_relay_side_effects: true,
defer_relay_side_effects: false,
});
if (!startResponse) {
return false;
}
if (selectedWashType === "Machine" && isServiceAllowed("MACHINE")) {
if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) {
let machineRelayResponse = null;
try {
machineRelayResponse = await enableMachineRelay(laneId);
-1
View File
@@ -66,7 +66,6 @@ export const SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS = String(
export const ALLOWED_ORIGINS = [
"https://truckwash.io",
"https://www.truckwash.io",
new URL(DEFAULT_PUBLIC_GATEWAY_API_URL).origin,
"http://localhost:5173",
"http://localhost:4173",
"http://localhost:4174",
+1 -47
View File
@@ -1150,14 +1150,6 @@
"field_required": "{field} @:{'words.generated.er'} @:{'words.generated.pakrævet'}",
"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": {
"host_targets": {
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
@@ -1853,21 +1845,6 @@
"week": "@:{'templates.generated.compat.common.week'}"
},
"collected_invoice": {
"move_customer": {
"card_title": "Flyt til kunde",
"confirm_button": "Flyt",
"confirm_text": "Flyt fakturasamling #{id} og {count} ordre til kunde #{customer}?",
"confirm_title": "Bekræft flytning",
"current_customer": "Nuværende kunde: #{customer}",
"customer_value": "Kunde #{customer}",
"error_title": "Kunne ikke flytte fakturasamling",
"invalid_customer": "Indtast et gyldigt kundenummer.",
"locked": "Låst",
"same_customer": "Vælg en anden kunde end den nuværende.",
"success_text": "{count} ordre blev flyttet til kunde #{customer}.",
"success_title": "Fakturasamling flyttet",
"title": "Flyt fakturasamling"
},
"economic": {
"booked_desc": "@.capitalize:{'words.generated.fakturaen'} @:{'words.generated.er'} @:{'words.generated.bogført'} @:{'words.generated.i'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}, @:{'words.generated.ingen'} @:{'words.generated.yderligere'} @:{'words.generated.handlinger'} @:{'words.generated.er'} @:{'words.generated.nødvendige'}.",
"booked_with_economic": "@.capitalize:{'words.generated.fakturaen'} @:{'words.generated.er'} @:{'words.generated.bogført'} @:{'words.generated.med'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}",
@@ -2338,20 +2315,6 @@
"delete_confirm": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'} @:{'words.generated.pa'}, @:{'words.generated.at'} @:{'words.generated.du'} @:{'words.generated.vil'} @:{'words.generated.slette'} @:{'words.generated.dette'} @:{'words.generated.produkt'}?",
"edit": "@:{'templates.generated.compat.products.edit_product'}"
},
"slack": {
"customer_registration_webhook_url": "Webhook-URL til kunderegistreringer",
"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_desc": "Slack-webhooks til systemhændelser.",
"send_test_webhook": "Send test-webhook",
"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",
"unavailable": "Slack-konfigurationen er ikke tilgængelig i denne API-udgivelse."
},
"shelly": {
"api_key_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.nøkkelen'} @:{'words.generated.bruges'} @:{'words.generated.til'} @:{'words.generated.a_2'} @:{'words.generated.autentisere'} @:{'words.generated.shelly'}-@:{'words.generated.integrationen'}",
"api_settings_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'} @:{'words.generated.forbindelsesindstillingerne'}.",
@@ -3821,13 +3784,6 @@
"unknown_qr_text": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.scannede'} @:{'words.generated.qr'} @:{'words.generated.kode'} @:{'words.generated.er'} @:{'words.generated.ikke'} @:{'words.generated.gyldig'} @:{'words.generated.for'} @:{'words.generated.denne'} @:{'words.generated.side'}.",
"unknown_qr_title": "@.capitalize:{'words.generated.ukendt'} @:{'words.generated.qr'} @:{'words.generated.kode'}"
},
"maintenance_menu": {
"confirm_clear_local": "Ryd alle lokale appdata og genindlaes nu?",
"force_update_clear_hint": "Rydder local storage, session storage, service workers og browsercaches.",
"force_update_clear": "Tving opdatering og ryd alt lokalt",
"title": "Vedligeholdelse",
"version": "Nuværende {current} / seneste {latest}"
},
"messages": {
"are_you_sure": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'}?",
"cannot_undo": "@.capitalize:{'words.generated.denne'} @:{'words.generated.handling'} @:{'words.generated.kan'} @:{'words.generated.ikke'} @:{'words.generated.fortrydes'}.",
@@ -5522,9 +5478,7 @@
"email_notifications_desc": "@:{'words.generated.modtag'} @:{'words.generated.e'}-mails @:{'words.generated.med'} bookingbekræftelser @:{'words.generated.og'} @:{'words.generated.vaskecertifikater'}.",
"sms_notifications_desc": "@:{'words.generated.modtag'} @:{'words.generated.sms'} @:{'words.generated.notifikationer'} @:{'words.generated.ved'} @:{'words.generated.forskellige'} @:{'words.generated.begivenheder'}.",
"sms_phone": "@.capitalize:{'words.generated.telefonnummer'} @:{'words.generated.til'} @:{'words.generated.sms'} @:{'words.generated.notifikationer'}",
"sms_phone_desc": "@.capitalize:{'words.generated.det'} @:{'words.generated.telefonnummer'}, @:{'words.generated.der'} @:{'words.generated.modtager'} @:{'words.generated.sms'} @:{'words.generated.notifikationer'}.",
"superuser_notifications": "Superuser-notifikationer",
"superuser_notifications_desc": "Modtag e-mailnotifikationer, når en ny kunde registrerer sig på siden."
"sms_phone_desc": "@.capitalize:{'words.generated.det'} @:{'words.generated.telefonnummer'}, @:{'words.generated.der'} @:{'words.generated.modtager'} @:{'words.generated.sms'} @:{'words.generated.notifikationer'}."
},
"security": {
"description": "@.capitalize:{'words.generated.dine'} sikkerhedsindstillinger",
+1 -47
View File
@@ -1260,14 +1260,6 @@
"field_required": "{field} @:{'words.generated.ist'} @:{'words.generated.erforderlich'}",
"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": {
"host_targets": {
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
@@ -1963,21 +1955,6 @@
"week": "Uke"
},
"collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": {
"booked_desc": "@.capitalize:{'words.generated.die'} @:{'words.generated.rechnung'} @:{'words.generated.ist'} @:{'words.generated.in'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.gebucht'}, @:{'words.generated.keine'} @:{'words.generated.weiteren'} @:{'words.generated.aktionen'} @:{'words.generated.sind'} @:{'words.generated.erforderlich'}.",
"booked_with_economic": "@:{'words.generated.rechnung'} @:{'words.generated.mit'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.gebucht'}",
@@ -2448,20 +2425,6 @@
"delete_confirm": "@.capitalize:{'words.generated.sind'} @.capitalize:{'words.generated.sie'} @:{'words.generated.sicher'}, @:{'words.generated.dass'} @.capitalize:{'words.generated.sie'} @:{'words.generated.dieses'} @.capitalize:{'words.generated.produkt'} @:{'words.generated.l'}?@:{'words.generated.schen'} @:{'words.generated.m'}?@:{'words.generated.chten'}?",
"edit": "@.capitalize:{'words.generated.produkt'} @:{'words.generated.bearbeiten'}"
},
"slack": {
"customer_registration_webhook_url": "Webhook-URL fuer Kundenregistrierungen",
"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_desc": "Slack-Webhooks fuer Systemereignisse.",
"send_test_webhook": "Test-Webhook senden",
"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",
"unavailable": "Die Slack-Konfiguration ist in dieser API-Version nicht verfuegbar."
},
"shelly": {
"api_key_desc": "@.capitalize:{'words.generated.der'} @:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.schl'}?@:{'words.generated.ssel'} @:{'words.generated.wird'} @:{'words.generated.zur'} @:{'words.generated.authentifizierung'} @:{'words.generated.der'} @:{'words.generated.shelly'}-@.capitalize:{'words.generated.integration'} @:{'words.generated.verwendet'}",
"api_settings_desc": "@:{'words.generated.api'}-@:{'words.generated.verbindungseinstellungen'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.shelly'}.",
@@ -3931,13 +3894,6 @@
"unknown_qr_text": "@.capitalize:{'words.generated.der'} gescannte @:{'words.generated.qr'}-@:{'words.generated.code'} @:{'words.generated.ist'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.diese'} @:{'words.generated.seite'} @:{'words.generated.nicht'} @:{'words.generated.g'}?@:{'words.generated.ltig'}.",
"unknown_qr_title": "@.capitalize:{'words.generated.unbekannter'} @:{'words.generated.qr'}-@:{'words.generated.code'}"
},
"maintenance_menu": {
"confirm_clear_local": "Alle lokalen App-Daten löschen und jetzt neu laden?",
"force_update_clear_hint": "Löscht Local Storage, Session Storage, Service Worker und Browser-Caches.",
"force_update_clear": "Update erzwingen und alles Lokale löschen",
"title": "Wartung",
"version": "Aktuell {current} / neueste {latest}"
},
"messages": {
"are_you_sure": "@.capitalize:{'words.generated.sind'} @.capitalize:{'words.generated.sie'} @:{'words.generated.sicher'}?",
"cannot_undo": "@.capitalize:{'words.generated.diese'} @:{'words.generated.aktion'} @:{'words.generated.kann'} @:{'words.generated.nicht'} @:{'words.generated.r'}?@:{'words.generated.ckg'}?@:{'words.generated.ngig'} @:{'words.generated.gemacht'} @:{'words.generated.werden'}.",
@@ -5632,9 +5588,7 @@
"email_notifications_desc": "@.upper:{'words.generated.e'}-@:{'words.generated.mails'} @:{'words.generated.mit'} @:{'words.generated.buchungsbest'}?@:{'words.generated.tigungen'} @:{'words.generated.und'} Waschzertifikaten @:{'words.generated.erhalten'}.",
"sms_notifications_desc": "@:{'words.generated.sms'}-@:{'words.generated.benachrichtigungen'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.verschiedene'} @:{'words.generated.ereignisse'} @:{'words.generated.erhalten'}.",
"sms_phone": "@:{'words.generated.telefonnummer'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.sms'}-@:{'words.generated.benachrichtigungen'}",
"sms_phone_desc": "@.capitalize:{'words.generated.die'} @:{'words.generated.telefonnummer'}, @:{'words.generated.die'} @:{'words.generated.sms'}-@:{'words.generated.benachrichtigungen'} @:{'words.generated.erh'}?@:{'words.generated.lt'}.",
"superuser_notifications": "Superuser-Benachrichtigungen",
"superuser_notifications_desc": "E-Mail-Benachrichtigungen erhalten, wenn sich ein neuer Kunde auf der Seite registriert."
"sms_phone_desc": "@.capitalize:{'words.generated.die'} @:{'words.generated.telefonnummer'}, @:{'words.generated.die'} @:{'words.generated.sms'}-@:{'words.generated.benachrichtigungen'} @:{'words.generated.erh'}?@:{'words.generated.lt'}."
},
"security": {
"description": "@.capitalize:{'words.generated.ihre'} Sicherheitseinstellungen",
+1 -47
View File
@@ -984,14 +984,6 @@
"field_required": "{field} @:{'words.generated.is'} @:{'words.generated.required'}",
"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": {
"host_targets": {
"database": "@:{'words.replication.services.database'} @:{'words.replication.host'}",
@@ -1687,21 +1679,6 @@
"week": "@:{'templates.generated.compat.common.week'}"
},
"collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": {
"booked_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.invoice'} @:{'words.generated.is'} @:{'words.generated.booked'} @:{'words.generated.in'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}, @:{'words.generated.no'} @:{'words.generated.further'} @:{'words.generated.actions'} @:{'words.generated.are'} @:{'words.generated.required'}.",
"booked_with_economic": "@.capitalize:{'words.generated.invoice'} @:{'words.generated.booked'} @:{'words.generated.with'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}",
@@ -2172,20 +2149,6 @@
"delete_confirm": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.sure'} @:{'words.generated.you'} @:{'words.generated.want'} @:{'words.generated.to'} @:{'words.generated.delete'} @:{'words.generated.this'} @:{'words.generated.product'}?",
"edit": "@:{'templates.generated.compat.products.edit_product'}"
},
"slack": {
"customer_registration_webhook_url": "Customer registration webhook URL",
"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_desc": "Slack webhooks for system events.",
"send_test_webhook": "Send test webhook",
"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",
"unavailable": "Slack configuration is not available on this API release."
},
"shelly": {
"api_key_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.shelly'} @:{'words.generated.api'} @:{'words.generated.key'} @:{'words.generated.is'} @:{'words.generated.used'} @:{'words.generated.to'} @:{'words.generated.authenticate'} @:{'words.replication.article.host_mention'} @:{'words.generated.shelly'} @:{'words.generated.integration'}",
"api_settings_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.shelly'} @:{'words.generated.api'} @:{'words.generated.connection'} @:{'words.generated.settings'}.",
@@ -3655,13 +3618,6 @@
"unknown_qr_text": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.scanned'} @:{'words.generated.qr'} @:{'words.generated.code'} @:{'words.generated.is'} @:{'words.generated.not'} @:{'words.generated.valid'} @:{'words.generated.for'} @:{'words.generated.this'} @:{'words.generated.page'}.",
"unknown_qr_title": "@.capitalize:{'words.generated.unknown'} @:{'words.generated.qr'} @.capitalize:{'words.generated.code'}"
},
"maintenance_menu": {
"confirm_clear_local": "Clear all local app data and reload now?",
"force_update_clear_hint": "Clears local storage, session storage, service workers, and browser caches.",
"force_update_clear": "Force update and clear all local",
"title": "Maintenance",
"version": "Current {current} / latest {latest}"
},
"messages": {
"are_you_sure": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.sure'}?",
"cannot_undo": "@.capitalize:{'words.generated.this'} decision @:{'words.generated.cannot'} @:{'words.generated.be'} @:{'words.generated.undone'}.",
@@ -5356,9 +5312,7 @@
"email_notifications_desc": "@.capitalize:{'words.generated.receive'} @:{'words.generated.emails'} @:{'words.generated.with'} @:{'words.generated.booking'} @:{'words.generated.confirmations'} @:{'words.generated.and'} @:{'words.generated.wash'} @:{'words.generated.certificates'}.",
"sms_notifications_desc": "@.capitalize:{'words.generated.receive'} @:{'words.generated.sms'} @:{'words.generated.notifications'} @:{'words.generated.for'} @:{'words.generated.various'} @:{'words.generated.events'}.",
"sms_phone": "@.capitalize:{'words.generated.phone'} @:{'words.generated.number'} @:{'words.generated.for'} @:{'words.generated.sms'} @:{'words.generated.notifications'}",
"sms_phone_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.phone'} @:{'words.generated.number'} @:{'words.generated.that'} @:{'words.generated.receives'} @:{'words.generated.sms'} @:{'words.generated.notifications'}.",
"superuser_notifications": "Superuser notifications",
"superuser_notifications_desc": "Receive email notifications when a new customer registers on the site."
"sms_phone_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.phone'} @:{'words.generated.number'} @:{'words.generated.that'} @:{'words.generated.receives'} @:{'words.generated.sms'} @:{'words.generated.notifications'}."
},
"security": {
"description": "@.capitalize:{'words.generated.your'} @:{'words.generated.security'} @:{'words.generated.settings'}",
-34
View File
@@ -892,21 +892,6 @@
"year": "@:common.year"
},
"collected_invoice": {
"move_customer": {
"card_title": "@:{'templates.generated.compat.collected_invoice.move_customer.card_title'}",
"confirm_button": "@:{'templates.generated.compat.collected_invoice.move_customer.confirm_button'}",
"confirm_text": "@:{'templates.generated.compat.collected_invoice.move_customer.confirm_text'}",
"confirm_title": "@:{'templates.generated.compat.collected_invoice.move_customer.confirm_title'}",
"current_customer": "@:{'templates.generated.compat.collected_invoice.move_customer.current_customer'}",
"customer_value": "@:{'templates.generated.compat.collected_invoice.move_customer.customer_value'}",
"error_title": "@:{'templates.generated.compat.collected_invoice.move_customer.error_title'}",
"invalid_customer": "@:{'templates.generated.compat.collected_invoice.move_customer.invalid_customer'}",
"locked": "@:{'templates.generated.compat.collected_invoice.move_customer.locked'}",
"same_customer": "@:{'templates.generated.compat.collected_invoice.move_customer.same_customer'}",
"success_text": "@:{'templates.generated.compat.collected_invoice.move_customer.success_text'}",
"success_title": "@:{'templates.generated.compat.collected_invoice.move_customer.success_title'}",
"title": "@:{'templates.generated.compat.collected_invoice.move_customer.title'}"
},
"economic": {
"booked_desc": "@:{'templates.generated.compat.collected_invoice.economic.booked_desc'}",
"booked_with_economic": "@:{'templates.generated.compat.collected_invoice.economic.booked_with_economic'}",
@@ -1362,15 +1347,6 @@
"subtitle": "@:products.subtitle",
"title": "@:common.products"
},
"slack": {
"customer_registration_webhook_url": "@:{'templates.generated.compat.configuration.slack.customer_registration_webhook_url'}",
"customer_registration_webhook_url_desc": "@:{'templates.generated.compat.configuration.slack.customer_registration_webhook_url_desc'}",
"notification_settings": "@:{'templates.generated.compat.configuration.slack.notification_settings'}",
"notification_settings_desc": "@:{'templates.generated.compat.configuration.slack.notification_settings_desc'}",
"subtitle": "@:{'templates.generated.compat.configuration.slack.subtitle'}",
"title": "@:{'templates.generated.compat.configuration.slack.title'}",
"unavailable": "@:{'templates.generated.compat.configuration.slack.unavailable'}"
},
"shelly": {
"api_key": "@:configuration.limble.api_key",
"api_key_desc": "@:{'templates.generated.compat.configuration.shelly.api_key_desc'}",
@@ -3154,13 +3130,6 @@
"unknown_qr_text": "@:{'templates.generated.compat.login_qr.unknown_qr_text'}",
"unknown_qr_title": "@:{'templates.generated.compat.login_qr.unknown_qr_title'}"
},
"maintenance_menu": {
"confirm_clear_local": "@:{'templates.generated.compat.maintenance_menu.confirm_clear_local'}",
"force_update_clear_hint": "@:{'templates.generated.compat.maintenance_menu.force_update_clear_hint'}",
"force_update_clear": "@:{'templates.generated.compat.maintenance_menu.force_update_clear'}",
"title": "@:{'templates.generated.compat.maintenance_menu.title'}",
"version": "@:{'templates.generated.compat.maintenance_menu.version'}"
},
"messages": {
"are_you_sure": "@:{'templates.generated.compat.messages.are_you_sure'}",
"cancelled": "@:{'templates.generated.compat.global.cancelled'}",
@@ -4762,7 +4731,6 @@
"department_relays": "@:{'templates.generated.compat.superuser.nav.department_relays'}",
"departments": "@:common.departments",
"employees": "@:{'templates.generated.compat.common.roless.employee'}",
"error_reports": "@:{'templates.generated.compat.superuser.nav.error_reports'}",
"invoices": "@:common.invoices",
"invoicing": "@:{'templates.generated.compat.superuser.nav.invoicing'}",
"orders": "@:{'templates.generated.compat.superuser.nav.orders'}",
@@ -5587,8 +5555,6 @@
"sms_phone": "@:{'templates.generated.compat.user_dashboard.profile.notifications.sms_phone'}",
"sms_phone_desc": "@:{'templates.generated.compat.user_dashboard.profile.notifications.sms_phone_desc'}",
"sms_subtitle": "@:time_booking_flow.sms",
"superuser_notifications": "@:{'templates.generated.compat.user_dashboard.profile.notifications.superuser_notifications'}",
"superuser_notifications_desc": "@:{'templates.generated.compat.user_dashboard.profile.notifications.superuser_notifications_desc'}",
"title": "@:nav.notifications"
},
"security": {
+1 -42
View File
@@ -1261,14 +1261,6 @@
"field_required": "{field} @:{'words.generated.er'} @:{'words.generated.pakrevd'}",
"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": {
"host_targets": {
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
@@ -1964,21 +1956,6 @@
"week": "@:{'templates.generated.compat.common.week'}"
},
"collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": {
"booked_desc": "@.capitalize:{'words.generated.fakturaen'} @:{'words.generated.er'} @:{'words.generated.bokført'} @:{'words.generated.i'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}, @:{'words.generated.ingen'} @:{'words.generated.ytterligere'} @:{'words.generated.handlinger'} @:{'words.generated.er'} @:{'words.generated.nødvendig'}.",
"booked_with_economic": "@.capitalize:{'words.generated.faktura'} @:{'words.generated.bestilles'} @:{'words.generated.hos'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}",
@@ -2449,15 +2426,6 @@
"delete_confirm": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'} @:{'words.generated.pa'} @:{'words.generated.at'} @:{'words.generated.du'} @:{'words.generated.vil'} @:{'words.generated.slette'} @:{'words.generated.dette'} produktet?",
"edit": "@:{'templates.generated.compat.products.edit_product'}"
},
"slack": {
"customer_registration_webhook_url": "Webhook-URL for kunderegistreringer",
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
"notification_settings": "Varslingsinnstillinger",
"notification_settings_desc": "Slack-webhooks for systemhendelser.",
"subtitle": "Konfigurasjon av Slack-varsler",
"title": "Slack-konfigurasjon",
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
},
"shelly": {
"api_key_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.nøkkelen'} @:{'words.generated.brukes'} @:{'words.generated.til'} @:{'words.generated.a'} @:{'words.generated.autentisere'} @:{'words.generated.shelly'}-@:{'words.generated.integrasjonen'}",
"api_settings_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.tilkoblingsinnstillingene'}.",
@@ -3927,13 +3895,6 @@
"unknown_qr_text": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.skannede'} @:{'words.generated.qr'}-@:{'words.generated.koden'} @:{'words.generated.er'} @:{'words.generated.ikke'} @:{'words.generated.gyldig'} @:{'words.generated.for'} @:{'words.generated.denne'} @:{'words.generated.siden'}.",
"unknown_qr_title": "@.capitalize:{'words.generated.ukjent'} @:{'words.generated.qr'}-@:{'words.generated.kode'}"
},
"maintenance_menu": {
"confirm_clear_local": "Fjern alle lokale appdata og last inn på nytt nå?",
"force_update_clear_hint": "Fjerner local storage, session storage, service workers og nettlesercacher.",
"force_update_clear": "Tving oppdatering og fjern alt lokalt",
"title": "Vedlikehold",
"version": "Gjeldende {current} / nyeste {latest}"
},
"messages": {
"are_you_sure": "@:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'}?",
"cannot_undo": "@.capitalize:{'words.generated.denne'} avgjørelsen @:{'words.generated.kan'} @:{'words.generated.ikke'} omgjøres.",
@@ -5628,9 +5589,7 @@
"email_notifications_desc": "@.capitalize:{'words.generated.motta'} @:{'words.generated.e'}-@:{'words.generated.post'} @:{'words.generated.med'} @:{'words.generated.bestillingsbekreftelser'} @:{'words.generated.og'} @:{'words.generated.vaskesertifikater'}.",
"sms_notifications_desc": "@.capitalize:{'words.generated.motta'} @:{'words.generated.sms'}-@:{'words.generated.varsler'} @:{'words.generated.for'} @:{'words.generated.ulike'} @:{'words.generated.arrangementer'}.",
"sms_phone": "@.capitalize:{'words.generated.telefonnummer'} @:{'words.generated.for'} @:{'words.generated.sms'}-@:{'words.generated.varsler'}",
"sms_phone_desc": "@.capitalize:{'words.generated.telefonnummeret'} @:{'words.generated.som'} @:{'words.generated.mottar'} @:{'words.generated.sms'}-@:{'words.generated.varsler'}.",
"superuser_notifications": "Superbrukervarsler",
"superuser_notifications_desc": "Motta e-postvarsler når en ny kunde registrerer seg på siden."
"sms_phone_desc": "@.capitalize:{'words.generated.telefonnummeret'} @:{'words.generated.som'} @:{'words.generated.mottar'} @:{'words.generated.sms'}-@:{'words.generated.varsler'}."
},
"security": {
"description": "Sikkerhetsinnstillingene @:{'words.generated.dine'}",
+1 -47
View File
@@ -1311,14 +1311,6 @@
"field_required": "{field} @:{'words.generated.kravs'}",
"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": {
"host_targets": {
"database": "@:{'words.replication.services.database'}-@:{'words.replication.host'}",
@@ -2014,21 +2006,6 @@
"week": "Uke"
},
"collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": {
"booked_desc": "@.capitalize:{'words.generated.fakturan'} @:{'words.generated.ar'} @:{'words.generated.bokford'} @:{'words.generated.i'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}, @:{'words.generated.inga'} @:{'words.generated.ytterligare'} @:{'words.generated.atgarder'} @:{'words.generated.kravs'}.",
"booked_with_economic": "@.capitalize:{'words.generated.faktura'} @:{'words.generated.bokford'} @:{'words.generated.i'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}",
@@ -2499,20 +2476,6 @@
"delete_confirm": "@.capitalize:{'words.generated.ar'} @:{'words.generated.du'} @:{'words.generated.saker'} @:{'words.generated.pa'} @:{'words.generated.att'} @:{'words.generated.du'} @:{'words.generated.vill'} @:{'words.generated.ta'} @:{'words.generated.bort'} @:{'words.generated.denna'} @:{'words.generated.produkt'}?",
"edit": "@:{'templates.generated.compat.products.edit_product'}"
},
"slack": {
"customer_registration_webhook_url": "Webhook-URL för kundregistreringar",
"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_desc": "Slack-webhooks för systemhändelser.",
"send_test_webhook": "Skicka test-webhook",
"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",
"unavailable": "Slack-konfigurationen är inte tillgänglig i den här API-versionen."
},
"shelly": {
"api_key_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.nyckeln'} @:{'words.generated.anvands'} @:{'words.generated.for'} @:{'words.generated.att'} @:{'words.generated.autentisera'} @:{'words.generated.shelly'}-@:{'words.generated.integrationen'}",
"api_settings_desc": "@.capitalize:{'words.generated.anslutningsinstallningar'} @:{'words.generated.for'} @:{'words.generated.shelly'} @:{'words.generated.api'}.",
@@ -3982,13 +3945,6 @@
"unknown_qr_text": "@.capitalize:{'words.generated.the'} @:{'words.generated.scanned'} @:{'words.generated.qr'} @:{'words.generated.code'} @:{'words.generated.is'} @:{'words.generated.not'} @:{'words.generated.valid'} @:{'words.generated.for_2'} @:{'words.generated.this'} @:{'words.generated.page'}.",
"unknown_qr_title": "@:{'words.generated.okand'} @:{'words.generated.qr'}-@:{'words.generated.kod'}"
},
"maintenance_menu": {
"confirm_clear_local": "Rensa all lokal appdata och ladda om nu?",
"force_update_clear_hint": "Rensar local storage, session storage, service workers och webbläsarcacher.",
"force_update_clear": "Tvinga uppdatering och rensa allt lokalt",
"title": "Underhåll",
"version": "Aktuell {current} / senaste {latest}"
},
"messages": {
"are_you_sure": "@.capitalize:{'words.generated.ar'} @:{'words.generated.du'} @:{'words.generated.saker'}?",
"cannot_undo": "@.capitalize:{'words.generated.denna'} @:{'words.generated.atgard'} @:{'words.generated.kan'} @:{'words.generated.inte'} @:{'words.generated.angras'}.",
@@ -5683,9 +5639,7 @@
"email_notifications_desc": "@.capitalize:{'words.generated.ta'} @:{'words.generated.emot'} @:{'words.generated.e'}-@:{'words.generated.post'} @:{'words.generated.med'} @:{'words.generated.bokningsbekraftelser'} @:{'words.generated.och'} @:{'words.generated.tvattcertifikat'}.",
"sms_notifications_desc": "@.capitalize:{'words.generated.ta'} @:{'words.generated.emot'} @:{'words.generated.sms'}-@:{'words.generated.aviseringar'} @:{'words.generated.for'} @:{'words.generated.olika'} @:{'words.generated.handelser'}.",
"sms_phone": "@.capitalize:{'words.generated.telefonnummer'} @:{'words.generated.for'} @:{'words.generated.sms'}-@:{'words.generated.aviseringar'}",
"sms_phone_desc": "@.capitalize:{'words.generated.telefonnumret'} @:{'words.generated.som'} @:{'words.generated.for'} @:{'words.generated.sms'}-@:{'words.generated.aviseringar'}.",
"superuser_notifications": "Superanvändaraviseringar",
"superuser_notifications_desc": "Ta emot e-postaviseringar när en ny kund registrerar sig på sidan."
"sms_phone_desc": "@.capitalize:{'words.generated.telefonnumret'} @:{'words.generated.som'} @:{'words.generated.for'} @:{'words.generated.sms'}-@:{'words.generated.aviseringar'}."
},
"security": {
"description": "@.capitalize:{'words.generated.dina'} säkerhetsinställningar",
+83 -96
View File
@@ -30,14 +30,6 @@
"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": {
"solutions": {
"customer": {
@@ -350,7 +342,7 @@
"attachments": "Vedhæftninger",
"attachments_add": "Tilføj vedhæftning",
"attachments_no_preview": "Ingen forhåndsvisning tilgængelig for denne filtype.",
"attachments_office_preview_unavailable": "Forhåndsvisning er ikke tilgængelig for Office-dokumenter. Download filen for at se den.",
"attachments_office_preview_unavailable": "Forhåndsvisning er ikke tilgjengelig for Office-dokumenter. Last ned for å se.",
"attachments_upload_description": "Upload en ny fil som vedhæftning til denne transaktion.",
"attachments_upload_action": "Upload",
"attachments_upload_error": "Fejl ved upload af vedhæftet fil.",
@@ -377,7 +369,7 @@
"customer_creation_error_generic": "Der opstod en fejl under oprettelse af kunden. Prøv igen.",
"delete": "Slet",
"details": "Detaljer",
"download": "Download",
"download": "Last ned",
"duplicate_order_warning": "Er du sikker på, at dette ikke allerede er oprettet? Mulige dubletter af denne ordre er fundet. Du kan fortsætte med at oprette ordren eller se ordredetaljer.",
"hide_order_details": "Skjul ordredetaljer",
"duplicates": {
@@ -580,7 +572,7 @@
"reference_required_title": "Referencenummer påkrævet",
"safety_seal": "Sikkerhedsforsegling",
"scanned": "Scannet",
"scanner": "Scanner",
"scanner": "Skanner",
"search_license_plate": "Søg efter nummerplade",
"see_note": "Se note",
"see_reference": "Se reference",
@@ -867,8 +859,24 @@
},
"day": "Dag",
"dayHeaderFormat": "ddd D/M",
"dayNames": ["Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag"],
"dayNamesShort": ["Søn", "Man", "Tir", "Ons", "Tor", "Fre", "Lør"],
"dayNames": [
"Søndag",
"Mandag",
"Tirsdag",
"Onsdag",
"Torsdag",
"Fredag",
"Lørdag"
],
"dayNamesShort": [
"Søn",
"Man",
"Tir",
"Ons",
"Tor",
"Fre",
"Lør"
],
"eventTimeFormat": "HH:mm",
"list": "Liste",
"month": "Måned",
@@ -886,7 +894,20 @@
"November",
"Desember"
],
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "Maj", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"],
"monthNamesShort": [
"Jan",
"Feb",
"Mar",
"Apr",
"Maj",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Des"
],
"next": "I denne",
"prev": "Forrige",
"slotLabelFormat": "HH:mm",
@@ -915,21 +936,6 @@
"year": "År"
},
"collected_invoice": {
"move_customer": {
"card_title": "Flyt til kunde",
"confirm_button": "Flyt",
"confirm_text": "Flyt fakturasamling #{id} og {count} ordre til kunde #{customer}?",
"confirm_title": "Bekræft flytning",
"current_customer": "Nuværende kunde: #{customer}",
"customer_value": "Kunde #{customer}",
"error_title": "Kunne ikke flytte fakturasamling",
"invalid_customer": "Indtast et gyldigt kundenummer.",
"locked": "Låst",
"same_customer": "Vælg en anden kunde end den nuværende.",
"success_text": "{count} ordre blev flyttet til kunde #{customer}.",
"success_title": "Fakturasamling flyttet",
"title": "Flyt fakturasamling"
},
"economic": {
"booked_desc": "Fakturaen er bogført i E-conomic, ingen yderligere handlinger er nødvendige.",
"booked_with_economic": "Fakturaen er bogført med E-conomic",
@@ -1258,7 +1264,7 @@
"reply_to_name_desc": "Navnet som emailen besvares til. F.eks. Support",
"send": "Send",
"send_test_email": "Send test email",
"send_test_email_prompt": "Indtast den e-mailadresse, du vil sende test-e-mailen til.",
"send_test_email_prompt": "Indtast venligst email adressen du vil sende test emailen til.",
"sender_identity": "Afsender identitet",
"sender_identity_desc": "Afsender identitet indstillinger for email systemet.",
"smtp_encryption": "SMTP kryptering",
@@ -1276,13 +1282,13 @@
"smtp_username_desc": "SMTP brugernavnet er brugernavnet som SMTP hosten bruger til at sende emailen.",
"subtitle": "Konfiguration af email systemet.",
"test_email_error": "Der opstod en fejl under afsendelse af test emailen.",
"test_email_sent": "Test-e-mail sendt",
"test_email_sent_success": "Test-e-mailen er blevet sendt til e-mailadressen.",
"test_email_sent": "Test email sendt",
"test_email_sent_success": "Test emailen er blevet sendt til email adressen.",
"title": "Email konfiguration"
},
"fxrates": {
"api_key": "API-Nøgle",
"api_key_desc": "API-nøglen til valutakurser bruges til at hente valutakurser",
"api_key": "API-nøkkel",
"api_key_desc": "Valutakurser API-nøkkelen bruges til å hente ut valutakurser",
"api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "FX Rates API forbindelsesindstillingerne.",
"conversion_rates": "Valutakurser",
@@ -1306,7 +1312,7 @@
},
"general": {
"currency": "Valuta",
"language": "Sprog",
"language": "Språk",
"subtitle": "Generelle indstillinger",
"timezone": "Tidszone",
"title": "Generelle indstillinger"
@@ -1316,13 +1322,13 @@
"title": "Integrationer"
},
"limble": {
"api_key": "API-Nøgle",
"api_key_desc": "Limble API-nøglen bruges til at autentificere Limble-integrationen",
"api_key": "API-nøkkel",
"api_key_desc": "Limble API-nøkkelen bruges til å autentisere Limble-integrationen",
"api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "Limble API forbindelsesindstillingerne.",
"client_id": "Klient-ID",
"client_id_desc": "Client ID bruges til at autentificere API forbindelsen.",
"client_secret": "Klienthemmelighed",
"client_secret": "Klienthemmelighet",
"client_secret_desc": "Client secret bruges til at autentificere API forbindelsen.",
"connection_failed_desc": "Forbindelsen til Limble API fejlede!",
"connection_success": "Forbindelse succesfuld",
@@ -1338,13 +1344,13 @@
"title": "Limble konfiguration"
},
"mailersend": {
"api_key": "API-Nøgle",
"api_key_desc": "Mailersend API-nøglen bruges til at autentificere Mailersend-integrationen",
"api_key": "API-nøkkel",
"api_key_desc": "Mailersend API-nøkkelen bruges til å autentisere Mailersend-integrationen",
"title": "Mailersend-konfiguration"
},
"motor_api": {
"api_key": "API-Nøgle",
"api_key_desc": "Motor API-nøglen bruges til at autentificere Motor API-integrationen",
"api_key": "API-nøkkel",
"api_key_desc": "Motor API-nøkkelen bruges til å autentisere Motor API-integrationen",
"title": "Motor API-konfiguration"
},
"motorapi": {
@@ -1363,7 +1369,7 @@
"title": "MotorAPI konfiguration"
},
"products": {
"add": "Tilføj produkt",
"add": "Legg til produkt",
"delete": "Slet produkt",
"delete_confirm": "Er du sikker på, at du vil slette dette produkt?",
"edit": "Rediger produkt",
@@ -1371,18 +1377,9 @@
"subtitle": "Administrer produkter",
"title": "Produkter"
},
"slack": {
"customer_registration_webhook_url": "Webhook-URL til kunderegistreringer",
"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_desc": "Slack-webhooks til systemhændelser.",
"subtitle": "Konfiguration af Slack-notifikationer",
"title": "Slack-konfiguration",
"unavailable": "Slack-konfigurationen er ikke tilgængelig i denne API-udgivelse."
},
"shelly": {
"api_key": "API-Nøgle",
"api_key_desc": "Shelly API-nøglen bruges til at autentificere Shelly-integrationen",
"api_key": "API-nøkkel",
"api_key_desc": "Shelly API-nøkkelen bruges til å autentisere Shelly-integrationen",
"api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "Shelly API forbindelsesindstillingerne.",
"connection_failed_desc": "Forbindelsen til Shelly API fejlede!",
@@ -1401,8 +1398,8 @@
"title": "Shelly konfiguration"
},
"stripe": {
"api_key": "API-Nøgle",
"api_key_desc": "Din Stripe API-nøgle for betalingsbehandling",
"api_key": "API-nøkkel",
"api_key_desc": "Din Stripe API-nøkkel for betalingsbehandling",
"api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "Stripe API forbindelsesindstillingerne.",
"customers": "Stripe kunder",
@@ -1437,8 +1434,8 @@
"title": "Brugere"
},
"virkdata": {
"api_key": "API-Nøgle",
"api_key_desc": "Virk Data API-nøglen bruges til at autentificere Virk Data-integrationen",
"api_key": "API-nøkkel",
"api_key_desc": "Virk Data API-nøkkelen bruges til å autentisere Virk Data-integrationen",
"api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "VirkData API forbindelsesindstillingerne.",
"company_selected": "Virksomhed valgt",
@@ -1461,8 +1458,8 @@
"title": "VirkData konfiguration"
},
"xlvask": {
"api_key": "API-Nøgle",
"api_key_desc": "XL Vask API-nøglen bruges til at autentificere XL Vask-integrationen",
"api_key": "API-nøkkel",
"api_key_desc": "XL Vask API-nøkkelen bruges til å autentisere XL Vask-integrationen",
"api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "XLVask API forbindelsesindstillingerne.",
"connection_failed": "Forbindelse fejlede",
@@ -2216,7 +2213,7 @@
"list": "Liste",
"subtitle": "Se hvordan dine afdelinger klarer sig",
"title": "Overblik",
"charts": "Grafer"
"charts": "Charts"
},
"pos": {
"subtitle": "Registrer salg",
@@ -2315,15 +2312,15 @@
"country": "Land",
"email": "E-mail",
"latitude": "Breddegrad",
"longitude": "Længdegrad",
"longitude": "Lengdegrad",
"name": "Navn",
"phone": "Telefon",
"submit": "Gem",
"submit": "Lagre",
"zip": "Postnummer"
},
"integrations": {
"mailersend_api_key": "Mailersend API-Nøgle",
"mailersend_api_key_desc": "Mailersend API-nøglen bruges til at autentificere Mailersend-integrationen",
"mailersend_api_key": "Mailersend API-nøkkel",
"mailersend_api_key_desc": "Mailersend API-nøkkelen bruges til å autentisere Mailersend-integrationen",
"subtitle": "Konfigurer integrationer for afdelingen",
"title": "Integrationer"
},
@@ -2331,9 +2328,9 @@
"new_department": "Ny afdeling",
"no_departments": "Ingen afdelinger fundet",
"opening_hours": {
"add": "Tilføj",
"closed": "Lukket",
"title": bningstider"
"add": "Legg til",
"closed": "Stengt",
"title": pningstider"
},
"phone": "Telefon",
"products": {
@@ -2345,7 +2342,7 @@
"tab": {
"info": "Info",
"integrations": "Integrationer",
"opening_hours": bningstider",
"opening_hours": pningstider",
"product_addons": "Produkttilvalg",
"wash_products": "Vaskeprodukter"
},
@@ -2366,7 +2363,7 @@
"name": "Navn",
"phone": "Telefon",
"role": "Rolle",
"submit": "Gem"
"submit": "Lagre"
},
"name": "Navn",
"new_employee": "Ny medarbejder",
@@ -2660,17 +2657,17 @@
"yes": "Ja"
},
"guest": {
"about": "Om os",
"access_code": "Adgangskode",
"about": "Om oss",
"access_code": "Tilgangskode",
"app_store": "App Store",
"book_wash": "Book vask",
"contact": "Kontakt os",
"continue": "Fortsæt",
"download_app": "Download app",
"contact": "Kontakta oss",
"continue": "Fortsett",
"download_app": "Ladda ner app",
"driver": {
"coming_soon": "Denne funktion er på vej!"
},
"enter_access_code": "Indtast adgangskode",
"enter_access_code": "Skriv inn tilgangskode",
"find_wash": "Find vask",
"home": {
"contact_or_call": "eller ring til os på",
@@ -2679,12 +2676,12 @@
"subtitle": "Velkommen til PLENO",
"title": "Forside"
},
"invalid_code": "Ugyldig adgangskode",
"invalid_code": "Ugyldig tilgangskode",
"play_store": "Google Play",
"pwa": {
"already_installed": "Pleno er allerede installeret på din enhed.",
"android_instructions": "Tryk på menuknappen og vælg \"Installer app\"",
"card_title": "Download Pleno",
"card_title": "Last ned Pleno",
"click_to_install": "Klik på knappen nedenfor for at installere Pleno på din enhed.",
"description": "Pleno er en Progressive Web App (PWA), der kan installeres på din enhed for en bedre oplevelse.",
"install": "Installer",
@@ -2694,14 +2691,14 @@
"instructions": "Instruktioner",
"ios_instructions": "Tryk på delingsknappen og vælg \"Tilføj til hjemmeskærm\"",
"subtitle": "Download appen på din enhed",
"title": "Download",
"title": "Last ned",
"use_supported_browser": "For at installere Pleno skal du bruge en understøttet browser og følge installationsvejledningen."
},
"scan_qr": "Scan QR-kode",
"scan_qr": "Skann QR-kode",
"start_wash": "Start vask",
"start_wash_subtitle": "Indtast adgangskoden for at fortsætte",
"start_wash_title": "Start vask som gæst",
"title": "Gæsteadgang",
"start_wash_subtitle": "Skriv inn tilgangskoden for å fortsette",
"start_wash_title": "Start vask som gjest",
"title": "Gjesttilgang",
"welcome": "Velkommen",
"welcome_message": "Velkommen til Truck Wash"
},
@@ -2798,13 +2795,6 @@
"unknown_qr_text": "Den scannede QR kode er ikke gyldig for denne side.",
"unknown_qr_title": "Ukendt QR kode"
},
"maintenance_menu": {
"confirm_clear_local": "Ryd alle lokale appdata og genindlæs nu?",
"force_update_clear_hint": "Rydder local storage, session storage, service workers og browsercaches.",
"force_update_clear": "Tving opdatering og ryd alt lokalt",
"title": "Vedligeholdelse",
"version": "Nuværende {current} / seneste {latest}"
},
"messages": {
"are_you_sure": "Er du sikker?",
"cancelled": "Annulleret",
@@ -3155,7 +3145,7 @@
"invoices": {
"columns": {
"amount": "Beløb",
"download": "Download",
"download": "Last ned",
"due_date": "Forfaldsdato",
"invoice_number": "Fakturanummer",
"paid": "Betalt"
@@ -3768,7 +3758,7 @@
"days": "dage",
"export": "Eksporter",
"growth": "Vækst",
"no_data": "Ingen data tilgængelige",
"no_data": "Ingen data tilgjengelig",
"none": "Ingen",
"orders": "Ordrer",
"overview": {
@@ -3872,7 +3862,6 @@
"xlvask": "XLVask",
"department_gates": "Afdelingsporte",
"department_relays": "Afdelingsreleer",
"error_reports": "Fejlrapporter",
"selfserve": "Selvvask"
},
"pages": {
@@ -4666,8 +4655,6 @@
"sms_phone": "Telefonnummer til SMS notifikationer",
"sms_phone_desc": "Det telefonnummer, der modtager SMS notifikationer.",
"sms_subtitle": "SMS",
"superuser_notifications": "Superuser-notifikationer",
"superuser_notifications_desc": "Modtag e-mailnotifikationer, når en ny kunde registrerer sig på siden.",
"title": "Notifikationer"
},
"security": {
@@ -5204,7 +5191,7 @@
"fixed_pricing": "Fastprisaftaler",
"subscriptions": "Abonnementer",
"customer_prices": "Kundepriser",
"other": "Anden bogført indtægt",
"other": "Annen bokført inntekt",
"months": "Måneder i visning"
},
"tabs": {
+33 -46
View File
@@ -30,14 +30,6 @@
"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": {
"solutions": {
"customer": {
@@ -867,8 +859,24 @@
},
"day": "Tag",
"dayHeaderFormat": "ddd D/M",
"dayNames": ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"],
"dayNamesShort": ["Søn", "Man", "Tir", "Mi", "Do", "Fre", "Lør"],
"dayNames": [
"Sonntag",
"Montag",
"Dienstag",
"Mittwoch",
"Donnerstag",
"Freitag",
"Samstag"
],
"dayNamesShort": [
"Søn",
"Man",
"Tir",
"Mi",
"Do",
"Fre",
"Lør"
],
"eventTimeFormat": "HH:mm",
"list": "Liste",
"month": "Monat",
@@ -886,7 +894,20 @@
"November",
"Desember"
],
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"],
"monthNamesShort": [
"Jan",
"Feb",
"Mar",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Des"
],
"next": "Neste",
"prev": "Vorherige",
"slotLabelFormat": "HH:mm",
@@ -915,21 +936,6 @@
"year": "Jahr"
},
"collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": {
"booked_desc": "Die Rechnung ist in E-conomic gebucht, keine weiteren Aktionen sind erforderlich.",
"booked_with_economic": "Rechnung mit E-conomic gebucht",
@@ -1371,15 +1377,6 @@
"subtitle": "Produkte verwalten",
"title": "Produkte"
},
"slack": {
"customer_registration_webhook_url": "Webhook-URL fuer Kundenregistrierungen",
"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_desc": "Slack-Webhooks fuer Systemereignisse.",
"subtitle": "Konfiguration von Slack-Benachrichtigungen",
"title": "Slack-Konfiguration",
"unavailable": "Die Slack-Konfiguration ist in dieser API-Version nicht verfuegbar."
},
"shelly": {
"api_key": "API-Schl?ssel",
"api_key_desc": "Der Shelly API-Schl?ssel wird zur Authentifizierung der Shelly-Integration verwendet",
@@ -2798,13 +2795,6 @@
"unknown_qr_text": "Der gescannte QR-Code ist f?r diese Seite nicht g?ltig.",
"unknown_qr_title": "Unbekannter QR-Code"
},
"maintenance_menu": {
"confirm_clear_local": "Alle lokalen App-Daten löschen und jetzt neu laden?",
"force_update_clear_hint": "Löscht Local Storage, Session Storage, Service Worker und Browser-Caches.",
"force_update_clear": "Update erzwingen und alles Lokale löschen",
"title": "Wartung",
"version": "Aktuell {current} / neueste {latest}"
},
"messages": {
"are_you_sure": "Sind Sie sicher?",
"cancelled": "Erfolgreich storniert",
@@ -3870,8 +3860,7 @@
"vehicles": "Fahrzeuge",
"xlvask": "XLVask",
"department_gates": "Abteilungstore",
"department_relays": "Abteilungsrelais",
"error_reports": "Fehlerberichte"
"department_relays": "Abteilungsrelais"
},
"pages": {
"categories": {
@@ -4652,8 +4641,6 @@
"sms_phone": "Telefonnummer f?r SMS-Benachrichtigungen",
"sms_phone_desc": "Die Telefonnummer, die SMS-Benachrichtigungen erh?lt.",
"sms_subtitle": "SMS",
"superuser_notifications": "Superuser-Benachrichtigungen",
"superuser_notifications_desc": "E-Mail-Benachrichtigungen erhalten, wenn sich ein neuer Kunde auf der Seite registriert.",
"title": "Benachrichtigungen"
},
"security": {
+33 -46
View File
@@ -30,14 +30,6 @@
"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": {
"solutions": {
"customer": {
@@ -867,8 +859,24 @@
},
"day": "Day",
"dayHeaderFormat": "ddd D/M",
"dayNames": ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
"dayNamesShort": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
"dayNames": [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday"
],
"dayNamesShort": [
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat"
],
"eventTimeFormat": "HH:mm",
"list": "List",
"month": "Month",
@@ -886,7 +894,20 @@
"November",
"December"
],
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
"monthNamesShort": [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
],
"next": "Next",
"prev": "Previous",
"slotLabelFormat": "HH:mm",
@@ -915,21 +936,6 @@
"year": "Year"
},
"collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": {
"booked_desc": "The invoice is booked in E-conomic, no further actions are required.",
"booked_with_economic": "Invoice booked with E-conomic",
@@ -1371,15 +1377,6 @@
"subtitle": "Manage products",
"title": "Products"
},
"slack": {
"customer_registration_webhook_url": "Customer registration webhook URL",
"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_desc": "Slack webhooks for system events.",
"subtitle": "Configuration of Slack notifications",
"title": "Slack configuration",
"unavailable": "Slack configuration is not available on this API release."
},
"shelly": {
"api_key": "API-nyckel",
"api_key_desc": "The Shelly API key is used to authenticate the Shelly integration",
@@ -2798,13 +2795,6 @@
"unknown_qr_text": "The scanned QR code is not valid for this page.",
"unknown_qr_title": "Unknown QR Code"
},
"maintenance_menu": {
"confirm_clear_local": "Clear all local app data and reload now?",
"force_update_clear_hint": "Clears local storage, session storage, service workers, and browser caches.",
"force_update_clear": "Force update and clear all local",
"title": "Maintenance",
"version": "Current {current} / latest {latest}"
},
"messages": {
"are_you_sure": "Are you sure?",
"cancelled": "Canceled successfully",
@@ -3870,8 +3860,7 @@
"vehicles": "Vehicles",
"xlvask": "XLVask",
"department_gates": "Department Gates",
"department_relays": "Department Relays",
"error_reports": "Error reports"
"department_relays": "Department Relays"
},
"pages": {
"categories": {
@@ -4664,8 +4653,6 @@
"sms_phone": "Phone number for SMS notifications",
"sms_phone_desc": "The phone number that receives SMS notifications.",
"sms_subtitle": "SMS",
"superuser_notifications": "Superuser notifications",
"superuser_notifications_desc": "Receive email notifications when a new customer registers on the site.",
"title": "Notifications"
},
"security": {
+33 -46
View File
@@ -30,14 +30,6 @@
"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": {
"solutions": {
"customer": {
@@ -867,8 +859,24 @@
},
"day": "Dag",
"dayHeaderFormat": "ddd D/M",
"dayNames": ["Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag"],
"dayNamesShort": ["Søn", "Mann", "Tir", "Ons", "Tor", "Fre", "Lør"],
"dayNames": [
"Søndag",
"Mandag",
"Tirsdag",
"Onsdag",
"Torsdag",
"Fredag",
"Lørdag"
],
"dayNamesShort": [
"Søn",
"Mann",
"Tir",
"Ons",
"Tor",
"Fre",
"Lør"
],
"eventTimeFormat": "HH:mm",
"list": "Liste",
"month": "Måned",
@@ -886,7 +894,20 @@
"november",
"desember"
],
"monthNamesShort": ["Jan", "feb", "Mar", "apr", "mai", "jun", "jul", "august", "sep", "Okt", "nov", "Av"],
"monthNamesShort": [
"Jan",
"feb",
"Mar",
"apr",
"mai",
"jun",
"jul",
"august",
"sep",
"Okt",
"nov",
"Av"
],
"next": "I dette",
"prev": "Forrige",
"slotLabelFormat": "HH:mm",
@@ -915,21 +936,6 @@
"year": "År"
},
"collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": {
"booked_desc": "Fakturaen er bokført i E-conomic, ingen ytterligere handlinger er nødvendig.",
"booked_with_economic": "Faktura bestilles hos E-conomic",
@@ -1371,15 +1377,6 @@
"subtitle": "Administrer produkter",
"title": "Produkter"
},
"slack": {
"customer_registration_webhook_url": "Webhook-URL for kunderegistreringer",
"customer_registration_webhook_url_desc": "Slack-webhook som mottar en melding når en ny kunderegistrering lykkes. La feltet stå tomt for å deaktivere.",
"notification_settings": "Varslingsinnstillinger",
"notification_settings_desc": "Slack-webhooks for systemhendelser.",
"subtitle": "Konfigurasjon av Slack-varsler",
"title": "Slack-konfigurasjon",
"unavailable": "Slack-konfigurasjonen er ikke tilgjengelig i denne API-utgivelsen."
},
"shelly": {
"api_key": "API-nøkkel",
"api_key_desc": "Shelly API-nøkkelen brukes til å autentisere Shelly-integrasjonen",
@@ -2798,13 +2795,6 @@
"unknown_qr_text": "Den skannede QR-koden er ikke gyldig for denne siden.",
"unknown_qr_title": "Ukjent QR-kode"
},
"maintenance_menu": {
"confirm_clear_local": "Fjern alle lokale appdata og last inn på nytt nå?",
"force_update_clear_hint": "Fjerner local storage, session storage, service workers og nettlesercacher.",
"force_update_clear": "Tving oppdatering og fjern alt lokalt",
"title": "Vedlikehold",
"version": "Gjeldende {current} / nyeste {latest}"
},
"messages": {
"are_you_sure": "er du sikker?",
"cancelled": "Avbrutt",
@@ -3870,8 +3860,7 @@
"vehicles": "Kjøretøy",
"xlvask": "XLVask",
"department_gates": "Avdelingsporter",
"department_relays": "Avdelingsreleer",
"error_reports": "Feilrapporter"
"department_relays": "Avdelingsreleer"
},
"pages": {
"categories": {
@@ -4652,8 +4641,6 @@
"sms_phone": "Telefonnummer for SMS-varsler",
"sms_phone_desc": "Telefonnummeret som mottar SMS-varsler.",
"sms_subtitle": "SMS",
"superuser_notifications": "Superbrukervarsler",
"superuser_notifications_desc": "Motta e-postvarsler når en ny kunde registrerer seg på siden.",
"title": "Varsler"
},
"security": {
+33 -46
View File
@@ -30,14 +30,6 @@
"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": {
"solutions": {
"customer": {
@@ -867,8 +859,24 @@
},
"day": "Dag",
"dayHeaderFormat": "ddd D/M",
"dayNames": ["Söndag", "Måndag", "Tisdag", "Onsdag", "Torsdag", "Fredag", "Lördag"],
"dayNamesShort": ["Sön", "Man", "Tir", "Ons", "Tor", "Fre", "Lör"],
"dayNames": [
"Söndag",
"Måndag",
"Tisdag",
"Onsdag",
"Torsdag",
"Fredag",
"Lördag"
],
"dayNamesShort": [
"Sön",
"Man",
"Tir",
"Ons",
"Tor",
"Fre",
"Lör"
],
"eventTimeFormat": "HH:mm",
"list": "Liste",
"month": "Månad",
@@ -886,7 +894,20 @@
"November",
"Desember"
],
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"],
"monthNamesShort": [
"Jan",
"Feb",
"Mar",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Des"
],
"next": "Neste",
"prev": "Föregående",
"slotLabelFormat": "HH:mm",
@@ -915,21 +936,6 @@
"year": "År"
},
"collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": {
"booked_desc": "Fakturan är bokförd i E-conomic, inga ytterligare åtgärder krävs.",
"booked_with_economic": "Faktura bokförd i E-conomic",
@@ -1371,15 +1377,6 @@
"subtitle": "Administrer produkter",
"title": "Produkter"
},
"slack": {
"customer_registration_webhook_url": "Webhook-URL för kundregistreringar",
"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_desc": "Slack-webhooks för systemhändelser.",
"subtitle": "Konfiguration av Slack-aviseringar",
"title": "Slack-konfiguration",
"unavailable": "Slack-konfigurationen är inte tillgänglig i den här API-versionen."
},
"shelly": {
"api_key": "API-nyckel",
"api_key_desc": "Shelly API-nyckeln används för att autentisera Shelly-integrationen",
@@ -2798,13 +2795,6 @@
"unknown_qr_text": "The scanned QR code is not valid for this page.",
"unknown_qr_title": "Okänd QR-kod"
},
"maintenance_menu": {
"confirm_clear_local": "Rensa all lokal appdata och ladda om nu?",
"force_update_clear_hint": "Rensar local storage, session storage, service workers och webbläsarcacher.",
"force_update_clear": "Tvinga uppdatering och rensa allt lokalt",
"title": "Underhåll",
"version": "Aktuell {current} / senaste {latest}"
},
"messages": {
"are_you_sure": "Är du säker?",
"cancelled": "Avbruten framgångsrikt",
@@ -3870,8 +3860,7 @@
"vehicles": "Fordon",
"xlvask": "XLVask",
"department_gates": "Avdelningsgrindar",
"department_relays": "Avdelningsreläer",
"error_reports": "Felrapporter"
"department_relays": "Avdelningsreläer"
},
"pages": {
"categories": {
@@ -4652,8 +4641,6 @@
"sms_phone": "Telefonnummer för SMS-aviseringar",
"sms_phone_desc": "Telefonnumret som för SMS-aviseringar.",
"sms_subtitle": "SMS",
"superuser_notifications": "Superanvändaraviseringar",
"superuser_notifications_desc": "Ta emot e-postaviseringar när en ny kund registrerar sig på sidan.",
"title": "Aviseringar"
},
"security": {
@@ -1,21 +1,6 @@
{
"compat": {
"collected_invoice": {
"move_customer": {
"card_title": "Flyt til kunde",
"confirm_button": "Flyt",
"confirm_text": "Flyt fakturasamling #{id} og {count} ordre til kunde #{customer}?",
"confirm_title": "Bekræft flytning",
"current_customer": "Nuværende kunde: #{customer}",
"customer_value": "Kunde #{customer}",
"error_title": "Kunne ikke flytte fakturasamling",
"invalid_customer": "Indtast et gyldigt kundenummer.",
"locked": "Låst",
"same_customer": "Vælg en anden kunde end den nuværende.",
"success_text": "{count} ordre blev flyttet til kunde #{customer}.",
"success_title": "Fakturasamling flyttet",
"title": "Flyt fakturasamling"
},
"economic": {
"booked_desc": "@.capitalize:{'terms.glossary.fakturaen'} @:{'terms.glossary.er'} @:{'terms.glossary.bogført'} @:{'terms.glossary.i'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}, @:{'terms.glossary.ingen'} @:{'terms.glossary.yderligere'} @:{'terms.glossary.handlinger'} @:{'terms.glossary.er'} @:{'terms.glossary.nødvendige'}.",
"booked_with_economic": "@.capitalize:{'terms.glossary.fakturaen'} @:{'terms.glossary.er'} @:{'terms.glossary.bogført'} @:{'terms.glossary.med'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}",
@@ -158,20 +158,6 @@
"delete_confirm": "@.capitalize:{'terms.glossary.er'} @:{'terms.glossary.du'} @:{'terms.glossary.sikker'} @:{'terms.glossary.pa'}, @:{'terms.glossary.at'} @:{'terms.glossary.du'} @:{'terms.glossary.vil'} @:{'terms.glossary.slette'} @:{'terms.glossary.dette'} @:{'terms.glossary.produkt'}?",
"edit": "@:{'phrases.compat.products.edit_product'}"
},
"slack": {
"customer_registration_webhook_url": "Webhook-URL til kunderegistreringer",
"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_desc": "Slack-webhooks til systemhændelser.",
"send_test_webhook": "Send test-webhook",
"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",
"unavailable": "Slack-konfigurationen er ikke tilgængelig i denne API-udgivelse."
},
"shelly": {
"api_key_desc": "@:{'terms.glossary.shelly'} @:{'terms.glossary.api'}-@:{'terms.glossary.nøkkelen'} @:{'terms.glossary.bruges'} @:{'terms.glossary.til'} @:{'terms.glossary.a_2'} @:{'terms.glossary.autentisere'} @:{'terms.glossary.shelly'}-@:{'terms.glossary.integrationen'}",
"api_settings_desc": "@:{'terms.glossary.shelly'} @:{'terms.glossary.api'} @:{'terms.glossary.forbindelsesindstillingerne'}.",
@@ -1,11 +0,0 @@
{
"compat": {
"maintenance_menu": {
"confirm_clear_local": "Ryd alle lokale appdata og genindlaes nu?",
"force_update_clear_hint": "Rydder local storage, session storage, service workers og browsercaches.",
"force_update_clear": "Tving opdatering og ryd alt lokalt",
"title": "Vedligeholdelse",
"version": "Nuværende {current} / seneste {latest}"
}
}
}
@@ -103,9 +103,7 @@
"email_notifications_desc": "@:{'terms.glossary.modtag'} @:{'terms.glossary.e'}-mails @:{'terms.glossary.med'} bookingbekræftelser @:{'terms.glossary.og'} @:{'terms.glossary.vaskecertifikater'}.",
"sms_notifications_desc": "@:{'terms.glossary.modtag'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifikationer'} @:{'terms.glossary.ved'} @:{'terms.glossary.forskellige'} @:{'terms.glossary.begivenheder'}.",
"sms_phone": "@.capitalize:{'terms.glossary.telefonnummer'} @:{'terms.glossary.til'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifikationer'}",
"sms_phone_desc": "@.capitalize:{'terms.glossary.det'} @:{'terms.glossary.telefonnummer'}, @:{'terms.glossary.der'} @:{'terms.glossary.modtager'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifikationer'}.",
"superuser_notifications": "Superuser-notifikationer",
"superuser_notifications_desc": "Modtag e-mailnotifikationer, når en ny kunde registrerer sig på siden."
"sms_phone_desc": "@.capitalize:{'terms.glossary.det'} @:{'terms.glossary.telefonnummer'}, @:{'terms.glossary.der'} @:{'terms.glossary.modtager'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifikationer'}."
},
"security": {
"description": "@.capitalize:{'terms.glossary.dine'} sikkerhedsindstillinger",
-8
View File
@@ -12,14 +12,6 @@
"field_required": "{field} @:{'terms.glossary.er'} @:{'terms.glossary.pakrævet'}",
"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": {
"host_targets": {
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
@@ -1,21 +1,6 @@
{
"compat": {
"collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": {
"booked_desc": "@.capitalize:{'terms.glossary.die'} @:{'terms.glossary.rechnung'} @:{'terms.glossary.ist'} @:{'terms.glossary.in'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.gebucht'}, @:{'terms.glossary.keine'} @:{'terms.glossary.weiteren'} @:{'terms.glossary.aktionen'} @:{'terms.glossary.sind'} @:{'terms.glossary.erforderlich'}.",
"booked_with_economic": "@:{'terms.glossary.rechnung'} @:{'terms.glossary.mit'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.gebucht'}",
@@ -158,20 +158,6 @@
"delete_confirm": "@.capitalize:{'terms.glossary.sind'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.sicher'}, @:{'terms.glossary.dass'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.dieses'} @.capitalize:{'terms.glossary.produkt'} @:{'terms.glossary.l'}?@:{'terms.glossary.schen'} @:{'terms.glossary.m'}?@:{'terms.glossary.chten'}?",
"edit": "@.capitalize:{'terms.glossary.produkt'} @:{'terms.glossary.bearbeiten'}"
},
"slack": {
"customer_registration_webhook_url": "Webhook-URL fuer Kundenregistrierungen",
"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_desc": "Slack-Webhooks fuer Systemereignisse.",
"send_test_webhook": "Test-Webhook senden",
"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",
"unavailable": "Die Slack-Konfiguration ist in dieser API-Version nicht verfuegbar."
},
"shelly": {
"api_key_desc": "@.capitalize:{'terms.glossary.der'} @:{'terms.glossary.shelly'} @:{'terms.glossary.api'}-@:{'terms.glossary.schl'}?@:{'terms.glossary.ssel'} @:{'terms.glossary.wird'} @:{'terms.glossary.zur'} @:{'terms.glossary.authentifizierung'} @:{'terms.glossary.der'} @:{'terms.glossary.shelly'}-@.capitalize:{'terms.glossary.integration'} @:{'terms.glossary.verwendet'}",
"api_settings_desc": "@:{'terms.glossary.api'}-@:{'terms.glossary.verbindungseinstellungen'} @:{'terms.glossary.f'}?@:{'terms.glossary.r'} @:{'terms.glossary.shelly'}.",
@@ -1,11 +0,0 @@
{
"compat": {
"maintenance_menu": {
"confirm_clear_local": "Alle lokalen App-Daten löschen und jetzt neu laden?",
"force_update_clear_hint": "Löscht Local Storage, Session Storage, Service Worker und Browser-Caches.",
"force_update_clear": "Update erzwingen und alles Lokale löschen",
"title": "Wartung",
"version": "Aktuell {current} / neueste {latest}"
}
}
}
@@ -103,9 +103,7 @@
"email_notifications_desc": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.mails'} @:{'terms.glossary.mit'} @:{'terms.glossary.buchungsbest'}?@:{'terms.glossary.tigungen'} @:{'terms.glossary.und'} Waschzertifikaten @:{'terms.glossary.erhalten'}.",
"sms_notifications_desc": "@:{'terms.glossary.sms'}-@:{'terms.glossary.benachrichtigungen'} @:{'terms.glossary.f'}?@:{'terms.glossary.r'} @:{'terms.glossary.verschiedene'} @:{'terms.glossary.ereignisse'} @:{'terms.glossary.erhalten'}.",
"sms_phone": "@:{'terms.glossary.telefonnummer'} @:{'terms.glossary.f'}?@:{'terms.glossary.r'} @:{'terms.glossary.sms'}-@:{'terms.glossary.benachrichtigungen'}",
"sms_phone_desc": "@.capitalize:{'terms.glossary.die'} @:{'terms.glossary.telefonnummer'}, @:{'terms.glossary.die'} @:{'terms.glossary.sms'}-@:{'terms.glossary.benachrichtigungen'} @:{'terms.glossary.erh'}?@:{'terms.glossary.lt'}.",
"superuser_notifications": "Superuser-Benachrichtigungen",
"superuser_notifications_desc": "E-Mail-Benachrichtigungen erhalten, wenn sich ein neuer Kunde auf der Seite registriert."
"sms_phone_desc": "@.capitalize:{'terms.glossary.die'} @:{'terms.glossary.telefonnummer'}, @:{'terms.glossary.die'} @:{'terms.glossary.sms'}-@:{'terms.glossary.benachrichtigungen'} @:{'terms.glossary.erh'}?@:{'terms.glossary.lt'}."
},
"security": {
"description": "@.capitalize:{'terms.glossary.ihre'} Sicherheitseinstellungen",
-8
View File
@@ -12,14 +12,6 @@
"field_required": "{field} @:{'terms.glossary.ist'} @:{'terms.glossary.erforderlich'}",
"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": {
"host_targets": {
"database": "@:{'terms.replication.services.database'}-@:{'terms.replication.host'}",
@@ -1,21 +1,6 @@
{
"compat": {
"collected_invoice": {
"move_customer": {
"card_title": "Move to customer",
"confirm_button": "Move",
"confirm_text": "Move invoice collection #{id} and {count} orders to customer #{customer}?",
"confirm_title": "Confirm move",
"current_customer": "Current customer: #{customer}",
"customer_value": "Customer #{customer}",
"error_title": "Could not move invoice collection",
"invalid_customer": "Enter a valid customer number.",
"locked": "Locked",
"same_customer": "Choose another customer than the current one.",
"success_text": "{count} orders were moved to customer #{customer}.",
"success_title": "Invoice collection moved",
"title": "Move invoice collection"
},
"economic": {
"booked_desc": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.invoice'} @:{'terms.glossary.is'} @:{'terms.glossary.booked'} @:{'terms.glossary.in'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}, @:{'terms.glossary.no'} @:{'terms.glossary.further'} @:{'terms.glossary.actions'} @:{'terms.glossary.are'} @:{'terms.glossary.required'}.",
"booked_with_economic": "@.capitalize:{'terms.glossary.invoice'} @:{'terms.glossary.booked'} @:{'terms.glossary.with'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}",
@@ -158,20 +158,6 @@
"delete_confirm": "@.capitalize:{'terms.glossary.are'} @:{'terms.glossary.you'} @:{'terms.glossary.sure'} @:{'terms.glossary.you'} @:{'terms.glossary.want'} @:{'terms.glossary.to'} @:{'terms.glossary.delete'} @:{'terms.glossary.this'} @:{'terms.glossary.product'}?",
"edit": "@:{'phrases.compat.products.edit_product'}"
},
"slack": {
"customer_registration_webhook_url": "Customer registration webhook URL",
"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_desc": "Slack webhooks for system events.",
"send_test_webhook": "Send test webhook",
"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",
"unavailable": "Slack configuration is not available on this API release."
},
"shelly": {
"api_key_desc": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.shelly'} @:{'terms.glossary.api'} @:{'terms.glossary.key'} @:{'terms.glossary.is'} @:{'terms.glossary.used'} @:{'terms.glossary.to'} @:{'terms.glossary.authenticate'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.shelly'} @:{'terms.glossary.integration'}",
"api_settings_desc": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.shelly'} @:{'terms.glossary.api'} @:{'terms.glossary.connection'} @:{'terms.glossary.settings'}.",

Some files were not shown because too many files have changed in this diff Show More