Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 2a64fcdf19 Stabilize self-serve mobile e2e checks 2026-06-11 18:21:35 +02:00
Jeppe Bundgaard f8b7bda74a Guard self-serve active wash restore after unmount 2026-06-11 18:04:42 +02:00
Jeppe Bundgaard 5a0fc5f36b Add self-serve condition evaluation coverage 2026-06-11 17:57:10 +02:00
Jeppe B 9e378b32ac Merge pull request #128 from copenhagentruckwash/fix/mobile-gps-department-selection
Fix mobile GPS department auto-selection
2026-06-11 17:53:38 +02:00
Jeppe Bundgaard 968587d847 Fix mobile GPS department auto-selection 2026-06-11 17:32:28 +02:00
Jeppe B fa809ccd0b Merge pull request #126 from copenhagentruckwash/fix/self-serve-path-editor-case-limit
Fix self-serve path editor case limit
2026-06-11 16:57:35 +02:00
Jeppe Bundgaard a8800a3be1 Avoid duplicate PR branch push workflows 2026-06-11 16:37:51 +02:00
Jeppe Bundgaard 1f23b58d1e Isolate frontend E2E workflow ports 2026-06-11 16:36:36 +02:00
Jeppe B c6b266b0de Merge pull request #127 from copenhagentruckwash/codex/slack-test-webhook
Add Slack test webhook button
2026-06-11 16:31:09 +02:00
Jeppe Bundgaard 3eba32c235 Avoid setup-node cache restore timeouts 2026-06-11 16:06:02 +02:00
Jeppe B ec76ae426e Merge pull request #125 from copenhagentruckwash/fix/mobile-pos-product-27-note
Fix mobile POS product 27 completion
2026-06-11 15:39:35 +02:00
Jeppe Bundgaard e35d59c652 Use frontend runners for CI 2026-06-11 15:24:12 +02:00
Jeppe Bundgaard 73fc89557b Add Slack test webhook button 2026-06-11 15:06:43 +02:00
Jeppe Bundgaard be98715045 Fix self-serve path editor case limit 2026-06-11 14:46:23 +02:00
Jeppe Bundgaard 4392b7915f Fix mobile POS product 27 completion 2026-06-11 14:34:53 +02:00
Jeppe B b1fb92549c Merge pull request #124 from copenhagentruckwash/fix/full-e2e-tests-20260611
Fix full e2e regressions
2026-06-11 14:22:32 +02:00
Jeppe Bundgaard 9bba666f3a Harden mobile e2e route readiness waits 2026-06-11 13:51:39 +02:00
Jeppe Bundgaard dcf0fd61ba Avoid sudo in Playwright browser fallback 2026-06-11 13:24:22 +02:00
Jeppe Bundgaard 3d02146911 Stabilize full e2e CI matrix 2026-06-11 13:13:06 +02:00
Jeppe Bundgaard 10bc822233 Fix full e2e regressions 2026-06-11 10:13:58 +02:00
Jeppe B baac1a243a Merge pull request #123 from copenhagentruckwash/fix/self-serve-machine-tasks-after-start
Keep machine tasks visible after start summary refresh
2026-06-10 22:11:00 +02:00
Jeppe Bundgaard 7c233d7c03 Keep machine tasks visible after start summary refresh 2026-06-10 20:53:02 +02:00
Jeppe B 7c19dbddf0 Merge pull request #122 from copenhagentruckwash/fix/self-serve-start-wash-type
Honor wash type in self-serve start command
2026-06-10 20:15:58 +02:00
Jeppe Bundgaard f4b248573a Honor wash type in self-serve start command 2026-06-10 19:18:24 +02:00
Jeppe B 14e454e9d9 Merge pull request #121 from copenhagentruckwash/fix-github-runner-test-failures-8jso54
Robust Playwright browser install, e2e/unit test reliability fixes, and session bootstrap improvements
2026-06-10 16:08:11 +02:00
Jeppe B ebab7d8804 Merge pull request #120 from copenhagentruckwash/fix-github-runner-test-failures
Stabilize flaky Playwright e2e tests: ensure session bootstrap, add test hooks and increase timeouts
2026-06-10 16:07:53 +02:00
Jeppe B 78a3fb1879 test: stabilize reference autocomplete unit cleanup 2026-06-10 16:06:36 +02:00
Jeppe B d42b58c4fe test: harden flaky chromium smoke specs 2026-06-10 15:27:14 +02:00
Jeppe B 724ad8e7a1 ci: stabilize Playwright runner installs and smoke tests 2026-06-10 02:57:02 +02:00
Jeppe B 2fe50729a5 ci: fall back for unsupported Playwright deps install 2026-06-10 00:52:14 +02:00
Jeppe B eb4eee1aef test: stabilize github runner e2e checks 2026-06-09 23:02:28 +02:00
Jeppe Bundgaard db972e2923 test: refresh invoicing flag counts from scoped flags 2026-06-09 16:03:23 +02:00
Jeppe Bundgaard 00817925e4 Add path case answer mapping and matching logic for improved path verification 2026-06-09 15:56:59 +02:00
Jeppe Bundgaard 347ab3c811 ci: allow e2e jobs on linux runner pool 2026-06-09 15:53:58 +02:00
Jeppe Bundgaard a3d05b15db test: stabilize edge gateway route readiness 2026-06-09 15:46:48 +02:00
Jeppe Bundgaard 9093d0d601 test: isolate request queue shortcut listeners 2026-06-09 15:20:15 +02:00
Jeppe Bundgaard 8c57b98627 Fix i18n CI runtime menu checks 2026-06-09 15:14:22 +02:00
Jeppe Bundgaard 4b99751c0e test: reset POS department route in unit setup 2026-06-09 15:13:06 +02:00
Jeppe Bundgaard 66f4331b4e Refactor machine allowance logic in self-serve studio and remove unused controls 2026-06-09 14:52:20 +02:00
Jeppe Bundgaard e1a824d565 Fix path editor answer row layout 2026-06-09 14:18:15 +02:00
Jeppe Bundgaard 883a88a551 ci: split playwright pr e2e matrix 2026-06-09 14:07:25 +02:00
Jeppe Bundgaard a328bc3767 Filter path editor to machine start cases 2026-06-09 13:57:33 +02:00
Jeppe Bundgaard 255447f788 Hide placeholder departments in admin navigation 2026-06-09 13:34:08 +02:00
Jeppe Bundgaard b3d57ca717 test: cover collected invoice move checks 2026-06-09 13:24:23 +02:00
Jeppe Bundgaard 45d21c4372 fix: restore self-serve lane step hook 2026-06-09 13:19:22 +02:00
Jeppe Bundgaard 7f7b5ae082 Add Swedish invoice collection move translation 2026-06-09 13:17:55 +02:00
Jeppe Bundgaard 028d8f1fc8 Add move to customer functionality for invoice collections in multiple languages 2026-06-09 13:16:26 +02:00
Jeppe Bundgaard 8d01ca1363 Implement move collected invoice to customer feature with API integration and tests 2026-06-09 13:16:05 +02:00
Jeppe Bundgaard a36403d279 Refactor self-serve fetch logic to use refs for state management; implement abort controller for active requests 2026-06-09 13:07:21 +02:00
Jeppe Bundgaard a78ad69c0c Add selfserve dynamic image size selector 2026-06-09 12:46:49 +02:00
Jeppe Bundgaard 22c856afb6 test: stabilize superuser firefox e2e 2026-06-09 12:34:11 +02:00
Jeppe Bundgaard 71a073ebf9 Add hidden frontend maintenance refresh 2026-06-09 12:32:58 +02:00
Jeppe Bundgaard f709cac95e Test wash type service sync 2026-06-09 12:13:35 +02:00
Jeppe Bundgaard 27867e48df test: update my wash start unit stubs 2026-06-09 11:50:21 +02:00
Jeppe Bundgaard f1a7e1dfcd test: stabilize POS draft full slice 2026-06-09 11:29:13 +02:00
Jeppe Bundgaard 31e9566f2d Refactor API route handling in tests to use centralized path patterns; enhance error handling in self-serve components 2026-06-09 10:52:20 +02:00
Jeppe Bundgaard f7f749e230 Fix PWA install scope 2026-06-08 20:56:32 +02:00
Jeppe Bundgaard f5b7da7b0a test: skip WebKit mobile invoice monitor header 2026-06-08 20:42:30 +02:00
Jeppe Bundgaard 2311188b13 test: align self-serve stop failure e2e 2026-06-08 20:25:01 +02:00
Jeppe Bundgaard 25637b52d9 test: update self-serve completion contract 2026-06-08 20:20:46 +02:00
Jeppe Bundgaard fddcc86cd6 fix: preserve self-serve stop failure banner 2026-06-08 20:17:44 +02:00
Jeppe Bundgaard c1b2be7d70 Enhance task ID handling in updateLaneAllowedServices; improve error handling in wash process 2026-06-08 19:56:02 +02:00
Jeppe Bundgaard 0a7b3a36aa test: stabilize release and department e2e mocks 2026-06-08 19:51:17 +02:00
Jeppe Bundgaard 57f102de71 test: skip desktop-only superuser specs on compact projects 2026-06-08 19:47:54 +02:00
Jeppe Bundgaard 6b22887a90 Remove guidance text for unavailable machine wash in self-serve flow 2026-06-08 18:58:33 +02:00
Jeppe Bundgaard 3b3f212ceb ci: use polling for Playwright Vite server 2026-06-08 18:54:25 +02:00
Jeppe Bundgaard 1a11e31836 ci: raise Playwright file descriptor limit 2026-06-08 18:46:56 +02:00
Jeppe Bundgaard d4b69c3990 test: align release runtime inspector privileges 2026-06-08 18:38:49 +02:00
Jeppe Bundgaard 4cbccd533a fix: stabilize POS route and action menu tests 2026-06-08 18:09:51 +02:00
Jeppe Bundgaard a9bc3ab0fc Fix POS booking match recursion 2026-06-08 17:29:06 +02:00
Jeppe Bundgaard d9f929df04 fix: restore Slack i18n compatibility 2026-06-08 17:25:56 +02:00
Jeppe Bundgaard bc154a4e6d Keep release wait after auto sync gateway timeout 2026-06-08 17:12:34 +02:00
Jeppe Bundgaard 7d48e40e05 Enhance release workflow with current commit check and enable concurrency cancellation 2026-06-08 17:02:42 +02:00
Jeppe Bundgaard 47af253bf8 Enhance self-serve logic to ignore stale answer sync responses and implement mutation request tracking 2026-06-08 16:53:03 +02:00
Jeppe Bundgaard e126dfccf9 Enhance accessibility and linting configuration
- Add tabindex attributes to input fields for better keyboard navigation
- Introduce ESLint configuration for improved code quality and consistency
- Update package.json to include linting scripts and dependencies
- Add tests for desktop tab navigation in POS flow
2026-06-08 16:35:56 +02:00
Jeppe Bundgaard be5191825d fix: distinguish Slack config permission errors 2026-06-08 16:33:52 +02:00
Jeppe Bundgaard 68cf9c4c06 Continue release wait after auto sync timeout 2026-06-08 16:25:24 +02:00
Jeppe Bundgaard e46b7ae58d Request release auto sync before artifact wait 2026-06-08 16:18:45 +02:00
Jeppe Bundgaard 3d81fb8dd7 Fix release verification for external build ids 2026-06-08 15:59:39 +02:00
Jeppe Bundgaard ff983dca60 Trigger frontend release 2026-06-08 15:48:21 +02:00
Jeppe Bundgaard fb0fceb6c4 Add booking count permission checks and handle API response failures gracefully 2026-06-08 15:26:57 +02:00
Jeppe Bundgaard 9591afb9a2 Add booking count permission checks and handle API response failures gracefully 2026-06-08 13:41:57 +02:00
Jeppe Bundgaard 0afde8ac8e Add support for webmanifest files in .htaccess and nginx configuration 2026-06-08 13:11:21 +02:00
Jeppe Bundgaard 4a83486b30 Update Danish translations for various UI elements and API keys 2026-06-08 12:51:10 +02:00
Jeppe Bundgaard a3ceac1636 Add Slack configuration module with customer registration webhook settings 2026-06-08 12:43:02 +02:00
Jeppe Bundgaard 8666d17a42 Add superuser new customer email notification settings 2026-06-08 12:19:32 +02:00
gold-dev 96d13b2400 Feat: Produktivitet overview 2026-06-08 11:32:30 +03:00
gold-dev 12fc1f80cc Feat: Rename ”Charts” til ”Grafer” 2026-06-08 10:50:37 +03:00
Jeppe B 2dfe4e9b37 Merge pull request #119 from copenhagentruckwash/fix-github-runner-test-failures
Fix self-serve wash progress actions, guided-wash emits and guest fallback UI; install Playwright deps for CI
2026-06-08 09:26:26 +02:00
Jeppe B 46c634e5a7 Fix self-serve wash test regressions 2026-06-08 09:13:24 +02:00
231 changed files with 9028 additions and 2868 deletions
-28
View File
@@ -1,28 +0,0 @@
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
+7 -3
View File
@@ -7,9 +7,13 @@ 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
@@ -20,9 +24,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=$QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader:latest \
-e QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
+80 -12
View File
@@ -1,21 +1,25 @@
name: Frontend Release
on:
push:
workflow_run:
workflows:
- Automated Tests
types:
- completed
branches:
- master
workflow_dispatch:
permissions:
contents: read
actions: read
concurrency:
group: frontend-release-${{ github.ref }}
cancel-in-progress: false
group: frontend-release-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
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
@@ -25,7 +29,7 @@ jobs:
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/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.sha }}
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
RELEASE_WAIT_INITIAL_SECONDS: 45
RELEASE_WAIT_TIMEOUT_SECONDS: 600
RELEASE_POLL_INTERVAL_SECONDS: 10
@@ -34,54 +38,114 @@ 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
run: npx playwright install --with-deps chromium
if: steps.branch-head.outputs.current == 'true'
run: node scripts/install-playwright-browsers.mjs 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: 14
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 }}
- 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
@@ -94,31 +158,35 @@ 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_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\"]}"
--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\"]}"
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.ref_name }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
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.sha }}
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
- name: Upload Playwright report
if: failure()
if: failure() && steps.branch-head.outputs.current == 'true'
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
path: output/playwright
if-no-files-found: ignore
retention-days: 14
retention-days: 3
+118 -22
View File
@@ -3,6 +3,8 @@ name: Automated Tests
on:
pull_request:
push:
branches:
- master
workflow_dispatch:
schedule:
- cron: "0 2 * * *"
@@ -11,13 +13,14 @@ permissions:
contents: read
concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.ref }}
group: frontend-tests-${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
format-tests:
# CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v5
@@ -26,7 +29,6 @@ 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
@@ -39,7 +41,8 @@ jobs:
build-and-unit:
needs: format-tests
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v5
@@ -48,11 +51,16 @@ 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
@@ -64,7 +72,18 @@ jobs:
e2e-pr:
if: github.event_name != 'schedule'
needs: build-and-unit
runs-on: [self-hosted, Linux, X64, default]
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
steps:
- name: Checkout repository
uses: actions/checkout@v5
@@ -98,33 +117,77 @@ 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: npx playwright install --with-deps chromium
run: node scripts/install-playwright-browsers.mjs chromium
- name: Run Playwright PR tests
run: npm run test:e2e:pr -- --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
- 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: Upload Playwright report
if: failure()
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: playwright-report-pr
path: output/playwright
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
path: |
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
if-no-files-found: ignore
retention-days: 7
retention-days: 3
e2e-full:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
needs: build-and-unit
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]
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 4
matrix:
role: [customer, subuser, admin, superuser]
browser: [chromium, firefox, webkit]
@@ -150,19 +213,52 @@ 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: npx playwright install --with-deps ${{ matrix.browser_install }}
run: node scripts/install-playwright-browsers.mjs ${{ matrix.browser_install }}
- name: Set Playwright dev server port
shell: bash
env:
MATRIX_ROLE: ${{ matrix.role }}
MATRIX_BROWSER: ${{ matrix.browser }}
MATRIX_DEVICE: ${{ matrix.device }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
workflow_offset=$(( (RUN_ID % 90) * 600 ))
case "$MATRIX_ROLE" in
customer) role_offset=0 ;;
subuser) role_offset=100 ;;
admin) role_offset=200 ;;
superuser) role_offset=300 ;;
*) echo "Unsupported Playwright role: $MATRIX_ROLE" >&2; exit 1 ;;
esac
case "$MATRIX_BROWSER" in
chromium) browser_offset=0 ;;
firefox) browser_offset=30 ;;
webkit) browser_offset=60 ;;
*) echo "Unsupported Playwright browser: $MATRIX_BROWSER" >&2; exit 1 ;;
esac
case "$MATRIX_DEVICE" in
mobile) device_offset=1 ;;
tablet) device_offset=2 ;;
desktop) device_offset=3 ;;
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
esac
echo "PLAYWRIGHT_DEV_PORT=$((10000 + workflow_offset + role_offset + browser_offset + device_offset))" >> "$GITHUB_ENV"
- name: Run full Playwright slice
run: npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
run: |
ulimit -n 16384 || true
npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
- name: Upload Playwright report
if: failure()
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
@@ -171,4 +267,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: 14
retention-days: 3
+3 -3
View File
@@ -56,9 +56,9 @@ android {
defaultConfig {
applicationId "io.truckwash.twa"
minSdkVersion 21
targetSdkVersion 35
versionCode 6
versionName "6"
targetSdkVersion 36
versionCode 7
versionName "7"
// 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":"pleno-pwa-test"}
{"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":"/"}
+139
View File
@@ -0,0 +1,139 @@
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",
},
},
];
+8
View File
@@ -8,6 +8,14 @@ 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;
+3 -3
View File
@@ -56,9 +56,9 @@ android {
defaultConfig {
applicationId "io.truckwash.twa.staging"
minSdkVersion 21
targetSdkVersion 35
versionCode 5
versionName "5"
targetSdkVersion 36
versionCode 6
versionName "6"
// 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":"pleno-pwa-test"}
{"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":"/"}
+1105 -7
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -7,6 +7,9 @@
"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}\"",
@@ -108,17 +111,23 @@
},
"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,10 +38,14 @@ 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
? {}
+11 -4
View File
@@ -19,6 +19,16 @@ 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"] : []),
@@ -449,10 +459,7 @@ export default async function globalSetup() {
const serverProcess = spawn(process.execPath, [viteCliPath, ...viteDevArgs], {
cwd: process.cwd(),
detached: true,
env: {
...process.env,
PLAYWRIGHT: "1",
},
env: viteServerEnv,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
+51 -20
View File
@@ -1,3 +1,4 @@
import fs from "node:fs";
import { defineConfig, devices } from "@playwright/test";
const baseURL = "http://127.0.0.1:4173";
@@ -5,6 +6,55 @@ 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/,
@@ -28,24 +78,5 @@ export default defineConfig({
timeout: 240_000,
reuseExistingServer: !isCI,
},
projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 5"],
},
},
{
name: "webkit-desktop",
use: {
...devices["Desktop Safari"],
},
},
],
projects,
});
+4
View File
@@ -2,6 +2,10 @@
Options -MultiViews
</IfModule>
<IfModule mod_mime.c>
AddType application/manifest+json .webmanifest
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
+1
View File
@@ -2,6 +2,7 @@
"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
@@ -0,0 +1,90 @@
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);
+25 -1
View File
@@ -1,4 +1,5 @@
import crypto from "node:crypto";
import fs from "node:fs";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -7,6 +8,24 @@ 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) {
@@ -121,6 +140,7 @@ 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");
@@ -130,7 +150,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 (expectedBuildId && manifest.build_id !== expectedBuildId) {
if (strictBuildId && 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) {
@@ -191,6 +211,10 @@ 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}`
);
+1
View File
@@ -80,6 +80,7 @@ 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,6 +22,7 @@ for (const signal of ["SIGINT", "SIGTERM"]) {
function parseArgs(rawArgs) {
const parsed = {
help: false,
coreOnly: false,
changedOnly: false,
listOnly: false,
base: "",
@@ -43,6 +44,11 @@ function parseArgs(rawArgs) {
continue;
}
if (value === "--core-only") {
parsed.coreOnly = true;
continue;
}
if (value === "--changed-only") {
parsed.changedOnly = true;
continue;
@@ -103,12 +109,14 @@ 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
`);
}
@@ -418,6 +426,10 @@ 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) {
@@ -428,6 +440,10 @@ 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,6 +20,7 @@ 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")
);
@@ -128,6 +129,7 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
</template>
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<FrontendMaintenanceMenu />
</template>
<style scoped></style>
@@ -2535,11 +2535,13 @@ const syncDesktopFlyoutPosition = () => {
dropdownContentEl.style.top = "";
}
const padding = 12;
const viewportInsets = getViewportInsets();
const viewportTop = viewportInsets.top;
const viewportBottom = viewportHeight - viewportInsets.bottom;
let top = triggerRect.bottom;
if (top + contentHeight > viewportHeight - padding) {
top = Math.max(padding, triggerRect.top - contentHeight);
if (top + contentHeight > viewportBottom) {
top = Math.max(viewportTop, triggerRect.top - contentHeight);
}
const nextFixedStyles = {
@@ -275,6 +275,10 @@ 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;
}
@@ -649,6 +653,41 @@ 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],
() => {
@@ -999,6 +1038,13 @@ 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,6 +21,7 @@ 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";
@@ -645,7 +646,7 @@ const pushDesktopStepTwoRoute = () => {
return;
}
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
};
const handleDesktopLastWashCopy = async (payload = {}) => {
@@ -760,7 +761,7 @@ watch(
</div>
<div class="pos-shell-actions">
<ButtonsBox class="pos-actions pos-actions--stacked">
<Cancel tabindex="2" class="is-fullwidth" />
<Cancel tabindex="5" class="is-fullwidth" />
</ButtonsBox>
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail">
<PosDesktopDuplicateWarning
@@ -53,6 +53,9 @@ 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
@@ -477,6 +480,8 @@ 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);
@@ -777,15 +782,18 @@ const buildDesiredOrderItemShapes = () => {
const addonShapes = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.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 ?? ""),
}));
.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 ?? ""),
};
});
const additionalShapes = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
@@ -952,16 +960,17 @@ const syncCurrentTransactionToOrder = async () => {
const addonPromises = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) =>
createOrderItem(
.map((addon: any) => {
const addonProduct = addon?.product ?? addon;
return createOrderItem(
normalizedOrderId,
addon.product.id,
addonProduct.id,
Number(addon.quantity),
createdPrimaryItemId,
addon.product?.notes || "",
addon.product.price
)
);
addonProduct?.notes || addon?.notes || "",
addonProduct.price ?? addon.price
);
});
const additionalPromises = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
@@ -973,11 +982,146 @@ 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,6 +10,7 @@ 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";
@@ -70,10 +71,10 @@ const formatCurrency = (amount) => {
const navigateToItems = () => {
setStep(2);
if (normalizedOrderId.value) {
window.history.pushState({}, '', `?id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
pushPosRouteState(`id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
return;
}
window.history.pushState({}, '', `?step=2`);
pushPosRouteState("step=2");
};
const clearSuccessfulMobileCardFlow = () => {
@@ -2,25 +2,43 @@
import { useGeolocation } from "@vueuse/core";
import { watch } from "vue";
import { locations } from "../objects/PosDepartmentStepMobileFlow.vue";
const emits = defineEmits<{
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
}>();
const { coords, locatedAt, error, resume, pause } = useGeolocation({
const props = withDefaults(defineProps<{
enableHighAccuracy?: boolean;
maximumAge?: number;
timeout?: number;
}>(), {
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 }) => {
if (newCoords.latitude && newCoords.longitude) {
const normalizedCoords = locations.normalizeCoordinatePair(newCoords);
if (normalizedCoords) {
const timestamp = getLocationTimestamp();
locations.set({
coords: {
latitude: newCoords.latitude,
longitude: newCoords.longitude,
},
timestamp: new Date(),
coords: normalizedCoords,
timestamp: new Date(timestamp),
locatedAt: timestamp,
errorMessage: error.value ? error.value.message : null,
})
emits('location-updated', newCoords);
emits('location-updated', normalizedCoords);
}
};
watch(coords, (newCoords) => {
@@ -405,7 +405,10 @@ const onClick = async () => {
isProcessingClick.value = true;
try {
await props.onBeforeStep();
const beforeStepResult = await props.onBeforeStep();
if (beforeStepResult === false) {
return;
}
// Proceed to the next step
switch (step.value) {
case 1:
@@ -28,6 +28,7 @@ watch(note, (newNote) => {
class="input is-searched"
v-model="note"
type="text"
data-testid="pos-mobile-product-note-input"
placeholder="Indtast note"
/>
</div>
@@ -100,18 +100,57 @@ 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: { latitude: number; longitude: number },
to: { latitude: number; longitude: number }
from: CoordinatePair,
to: CoordinatePair
): 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(to.latitude - from.latitude);
const dLon = toRad(to.longitude - from.longitude);
const lat1 = toRad(from.latitude);
const lat2 = toRad(to.latitude);
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 a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
@@ -124,6 +163,8 @@ const locations = {
set: setLocation,
get: getLocation,
clear: clearLocation,
normalizeCoordinatePair,
hasValidCoordinatePair,
getDistance,
defaultTimeout: locationTimeout,
};
@@ -18,7 +18,9 @@ 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>
@@ -17,6 +17,7 @@ interface Props {
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
retryTestId?: string | null;
}
withDefaults(
@@ -26,12 +27,14 @@ withDefaults(
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
retryTestId?: string | null;
}>(),
{
type: "is-danger",
iconLeft: "sync-alt",
loading: false,
showRetry: true,
retryTestId: null,
}
);
@@ -41,13 +44,7 @@ const emit = defineEmits<{
</script>
<template>
<b-message
v-if="message"
:type="type"
has-icon
:closable="false"
class="self-serve-error-banner"
>
<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
@@ -57,6 +54,7 @@ const emit = defineEmits<{
icon-pack="fas"
:icon-left="iconLeft"
:loading="loading"
:data-testid="retryTestId || undefined"
@click="emit('retry')"
>
{{ $t("common.try_again") }}
@@ -93,8 +93,6 @@ const emit = defineEmits<{
native-value="Machine"
type="is-link"
:disabled="!isMachineAvailable(selectedLaneId)"
:title="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
:aria-label="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:washType', 'Machine')"
@input="emit('update:washType', 'Machine')"
@@ -117,14 +115,6 @@ const emit = defineEmits<{
</b-radio-button>
</div>
</div>
<b-message
v-if="!isMachineAvailable(selectedLaneId)"
type="is-warning"
aria-close-label="Luk besked"
data-testid="self-serve-machine-unavailable-guidance"
>
{{ $t("self_wash.machine_unavailable_for_lane") }}
</b-message>
</section>
</div>
</template>
@@ -20,16 +20,20 @@ const emit = defineEmits<{
const isDynamicImageLoading = ref(false);
const failedDynamicImageUrl = ref<string | null>(null);
const showDynamicImageFrame = computed(
() => !!props.dynamicImageUrl
&& !isDynamicImageLoading.value
&& failedDynamicImageUrl.value !== props.dynamicImageUrl
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 });
watch(
() => props.dynamicImageUrl,
(dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
failedDynamicImageUrl.value = null;
},
{ immediate: true }
);
const emitToggleTask = (taskId: number, value: boolean) => {
emit("toggle-task", taskId, value);
@@ -78,11 +82,20 @@ const onDynamicImageError = () => {
/>
<div
v-if="showDynamicImageFrame"
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"
@@ -117,6 +130,17 @@ const onDynamicImageError = () => {
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;
@@ -30,25 +30,28 @@ interface Props {
isCompletingWash: boolean;
openingPropertyAccessGate: boolean;
openingPropertyExitGate: boolean;
showProgressActions?: boolean;
}
defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
showProgressActions: true,
});
const emit = defineEmits<{
"update:currentGuidedWashStep": [step: number];
goPreviousGuidedWashStep: [];
goNextGuidedWashStep: [];
completeWash: [];
openPropertyAccessGate: [laneId: number | null];
openPropertyExitGate: [laneId: number | null];
openPropertyAccessGate: [];
openPropertyExitGate: [];
requestAssistance: [];
}>();
const isLastGuidedWashStep = computed(
() =>
typeof guidedWashFlowSteps !== "undefined" &&
guidedWashFlowSteps.length > 0 &&
currentGuidedWashStep >= guidedWashFlowSteps.length - 1
Array.isArray(props.guidedWashFlowSteps) &&
props.guidedWashFlowSteps.length > 0 &&
props.currentGuidedWashStep >= props.guidedWashFlowSteps.length - 1
);
function handleGoPrevious() {
@@ -63,12 +66,12 @@ function handleComplete() {
emit("completeWash");
}
function handleOpenAccessGate(laneId: number | null) {
emit("openPropertyAccessGate", laneId);
function handleOpenAccessGate() {
emit("openPropertyAccessGate");
}
function handleOpenExitGate(laneId: number | null) {
emit("openPropertyExitGate", laneId);
function handleOpenExitGate() {
emit("openPropertyExitGate");
}
function handleRequestAssistance() {
@@ -80,7 +83,7 @@ function handleRequestAssistance() {
<div data-testid="self-serve-wash-progress">
<!-- Finishing screen -->
<div
v-if="isCompletingWash"
v-if="isCompletingWash && showProgressActions"
class="notification is-info is-light self-serve-finishing-screen"
data-testid="self-serve-finishing-wash"
>
@@ -94,6 +97,7 @@ function handleRequestAssistance() {
parent can plug in SelfServeGuidedInstructions or its own implementation.
-->
<slot
v-if="showProgressActions"
name="guided-instructions"
:current-step="currentGuidedWashStep"
:steps="guidedWashFlowSteps"
@@ -105,6 +109,7 @@ function handleRequestAssistance() {
<!-- Bottom action bar while wash is in progress -->
<div
v-show="showProgressActions"
class="self-serve-bottom-actions"
data-testid="self-serve-bottom-actions"
>
@@ -132,7 +137,7 @@ function handleRequestAssistance() {
data-testid="self-serve-nav-open-property-access-gate"
:loading="openingPropertyAccessGate"
:disabled="openingPropertyAccessGate"
@click.prevent="$emit('openPropertyAccessGate', $props)"
@click.prevent="handleOpenAccessGate"
>
{{ $t("self_wash.open_property_access_gate") }}
</b-button>
@@ -145,7 +150,7 @@ function handleRequestAssistance() {
data-testid="self-serve-nav-open-property-exit-gate"
:loading="openingPropertyExitGate"
:disabled="openingPropertyExitGate"
@click.prevent="$emit('openPropertyExitGate', $props)"
@click.prevent="handleOpenExitGate"
>
{{ $t("self_wash.open_property_exit_gate") }}
</b-button>
@@ -62,8 +62,6 @@ const emit = defineEmits<{
native-value="Machine"
type="is-link"
:disabled="!isMachineAvailable"
:title="!isMachineAvailable ? $t('self_wash.machine_unavailable_for_lane') : null"
:aria-label="!isMachineAvailable ? $t('self_wash.machine_unavailable_for_lane') : null"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:modelValue', 'Machine')"
@input="emit('update:modelValue', 'Machine')"
@@ -86,15 +84,6 @@ const emit = defineEmits<{
</b-radio-button>
</div>
</div>
<b-message
v-if="!isMachineAvailable"
type="is-warning"
aria-close-label="Luk besked"
data-testid="self-serve-machine-unavailable-guidance"
>
{{ $t("self_wash.machine_unavailable_for_lane") }}
</b-message>
</section>
</template>
@@ -129,6 +129,19 @@ 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)
));
@@ -162,7 +175,17 @@ 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>{{ item.product.name }}</td>
<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 v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
@@ -170,7 +193,17 @@ 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 style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
<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 v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
@@ -193,7 +226,17 @@ 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>+ {{ addon_item.product.name }}</td>
<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 v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
@@ -201,7 +244,17 @@ 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 style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
<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 v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
@@ -237,6 +290,14 @@ 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>
@@ -248,6 +309,14 @@ 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>
@@ -265,5 +334,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
</template>
<style scoped>
.invoice-period-item-flag-indicator {
vertical-align: middle;
}
</style>
@@ -251,7 +251,7 @@ const onResendInvite = async (subuser) => {
<td>{{ formatDateTime(subuser.updated_at) }}</td>
<td>
<div class="buttons is-justify-content-flex-end action-buttons">
<div class="buttons is-justify-content-flex-end action-buttons" :data-testid="`subuser-actions-${subuser.id}`">
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
<template #actions>
<ActionSettingsWheelItem
@@ -255,6 +255,16 @@ 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,
@@ -350,17 +360,23 @@ const mergeBookingMatchDetails = (booking) => {
}
let didMergeBooking = false;
bookingMatches.value = bookingMatches.value.map((entry) => {
let didChangeBooking = false;
const nextBookingMatches = bookingMatches.value.map((entry) => {
if (normalizeBookingId(entry?.id) !== normalizedBookingId) {
return entry;
}
didMergeBooking = true;
return { ...entry, ...booking };
const mergedBooking = { ...entry, ...booking };
const hasChangedEntry = hasBookingDetailsChanged(entry, mergedBooking);
didChangeBooking = didChangeBooking || hasChangedEntry;
return hasChangedEntry ? mergedBooking : entry;
});
if (!didMergeBooking) {
bookingMatches.value = [...bookingMatches.value, booking];
} else if (didChangeBooking) {
bookingMatches.value = nextBookingMatches;
}
};
@@ -1037,6 +1037,7 @@ const getCurrentIconColor = () => {
@blur="lostfocus"
@keydown="arrowKeyHandler"
id="reg_1"
tabindex="1"
autocomplete="off"
/>
</template>
@@ -1059,6 +1060,7 @@ const getCurrentIconColor = () => {
@blur="lostfocus"
@keydown="arrowKeyHandler"
id="reg_1"
tabindex="1"
autocomplete="off"
/>
</div>
@@ -394,12 +394,16 @@ 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"
@@ -0,0 +1,217 @@
<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,6 +44,7 @@ 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);
@@ -366,6 +367,9 @@ 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;
@@ -1496,4 +1500,3 @@ 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 { isAccessibleVisibleDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
import { isAccessibleVisibleNamedDepartment, 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: !isAccessibleVisibleDepartment(department, SessionUser.canAccessAssignedDepartment),
hidden: !isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment),
});
});
menu_items.value[1].options = options;
@@ -13,7 +13,9 @@ 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);
@@ -70,6 +72,8 @@ 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;
};
@@ -148,6 +152,19 @@ 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,
@@ -376,17 +393,26 @@ const items = computed<NavigationItemProps[]>(() => [
hidden: isDepartmentSet.value,
classes: [ADMIN_DEPARTMENT_SELECTION_CLASS],
permissions: ["admin", "list_departments"],
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,
};
}),
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[],
},
// Kassesystem
{
@@ -9,6 +9,7 @@ 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);
@@ -37,23 +38,29 @@ let queuedSuperUserBookingLoadingIndicator = false;
let queuedSuperUserDraftLoadingIndicator = false;
const canUseSuperUserNavigationCounts = () => SessionUser.canAccessSuperUser();
const canUseSuperUserBookingNavigationCounts = () =>
canUseSuperUserNavigationCounts() && hasExplicitBookingCountPermission(SessionUser);
const resetSuperUserNavigationCounts = () => {
const resetSuperUserBookingCounts = () => {
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;
};
@@ -125,8 +132,8 @@ const getSuperUserDraftsBadge = () => {
};
const fetchCurrentSuperUserBookingCount = async ({ showLoadingIndicator = false } = {}) => {
if (!canUseSuperUserNavigationCounts()) {
resetSuperUserNavigationCounts();
if (!canUseSuperUserBookingNavigationCounts()) {
resetSuperUserBookingCounts();
return;
}
@@ -1,4 +1,8 @@
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);
@@ -24,11 +28,7 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
if (!normalizedDepartmentId) {
return {
past: 0,
current: 0,
future: 0,
};
return emptyBookingCounts();
}
try {
@@ -38,12 +38,11 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
return normalizeBookingCounts(response);
} catch (error) {
console.error("Error fetching department order-booking counts:", error);
return {
past: 0,
current: 0,
future: 0,
};
if (!isExpectedBookingCountRequestFailure(error)) {
console.error("Error fetching department order-booking counts:", error);
}
return emptyBookingCounts();
}
};
@@ -0,0 +1,24 @@
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,4 +1,8 @@
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);
@@ -20,12 +24,11 @@ export const fetchSuperUserBookingCounts = async () => {
const response = await authenticatedRequest("/order-bookings/counts", "GET", {});
return normalizeBookingCounts(response);
} catch (error) {
console.error("Error fetching superuser booking counts:", error);
return {
past: 0,
current: 0,
future: 0,
};
if (!isExpectedBookingCountRequestFailure(error)) {
console.error("Error fetching superuser booking counts:", error);
}
return emptyBookingCounts();
}
};
@@ -1,9 +1,8 @@
<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 } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { pingApiServer } from "@/services/apiHealth.js";
const props = defineProps({
@@ -42,12 +41,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) {
@@ -60,10 +59,16 @@ const handleSlowSessionBootstrap = async () => {
}
};
watch(isSessionInitiated, (initiated) => {
if (initiated) {
clearSessionTimeout();
}
});
onMounted(() => {
sessionTimeout = setTimeout(() => {
void handleSlowSessionBootstrap();
}, 5000);
}, 15000);
});
onBeforeUnmount(clearSessionTimeout);
@@ -89,9 +94,18 @@ 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">
@@ -108,7 +122,9 @@ 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>
@@ -116,6 +132,4 @@ onBeforeUnmount(clearSessionTimeout);
</div>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -1,42 +1,21 @@
<script setup>
import { computed, inject, ref } from "vue";
import { 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 {
clearSelectedReleaseChannel,
releaseChannelSelectorVisible,
switchSelectedReleaseChannel,
} from "@/services/releaseChannelAvailability.js";
import {
isReleaseSourceOverrideAvailable,
RELEASE_SOURCE_MODES,
setReleaseSourceOverride,
} from "@/services/releaseBootstrap.js";
import { releaseChannelSelectorVisible, switchSelectedReleaseChannel } from "@/services/releaseChannelAvailability.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;
@@ -48,21 +27,14 @@ 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>
@@ -74,17 +46,6 @@ const useLocalFrontend = () => {
: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>
@@ -93,37 +54,6 @@ const useLocalFrontend = () => {
</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;
+14 -1
View File
@@ -295,10 +295,23 @@ 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;
overflow-wrap: anywhere;
line-height: 1.25;
min-width: min(12rem, 100%);
overflow-wrap: break-word;
word-break: normal;
}
.release-context-bar__endpoints {
@@ -65,7 +65,7 @@ const normalizeActiveWashResponse = (url, method, response) => {
};
};
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null) => {
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null, options = {}) => {
const token = localStorage.getItem('token');
if (!token) {
//throw new Error('No token was found, unable to make authenticated request');
@@ -94,6 +94,7 @@ 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
}),
@@ -184,6 +184,7 @@ 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;
@@ -528,6 +529,8 @@ 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;
@@ -623,6 +626,7 @@ 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,
@@ -647,6 +651,18 @@ 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,6 +456,29 @@ 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) => {
@@ -820,4 +843,3 @@ 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 = null) => {
previewAllowed: async (laneId, reg, vehicleTypeId, options = {}) => {
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)
return authenticatedRequest("/department/selfserve/vehicle/allowed", "GET", params, null, null, options)
.then((response) => response.data.data || response.data);
},
washSummary: async (params = {}) => {
return authenticatedRequest("/department/selfserve/washes/summary", "GET", params)
washSummary: async (params, options) => {
return authenticatedRequest("/department/selfserve/washes/summary", "GET", params || {}, null, null, options || {})
.then((response) => response.data.data || response.data);
}
},
@@ -61,6 +61,14 @@ 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");
@@ -0,0 +1,34 @@
<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>
@@ -0,0 +1,21 @@
<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,6 +28,7 @@ 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;
@@ -72,6 +73,7 @@ export const SuperUserObject = {
get failover() { return Failover; },
get coolify() { return Coolify; },
get releasemanager() { return ReleaseManager; },
get slack() { return Slack; },
},
/** Intimidate a user */
intimidate: {
+21 -11
View File
@@ -3,7 +3,6 @@ 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";
@@ -131,12 +130,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
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=3`);
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=3`);
}
if (step.value === 3) {
if (normalizedOptions.isMobile === false && doesOrderContainMaterial()) {
step.value = 4;
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=4`);
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=4`);
return;
}
@@ -231,11 +230,7 @@ const toPositiveInteger = (value) => {
};
const getRouteDepartmentId = () => {
try {
return toPositiveInteger(useRouter().currentRoute.value.params.departmentId);
} catch (error) {
return null;
}
return getWindowPathDepartmentId();
};
const getSessionUrlDepartmentId = () => {
@@ -757,7 +752,7 @@ export const reset_all_values = () => {
// Reset all values to their initial state
clearActivePosOrderContext({ clearStep: true });
// Clear the query parameters
window.history.pushState({}, "", `?step=1`);
pushPosRouteState("step=1");
};
/** Whenever the reg_1, reg_2 or reg_3 changes, remove any spaces and make the string uppercase */
@@ -768,6 +763,21 @@ 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);
@@ -914,7 +924,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
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
return true;
})
.catch((error) => {
@@ -1631,7 +1641,7 @@ export const showDeleteEverythingDialog = () => {
/** Clear cache */
export const clearCache = () => {
// Reset the query parameters
window.history.pushState({}, "", `?step=1`);
pushPosRouteState("step=1");
// Clear the customer data
clearCustomerSelection();
// Clear the order data
@@ -13,8 +13,7 @@ import {
getDepartmentSelectionVisibility,
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import {
isAccessibleVisibleDepartment,
isDepartmentLabelValid,
isAccessibleVisibleNamedDepartment,
sortByDepartmentPriorityOrder,
} from "@/services/departmentVisibility.js";
@@ -35,7 +34,7 @@ const options = computed(() => {
// Filter out departments the user does not have access to
return departments.value
.filter((department: department) => {
return isAccessibleVisibleDepartment(department, SessionUser.canAccessAssignedDepartment);
return isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment);
})
.map((department: department) => {
return {
@@ -56,7 +55,7 @@ const mobileOptions = computed(() => {
});
const desktopOptions = computed(() => {
return sortByDepartmentPriorityOrder(options.value.filter((option) => isDepartmentLabelValid(option.label)));
return sortByDepartmentPriorityOrder(options.value);
});
const selectDepartment = (departmentId: number | string) => {
@@ -92,11 +91,17 @@ const getDistanceToDepartment = (departmentId: number | null, optionList = mobil
return 0;
}
const department = optionList.find((dept) => dept.value === departmentId);
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 }
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
);
return Number.isFinite(distance) ? distance : 0;
}
return 0;
};
@@ -1,13 +1,26 @@
<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: {
@@ -45,6 +58,7 @@ function startCamera() {
})
.catch((err) => {
isCameraActive.value = false;
cameraErrorKey.value = getCameraErrorKey(err);
console.error('Camera access error:', err);
});
}
@@ -171,8 +185,7 @@ watch(() => isCameraActive.value, (newVal) => {
></canvas>
<div v-if="!isCameraActive" class="camera-inactive">
<p>Camera is not active.</p>
<p>Please enable camera access.</p>
<p>{{ t(cameraErrorKey) }}</p>
</div>
</div>
</template>
+136 -45
View File
@@ -1,4 +1,4 @@
import { computed, ref } from "vue";
import { computed, getCurrentInstance, onUnmounted, ref } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
@@ -345,7 +345,8 @@ export function useSelfServeLogic() {
const lastResolvedVehicleTypeId = ref(null);
const summaryVisibleQuestionIds = ref([]);
const summaryQuestionOrder = ref({});
const latestFetchRequestId = ref(0);
const activeFetchAbortController = ref(null);
const latestMutationRequestId = ref(0);
const latestSuccessfulFetchKey = ref(null);
const inFlightFetchKey = ref(null);
@@ -371,17 +372,67 @@ export function useSelfServeLogic() {
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 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 isFetchRequestActive = (requestId) => (
requestId === null
|| requestId === undefined
|| requestId === latestFetchRequestId.value
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))
@@ -488,8 +539,8 @@ export function useSelfServeLogic() {
};
const applyPreviewData = async (previewData, options = {}) => {
const { mergeQuestions = false, requestId = null } = options;
if (!isFetchRequestActive(requestId)) {
const { mergeQuestions = false, signal = null } = options;
if (!isFetchSignalActive(signal)) {
return false;
}
@@ -544,7 +595,7 @@ export function useSelfServeLogic() {
applyAllowedServices(previewData);
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
if (!isFetchRequestActive(requestId)) {
if (!isFetchSignalActive(signal)) {
return false;
}
@@ -553,8 +604,8 @@ export function useSelfServeLogic() {
};
const applySummaryData = async (summaryData, options = {}) => {
const { requestId = null } = options;
if (!isFetchRequestActive(requestId)) {
const { signal = null } = options;
if (!isFetchSignalActive(signal)) {
return false;
}
@@ -596,7 +647,7 @@ export function useSelfServeLogic() {
const mergedTasks = mergeTaskSets(tasks.value, normalizedTasks);
const hydratedTasks = await hydrateTaskAttachments(mergedTasks);
if (!isFetchRequestActive(requestId)) {
if (!isFetchSignalActive(signal)) {
return false;
}
@@ -626,7 +677,7 @@ export function useSelfServeLogic() {
};
const fetchWashSummary = async (params = {}, manageLoading = true, options = {}) => {
const { requestId = null } = options;
const { signal = null } = options;
if (!params.session_id && !(params.lane_id && params.reg)) {
return null;
}
@@ -636,21 +687,24 @@ export function useSelfServeLogic() {
}
try {
const summaryData = await SessionUser.objects.self_serve_vehicle_conditions.get.washSummary(params);
const didApply = await applySummaryData(summaryData, { requestId });
const summaryData = await requestWashSummary(params, signal);
const didApply = await applySummaryData(summaryData, { signal });
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 (isFetchRequestActive(requestId)) {
if (isFetchSignalActive(signal)) {
requestError.value = extractErrorMessage(error, "Kunne ikke hente vaskestatus. Prøv igen.");
}
return null;
} finally {
if (manageLoading) {
if (manageLoading && isFetchSignalActive(signal)) {
loading.value = false;
}
}
@@ -662,9 +716,8 @@ export function useSelfServeLogic() {
return null;
}
const requestId = beginFetchRequest();
if (!laneId || !reg || reg.trim().length < 2) {
abortActiveFetchRequest();
latestSuccessfulFetchKey.value = null;
inFlightFetchKey.value = null;
preview.value = null;
@@ -692,6 +745,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;
@@ -709,18 +764,18 @@ export function useSelfServeLogic() {
const contextKey = `${parseInt(laneId)}:${normalizedReg}:${contextVehicleTypeKey}`;
const shouldMergeQuestions = lastPreviewContextKey.value === contextKey;
const previewData = hasVehicleTypeOverride
? await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg, normalizedVehicleTypeId)
: await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg);
? await requestPreviewAllowed(laneId, normalizedReg, normalizedVehicleTypeId, signal)
: await requestPreviewAllowed(laneId, normalizedReg, null, signal);
if (!isFetchRequestActive(requestId)) {
if (!isFetchSignalActive(signal)) {
return null;
}
const didApplyPreview = await applyPreviewData(previewData, {
mergeQuestions: shouldMergeQuestions,
requestId,
signal,
});
if (!didApplyPreview || !isFetchRequestActive(requestId)) {
if (!didApplyPreview || !isFetchSignalActive(signal)) {
return null;
}
@@ -735,11 +790,11 @@ export function useSelfServeLogic() {
summaryData = await fetchWashSummary(
{ session_id: previewData.session.id },
false,
{ requestId }
{ signal }
);
}
if (!isFetchRequestActive(requestId)) {
if (!isFetchSignalActive(signal)) {
return null;
}
@@ -761,9 +816,9 @@ export function useSelfServeLogic() {
const fallbackSummary = await fetchWashSummary(
fallbackSummaryParams,
false,
{ requestId }
{ signal }
);
if (!isFetchRequestActive(requestId)) {
if (!isFetchSignalActive(signal)) {
return null;
}
@@ -772,26 +827,31 @@ export function useSelfServeLogic() {
}
// If summary is not available yet, keep the preview list visible so the Questions step is not empty.
if (!hasSummaryQuestions && isFetchRequestActive(requestId)) {
if (!hasSummaryQuestions && isFetchSignalActive(signal)) {
setSummaryVisibleQuestions(questions.value);
}
if (isFetchRequestActive(requestId)) {
if (isFetchSignalActive(signal)) {
latestSuccessfulFetchKey.value = fetchKey;
}
return previewData;
} catch (error) {
if (isCanceledRequest(error)) {
return null;
}
console.error("Error fetching self-serve preview:", error);
if (isFetchRequestActive(requestId)) {
if (isFetchSignalActive(signal)) {
requestError.value = extractErrorMessage(error);
}
return null;
} finally {
if (inFlightFetchKey.value === fetchKey) {
inFlightFetchKey.value = null;
}
if (isFetchRequestActive(requestId)) {
const isCurrentFetch = !abortController || activeFetchAbortController.value === abortController;
if (isCurrentFetch) {
if (inFlightFetchKey.value === fetchKey) {
inFlightFetchKey.value = null;
}
activeFetchAbortController.value = null;
loading.value = false;
}
}
@@ -802,6 +862,7 @@ export function useSelfServeLogic() {
return null;
}
const mutationRequestId = beginMutationRequest();
loading.value = true;
try {
const normalizedReg = reg.trim().toUpperCase();
@@ -829,6 +890,10 @@ 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,
@@ -846,6 +911,9 @@ export function useSelfServeLogic() {
}
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg, { force: true });
if (!isMutationRequestActive(mutationRequestId)) {
return payload;
}
answers.value = {
...answers.value,
[parseInt(questionId)]: value,
@@ -853,10 +921,14 @@ export function useSelfServeLogic() {
return payload;
} catch (error) {
console.error("Error synchronizing vehicle answer:", error);
requestError.value = extractErrorMessage(error, "Kunne ikke gemme svaret. Prøv igen.");
if (isMutationRequestActive(mutationRequestId)) {
requestError.value = extractErrorMessage(error, "Kunne ikke gemme svaret. Prøv igen.");
}
throw error;
} finally {
loading.value = false;
if (isMutationRequestActive(mutationRequestId)) {
loading.value = false;
}
}
};
@@ -865,6 +937,7 @@ export function useSelfServeLogic() {
return { deletedCount: 0 };
}
const mutationRequestId = beginMutationRequest();
loading.value = true;
try {
const normalizedReg = reg.trim().toUpperCase();
@@ -900,6 +973,10 @@ 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
@@ -915,10 +992,14 @@ export function useSelfServeLogic() {
return { deletedCount: conditionIdsToDelete.length };
} catch (error) {
console.error("Error clearing self-serve answers:", error);
requestError.value = extractErrorMessage(error, "Kunne ikke nulstille svar. Prøv igen.");
if (isMutationRequestActive(mutationRequestId)) {
requestError.value = extractErrorMessage(error, "Kunne ikke nulstille svar. Prøv igen.");
}
throw error;
} finally {
loading.value = false;
if (isMutationRequestActive(mutationRequestId)) {
loading.value = false;
}
}
};
@@ -1080,10 +1161,13 @@ export function useSelfServeLogic() {
return activeTaskServices.value.includes(serviceName);
};
const updateLaneAllowedServices = async (laneId) => {
const updateLaneAllowedServices = async (laneId, options = {}) => {
if (!laneId) return;
const activeTaskIds = activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
const taskIds = activeTaskIds.length > 0
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);
@@ -1143,6 +1227,12 @@ export function useSelfServeLogic() {
}
};
if (getCurrentInstance()) {
onUnmounted(() => {
abortActiveFetchRequest();
});
}
return {
loading,
error: requestError,
@@ -1172,6 +1262,7 @@ export function useSelfServeLogic() {
allowed,
fetchSelfServeData,
fetchWashSummary,
cancelSelfServeFetch: abortActiveFetchRequest,
syncVehicleAnswer,
clearVehicleAnswers,
evaluateRule,
+44 -20
View File
@@ -17,6 +17,18 @@ 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,
@@ -81,7 +93,8 @@ export function useWashDepartments(options = {}) {
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
const hasLocationCoordinates = (locationValue = locations.location.value) => !!locationValue?.coords;
const hasLocationCoordinates = (locationValue = locations.location.value) =>
normalizeLocationCoordinates(locationValue) !== null;
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
@@ -138,23 +151,21 @@ export function useWashDepartments(options = {}) {
return nearestDepartment.value;
}
const from = normalizeLocationCoordinates(locationValue);
let currentNearestDepartment = {
id: null,
distance: Infinity,
};
guestDepartments.value.forEach((department) => {
const from = {
latitude: locationValue.coords.latitude,
longitude: locationValue.coords.longitude,
};
const to = {
latitude: department.latitude,
longitude: department.longitude,
};
const to = normalizeDepartmentCoordinates(department);
if (!from || !to) {
return;
}
const distance = locations.getDistance(from, to);
if (distance < currentNearestDepartment.distance) {
if (Number.isFinite(distance) && distance < currentNearestDepartment.distance) {
currentNearestDepartment = toDepartmentViewModel(department, distance);
}
});
@@ -179,20 +190,33 @@ export function useWashDepartments(options = {}) {
};
const orderDepartmentsByDistance = (departmentsList = guestDepartments.value) => {
if (!locations.location.value?.coords) {
const currentCoords = normalizeLocationCoordinates(locations.location.value);
if (!currentCoords) {
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 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 }
);
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;
}
return distanceA - distanceB;
});
+3 -1
View File
@@ -250,16 +250,18 @@ 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,
});
if (!startResponse) {
return false;
}
if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) {
if (selectedWashType === "Machine" && isServiceAllowed("MACHINE")) {
let machineRelayResponse = null;
try {
machineRelayResponse = await enableMachineRelay(laneId);
+47 -2
View File
@@ -1150,6 +1150,14 @@
"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'}",
@@ -1845,6 +1853,21 @@
"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'}",
@@ -2315,6 +2338,20 @@
"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'}.",
@@ -3784,6 +3821,13 @@
"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'}.",
@@ -4595,7 +4639,6 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.indlæser'} @:{'words.generated.tilgængelige'} @:{'words.generated.køretøjstyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
"machine": "@.capitalize:{'words.generated.maskine'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Optaget",
"open_property_access_gate": "@:{'words.generated.abn'} adgangsport",
@@ -5479,7 +5522,9 @@
"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'}."
"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."
},
"security": {
"description": "@.capitalize:{'words.generated.dine'} sikkerhedsindstillinger",
+47 -2
View File
@@ -1260,6 +1260,14 @@
"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'}",
@@ -1955,6 +1963,21 @@
"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'}",
@@ -2425,6 +2448,20 @@
"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'}.",
@@ -3894,6 +3931,13 @@
"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'}.",
@@ -4705,7 +4749,6 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.verf'}?@:{'words.generated.gbare'} @:{'words.generated.fahrzeugtypen'} @:{'words.generated.werden'} @:{'words.generated.geladen'}...",
"lane_unavailable": "Waschspur nicht verfugbar",
"machine": "@:{'words.generated.maschine'}",
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Belegt",
"open_property_access_gate": "Grundstueckszugangstor @:{'words.generated.oeffnen'}",
@@ -5589,7 +5632,9 @@
"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'}."
"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."
},
"security": {
"description": "@.capitalize:{'words.generated.ihre'} Sicherheitseinstellungen",
+47 -2
View File
@@ -984,6 +984,14 @@
"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'}",
@@ -1679,6 +1687,21 @@
"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'}",
@@ -2149,6 +2172,20 @@
"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'}.",
@@ -3618,6 +3655,13 @@
"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'}.",
@@ -4429,7 +4473,6 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.loading'} @:{'words.generated.available'} @:{'words.generated.vehicle'} @:{'words.generated.types'}...",
"lane_unavailable": "Lane unavailable",
"machine": "@.capitalize:{'words.generated.machine'}",
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Occupied",
"open_property_access_gate": "@.capitalize:{'words.generated.open'} @:{'words.generated.property'} @:{'words.generated.access'} @:{'words.generated.gate'}",
@@ -5313,7 +5356,9 @@
"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'}."
"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."
},
"security": {
"description": "@.capitalize:{'words.generated.your'} @:{'words.generated.security'} @:{'words.generated.settings'}",
+34 -1
View File
@@ -892,6 +892,21 @@
"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'}",
@@ -1347,6 +1362,15 @@
"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'}",
@@ -3130,6 +3154,13 @@
"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'}",
@@ -4224,7 +4255,6 @@
"loading_vehicle_types": "@:{'templates.generated.compat.self_wash.loading_vehicle_types'}",
"lane_unavailable": "@:{'templates.generated.compat.self_wash.lane_unavailable'}",
"machine": "@:{'templates.generated.compat.self_wash.machine'}",
"machine_unavailable_for_lane": "@:{'templates.generated.compat.self_wash.machine_unavailable_for_lane'}",
"no_departments_found": "@:{'templates.generated.compat.self_wash.no_departments_found'}",
"no_vehicles_found": "@:vehicles.no_vehicles",
"occupied": "@:{'templates.generated.compat.self_wash.occupied'}",
@@ -4732,6 +4762,7 @@
"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'}",
@@ -5556,6 +5587,8 @@
"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": {
+42 -2
View File
@@ -1261,6 +1261,14 @@
"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'}",
@@ -1956,6 +1964,21 @@
"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'}",
@@ -2426,6 +2449,15 @@
"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'}.",
@@ -3895,6 +3927,13 @@
"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.",
@@ -4706,7 +4745,6 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.laster'} @:{'words.generated.inn'} @:{'words.generated.tilgjengelige'} @:{'words.generated.kjøretøytyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
"machine": "@.capitalize:{'words.generated.maskin'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Okkupert",
"open_property_access_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} inngangsport",
@@ -5590,7 +5628,9 @@
"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'}."
"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."
},
"security": {
"description": "Sikkerhetsinnstillingene @:{'words.generated.dine'}",
+47 -2
View File
@@ -1311,6 +1311,14 @@
"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'}",
@@ -2006,6 +2014,21 @@
"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'}",
@@ -2476,6 +2499,20 @@
"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'}.",
@@ -3945,6 +3982,13 @@
"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'}.",
@@ -4756,7 +4800,6 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.laser'} @:{'words.generated.in'} @:{'words.generated.tillgangliga'} @:{'words.generated.fordonstyper'}...",
"lane_unavailable": "Tvattbanan ar inte tillganglig",
"machine": "@.capitalize:{'words.generated.machine'}",
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
"no_departments_found": "@.capitalize:{'words.generated.inga'} @:{'words.generated.avdelningar'} @:{'words.generated.hittades'}",
"occupied": "Upptagen",
"open_property_access_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} ingangsgrind",
@@ -5640,7 +5683,9 @@
"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'}."
"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."
},
"security": {
"description": "@.capitalize:{'words.generated.dina'} säkerhetsinställningar",
+96 -84
View File
@@ -30,6 +30,14 @@
"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": {
@@ -342,7 +350,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 tilgjengelig for Office-dokumenter. Last ned for å se.",
"attachments_office_preview_unavailable": "Forhåndsvisning er ikke tilgængelig for Office-dokumenter. Download filen for at se den.",
"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.",
@@ -369,7 +377,7 @@
"customer_creation_error_generic": "Der opstod en fejl under oprettelse af kunden. Prøv igen.",
"delete": "Slet",
"details": "Detaljer",
"download": "Last ned",
"download": "Download",
"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": {
@@ -572,7 +580,7 @@
"reference_required_title": "Referencenummer påkrævet",
"safety_seal": "Sikkerhedsforsegling",
"scanned": "Scannet",
"scanner": "Skanner",
"scanner": "Scanner",
"search_license_plate": "Søg efter nummerplade",
"see_note": "Se note",
"see_reference": "Se reference",
@@ -859,24 +867,8 @@
},
"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",
@@ -894,20 +886,7 @@
"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",
@@ -936,6 +915,21 @@
"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",
@@ -1264,7 +1258,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 venligst email adressen du vil sende test emailen til.",
"send_test_email_prompt": "Indtast den e-mailadresse, du vil sende test-e-mailen til.",
"sender_identity": "Afsender identitet",
"sender_identity_desc": "Afsender identitet indstillinger for email systemet.",
"smtp_encryption": "SMTP kryptering",
@@ -1282,13 +1276,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 email sendt",
"test_email_sent_success": "Test emailen er blevet sendt til email adressen.",
"test_email_sent": "Test-e-mail sendt",
"test_email_sent_success": "Test-e-mailen er blevet sendt til e-mailadressen.",
"title": "Email konfiguration"
},
"fxrates": {
"api_key": "API-nøkkel",
"api_key_desc": "Valutakurser API-nøkkelen bruges til å hente ut valutakurser",
"api_key": "API-Nøgle",
"api_key_desc": "API-nøglen til valutakurser bruges til at hente valutakurser",
"api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "FX Rates API forbindelsesindstillingerne.",
"conversion_rates": "Valutakurser",
@@ -1312,7 +1306,7 @@
},
"general": {
"currency": "Valuta",
"language": "Språk",
"language": "Sprog",
"subtitle": "Generelle indstillinger",
"timezone": "Tidszone",
"title": "Generelle indstillinger"
@@ -1322,13 +1316,13 @@
"title": "Integrationer"
},
"limble": {
"api_key": "API-nøkkel",
"api_key_desc": "Limble API-nøkkelen bruges til å autentisere Limble-integrationen",
"api_key": "API-Nøgle",
"api_key_desc": "Limble API-nøglen bruges til at autentificere 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": "Klienthemmelighet",
"client_secret": "Klienthemmelighed",
"client_secret_desc": "Client secret bruges til at autentificere API forbindelsen.",
"connection_failed_desc": "Forbindelsen til Limble API fejlede!",
"connection_success": "Forbindelse succesfuld",
@@ -1344,13 +1338,13 @@
"title": "Limble konfiguration"
},
"mailersend": {
"api_key": "API-nøkkel",
"api_key_desc": "Mailersend API-nøkkelen bruges til å autentisere Mailersend-integrationen",
"api_key": "API-Nøgle",
"api_key_desc": "Mailersend API-nøglen bruges til at autentificere Mailersend-integrationen",
"title": "Mailersend-konfiguration"
},
"motor_api": {
"api_key": "API-nøkkel",
"api_key_desc": "Motor API-nøkkelen bruges til å autentisere Motor API-integrationen",
"api_key": "API-Nøgle",
"api_key_desc": "Motor API-nøglen bruges til at autentificere Motor API-integrationen",
"title": "Motor API-konfiguration"
},
"motorapi": {
@@ -1369,7 +1363,7 @@
"title": "MotorAPI konfiguration"
},
"products": {
"add": "Legg til produkt",
"add": "Tilføj produkt",
"delete": "Slet produkt",
"delete_confirm": "Er du sikker på, at du vil slette dette produkt?",
"edit": "Rediger produkt",
@@ -1377,9 +1371,18 @@
"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økkel",
"api_key_desc": "Shelly API-nøkkelen bruges til å autentisere Shelly-integrationen",
"api_key": "API-Nøgle",
"api_key_desc": "Shelly API-nøglen bruges til at autentificere Shelly-integrationen",
"api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "Shelly API forbindelsesindstillingerne.",
"connection_failed_desc": "Forbindelsen til Shelly API fejlede!",
@@ -1398,8 +1401,8 @@
"title": "Shelly konfiguration"
},
"stripe": {
"api_key": "API-nøkkel",
"api_key_desc": "Din Stripe API-nøkkel for betalingsbehandling",
"api_key": "API-Nøgle",
"api_key_desc": "Din Stripe API-nøgle for betalingsbehandling",
"api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "Stripe API forbindelsesindstillingerne.",
"customers": "Stripe kunder",
@@ -1434,8 +1437,8 @@
"title": "Brugere"
},
"virkdata": {
"api_key": "API-nøkkel",
"api_key_desc": "Virk Data API-nøkkelen bruges til å autentisere Virk Data-integrationen",
"api_key": "API-Nøgle",
"api_key_desc": "Virk Data API-nøglen bruges til at autentificere Virk Data-integrationen",
"api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "VirkData API forbindelsesindstillingerne.",
"company_selected": "Virksomhed valgt",
@@ -1458,8 +1461,8 @@
"title": "VirkData konfiguration"
},
"xlvask": {
"api_key": "API-nøkkel",
"api_key_desc": "XL Vask API-nøkkelen bruges til å autentisere XL Vask-integrationen",
"api_key": "API-Nøgle",
"api_key_desc": "XL Vask API-nøglen bruges til at autentificere XL Vask-integrationen",
"api_settings": "API forbindelsesindstillinger",
"api_settings_desc": "XLVask API forbindelsesindstillingerne.",
"connection_failed": "Forbindelse fejlede",
@@ -2213,7 +2216,7 @@
"list": "Liste",
"subtitle": "Se hvordan dine afdelinger klarer sig",
"title": "Overblik",
"charts": "Charts"
"charts": "Grafer"
},
"pos": {
"subtitle": "Registrer salg",
@@ -2312,15 +2315,15 @@
"country": "Land",
"email": "E-mail",
"latitude": "Breddegrad",
"longitude": "Lengdegrad",
"longitude": "Længdegrad",
"name": "Navn",
"phone": "Telefon",
"submit": "Lagre",
"submit": "Gem",
"zip": "Postnummer"
},
"integrations": {
"mailersend_api_key": "Mailersend API-nøkkel",
"mailersend_api_key_desc": "Mailersend API-nøkkelen bruges til å autentisere Mailersend-integrationen",
"mailersend_api_key": "Mailersend API-Nøgle",
"mailersend_api_key_desc": "Mailersend API-nøglen bruges til at autentificere Mailersend-integrationen",
"subtitle": "Konfigurer integrationer for afdelingen",
"title": "Integrationer"
},
@@ -2328,9 +2331,9 @@
"new_department": "Ny afdeling",
"no_departments": "Ingen afdelinger fundet",
"opening_hours": {
"add": "Legg til",
"closed": "Stengt",
"title": pningstider"
"add": "Tilføj",
"closed": "Lukket",
"title": bningstider"
},
"phone": "Telefon",
"products": {
@@ -2342,7 +2345,7 @@
"tab": {
"info": "Info",
"integrations": "Integrationer",
"opening_hours": pningstider",
"opening_hours": bningstider",
"product_addons": "Produkttilvalg",
"wash_products": "Vaskeprodukter"
},
@@ -2363,7 +2366,7 @@
"name": "Navn",
"phone": "Telefon",
"role": "Rolle",
"submit": "Lagre"
"submit": "Gem"
},
"name": "Navn",
"new_employee": "Ny medarbejder",
@@ -2657,17 +2660,17 @@
"yes": "Ja"
},
"guest": {
"about": "Om oss",
"access_code": "Tilgangskode",
"about": "Om os",
"access_code": "Adgangskode",
"app_store": "App Store",
"book_wash": "Book vask",
"contact": "Kontakta oss",
"continue": "Fortsett",
"download_app": "Ladda ner app",
"contact": "Kontakt os",
"continue": "Fortsæt",
"download_app": "Download app",
"driver": {
"coming_soon": "Denne funktion er på vej!"
},
"enter_access_code": "Skriv inn tilgangskode",
"enter_access_code": "Indtast adgangskode",
"find_wash": "Find vask",
"home": {
"contact_or_call": "eller ring til os på",
@@ -2676,12 +2679,12 @@
"subtitle": "Velkommen til PLENO",
"title": "Forside"
},
"invalid_code": "Ugyldig tilgangskode",
"invalid_code": "Ugyldig adgangskode",
"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": "Last ned Pleno",
"card_title": "Download 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",
@@ -2691,14 +2694,14 @@
"instructions": "Instruktioner",
"ios_instructions": "Tryk på delingsknappen og vælg \"Tilføj til hjemmeskærm\"",
"subtitle": "Download appen på din enhed",
"title": "Last ned",
"title": "Download",
"use_supported_browser": "For at installere Pleno skal du bruge en understøttet browser og følge installationsvejledningen."
},
"scan_qr": "Skann QR-kode",
"scan_qr": "Scan QR-kode",
"start_wash": "Start vask",
"start_wash_subtitle": "Skriv inn tilgangskoden for å fortsette",
"start_wash_title": "Start vask som gjest",
"title": "Gjesttilgang",
"start_wash_subtitle": "Indtast adgangskoden for at fortsætte",
"start_wash_title": "Start vask som gæst",
"title": "Gæsteadgang",
"welcome": "Velkommen",
"welcome_message": "Velkommen til Truck Wash"
},
@@ -2795,6 +2798,13 @@
"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",
@@ -3145,7 +3155,7 @@
"invoices": {
"columns": {
"amount": "Beløb",
"download": "Last ned",
"download": "Download",
"due_date": "Forfaldsdato",
"invoice_number": "Fakturanummer",
"paid": "Betalt"
@@ -3663,7 +3673,6 @@
"loading_vehicle_types": "Indlæser tilgængelige køretøjstyper...",
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
"machine": "Maskine",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
"vehicle_step_missing_customer_number": "Indtast kundenummer for at fortsætte.",
"vehicle_step_missing_department": "Vent på, at nærmeste afdeling er indlæst, før du fortsætter.",
"vehicle_step_missing_registration": "Indtast registreringsnummer for at fortsætte.",
@@ -3759,7 +3768,7 @@
"days": "dage",
"export": "Eksporter",
"growth": "Vækst",
"no_data": "Ingen data tilgjengelig",
"no_data": "Ingen data tilgængelige",
"none": "Ingen",
"orders": "Ordrer",
"overview": {
@@ -3863,6 +3872,7 @@
"xlvask": "XLVask",
"department_gates": "Afdelingsporte",
"department_relays": "Afdelingsreleer",
"error_reports": "Fejlrapporter",
"selfserve": "Selvvask"
},
"pages": {
@@ -4656,6 +4666,8 @@
"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": {
@@ -5192,7 +5204,7 @@
"fixed_pricing": "Fastprisaftaler",
"subscriptions": "Abonnementer",
"customer_prices": "Kundepriser",
"other": "Annen bokført inntekt",
"other": "Anden bogført indtægt",
"months": "Måneder i visning"
},
"tabs": {
+46 -34
View File
@@ -30,6 +30,14 @@
"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": {
@@ -859,24 +867,8 @@
},
"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",
@@ -894,20 +886,7 @@
"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",
@@ -936,6 +915,21 @@
"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",
@@ -1377,6 +1371,15 @@
"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",
@@ -2795,6 +2798,13 @@
"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",
@@ -3663,7 +3673,6 @@
"loading_vehicle_types": "Verf?gbare Fahrzeugtypen werden geladen...",
"lane_unavailable": "Waschspur nicht verfugbar",
"machine": "Maschine",
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
"vehicle_step_missing_customer_number": "Geben Sie Ihre Kundennummer ein, um fortzufahren.",
"vehicle_step_missing_department": "Warten Sie, bis die nachste Abteilung geladen ist, bevor Sie fortfahren.",
"vehicle_step_missing_registration": "Geben Sie das Kennzeichen ein, um fortzufahren.",
@@ -3861,7 +3870,8 @@
"vehicles": "Fahrzeuge",
"xlvask": "XLVask",
"department_gates": "Abteilungstore",
"department_relays": "Abteilungsrelais"
"department_relays": "Abteilungsrelais",
"error_reports": "Fehlerberichte"
},
"pages": {
"categories": {
@@ -4642,6 +4652,8 @@
"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": {
+46 -34
View File
@@ -30,6 +30,14 @@
"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": {
@@ -859,24 +867,8 @@
},
"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",
@@ -894,20 +886,7 @@
"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",
@@ -936,6 +915,21 @@
"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",
@@ -1377,6 +1371,15 @@
"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",
@@ -2795,6 +2798,13 @@
"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",
@@ -3662,7 +3672,6 @@
"loading_vehicle_types": "Loading available vehicle types...",
"lane_unavailable": "Lane unavailable",
"machine": "Machine",
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
"vehicle_step_missing_customer_number": "Enter your customer number to continue.",
"vehicle_step_missing_department": "Wait for the nearest department to load before continuing.",
"vehicle_step_missing_registration": "Enter your registration number to continue.",
@@ -3861,7 +3870,8 @@
"vehicles": "Vehicles",
"xlvask": "XLVask",
"department_gates": "Department Gates",
"department_relays": "Department Relays"
"department_relays": "Department Relays",
"error_reports": "Error reports"
},
"pages": {
"categories": {
@@ -4654,6 +4664,8 @@
"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": {
+46 -34
View File
@@ -30,6 +30,14 @@
"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": {
@@ -859,24 +867,8 @@
},
"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",
@@ -894,20 +886,7 @@
"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",
@@ -936,6 +915,21 @@
"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",
@@ -1377,6 +1371,15 @@
"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",
@@ -2795,6 +2798,13 @@
"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",
@@ -3663,7 +3673,6 @@
"loading_vehicle_types": "Laster inn tilgjengelige kjøretøytyper...",
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
"machine": "Maskin",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
"vehicle_step_missing_customer_number": "Skriv inn kundenummer for a fortsette.",
"vehicle_step_missing_department": "Vent til naermeste avdeling er lastet inn for du fortsetter.",
"vehicle_step_missing_registration": "Skriv inn registreringsnummer for a fortsette.",
@@ -3861,7 +3870,8 @@
"vehicles": "Kjøretøy",
"xlvask": "XLVask",
"department_gates": "Avdelingsporter",
"department_relays": "Avdelingsreleer"
"department_relays": "Avdelingsreleer",
"error_reports": "Feilrapporter"
},
"pages": {
"categories": {
@@ -4642,6 +4652,8 @@
"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": {
+46 -34
View File
@@ -30,6 +30,14 @@
"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": {
@@ -859,24 +867,8 @@
},
"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",
@@ -894,20 +886,7 @@
"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",
@@ -936,6 +915,21 @@
"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",
@@ -1377,6 +1371,15 @@
"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",
@@ -2795,6 +2798,13 @@
"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",
@@ -3663,7 +3673,6 @@
"loading_vehicle_types": "Läser in tillgängliga fordonstyper...",
"lane_unavailable": "Tvattbanan ar inte tillganglig",
"machine": "Machine",
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
"vehicle_step_missing_customer_number": "Ange kundnummer for att fortsatta.",
"vehicle_step_missing_department": "Vanta tills narmaste avdelning har laddats innan du fortsatter.",
"vehicle_step_missing_registration": "Ange registreringsnummer for att fortsatta.",
@@ -3861,7 +3870,8 @@
"vehicles": "Fordon",
"xlvask": "XLVask",
"department_gates": "Avdelningsgrindar",
"department_relays": "Avdelningsreläer"
"department_relays": "Avdelningsreläer",
"error_reports": "Felrapporter"
},
"pages": {
"categories": {
@@ -4642,6 +4652,8 @@
"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,6 +1,21 @@
{
"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,6 +158,20 @@
"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'}.",
@@ -0,0 +1,11 @@
{
"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}"
}
}
}
@@ -18,7 +18,6 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.indlæser'} @:{'terms.glossary.tilgængelige'} @:{'terms.glossary.køretøjstyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
"machine": "@.capitalize:{'terms.glossary.maskine'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Optaget",
"open_property_access_gate": "@:{'terms.glossary.abn'} adgangsport",
@@ -103,7 +103,9 @@
"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'}."
"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."
},
"security": {
"description": "@.capitalize:{'terms.glossary.dine'} sikkerhedsindstillinger",
+8
View File
@@ -12,6 +12,14 @@
"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,6 +1,21 @@
{
"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,6 +158,20 @@
"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'}.",
@@ -0,0 +1,11 @@
{
"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}"
}
}
}
@@ -18,7 +18,6 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.verf'}?@:{'terms.glossary.gbare'} @:{'terms.glossary.fahrzeugtypen'} @:{'terms.glossary.werden'} @:{'terms.glossary.geladen'}...",
"lane_unavailable": "Waschspur nicht verfugbar",
"machine": "@:{'terms.glossary.maschine'}",
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Belegt",
"open_property_access_gate": "Grundstueckszugangstor @:{'terms.glossary.oeffnen'}",
@@ -103,7 +103,9 @@
"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'}."
"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."
},
"security": {
"description": "@.capitalize:{'terms.glossary.ihre'} Sicherheitseinstellungen",
+8
View File
@@ -12,6 +12,14 @@
"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,6 +1,21 @@
{
"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,6 +158,20 @@
"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'}.",
@@ -0,0 +1,11 @@
{
"compat": {
"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}"
}
}
}
@@ -18,7 +18,6 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.loading'} @:{'terms.glossary.available'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.types'}...",
"lane_unavailable": "Lane unavailable",
"machine": "@.capitalize:{'terms.glossary.machine'}",
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Occupied",
"open_property_access_gate": "@.capitalize:{'terms.glossary.open'} @:{'terms.glossary.property'} @:{'terms.glossary.access'} @:{'terms.glossary.gate'}",
@@ -103,7 +103,9 @@
"email_notifications_desc": "@.capitalize:{'terms.glossary.receive'} @:{'terms.glossary.emails'} @:{'terms.glossary.with'} @:{'terms.glossary.booking'} @:{'terms.glossary.confirmations'} @:{'terms.glossary.and'} @:{'terms.glossary.wash'} @:{'terms.glossary.certificates'}.",
"sms_notifications_desc": "@.capitalize:{'terms.glossary.receive'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifications'} @:{'terms.glossary.for'} @:{'terms.glossary.various'} @:{'terms.glossary.events'}.",
"sms_phone": "@.capitalize:{'terms.glossary.phone'} @:{'terms.glossary.number'} @:{'terms.glossary.for'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifications'}",
"sms_phone_desc": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.phone'} @:{'terms.glossary.number'} @:{'terms.glossary.that'} @:{'terms.glossary.receives'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifications'}."
"sms_phone_desc": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.phone'} @:{'terms.glossary.number'} @:{'terms.glossary.that'} @:{'terms.glossary.receives'} @:{'terms.glossary.sms'} @:{'terms.glossary.notifications'}.",
"superuser_notifications": "Superuser notifications",
"superuser_notifications_desc": "Receive email notifications when a new customer registers on the site."
},
"security": {
"description": "@.capitalize:{'terms.glossary.your'} @:{'terms.glossary.security'} @:{'terms.glossary.settings'}",
+8
View File
@@ -12,6 +12,14 @@
"field_required": "{field} @:{'terms.glossary.is'} @:{'terms.glossary.required'}",
"no_entity_available": "@.capitalize:{'terms.glossary.no'} {entity} @:{'terms.glossary.available'}."
},
"redirect": {
"mobile_department_auto_select": {
"manual_button": "Select department manually",
"manual_title": "Select your department",
"manual_loading": "Loading departments...",
"use_department": "Select {name}"
}
},
"replication": {
"host_targets": {
"database": "@:{'terms.replication.services.database'} @:{'terms.replication.host'}",
@@ -1,5 +1,20 @@
{
"collected_invoice": {
"move_customer": {
"card_title": "@:{'phrases.compat.collected_invoice.move_customer.card_title'}",
"confirm_button": "@:{'phrases.compat.collected_invoice.move_customer.confirm_button'}",
"confirm_text": "@:{'phrases.compat.collected_invoice.move_customer.confirm_text'}",
"confirm_title": "@:{'phrases.compat.collected_invoice.move_customer.confirm_title'}",
"current_customer": "@:{'phrases.compat.collected_invoice.move_customer.current_customer'}",
"customer_value": "@:{'phrases.compat.collected_invoice.move_customer.customer_value'}",
"error_title": "@:{'phrases.compat.collected_invoice.move_customer.error_title'}",
"invalid_customer": "@:{'phrases.compat.collected_invoice.move_customer.invalid_customer'}",
"locked": "@:{'phrases.compat.collected_invoice.move_customer.locked'}",
"same_customer": "@:{'phrases.compat.collected_invoice.move_customer.same_customer'}",
"success_text": "@:{'phrases.compat.collected_invoice.move_customer.success_text'}",
"success_title": "@:{'phrases.compat.collected_invoice.move_customer.success_title'}",
"title": "@:{'phrases.compat.collected_invoice.move_customer.title'}"
},
"economic": {
"booked_desc": "@:{'phrases.compat.collected_invoice.economic.booked_desc'}",
"booked_with_economic": "@:{'phrases.compat.collected_invoice.economic.booked_with_economic'}",
@@ -184,6 +184,15 @@
"subtitle": "@:products.subtitle",
"title": "@:common.products"
},
"slack": {
"customer_registration_webhook_url": "@:{'phrases.compat.configuration.slack.customer_registration_webhook_url'}",
"customer_registration_webhook_url_desc": "@:{'phrases.compat.configuration.slack.customer_registration_webhook_url_desc'}",
"notification_settings": "@:{'phrases.compat.configuration.slack.notification_settings'}",
"notification_settings_desc": "@:{'phrases.compat.configuration.slack.notification_settings_desc'}",
"subtitle": "@:{'phrases.compat.configuration.slack.subtitle'}",
"title": "@:{'phrases.compat.configuration.slack.title'}",
"unavailable": "@:{'phrases.compat.configuration.slack.unavailable'}"
},
"shelly": {
"api_key": "@:configuration.limble.api_key",
"api_key_desc": "@:{'phrases.compat.configuration.shelly.api_key_desc'}",
@@ -0,0 +1,9 @@
{
"maintenance_menu": {
"confirm_clear_local": "@:{'phrases.compat.maintenance_menu.confirm_clear_local'}",
"force_update_clear_hint": "@:{'phrases.compat.maintenance_menu.force_update_clear_hint'}",
"force_update_clear": "@:{'phrases.compat.maintenance_menu.force_update_clear'}",
"title": "@:{'phrases.compat.maintenance_menu.title'}",
"version": "@:{'phrases.compat.maintenance_menu.version'}"
}
}
@@ -21,7 +21,6 @@
"loading_vehicle_types": "@:{'phrases.compat.self_wash.loading_vehicle_types'}",
"lane_unavailable": "@:{'phrases.compat.self_wash.lane_unavailable'}",
"machine": "@:{'phrases.compat.self_wash.machine'}",
"machine_unavailable_for_lane": "@:{'phrases.compat.self_wash.machine_unavailable_for_lane'}",
"no_departments_found": "@:{'phrases.compat.self_wash.no_departments_found'}",
"no_vehicles_found": "@:vehicles.no_vehicles",
"occupied": "@:{'phrases.compat.self_wash.occupied'}",

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