Compare commits
152
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bba666f3a | ||
|
|
dcf0fd61ba | ||
|
|
3d02146911 | ||
|
|
10bc822233 | ||
|
|
baac1a243a | ||
|
|
7c233d7c03 | ||
|
|
7c19dbddf0 | ||
|
|
f4b248573a | ||
|
|
14e454e9d9 | ||
|
|
ebab7d8804 | ||
|
|
78a3fb1879 | ||
|
|
d42b58c4fe | ||
|
|
724ad8e7a1 | ||
|
|
2fe50729a5 | ||
|
|
eb4eee1aef | ||
|
|
db972e2923 | ||
|
|
00817925e4 | ||
|
|
347ab3c811 | ||
|
|
a3d05b15db | ||
|
|
9093d0d601 | ||
|
|
8c57b98627 | ||
|
|
4b99751c0e | ||
|
|
66f4331b4e | ||
|
|
e1a824d565 | ||
|
|
883a88a551 | ||
|
|
a328bc3767 | ||
|
|
255447f788 | ||
|
|
b3d57ca717 | ||
|
|
45d21c4372 | ||
|
|
7f7b5ae082 | ||
|
|
028d8f1fc8 | ||
|
|
8d01ca1363 | ||
|
|
a36403d279 | ||
|
|
a78ad69c0c | ||
|
|
22c856afb6 | ||
|
|
71a073ebf9 | ||
|
|
f709cac95e | ||
|
|
27867e48df | ||
|
|
f1a7e1dfcd | ||
|
|
31e9566f2d | ||
|
|
f7f749e230 | ||
|
|
f5b7da7b0a | ||
|
|
2311188b13 | ||
|
|
25637b52d9 | ||
|
|
fddcc86cd6 | ||
|
|
c1b2be7d70 | ||
|
|
0a7b3a36aa | ||
|
|
57f102de71 | ||
|
|
6b22887a90 | ||
|
|
3b3f212ceb | ||
|
|
1a11e31836 | ||
|
|
d4b69c3990 | ||
|
|
4cbccd533a | ||
|
|
a9bc3ab0fc | ||
|
|
d9f929df04 | ||
|
|
bc154a4e6d | ||
|
|
7d48e40e05 | ||
|
|
47af253bf8 | ||
|
|
e126dfccf9 | ||
|
|
be5191825d | ||
|
|
68cf9c4c06 | ||
|
|
e46b7ae58d | ||
|
|
3d81fb8dd7 | ||
|
|
ff983dca60 | ||
|
|
fb0fceb6c4 | ||
|
|
9591afb9a2 | ||
|
|
0afde8ac8e | ||
|
|
4a83486b30 | ||
|
|
a3ceac1636 | ||
|
|
8666d17a42 | ||
|
|
96d13b2400 | ||
|
|
12fc1f80cc | ||
|
|
2dfe4e9b37 | ||
|
|
46c634e5a7 | ||
|
|
880680d7f8 | ||
|
|
7d5ed772c2 | ||
|
|
83fbcdeaa8 | ||
|
|
55a761d3a5 | ||
|
|
abe8e7750f | ||
|
|
aeef75b831 | ||
|
|
d6356d0244 | ||
|
|
bbf2aeb95f | ||
|
|
3a7d78ae05 | ||
|
|
2a2f752692 | ||
|
|
8883bf8a07 | ||
|
|
6ec80e2796 | ||
|
|
849a7b77cf | ||
|
|
36ccd0c381 | ||
|
|
58e7c8d6c9 | ||
|
|
d04fc8cc0f | ||
|
|
339a1b1452 | ||
|
|
2d2d63d09e | ||
|
|
8241f7fd2f | ||
|
|
985571af87 | ||
|
|
82e1e4a466 | ||
|
|
9a7f7bbb78 | ||
|
|
359f29b75e | ||
|
|
ceced7cbaa | ||
|
|
6b5cd00b10 | ||
|
|
02eae47d34 | ||
|
|
3f01d38676 | ||
|
|
390a29ef55 | ||
|
|
b7f5786747 | ||
|
|
814c535757 | ||
|
|
ed0e27e4fd | ||
|
|
659df480d7 | ||
|
|
d0c45f6180 | ||
|
|
5a20461bd4 | ||
|
|
849f52fb8e | ||
|
|
da0c699b88 | ||
|
|
af5514c982 | ||
|
|
cc689e80a9 | ||
|
|
3b51374678 | ||
|
|
f07569db98 | ||
|
|
c5fcb90683 | ||
|
|
270f87dee1 | ||
|
|
765b8f3b35 | ||
|
|
3ff695899c | ||
|
|
be35baa838 | ||
|
|
470d4d7314 | ||
|
|
0976608a25 | ||
|
|
e7f4dfa07e | ||
|
|
2c68f335c6 | ||
|
|
cf29589e29 | ||
|
|
05cabd8edc | ||
|
|
2f4ecba51a | ||
|
|
74a7c9f23f | ||
|
|
b075a717f7 | ||
|
|
a3dcb888d3 | ||
|
|
02706af508 | ||
|
|
1f2c007776 | ||
|
|
42dfdfef13 | ||
|
|
b40bf0b9e1 | ||
|
|
cc15a9c468 | ||
|
|
5d8b1eecb4 | ||
|
|
093d82d762 | ||
|
|
686577c209 | ||
|
|
c2b333a78b | ||
|
|
cef8b8bf0f | ||
|
|
62a46710ba | ||
|
|
a3e6feb07e | ||
|
|
2ad093d4f1 | ||
|
|
6017d576fc | ||
|
|
08ac38dc87 | ||
|
|
3960daa556 | ||
|
|
395cbd87dd | ||
|
|
23429c047f | ||
|
|
af68748c16 | ||
|
|
18ab007f87 | ||
|
|
63942045be | ||
|
|
a4cdc6e4f3 | ||
|
|
07c611bdbe |
@@ -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,9 +7,13 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
upload-qodana-config:
|
upload-qodana-config:
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -20,9 +24,9 @@ jobs:
|
|||||||
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
|
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v $(pwd):/workspace \
|
-v "$(pwd):/workspace" \
|
||||||
-w /workspace \
|
-w /workspace \
|
||||||
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
|
-e QODANA_CONFIGURATIONS_TOKEN \
|
||||||
jetbrains/qodana-configuration-uploader:latest \
|
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \
|
||||||
--global-configs-file qodana-global-configurations.yaml \
|
--global-configs-file qodana-global-configurations.yaml \
|
||||||
--qodana-host https://qodana.cloud
|
--qodana-host https://qodana.cloud
|
||||||
|
|||||||
@@ -1,30 +1,35 @@
|
|||||||
name: Frontend Release
|
name: Frontend Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- Automated Tests
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: frontend-release-${{ github.ref }}
|
group: frontend-release-${{ github.event.workflow_run.head_branch }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-upload-and-verify:
|
build-upload-and-verify:
|
||||||
|
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
env:
|
env:
|
||||||
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||||
PLAYWRIGHT_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
|
||||||
|
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||||
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
|
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
|
||||||
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
|
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
|
||||||
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
RELEASE_EXPECTED_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_INITIAL_SECONDS: 45
|
||||||
RELEASE_WAIT_TIMEOUT_SECONDS: 600
|
RELEASE_WAIT_TIMEOUT_SECONDS: 600
|
||||||
RELEASE_POLL_INTERVAL_SECONDS: 10
|
RELEASE_POLL_INTERVAL_SECONDS: 10
|
||||||
@@ -33,54 +38,114 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
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
|
- name: Setup Node.js
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm ci --legacy-peer-deps
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
- name: Check AI workflow sync
|
- name: Check AI workflow sync
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: node scripts/sync-ai-workflow.mjs --check
|
run: node scripts/sync-ai-workflow.mjs --check
|
||||||
|
|
||||||
- name: Source and i18n checks
|
- name: Source and i18n checks
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: |
|
run: |
|
||||||
npm run text:check-encoding
|
npm run text:check-encoding
|
||||||
npm run i18n:v2:source-check
|
npm run i18n:v2:source-check
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run test:unit
|
run: npm run test:unit
|
||||||
env:
|
env:
|
||||||
VITEST_BATCH_SIZE: 5
|
VITEST_BATCH_SIZE: 5
|
||||||
|
|
||||||
- name: Build release artifact
|
- name: Build release artifact
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Install Playwright Chromium
|
- 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
|
- name: Production Playwright gate
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run test:e2e:prod
|
run: npm run test:e2e:prod
|
||||||
|
|
||||||
- name: Upload dist artifact
|
- name: Upload dist artifact
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
|
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
|
||||||
path: dist
|
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
|
- name: Wait for Coolify release artifact
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run release:verify-upload
|
run: npm run release:verify-upload
|
||||||
|
|
||||||
- name: Public live Playwright gate
|
- name: Public live Playwright gate
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run test:e2e:live:public
|
run: npm run test:e2e:live:public
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --use-system-ca
|
NODE_OPTIONS: --use-system-ca
|
||||||
|
|
||||||
- name: Credentialed live Playwright gate
|
- name: Credentialed live Playwright gate
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run test:e2e:live:roles
|
run: npm run test:e2e:live:roles
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --use-system-ca
|
NODE_OPTIONS: --use-system-ca
|
||||||
@@ -93,31 +158,35 @@ jobs:
|
|||||||
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
||||||
|
|
||||||
- name: Record Release Manager gate
|
- name: Record Release Manager gate
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: |
|
run: |
|
||||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
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 \
|
curl --fail --show-error --silent \
|
||||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-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:
|
env:
|
||||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
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_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
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 }}
|
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
|
||||||
- name: Update server version after verification
|
- name: Update server version after verification
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run release:update-server-version
|
run: npm run release:update-server-version
|
||||||
env:
|
env:
|
||||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||||
RELEASE_VERSION: ${{ github.sha }}
|
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: failure()
|
if: failure() && steps.branch-head.outputs.current == 'true'
|
||||||
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
|
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
|
||||||
path: output/playwright
|
path: output/playwright
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 14
|
retention-days: 3
|
||||||
|
|||||||
+109
-15
@@ -18,6 +18,7 @@ jobs:
|
|||||||
format-tests:
|
format-tests:
|
||||||
# CI runs on the repository's self-hosted runner pool.
|
# CI runs on the repository's self-hosted runner pool.
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -40,6 +41,7 @@ jobs:
|
|||||||
build-and-unit:
|
build-and-unit:
|
||||||
needs: format-tests
|
needs: format-tests
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -53,6 +55,12 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --legacy-peer-deps
|
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
|
- name: Build sanity check
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
@@ -64,7 +72,18 @@ jobs:
|
|||||||
e2e-pr:
|
e2e-pr:
|
||||||
if: github.event_name != 'schedule'
|
if: github.event_name != 'schedule'
|
||||||
needs: build-and-unit
|
needs: build-and-unit
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||||
|
runs-on: [self-hosted, Linux, X64]
|
||||||
|
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:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -104,27 +123,70 @@ jobs:
|
|||||||
run: npm ci --legacy-peer-deps
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: npx playwright install --with-deps chromium
|
run: node scripts/install-playwright-browsers.mjs chromium
|
||||||
|
|
||||||
- name: Run Playwright PR tests
|
- name: Set Playwright dev server port
|
||||||
run: npm run test:e2e:pr -- --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
|
shell: bash
|
||||||
|
env:
|
||||||
|
MATRIX_SUITE: ${{ matrix.suite }}
|
||||||
|
MATRIX_PROJECT: ${{ matrix.project }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
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=$((5200 + 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
|
- name: Upload Playwright report
|
||||||
if: failure()
|
if: failure() || cancelled()
|
||||||
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report-pr
|
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||||
path: output/playwright
|
path: |
|
||||||
|
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
|
||||||
|
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 7
|
retention-days: 3
|
||||||
|
|
||||||
e2e-full:
|
e2e-full:
|
||||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
|
if: >
|
||||||
needs: build-and-unit
|
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 }}
|
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64]
|
||||||
|
timeout-minutes: 60
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
max-parallel: 4
|
||||||
matrix:
|
matrix:
|
||||||
role: [customer, subuser, admin, superuser]
|
role: [customer, subuser, admin, superuser]
|
||||||
browser: [chromium, firefox, webkit]
|
browser: [chromium, firefox, webkit]
|
||||||
@@ -156,13 +218,45 @@ jobs:
|
|||||||
run: npm ci --legacy-peer-deps
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- 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: |
|
||||||
|
set -euo pipefail
|
||||||
|
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=$((5300 + role_offset + browser_offset + device_offset))" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Run full Playwright slice
|
- 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
|
- name: Upload Playwright report
|
||||||
if: failure()
|
if: failure() || cancelled()
|
||||||
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
|
name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
|
||||||
@@ -171,4 +265,4 @@ jobs:
|
|||||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
|
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
|
||||||
output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt
|
output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 14
|
retention-days: 3
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"setupCompletedAt": "2026-06-07T11:37:04.444Z"
|
||||||
|
}
|
||||||
+3
-3
@@ -56,9 +56,9 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "io.truckwash.twa"
|
applicationId "io.truckwash.twa"
|
||||||
minSdkVersion 21
|
minSdkVersion 21
|
||||||
targetSdkVersion 35
|
targetSdkVersion 36
|
||||||
versionCode 6
|
versionCode 7
|
||||||
versionName "6"
|
versionName "7"
|
||||||
|
|
||||||
// The name for the application
|
// The name for the application
|
||||||
resValue "string", "appName", twaManifest.name
|
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":"/"}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# User wash start production-readiness QA matrix
|
||||||
|
|
||||||
|
This note documents deterministic coverage for the self-serve user wash start flow. The `@dynamic-image` Playwright case remains separated because it validates rendered image behavior in addition to deterministic state and API transitions.
|
||||||
|
|
||||||
|
## Unit matrix
|
||||||
|
|
||||||
|
| Area | Required scenario | Coverage |
|
||||||
|
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `useWashFlowState` | Step transitions for vehicle, questions, lane, tasks, in-progress, completed | `tests/unit/use-wash-flow-state-production.spec.js` validates step clickability, next-button gating, questions-to-lane updates, lane-to-start target selection, task completion transition, in-progress navigation, and completed-step non-clickability contract. |
|
||||||
|
| `useWashSessionActions` | START success and failure | `tests/unit/use-wash-session-actions-production.spec.js` covers successful manual START state mutation and failed START retryability without active-wash mutation. |
|
||||||
|
| `useWashSessionActions` | Relay enable success and relay enable failure with STOP rollback | `tests/unit/use-wash-session-actions-production.spec.js` covers machine relay success and relay failure rollback via STOP. |
|
||||||
|
| `useWashSessionActions` | STOP failure and already-stopped STOP recovery | `tests/unit/use-wash-session-actions-production.spec.js` covers failed STOP preserving active state and already-not-occupied STOP clearing local state. |
|
||||||
|
| `useSelfServeLogic` | Preview/summary merge | `tests/unit/use-self-serve-logic-production.spec.js` covers preview questions merging with summary questions, answer maps, visible question order, tasks, and allowed services. |
|
||||||
|
| `useSelfServeLogic` | Request race handling | `tests/unit/use-self-serve-logic-production.spec.js` covers stale preview/summary responses being ignored when a newer request wins. |
|
||||||
|
| `useSelfServeLogic` | Allowed services updates | `tests/unit/use-self-serve-logic-production.spec.js` covers lane allowed-service endpoint updates and first-failure fallback behavior. |
|
||||||
|
| `useSelfServeLogic` | Answer sync failures | `tests/unit/use-self-serve-logic-production.spec.js` covers error reporting while preserving the caller's optimistic local answer. |
|
||||||
|
| `MyWashStart.vue` | Local progress restore, server active-wash restore, recent-completion suppression, unmount cleanup, duplicate-fetch prevention | `tests/unit/my-wash-start-production.spec.js` locks the component contracts for restore ordering, authenticated active-wash application, recent-completion suppression across restore/polling, unmount cleanup, and duplicate-fetch/sync guards. |
|
||||||
|
|
||||||
|
## E2E mocked matrix
|
||||||
|
|
||||||
|
| Required scenario | Coverage |
|
||||||
|
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Full manual flow | `tests/e2e/self-serve-wash.spec.js` covers direct manual wash start, guided completion, STOP, exit-gate open, completed state, and close/reset. |
|
||||||
|
| Full machine flow | `tests/e2e/self-serve-wash.spec.js` covers machine task rendering, dynamic image progress, required task completion, machine start path, and reload persistence. |
|
||||||
|
| Reload/resume active wash | `tests/e2e/self-serve-wash.spec.js` covers local progress reload and authenticated server active-wash resume from another device. |
|
||||||
|
| Backend says wash completed during polling | `tests/e2e/self-serve-wash.spec.js` covers server polling of active wash and local transition to completed when the backend no longer reports the matching in-progress wash. |
|
||||||
|
| Network failures for preview, answer sync, START, relay enable, STOP | `tests/e2e/self-serve-wash.spec.js` covers retryable START failure, STOP failure preservation, allowed-services gateway timeout/retry, answer sync background resilience, and unit-level relay rollback. Add mocked network route overrides when expanding browser-level failure assertions. |
|
||||||
|
|
||||||
|
## Optional live smoke
|
||||||
|
|
||||||
|
| Optional scenario | Coverage |
|
||||||
|
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Dynamic image smoke | The `@dynamic-image` Playwright test in `tests/e2e/self-serve-wash.spec.js` is tagged separately from deterministic CI coverage so it can be included or excluded explicitly with Playwright grep controls. |
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
+13
-4
@@ -2,10 +2,19 @@
|
|||||||
<html lang="" class="theme-light" data-theme="light">
|
<html lang="" class="theme-light" data-theme="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
|
<script>
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
|
(function () {
|
||||||
<link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
|
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/);
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
|
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/';
|
||||||
|
var base = document.createElement('base');
|
||||||
|
base.href = href;
|
||||||
|
document.currentScript.after(base);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="icon" type="image/png" href="%BASE_URL%assets/favicons/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="%BASE_URL%assets/favicons/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="%BASE_URL%assets/favicons/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="%BASE_URL%assets/favicons/apple-touch-icon.png" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
|
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ server {
|
|||||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||||
add_header X-Frame-Options "SAMEORIGIN" 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$ {
|
location ~ ^/(release-entry|release-manifest)\.json$ {
|
||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||||
@@ -29,7 +37,7 @@ server {
|
|||||||
try_files /$static_asset_path =404;
|
try_files /$static_asset_path =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(?:.+/)?(?<static_file_path>manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
|
location ~ ^/(?:.+/)?(?<static_file_path>index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
|
||||||
try_files /$static_file_path =404;
|
try_files /$static_file_path =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "io.truckwash.twa.staging"
|
applicationId "io.truckwash.twa.staging"
|
||||||
minSdkVersion 21
|
minSdkVersion 21
|
||||||
targetSdkVersion 35
|
targetSdkVersion 36
|
||||||
versionCode 5
|
versionCode 6
|
||||||
versionName "5"
|
versionName "6"
|
||||||
|
|
||||||
// The name for the application
|
// The name for the application
|
||||||
resValue "string", "appName", twaManifest.name
|
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":"/"}
|
||||||
|
|||||||
Generated
+1105
-7
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:dev": "vite build --mode development",
|
"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": "prettier --write \"tests/**/*.{js,ts}\"",
|
||||||
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
|
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
|
||||||
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
|
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
|
||||||
@@ -108,17 +111,23 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@creativebulma/bulma-divider": "^1.1.0",
|
"@creativebulma/bulma-divider": "^1.1.0",
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@event-calendar/core": "^4.1.0",
|
"@event-calendar/core": "^4.1.0",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/event-calendar__core": "^3.7.0",
|
"@types/event-calendar__core": "^3.7.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"eslint": "^10.4.1",
|
||||||
|
"eslint-plugin-vue": "^10.9.2",
|
||||||
|
"globals": "^17.6.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"prettier": "2.8.8",
|
"prettier": "2.8.8",
|
||||||
"sass-embedded": "^1.81.0",
|
"sass-embedded": "^1.81.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.60.1",
|
||||||
"vite": "7.1.11",
|
"vite": "7.1.11",
|
||||||
"vite-plugin-pwa": "^1.0.2",
|
"vite-plugin-pwa": "^1.0.2",
|
||||||
"vite-plugin-vue-devtools": "^7.5.4",
|
"vite-plugin-vue-devtools": "^7.5.4",
|
||||||
|
|||||||
@@ -38,10 +38,14 @@ function buildProject(name: string, browserName: "chromium" | "firefox" | "webki
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
testIgnore: ["**/release/**"],
|
testIgnore: ["**/release/**"],
|
||||||
|
snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}-win32{ext}",
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: isCI,
|
forbidOnly: isCI,
|
||||||
retries: isCI ? 2 : 0,
|
retries: isCI ? 2 : 0,
|
||||||
|
expect: {
|
||||||
|
timeout: 15_000,
|
||||||
|
},
|
||||||
workers,
|
workers,
|
||||||
...(process.env.PLAYWRIGHT_BASE_URL
|
...(process.env.PLAYWRIGHT_BASE_URL
|
||||||
? {}
|
? {}
|
||||||
|
|||||||
@@ -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 viteCliPath = path.resolve(process.cwd(), "node_modules/vite/bin/vite.js");
|
||||||
const serverOutputLimit = 80;
|
const serverOutputLimit = 80;
|
||||||
const activeOutputReaders = [];
|
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.
|
// Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot.
|
||||||
const viteDevArgs = [
|
const viteDevArgs = [
|
||||||
...(process.env.PLAYWRIGHT_VITE_FORCE === "1" ? ["--force"] : []),
|
...(process.env.PLAYWRIGHT_VITE_FORCE === "1" ? ["--force"] : []),
|
||||||
@@ -449,10 +459,7 @@ export default async function globalSetup() {
|
|||||||
const serverProcess = spawn(process.execPath, [viteCliPath, ...viteDevArgs], {
|
const serverProcess = spawn(process.execPath, [viteCliPath, ...viteDevArgs], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
detached: true,
|
detached: true,
|
||||||
env: {
|
env: viteServerEnv,
|
||||||
...process.env,
|
|
||||||
PLAYWRIGHT: "1",
|
|
||||||
},
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
|
|||||||
+51
-20
@@ -1,3 +1,4 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const baseURL = "http://127.0.0.1:4173";
|
const baseURL = "http://127.0.0.1:4173";
|
||||||
@@ -5,6 +6,55 @@ const isCI = !!process.env.CI;
|
|||||||
|
|
||||||
process.env.PLAYWRIGHT_BASE_URL = baseURL;
|
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({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e/release",
|
testDir: "./tests/e2e/release",
|
||||||
testMatch: /.*\.local-prod\.spec\.ts/,
|
testMatch: /.*\.local-prod\.spec\.ts/,
|
||||||
@@ -28,24 +78,5 @@ export default defineConfig({
|
|||||||
timeout: 240_000,
|
timeout: 240_000,
|
||||||
reuseExistingServer: !isCI,
|
reuseExistingServer: !isCI,
|
||||||
},
|
},
|
||||||
projects: [
|
projects,
|
||||||
{
|
|
||||||
name: "chromium-desktop",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Chrome"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "chromium-mobile",
|
|
||||||
use: {
|
|
||||||
...devices["Pixel 5"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "webkit-desktop",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Safari"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-4
@@ -2,6 +2,10 @@
|
|||||||
Options -MultiViews
|
Options -MultiViews
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_mime.c>
|
||||||
|
AddType application/manifest+json .webmanifest
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
<IfModule mod_rewrite.c>
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
|
|
||||||
@@ -23,17 +27,17 @@
|
|||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteRule ^.+/((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
|
RewriteRule ^.+/((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
|
||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
|
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
|
||||||
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
|
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
|
||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
|
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
|
||||||
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
|
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
|
||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
@@ -41,7 +45,7 @@
|
|||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteRule ^(?:.*?/)?(?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
|
RewriteRule ^(?:.*?/)?(?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
|
||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "Truck Wash Kundeportal",
|
"name": "Truck Wash Kundeportal",
|
||||||
"short_name": "Truck Wash",
|
"short_name": "Truck Wash",
|
||||||
"description": "Access your Truck Wash accounts and transactions from anywhere.",
|
"description": "Access your Truck Wash accounts and transactions from anywhere.",
|
||||||
|
"id": "/",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "assets/favicons/web-app-manifest-192x192.png",
|
"src": "assets/favicons/web-app-manifest-192x192.png",
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
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;
|
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() {
|
function requiredUrl() {
|
||||||
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
|
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -121,6 +140,7 @@ async function verifyShell(baseUrl, shellPath) {
|
|||||||
async function verifyRelease(baseUrl) {
|
async function verifyRelease(baseUrl) {
|
||||||
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
|
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 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 manifest = await fetchJson(baseUrl, "release-manifest.json");
|
||||||
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
|
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
|
||||||
|
|
||||||
@@ -130,7 +150,7 @@ async function verifyRelease(baseUrl) {
|
|||||||
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
|
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
|
||||||
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${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}`);
|
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
|
||||||
}
|
}
|
||||||
if (releaseEntry.entry !== manifest.entry) {
|
if (releaseEntry.entry !== manifest.entry) {
|
||||||
@@ -191,6 +211,10 @@ async function main() {
|
|||||||
attempt += 1;
|
attempt += 1;
|
||||||
try {
|
try {
|
||||||
const result = await verifyRelease(baseUrl);
|
const result = await verifyRelease(baseUrl);
|
||||||
|
appendGithubEnv({
|
||||||
|
RELEASE_VERIFIED_BUILD_ID: result.build_id,
|
||||||
|
RELEASE_VERIFIED_COMMIT: result.commit_sha,
|
||||||
|
});
|
||||||
console.log(
|
console.log(
|
||||||
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
|
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const ownedFilesByRole = {
|
|||||||
"userHome.spec.ts",
|
"userHome.spec.ts",
|
||||||
"userInvoices.spec.ts",
|
"userInvoices.spec.ts",
|
||||||
"userMyWashStart.spec.ts",
|
"userMyWashStart.spec.ts",
|
||||||
|
"userMyWashStartFlow.spec.ts",
|
||||||
"userProfileInvoicing.spec.ts",
|
"userProfileInvoicing.spec.ts",
|
||||||
"userProfileNotifications.spec.ts",
|
"userProfileNotifications.spec.ts",
|
||||||
"userProfileSecurity.spec.ts",
|
"userProfileSecurity.spec.ts",
|
||||||
@@ -79,6 +80,7 @@ export const ownedFilesByRole = {
|
|||||||
"edge-gateways.visual.spec.js",
|
"edge-gateways.visual.spec.js",
|
||||||
"errorReports.spec.ts",
|
"errorReports.spec.ts",
|
||||||
"failover-config.source.spec.ts",
|
"failover-config.source.spec.ts",
|
||||||
|
"collected-invoice-move-customer.spec.ts",
|
||||||
"invoice-distribution.smoke.spec.js",
|
"invoice-distribution.smoke.spec.js",
|
||||||
"invoice-transfer-monitor.spec.ts",
|
"invoice-transfer-monitor.spec.ts",
|
||||||
"invoice-transfer-queue-history.spec.js",
|
"invoice-transfer-queue-history.spec.js",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ for (const signal of ["SIGINT", "SIGTERM"]) {
|
|||||||
function parseArgs(rawArgs) {
|
function parseArgs(rawArgs) {
|
||||||
const parsed = {
|
const parsed = {
|
||||||
help: false,
|
help: false,
|
||||||
|
coreOnly: false,
|
||||||
changedOnly: false,
|
changedOnly: false,
|
||||||
listOnly: false,
|
listOnly: false,
|
||||||
base: "",
|
base: "",
|
||||||
@@ -43,6 +44,11 @@ function parseArgs(rawArgs) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value === "--core-only") {
|
||||||
|
parsed.coreOnly = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (value === "--changed-only") {
|
if (value === "--changed-only") {
|
||||||
parsed.changedOnly = true;
|
parsed.changedOnly = true;
|
||||||
continue;
|
continue;
|
||||||
@@ -103,12 +109,14 @@ Options:
|
|||||||
--base <ref> Base ref for changed-area detection
|
--base <ref> Base ref for changed-area detection
|
||||||
--head <ref> Head ref for changed-area detection. Default: HEAD
|
--head <ref> Head ref for changed-area detection. Default: HEAD
|
||||||
--project <name> Restrict to one Chromium Playwright project. Can be repeated.
|
--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
|
--changed-only Run changed-area selection without the core @pr gate
|
||||||
--list-only List selected tests instead of running them
|
--list-only List selected tests instead of running them
|
||||||
-h, --help Show help
|
-h, --help Show help
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
npm run test:e2e:pr
|
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
|
npm run test:e2e:changed -- --base=HEAD~1 --head=HEAD
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -418,6 +426,10 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.coreOnly && args.changedOnly) {
|
||||||
|
throw new Error("--core-only and --changed-only cannot be used together.");
|
||||||
|
}
|
||||||
|
|
||||||
await fs.access(playwrightCliPath);
|
await fs.access(playwrightCliPath);
|
||||||
|
|
||||||
if (!args.changedOnly) {
|
if (!args.changedOnly) {
|
||||||
@@ -428,6 +440,10 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.coreOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const changed = await getChangedFiles();
|
const changed = await getChangedFiles();
|
||||||
if (changed.unavailable) {
|
if (changed.unavailable) {
|
||||||
console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`);
|
console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/L
|
|||||||
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
|
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
|
||||||
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
|
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
|
||||||
const ErrorReportLauncher = defineAsyncComponent(() => import("@/components/global/ErrorReportLauncher.vue"));
|
const ErrorReportLauncher = defineAsyncComponent(() => import("@/components/global/ErrorReportLauncher.vue"));
|
||||||
|
const FrontendMaintenanceMenu = defineAsyncComponent(() => import("@/components/global/FrontendMaintenanceMenu.vue"));
|
||||||
const ReleaseChannelUnavailable = defineAsyncComponent(() =>
|
const ReleaseChannelUnavailable = defineAsyncComponent(() =>
|
||||||
import("@/components/release/ReleaseChannelUnavailable.vue")
|
import("@/components/release/ReleaseChannelUnavailable.vue")
|
||||||
);
|
);
|
||||||
@@ -128,6 +129,7 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
|
|||||||
</template>
|
</template>
|
||||||
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
||||||
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
||||||
|
<FrontendMaintenanceMenu />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -2535,11 +2535,13 @@ const syncDesktopFlyoutPosition = () => {
|
|||||||
dropdownContentEl.style.top = "";
|
dropdownContentEl.style.top = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const padding = 12;
|
const viewportInsets = getViewportInsets();
|
||||||
|
const viewportTop = viewportInsets.top;
|
||||||
|
const viewportBottom = viewportHeight - viewportInsets.bottom;
|
||||||
let top = triggerRect.bottom;
|
let top = triggerRect.bottom;
|
||||||
|
|
||||||
if (top + contentHeight > viewportHeight - padding) {
|
if (top + contentHeight > viewportBottom) {
|
||||||
top = Math.max(padding, triggerRect.top - contentHeight);
|
top = Math.max(viewportTop, triggerRect.top - contentHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextFixedStyles = {
|
const nextFixedStyles = {
|
||||||
|
|||||||
@@ -275,6 +275,10 @@ const getInvoiceCollectionResponseOrders = (response) => {
|
|||||||
return response.data.orders;
|
return response.data.orders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(response?.data?.data?.orders)) {
|
||||||
|
return response.data.data.orders;
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(response?.includes?.orders)) {
|
if (Array.isArray(response?.includes?.orders)) {
|
||||||
return response.includes.orders;
|
return response.includes.orders;
|
||||||
}
|
}
|
||||||
@@ -649,6 +653,41 @@ const getInvoiceCollectionDetails = (invoiceCollectionId) => {
|
|||||||
}
|
}
|
||||||
return null;
|
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(
|
watch(
|
||||||
[() => props.orders, () => props.excludedOrderIds, () => props.groupInvoiceCollection],
|
[() => props.orders, () => props.excludedOrderIds, () => props.groupInvoiceCollection],
|
||||||
() => {
|
() => {
|
||||||
@@ -999,6 +1038,13 @@ const formatCashierName = (order) => {
|
|||||||
}}
|
}}
|
||||||
er ikke vist, men vil muligvis blive faktureret alligevel.</span
|
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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
step,
|
step,
|
||||||
setDesktopStep1PreflightHandler,
|
setDesktopStep1PreflightHandler,
|
||||||
clearDesktopStep1PreflightHandler,
|
clearDesktopStep1PreflightHandler,
|
||||||
|
pushPosRouteState,
|
||||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||||
import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue";
|
import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue";
|
||||||
import { POS_STEP_1_VERSION } from "@/config.js";
|
import { POS_STEP_1_VERSION } from "@/config.js";
|
||||||
@@ -645,7 +646,7 @@ const pushDesktopStepTwoRoute = () => {
|
|||||||
return;
|
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 = {}) => {
|
const handleDesktopLastWashCopy = async (payload = {}) => {
|
||||||
@@ -760,7 +761,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
<div class="pos-shell-actions">
|
<div class="pos-shell-actions">
|
||||||
<ButtonsBox class="pos-actions pos-actions--stacked">
|
<ButtonsBox class="pos-actions pos-actions--stacked">
|
||||||
<Cancel tabindex="2" class="is-fullwidth" />
|
<Cancel tabindex="5" class="is-fullwidth" />
|
||||||
</ButtonsBox>
|
</ButtonsBox>
|
||||||
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail">
|
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail">
|
||||||
<PosDesktopDuplicateWarning
|
<PosDesktopDuplicateWarning
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
reg_1,
|
reg_1,
|
||||||
reset_all_values,
|
reset_all_values,
|
||||||
setStep,
|
setStep,
|
||||||
|
pushPosRouteState,
|
||||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { StripeModule } from "@/components/stripe/StripeModule.vue";
|
import { StripeModule } from "@/components/stripe/StripeModule.vue";
|
||||||
@@ -70,10 +71,10 @@ const formatCurrency = (amount) => {
|
|||||||
const navigateToItems = () => {
|
const navigateToItems = () => {
|
||||||
setStep(2);
|
setStep(2);
|
||||||
if (normalizedOrderId.value) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
window.history.pushState({}, '', `?step=2`);
|
pushPosRouteState("step=2");
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSuccessfulMobileCardFlow = () => {
|
const clearSuccessfulMobileCardFlow = () => {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<!--
|
||||||
|
ErrorBanner.vue – reusable self-serve error notification bar.
|
||||||
|
|
||||||
|
Props:
|
||||||
|
message – error text to display (blank ⇒ hidden)
|
||||||
|
type – Bulma tint: is-danger | is-warning (default is-danger)
|
||||||
|
iconLeft – Font Awesome icon name
|
||||||
|
:loading – whether retry button is in loading state
|
||||||
|
:showRetry – whether to show the retry button
|
||||||
|
Emit:
|
||||||
|
retry – fired when retry button is clicked
|
||||||
|
-->
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
message: string | null;
|
||||||
|
type?: "is-danger" | "is-warning";
|
||||||
|
iconLeft?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
showRetry?: boolean;
|
||||||
|
retryTestId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
message: string | null;
|
||||||
|
type?: "is-danger" | "is-warning";
|
||||||
|
iconLeft?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
showRetry?: boolean;
|
||||||
|
retryTestId?: string | null;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
type: "is-danger",
|
||||||
|
iconLeft: "sync-alt",
|
||||||
|
loading: false,
|
||||||
|
showRetry: true,
|
||||||
|
retryTestId: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
retry: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<b-message v-if="message" :type="type" has-icon :closable="false" class="self-serve-error-banner">
|
||||||
|
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
|
||||||
|
<span class="mr-3">{{ message }}</span>
|
||||||
|
<b-button
|
||||||
|
v-if="showRetry"
|
||||||
|
size="is-small"
|
||||||
|
:type="`${type} is-light`"
|
||||||
|
icon-pack="fas"
|
||||||
|
:icon-left="iconLeft"
|
||||||
|
:loading="loading"
|
||||||
|
:data-testid="retryTestId || undefined"
|
||||||
|
@click="emit('retry')"
|
||||||
|
>
|
||||||
|
{{ $t("common.try_again") }}
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
</b-message>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.self-serve-error-banner {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<!--
|
||||||
|
LaneSelectionSection.vue – bane-valg radio buttons and lane status display.
|
||||||
|
|
||||||
|
Props:
|
||||||
|
lanes – array of lane objects from the department
|
||||||
|
selectedLaneId – currently selected lane id
|
||||||
|
washType – "Manual" | "Machine"
|
||||||
|
departmentName – human-readable department name
|
||||||
|
isLaneAvailableFn – function(lane) => boolean
|
||||||
|
isMachineAvailableFn – function(laneId) => boolean
|
||||||
|
|
||||||
|
Emit:
|
||||||
|
update:selectedLaneId – new lane id selected
|
||||||
|
update:washType – "Manual" | "Machine"
|
||||||
|
-->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import SelfServeLaneStep from "@/components/displays/selfServe/SelfServeLaneStep.vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
lanes: any[];
|
||||||
|
selectedLaneId: number | string | null;
|
||||||
|
washType: string;
|
||||||
|
departmentName: string | null;
|
||||||
|
isLaneAvailableFn: (lane: any) => boolean;
|
||||||
|
isMachineAvailableFn: (laneId: number | string | null) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:selectedLaneId": [id: number];
|
||||||
|
"update:washType": [type: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleLaneUpdate(id: number) {
|
||||||
|
emit("update:selectedLaneId", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWashTypeUpdate(type: string) {
|
||||||
|
emit("update:washType", type);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelfServeLaneStep
|
||||||
|
:lanes="lanes"
|
||||||
|
:selected-lane-id="selectedLaneId"
|
||||||
|
:wash-type="washType"
|
||||||
|
:department-name="departmentName"
|
||||||
|
:is-lane-available="isLaneAvailableFn"
|
||||||
|
:is-machine-available="isMachineAvailableFn"
|
||||||
|
@update:selected-lane-id="handleLaneUpdate"
|
||||||
|
@update:wash-type="handleWashTypeUpdate"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Lane selection styling is inherited from SelfServeLaneStep */
|
||||||
|
</style>
|
||||||
@@ -93,8 +93,6 @@ const emit = defineEmits<{
|
|||||||
native-value="Machine"
|
native-value="Machine"
|
||||||
type="is-link"
|
type="is-link"
|
||||||
:disabled="!isMachineAvailable(selectedLaneId)"
|
: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"
|
data-testid="self-serve-wash-type-machine"
|
||||||
@update:model-value="emit('update:washType', 'Machine')"
|
@update:model-value="emit('update:washType', 'Machine')"
|
||||||
@input="emit('update:washType', 'Machine')"
|
@input="emit('update:washType', 'Machine')"
|
||||||
@@ -117,14 +115,6 @@ const emit = defineEmits<{
|
|||||||
</b-radio-button>
|
</b-radio-button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -21,16 +21,6 @@ const isImageAttachment = (attachment: any) => (
|
|||||||
&& attachment.content.other.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)
|
&& attachment.content.other.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasTaskDescription = (task: any) => {
|
|
||||||
const description = task?.description;
|
|
||||||
if (typeof description !== "string") {
|
|
||||||
return !!description;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedDescription = description.trim();
|
|
||||||
return trimmedDescription.length > 0 && trimmedDescription !== "-";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getImageAttachments = (task: any) => {
|
const getImageAttachments = (task: any) => {
|
||||||
if (!Array.isArray(task?.attachments)) {
|
if (!Array.isArray(task?.attachments)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -83,7 +73,7 @@ const formatTaskTitle = (task: any) => {
|
|||||||
const title = String(task?.task || "");
|
const title = String(task?.task || "");
|
||||||
const programWheelSelection = getProgramWheelSelection(task);
|
const programWheelSelection = getProgramWheelSelection(task);
|
||||||
|
|
||||||
if (programWheelSelection === null) {
|
if (programWheelSelection === null || /\bprogram\s*#\d+\b/i.test(title)) {
|
||||||
return title;
|
return title;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,13 +117,11 @@ const formatTaskTitle = (task: any) => {
|
|||||||
type="is-success"
|
type="is-success"
|
||||||
:data-testid="`self-serve-task-${task.id}-toggle`"
|
:data-testid="`self-serve-task-${task.id}-toggle`"
|
||||||
@update:model-value="emit('toggle-task', task.id, $event)"
|
@update:model-value="emit('toggle-task', task.id, $event)"
|
||||||
@input="emit('toggle-task', task.id, $event)"
|
|
||||||
/>
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
</div>
|
</div>
|
||||||
<div class="self-serve-task-image-overlay-text">
|
<div class="self-serve-task-image-overlay-text">
|
||||||
<p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p>
|
<p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p>
|
||||||
<p v-if="hasTaskDescription(task)" class="self-serve-task-description">{{ task.description }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,22 +144,21 @@ const formatTaskTitle = (task: any) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="columns is-mobile is-vcentered">
|
<div v-else class="self-serve-task-row">
|
||||||
<div v-if="props.showCheckboxes" class="column is-narrow">
|
<div v-if="props.showCheckboxes" class="self-serve-task-checkbox">
|
||||||
<b-field>
|
<b-field>
|
||||||
<b-checkbox
|
<b-checkbox
|
||||||
|
class="mr-0 pr-0"
|
||||||
size="is-large"
|
size="is-large"
|
||||||
:model-value="completedTasks[task.id] === true"
|
:model-value="completedTasks[task.id] === true"
|
||||||
type="is-success"
|
type="is-success"
|
||||||
:data-testid="`self-serve-task-${task.id}-toggle`"
|
:data-testid="`self-serve-task-${task.id}-toggle`"
|
||||||
@update:model-value="emit('toggle-task', task.id, $event)"
|
@update:model-value="emit('toggle-task', task.id, $event)"
|
||||||
@input="emit('toggle-task', task.id, $event)"
|
|
||||||
/>
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="self-serve-task-content">
|
||||||
<p><strong>{{ formatTaskTitle(task) }}</strong></p>
|
<p><strong>{{ formatTaskTitle(task) }}</strong></p>
|
||||||
<p v-if="hasTaskDescription(task)" class="is-size-7">{{ task.description }}</p>
|
|
||||||
<div v-if="getVisibleServices(task).length > 0" class="tags mt-2">
|
<div v-if="getVisibleServices(task).length > 0" class="tags mt-2">
|
||||||
<span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span>
|
<span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,7 +226,7 @@ const formatTaskTitle = (task: any) => {
|
|||||||
padding: 0.65rem 0.75rem;
|
padding: 0.65rem 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.6rem;
|
gap: 0.35rem;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,12 +234,30 @@ const formatTaskTitle = (task: any) => {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.self-serve-task-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-task-checkbox {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-task-checkbox :deep(.field) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-task-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-task-content p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.self-serve-task-title {
|
.self-serve-task-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.self-serve-task-description {
|
|
||||||
margin: 0.25rem 0 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { BIcon, BSkeleton } from "buefy";
|
import { BIcon } from "buefy";
|
||||||
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
|
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -19,10 +19,21 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isDynamicImageLoading = ref(false);
|
const isDynamicImageLoading = ref(false);
|
||||||
|
const failedDynamicImageUrl = ref<string | null>(null);
|
||||||
|
const hasRenderableDynamicImageUrl = computed(
|
||||||
|
() => !!props.dynamicImageUrl && failedDynamicImageUrl.value !== props.dynamicImageUrl
|
||||||
|
);
|
||||||
|
const showDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value && !isDynamicImageLoading.value);
|
||||||
|
const shouldRenderDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value);
|
||||||
|
|
||||||
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
|
watch(
|
||||||
isDynamicImageLoading.value = !!dynamicImageUrl;
|
() => props.dynamicImageUrl,
|
||||||
}, { immediate: true });
|
(dynamicImageUrl) => {
|
||||||
|
isDynamicImageLoading.value = !!dynamicImageUrl;
|
||||||
|
failedDynamicImageUrl.value = null;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
const emitToggleTask = (taskId: number, value: boolean) => {
|
const emitToggleTask = (taskId: number, value: boolean) => {
|
||||||
emit("toggle-task", taskId, value);
|
emit("toggle-task", taskId, value);
|
||||||
@@ -38,6 +49,7 @@ const onDynamicImageLoad = () => {
|
|||||||
|
|
||||||
const onDynamicImageError = () => {
|
const onDynamicImageError = () => {
|
||||||
isDynamicImageLoading.value = false;
|
isDynamicImageLoading.value = false;
|
||||||
|
failedDynamicImageUrl.value = props.dynamicImageUrl;
|
||||||
emit("clear-dynamic-image");
|
emit("clear-dynamic-image");
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -58,26 +70,37 @@ const onDynamicImageError = () => {
|
|||||||
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
v-if="dynamicImageUrl && isDynamicImageLoading"
|
||||||
|
:key="`${dynamicImageUrl}:preload`"
|
||||||
|
:src="dynamicImageUrl"
|
||||||
|
alt=""
|
||||||
|
data-testid="self-serve-dynamic-image-preload"
|
||||||
|
class="self-serve-dynamic-image-preload"
|
||||||
|
@load="onDynamicImageLoad"
|
||||||
|
@error="onDynamicImageError"
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="dynamicImageUrl"
|
v-if="shouldRenderDynamicImageFrame"
|
||||||
class="self-serve-dynamic-image-frame mb-4"
|
class="self-serve-dynamic-image-frame mb-4"
|
||||||
:class="{ 'is-loading': isDynamicImageLoading }"
|
|
||||||
data-testid="self-serve-dynamic-image-frame"
|
data-testid="self-serve-dynamic-image-frame"
|
||||||
>
|
>
|
||||||
<b-skeleton
|
<div
|
||||||
v-if="isDynamicImageLoading"
|
v-if="isDynamicImageLoading"
|
||||||
class="self-serve-dynamic-image-skeleton"
|
class="self-serve-dynamic-image-skeleton"
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
data-testid="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
|
<img
|
||||||
|
v-if="showDynamicImageFrame"
|
||||||
:key="dynamicImageUrl"
|
:key="dynamicImageUrl"
|
||||||
:src="dynamicImageUrl"
|
:src="dynamicImageUrl"
|
||||||
alt="Machine status"
|
alt="Machine status"
|
||||||
data-testid="self-serve-dynamic-image"
|
data-testid="self-serve-dynamic-image"
|
||||||
class="self-serve-dynamic-image"
|
class="self-serve-dynamic-image"
|
||||||
:class="{ 'is-loading': isDynamicImageLoading }"
|
|
||||||
@load="onDynamicImageLoad"
|
@load="onDynamicImageLoad"
|
||||||
@error="onDynamicImageError"
|
@error="onDynamicImageError"
|
||||||
/>
|
/>
|
||||||
@@ -107,19 +130,31 @@ const onDynamicImageError = () => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.self-serve-dynamic-image-skeleton {
|
||||||
|
align-items: center;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: #edf2f7;
|
||||||
|
color: #112f5f;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-dynamic-image-preload {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 769px) {
|
@media screen and (min-width: 769px) {
|
||||||
.self-serve-dynamic-image-frame {
|
.self-serve-dynamic-image-frame {
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.self-serve-dynamic-image-skeleton {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.self-serve-dynamic-image {
|
.self-serve-dynamic-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -128,8 +163,4 @@ const onDynamicImageError = () => {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
transition: opacity 120ms ease;
|
transition: opacity 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.self-serve-dynamic-image.is-loading {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
|
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
|
||||||
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
|
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
|
||||||
|
|
||||||
@@ -21,8 +21,11 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "update:customerNumber", value: string): void;
|
(e: "update:customerNumber", value: string): void;
|
||||||
|
(e: "update:customer-number", value: string): void;
|
||||||
(e: "update:registrationNumber", value: string): void;
|
(e: "update:registrationNumber", value: string): void;
|
||||||
|
(e: "update:registration-number", value: string): void;
|
||||||
(e: "select-vehicle-type", selection: any): void;
|
(e: "select-vehicle-type", selection: any): void;
|
||||||
|
(e: "selected", selection: any): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const extractRegistrationValue = (value: unknown): string => {
|
const extractRegistrationValue = (value: unknown): string => {
|
||||||
@@ -49,6 +52,19 @@ const normalizeRegistration = (value: unknown) => extractRegistrationValue(value
|
|||||||
|
|
||||||
const typedRegistration = ref(normalizeRegistration(props.registrationNumber));
|
const typedRegistration = ref(normalizeRegistration(props.registrationNumber));
|
||||||
|
|
||||||
|
const filteredRegistrationOptions = computed(() => {
|
||||||
|
const query = normalizeRegistration(typedRegistration.value);
|
||||||
|
const options = props.registrationOptions || [];
|
||||||
|
if (!query) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.filter((option) => {
|
||||||
|
const normalizedOption = normalizeRegistration(option);
|
||||||
|
return normalizedOption !== query && normalizedOption.includes(query);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
watch(() => props.registrationNumber, (newValue) => {
|
watch(() => props.registrationNumber, (newValue) => {
|
||||||
typedRegistration.value = normalizeRegistration(newValue);
|
typedRegistration.value = normalizeRegistration(newValue);
|
||||||
});
|
});
|
||||||
@@ -57,6 +73,18 @@ const emitRegistration = (value: unknown) => {
|
|||||||
const normalized = normalizeRegistration(value);
|
const normalized = normalizeRegistration(value);
|
||||||
typedRegistration.value = normalized;
|
typedRegistration.value = normalized;
|
||||||
emit("update:registrationNumber", normalized);
|
emit("update:registrationNumber", normalized);
|
||||||
|
emit("update:registration-number", normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitCustomerNumber = (value: unknown) => {
|
||||||
|
const normalized = String(value || "");
|
||||||
|
emit("update:customerNumber", normalized);
|
||||||
|
emit("update:customer-number", normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitVehicleTypeSelection = (selection: any) => {
|
||||||
|
emit("select-vehicle-type", selection);
|
||||||
|
emit("selected", selection);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -68,38 +96,41 @@ const emitRegistration = (value: unknown) => {
|
|||||||
:model-value="customerNumber || ''"
|
:model-value="customerNumber || ''"
|
||||||
data-testid="self-serve-customer-number"
|
data-testid="self-serve-customer-number"
|
||||||
:placeholder="$t('self_wash.enter_customer_number')"
|
:placeholder="$t('self_wash.enter_customer_number')"
|
||||||
@input="emit('update:customerNumber', String($event || ''))"
|
@input="emitCustomerNumber($event)"
|
||||||
|
@update:modelValue="emitCustomerNumber($event)"
|
||||||
/>
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
<b-field :label="$t('self_wash.registration_number')">
|
<b-field :label="$t('self_wash.registration_number')" class="self-serve-registration-field">
|
||||||
<b-autocomplete
|
<div class="self-serve-registration-control">
|
||||||
:model-value="typedRegistration"
|
<b-autocomplete
|
||||||
data-testid="self-serve-registration"
|
:model-value="typedRegistration"
|
||||||
:data="registrationOptions"
|
data-testid="self-serve-registration"
|
||||||
:placeholder="$t('self_wash.enter_registration_number')"
|
:data="filteredRegistrationOptions"
|
||||||
:debounce="300"
|
:placeholder="$t('self_wash.enter_registration_number')"
|
||||||
:min-length="1"
|
:debounce="300"
|
||||||
:filter="(option, query) => option.toLowerCase().includes(query.toLowerCase())"
|
:min-length="1"
|
||||||
icon-pack="fas"
|
icon-pack="fas"
|
||||||
icon="car-side"
|
icon="car-side"
|
||||||
clearable
|
clearable
|
||||||
:loading="registrationOptions.length === 0 && isCustomerVehiclesLoading"
|
:loading="filteredRegistrationOptions.length === 0 && isCustomerVehiclesLoading"
|
||||||
:selectable-header="true"
|
@select="emitRegistration($event)"
|
||||||
@select="emitRegistration($event)"
|
@typing="emitRegistration($event)"
|
||||||
@typing="emitRegistration($event)"
|
@update:modelValue="emitRegistration($event)"
|
||||||
@update:modelValue="emitRegistration($event)"
|
>
|
||||||
@select-header="emitRegistration(typedRegistration)"
|
</b-autocomplete>
|
||||||
>
|
<p
|
||||||
<template #header>
|
v-if="typedRegistration"
|
||||||
|
class="help self-serve-registration-guidance"
|
||||||
|
data-testid="self-serve-registration-guidance"
|
||||||
|
>
|
||||||
<template v-if="hasMatchingVehicle">
|
<template v-if="hasMatchingVehicle">
|
||||||
{{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }}
|
{{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
{{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }}
|
{{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }}
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</p>
|
||||||
<template #empty>{{ $t("self_wash.no_vehicles_found") }}</template>
|
</div>
|
||||||
</b-autocomplete>
|
|
||||||
</b-field>
|
</b-field>
|
||||||
<b-message
|
<b-message
|
||||||
v-if="props.vehicleStepError"
|
v-if="props.vehicleStepError"
|
||||||
@@ -124,7 +155,7 @@ const emitRegistration = (value: unknown) => {
|
|||||||
<SelfServeVehicleTypeSelector
|
<SelfServeVehicleTypeSelector
|
||||||
:selectedVehicleTypeId="selectedVehicleTypeId"
|
:selectedVehicleTypeId="selectedVehicleTypeId"
|
||||||
:restrictToProductIds="availableProductIds"
|
:restrictToProductIds="availableProductIds"
|
||||||
@selected="emit('select-vehicle-type', $event)"
|
@selected="emitVehicleTypeSelection"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="vehicleTypes.length === 0">
|
<template v-else-if="vehicleTypes.length === 0">
|
||||||
@@ -140,3 +171,34 @@ const emitRegistration = (value: unknown) => {
|
|||||||
</b-field>
|
</b-field>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.self-serve-registration-control {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-registration-guidance {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-registration-field :deep(.dropdown),
|
||||||
|
.self-serve-registration-field :deep(.autocomplete) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-registration-field :deep(.dropdown-menu) {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-registration-field :deep(.dropdown-item) {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<!--
|
||||||
|
VehicleInputSection.vue – customer number, license plate, and vehicle type inputs.
|
||||||
|
|
||||||
|
Props:
|
||||||
|
showCustomerNumberInput – whether to render the customer number field
|
||||||
|
customerNumber – current customer number string
|
||||||
|
registrationNumber – current license plate string
|
||||||
|
registrationOptions – autocomplete options for existing vehicles
|
||||||
|
selectedVehicleTypeId – currently selected vehicle type id
|
||||||
|
selectedVehicleName – human-readable name of selected vehicle type
|
||||||
|
availableProductIds – allowed product ids for the department
|
||||||
|
vehicleTypes – list of vehicle type options
|
||||||
|
|
||||||
|
Emit:
|
||||||
|
update:customerNumber – new customer number string
|
||||||
|
update:registrationNumber – new license plate string
|
||||||
|
selectVehicleType – selected VehicleTypeTemplate object
|
||||||
|
-->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import SelfServeVehicleStep from "@/components/displays/selfServe/SelfServeVehicleStep.vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showCustomerNumberInput: boolean;
|
||||||
|
customerNumber: string | null;
|
||||||
|
registrationNumber: string | null;
|
||||||
|
registrationOptions: string[];
|
||||||
|
selectedVehicleTypeId: number | null;
|
||||||
|
selectedVehicleName: string | null;
|
||||||
|
availableProductIds: (number | string)[];
|
||||||
|
vehicleTypes: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:customerNumber": [value: string];
|
||||||
|
"update:registrationNumber": [value: string];
|
||||||
|
selectVehicleType: [type: { id: number }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleCustomerNumberUpdate(value: string) {
|
||||||
|
emit("update:customerNumber", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRegistrationNumberUpdate(value: string) {
|
||||||
|
emit("update:registrationNumber", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectVehicleType(selection: any) {
|
||||||
|
emit("selectVehicleType", selection);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelfServeVehicleStep
|
||||||
|
:customer-number="customerNumber"
|
||||||
|
:show-customer-number-input="showCustomerNumberInput"
|
||||||
|
:registration-number="registrationNumber"
|
||||||
|
:registration-options="registrationOptions"
|
||||||
|
:is-customer-vehicles-loading="false"
|
||||||
|
:has-matching-vehicle="false"
|
||||||
|
:selected-vehicle-type-id="selectedVehicleTypeId"
|
||||||
|
:selected-vehicle-name="selectedVehicleName"
|
||||||
|
:selected-vehicle-description="null"
|
||||||
|
:available-product-ids="availableProductIds"
|
||||||
|
:vehicle-types="vehicleTypes"
|
||||||
|
:vehicle-step-error="null"
|
||||||
|
:vehicle-step-guidance="null"
|
||||||
|
@update:customer-number="handleCustomerNumberUpdate"
|
||||||
|
@update:customerNumber="handleCustomerNumberUpdate"
|
||||||
|
@update:registration-number="handleRegistrationNumberUpdate"
|
||||||
|
@update:registrationNumber="handleRegistrationNumberUpdate"
|
||||||
|
@select-vehicle-type="handleSelectVehicleType"
|
||||||
|
@selected="handleSelectVehicleType"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Vehicle input section styling is inherited from SelfServeVehicleStep */
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<!--
|
||||||
|
WashProgressCard.vue – timer, guided instructions, and bottom actions during active wash.
|
||||||
|
|
||||||
|
Renders when a wash session is in progress (WASH_IN_PROGRESS step).
|
||||||
|
|
||||||
|
Props:
|
||||||
|
currentGuidedWashStep – index into guidedWashFlowSteps
|
||||||
|
guidedWashFlowSteps – array of instruction step objects
|
||||||
|
formattedElapsed – human-readable elapsed time string
|
||||||
|
isCompletingWash – whether the wash is finishing right now
|
||||||
|
openingPropertyAccessGate – loading state for access gate button
|
||||||
|
openingPropertyExitGate – loading state for exit gate button
|
||||||
|
Emit:
|
||||||
|
update:currentGuidedWashStep – new step index
|
||||||
|
goPreviousGuidedWashStep – navigate to previous instruction
|
||||||
|
goNextGuidedWashStep – navigate to next instruction
|
||||||
|
completeWash – finish the wash session
|
||||||
|
openPropertyAccessGate – open the property access gate (laneId)
|
||||||
|
openPropertyExitGate – open the property exit gate (laneId)
|
||||||
|
requestAssistance – call for assistance
|
||||||
|
-->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { BButton } from "buefy";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentGuidedWashStep: number;
|
||||||
|
guidedWashFlowSteps: any[];
|
||||||
|
formattedElapsed: string;
|
||||||
|
isCompletingWash: boolean;
|
||||||
|
openingPropertyAccessGate: boolean;
|
||||||
|
openingPropertyExitGate: boolean;
|
||||||
|
showProgressActions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showProgressActions: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:currentGuidedWashStep": [step: number];
|
||||||
|
goPreviousGuidedWashStep: [];
|
||||||
|
goNextGuidedWashStep: [];
|
||||||
|
completeWash: [];
|
||||||
|
openPropertyAccessGate: [];
|
||||||
|
openPropertyExitGate: [];
|
||||||
|
requestAssistance: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isLastGuidedWashStep = computed(
|
||||||
|
() =>
|
||||||
|
Array.isArray(props.guidedWashFlowSteps) &&
|
||||||
|
props.guidedWashFlowSteps.length > 0 &&
|
||||||
|
props.currentGuidedWashStep >= props.guidedWashFlowSteps.length - 1
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleGoPrevious() {
|
||||||
|
emit("goPreviousGuidedWashStep");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGoNext() {
|
||||||
|
emit("goNextGuidedWashStep");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleComplete() {
|
||||||
|
emit("completeWash");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenAccessGate() {
|
||||||
|
emit("openPropertyAccessGate");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenExitGate() {
|
||||||
|
emit("openPropertyExitGate");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRequestAssistance() {
|
||||||
|
emit("requestAssistance");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div data-testid="self-serve-wash-progress">
|
||||||
|
<!-- Finishing screen -->
|
||||||
|
<div
|
||||||
|
v-if="isCompletingWash && showProgressActions"
|
||||||
|
class="notification is-info is-light self-serve-finishing-screen"
|
||||||
|
data-testid="self-serve-finishing-wash"
|
||||||
|
>
|
||||||
|
<p class="title is-5 mb-0">{{ $t("self_wash.finishing_wash_exit_opening") }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Guided wash instructions (stepped walkthrough) -->
|
||||||
|
<template v-if="!isCompletingWash">
|
||||||
|
<!--
|
||||||
|
Accept a callback slot for the guided-instructions component so the
|
||||||
|
parent can plug in SelfServeGuidedInstructions or its own implementation.
|
||||||
|
-->
|
||||||
|
<slot
|
||||||
|
v-if="showProgressActions"
|
||||||
|
name="guided-instructions"
|
||||||
|
:current-step="currentGuidedWashStep"
|
||||||
|
:steps="guidedWashFlowSteps"
|
||||||
|
:is-last-step="isLastGuidedWashStep"
|
||||||
|
@update:current-step="emit('update:currentGuidedWashStep', $event)"
|
||||||
|
>
|
||||||
|
<!-- Default: render nothing (parent provides via slot) -->
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<!-- Bottom action bar while wash is in progress -->
|
||||||
|
<div
|
||||||
|
v-show="showProgressActions"
|
||||||
|
class="self-serve-bottom-actions"
|
||||||
|
data-testid="self-serve-bottom-actions"
|
||||||
|
>
|
||||||
|
<!-- Help / assistance -->
|
||||||
|
<div class="self-serve-bottom-actions__row" data-testid="self-serve-session-bottom-actions">
|
||||||
|
<b-button
|
||||||
|
class="self-serve-bottom-actions__button"
|
||||||
|
type="is-link is-light"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-right="info-circle"
|
||||||
|
data-testid="self-serve-nav-help"
|
||||||
|
@click.prevent="handleRequestAssistance"
|
||||||
|
>
|
||||||
|
{{ $t("self_wash.assistance") }}
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Property gate controls -->
|
||||||
|
<div class="self-serve-bottom-actions__row" data-testid="self-serve-property-gate-actions">
|
||||||
|
<b-button
|
||||||
|
class="self-serve-bottom-actions__button"
|
||||||
|
type="is-link is-light"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-left="sign-in-alt"
|
||||||
|
data-testid="self-serve-nav-open-property-access-gate"
|
||||||
|
:loading="openingPropertyAccessGate"
|
||||||
|
:disabled="openingPropertyAccessGate"
|
||||||
|
@click.prevent="handleOpenAccessGate"
|
||||||
|
>
|
||||||
|
{{ $t("self_wash.open_property_access_gate") }}
|
||||||
|
</b-button>
|
||||||
|
|
||||||
|
<b-button
|
||||||
|
class="self-serve-bottom-actions__button"
|
||||||
|
type="is-link is-light"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-left="sign-out-alt"
|
||||||
|
data-testid="self-serve-nav-open-property-exit-gate"
|
||||||
|
:loading="openingPropertyExitGate"
|
||||||
|
:disabled="openingPropertyExitGate"
|
||||||
|
@click.prevent="handleOpenExitGate"
|
||||||
|
>
|
||||||
|
{{ $t("self_wash.open_property_exit_gate") }}
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Guided step navigation -->
|
||||||
|
<div class="self-serve-bottom-actions__row" data-testid="self-serve-guided-bottom-actions">
|
||||||
|
<b-button
|
||||||
|
class="self-serve-bottom-actions__button"
|
||||||
|
type="is-link is-light"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-left="arrow-left"
|
||||||
|
data-testid="self-serve-guided-prev"
|
||||||
|
:disabled="currentGuidedWashStep === 0"
|
||||||
|
@click.prevent="handleGoPrevious"
|
||||||
|
>
|
||||||
|
{{ $t("common.previous") }}
|
||||||
|
</b-button>
|
||||||
|
|
||||||
|
<b-button
|
||||||
|
v-if="!isLastGuidedWashStep"
|
||||||
|
class="self-serve-bottom-actions__button"
|
||||||
|
type="is-link"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-right="arrow-right"
|
||||||
|
data-testid="self-serve-guided-next"
|
||||||
|
@click.prevent="handleGoNext"
|
||||||
|
>
|
||||||
|
{{ $t("common.next") }}
|
||||||
|
</b-button>
|
||||||
|
|
||||||
|
<b-button
|
||||||
|
v-else
|
||||||
|
class="self-serve-bottom-actions__button"
|
||||||
|
type="is-link"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-right="check"
|
||||||
|
data-testid="self-serve-nav-complete"
|
||||||
|
:loading="isCompletingWash"
|
||||||
|
:disabled="isCompletingWash"
|
||||||
|
@click.prevent="handleComplete"
|
||||||
|
>
|
||||||
|
{{ $t("common.done") }}
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.self-serve-finishing-screen {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 32rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-bottom-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0.75rem auto 0;
|
||||||
|
max-width: 30rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-bottom-actions__row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-bottom-actions__button {
|
||||||
|
flex: 1 1 0;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
max-width: 14rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.self-serve-bottom-actions {
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #dfe5f0;
|
||||||
|
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
box-shadow: 0 -0.25rem 0.75rem rgba(17, 47, 95, 0.08);
|
||||||
|
left: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-width: none;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
z-index: 41;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-bottom-actions__button {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
max-width: none;
|
||||||
|
min-height: 2.65rem;
|
||||||
|
padding-left: 0.55rem;
|
||||||
|
padding-right: 0.55rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<!--
|
||||||
|
WashTypeSelector.vue – manual vs machine wash toggle.
|
||||||
|
|
||||||
|
Renders two radio-style cards: "Manual" (always available) and
|
||||||
|
"Machine" (available when the lane supports it).
|
||||||
|
|
||||||
|
Props:
|
||||||
|
modelValue – current wash type ("Manual" | "Machine")
|
||||||
|
isMachineAvailable – whether machine wash is available for this lane
|
||||||
|
|
||||||
|
Emit:
|
||||||
|
update:modelValue – new wash type string
|
||||||
|
-->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { BRadioButton } from "buefy";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: string;
|
||||||
|
isMachineAvailable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [type: string];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="self-serve-choice-group" data-testid="self-serve-wash-type-group">
|
||||||
|
<h2 class="self-serve-choice-label">{{ $t("self_wash.wash_type") }}</h2>
|
||||||
|
<div
|
||||||
|
class="self-serve-choice-grid self-serve-choice-grid--centered"
|
||||||
|
data-testid="self-serve-wash-type-options"
|
||||||
|
>
|
||||||
|
<!-- Manual option (always available) -->
|
||||||
|
<div class="self-serve-choice-grid__item">
|
||||||
|
<b-radio-button
|
||||||
|
class="self-serve-choice-card"
|
||||||
|
:model-value="modelValue"
|
||||||
|
native-value="Manual"
|
||||||
|
type="is-link"
|
||||||
|
data-testid="self-serve-wash-type-manual"
|
||||||
|
@update:model-value="emit('update:modelValue', 'Manual')"
|
||||||
|
@input="emit('update:modelValue', 'Manual')"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span>{{ $t("self_wash.manual") }}<br /></span>
|
||||||
|
<small>
|
||||||
|
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
|
||||||
|
{{ $t("self_wash.available") }}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</b-radio-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Machine option (conditioned) -->
|
||||||
|
<div class="self-serve-choice-grid__item">
|
||||||
|
<b-radio-button
|
||||||
|
class="self-serve-choice-card"
|
||||||
|
:model-value="modelValue"
|
||||||
|
native-value="Machine"
|
||||||
|
type="is-link"
|
||||||
|
:disabled="!isMachineAvailable"
|
||||||
|
data-testid="self-serve-wash-type-machine"
|
||||||
|
@update:model-value="emit('update:modelValue', 'Machine')"
|
||||||
|
@input="emit('update:modelValue', 'Machine')"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span>{{ $t("self_wash.machine") }}<br /></span>
|
||||||
|
<span v-if="!isMachineAvailable">
|
||||||
|
<small>
|
||||||
|
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
|
||||||
|
{{ $t("self_wash.unavailable") }}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<small>
|
||||||
|
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
|
||||||
|
{{ $t("self_wash.available") }}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</b-radio-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.self-serve-choice-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-label {
|
||||||
|
color: #303440;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-grid--centered {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-grid__item {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-card {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 5rem;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
white-space: normal;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-card :deep(.button) {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 5rem;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
white-space: normal;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-card :deep(.button),
|
||||||
|
.self-serve-choice-card span,
|
||||||
|
.self-serve-choice-card :deep(.button span) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-card small,
|
||||||
|
.self-serve-choice-card :deep(.button small) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 769px) {
|
||||||
|
.self-serve-choice-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(11rem, 15rem));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -129,6 +129,19 @@ const getOrderItemFlags = (orderItem) => sortInvoicePeriodFlags((props.invoicePe
|
|||||||
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
|
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(() => (
|
const displayColumnCount = computed(() => (
|
||||||
2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
|
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)">
|
<template v-if="!isOrderItemAddon(item)">
|
||||||
<!-- If the item is included in the invoice, show it normally -->
|
<!-- If the item is included in the invoice, show it normally -->
|
||||||
<tr v-if="item.include_in_invoice">
|
<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.displayReference">{{ item.reference }}</td>
|
||||||
<td v-if="props.displayNotes">{{ item.notes }}</td>
|
<td v-if="props.displayNotes">{{ item.notes }}</td>
|
||||||
<td>{{ item.quantity }}</td>
|
<td>{{ item.quantity }}</td>
|
||||||
@@ -170,7 +193,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
|||||||
</tr>
|
</tr>
|
||||||
<!-- If the item is not included in the invoice, show it with a strikethrough -->
|
<!-- If the item is not included in the invoice, show it with a strikethrough -->
|
||||||
<tr v-else class="has-background-warning-light">
|
<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.displayReference">{{ item.reference }}</td>
|
||||||
<td v-if="props.displayNotes">{{ item.notes }}</td>
|
<td v-if="props.displayNotes">{{ item.notes }}</td>
|
||||||
<td>{{ item.quantity }}</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">
|
<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 -->
|
<!-- If the add-on item is included in the invoice, show it normally -->
|
||||||
<tr v-if="addon_item.include_in_invoice">
|
<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.displayReference">{{ addon_item.reference }}</td>
|
||||||
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
|
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
|
||||||
<td>{{ addon_item.quantity }}</td>
|
<td>{{ addon_item.quantity }}</td>
|
||||||
@@ -201,7 +244,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
|||||||
</tr>
|
</tr>
|
||||||
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
|
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
|
||||||
<tr v-else class="has-background-warning">
|
<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.displayReference">{{ addon_item.reference }}</td>
|
||||||
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
|
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
|
||||||
<td>{{ addon_item.quantity }}</td>
|
<td>{{ addon_item.quantity }}</td>
|
||||||
@@ -237,6 +290,14 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
|||||||
<!-- If the item is a primary item, show it -->
|
<!-- If the item is a primary item, show it -->
|
||||||
<template v-if="!isOrderItemAddon(item)">
|
<template v-if="!isOrderItemAddon(item)">
|
||||||
<div>
|
<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 }}
|
<strong>{{ item.product.name }}</strong> x {{ item.quantity }}
|
||||||
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span>
|
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,6 +309,14 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
|||||||
<!-- Show the add-on items -->
|
<!-- Show the add-on items -->
|
||||||
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
|
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
|
||||||
<small>
|
<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 }}
|
+ {{ addon_item.product.name }} x {{ addon_item.quantity }}
|
||||||
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
|
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
|
||||||
</small>
|
</small>
|
||||||
@@ -265,5 +334,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.invoice-period-item-flag-indicator {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ const onResendInvite = async (subuser) => {
|
|||||||
<td>{{ formatDateTime(subuser.updated_at) }}</td>
|
<td>{{ formatDateTime(subuser.updated_at) }}</td>
|
||||||
|
|
||||||
<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)">
|
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<ActionSettingsWheelItem
|
<ActionSettingsWheelItem
|
||||||
|
|||||||
@@ -255,6 +255,16 @@ const getBookingMatchesStateKey = (matches) => {
|
|||||||
return matches.map((booking) => getBookingStateKey(booking)).join("||");
|
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 setVehicleObject = (emittedVehicleObject, options = {}) => {
|
||||||
const normalizedOptions = {
|
const normalizedOptions = {
|
||||||
preserveManualReference: true,
|
preserveManualReference: true,
|
||||||
@@ -350,17 +360,23 @@ const mergeBookingMatchDetails = (booking) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let didMergeBooking = false;
|
let didMergeBooking = false;
|
||||||
bookingMatches.value = bookingMatches.value.map((entry) => {
|
let didChangeBooking = false;
|
||||||
|
const nextBookingMatches = bookingMatches.value.map((entry) => {
|
||||||
if (normalizeBookingId(entry?.id) !== normalizedBookingId) {
|
if (normalizeBookingId(entry?.id) !== normalizedBookingId) {
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
didMergeBooking = true;
|
didMergeBooking = true;
|
||||||
return { ...entry, ...booking };
|
const mergedBooking = { ...entry, ...booking };
|
||||||
|
const hasChangedEntry = hasBookingDetailsChanged(entry, mergedBooking);
|
||||||
|
didChangeBooking = didChangeBooking || hasChangedEntry;
|
||||||
|
return hasChangedEntry ? mergedBooking : entry;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!didMergeBooking) {
|
if (!didMergeBooking) {
|
||||||
bookingMatches.value = [...bookingMatches.value, booking];
|
bookingMatches.value = [...bookingMatches.value, booking];
|
||||||
|
} else if (didChangeBooking) {
|
||||||
|
bookingMatches.value = nextBookingMatches;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1037,6 +1037,7 @@ const getCurrentIconColor = () => {
|
|||||||
@blur="lostfocus"
|
@blur="lostfocus"
|
||||||
@keydown="arrowKeyHandler"
|
@keydown="arrowKeyHandler"
|
||||||
id="reg_1"
|
id="reg_1"
|
||||||
|
tabindex="1"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -1059,6 +1060,7 @@ const getCurrentIconColor = () => {
|
|||||||
@blur="lostfocus"
|
@blur="lostfocus"
|
||||||
@keydown="arrowKeyHandler"
|
@keydown="arrowKeyHandler"
|
||||||
id="reg_1"
|
id="reg_1"
|
||||||
|
tabindex="1"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -394,12 +394,16 @@ onBeforeUnmount(() => {
|
|||||||
v-model="typedReference"
|
v-model="typedReference"
|
||||||
:data="groupedSuggestions"
|
:data="groupedSuggestions"
|
||||||
field="reference"
|
field="reference"
|
||||||
|
input-id="reference"
|
||||||
|
data-testid="pos-desktop-step-1-reference-input"
|
||||||
group-field="group"
|
group-field="group"
|
||||||
group-options="items"
|
group-options="items"
|
||||||
:keep-first="true"
|
:keep-first="true"
|
||||||
open-on-focus
|
open-on-focus
|
||||||
expanded
|
expanded
|
||||||
:loading="isFetching"
|
:loading="isFetching"
|
||||||
|
:tabindex="props.tabindex"
|
||||||
|
:aria-invalid="props.requiredWarning ? 'true' : undefined"
|
||||||
custom-class="has-sharp-edges"
|
custom-class="has-sharp-edges"
|
||||||
icon-pack="fas"
|
icon-pack="fas"
|
||||||
max-height="320"
|
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 SHIFT_MULTI_PRESS_WINDOW_MS = 700;
|
||||||
const SYSTEM_SEARCH_CLOSE_EVENT = "system-search:close";
|
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 isExpanded = ref(REQUEST_QUEUE_CONFIG.inspector.expandedByDefault);
|
||||||
const isShortcutActivated = ref(false);
|
const isShortcutActivated = ref(false);
|
||||||
@@ -366,6 +367,9 @@ const handleWindowKeydown = (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (shiftKeyPressCount.value >= 3) {
|
if (shiftKeyPressCount.value >= 3) {
|
||||||
|
event.preventDefault?.();
|
||||||
|
event.stopImmediatePropagation?.();
|
||||||
|
window.dispatchEvent(new CustomEvent(FRONTEND_MAINTENANCE_MENU_CLOSE_EVENT));
|
||||||
closeSystemSearchForSuperuser();
|
closeSystemSearchForSuperuser();
|
||||||
isShortcutActivated.value = true;
|
isShortcutActivated.value = true;
|
||||||
isExpanded.value = true;
|
isExpanded.value = true;
|
||||||
@@ -1496,4 +1500,3 @@ onBeforeUnmount(() => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import MenuDefault from "@/components/menus/MenuDefault.vue";
|
|||||||
import { ref, watch, onMounted } from "vue";
|
import { ref, watch, onMounted } from "vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { useRoute } from "vue-router";
|
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
|
// Get the department ID from the URL
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
|
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
|
||||||
@@ -26,7 +26,7 @@ const loadDepartments = () => {
|
|||||||
value: department.id,
|
value: department.id,
|
||||||
icon: "fas fa-building",
|
icon: "fas fa-building",
|
||||||
children: [],
|
children: [],
|
||||||
hidden: !isAccessibleVisibleDepartment(department, SessionUser.canAccessAssignedDepartment),
|
hidden: !isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
menu_items.value[1].options = options;
|
menu_items.value[1].options = options;
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
} from "@/composables/useDraftTransactionCustomer.js";
|
} from "@/composables/useDraftTransactionCustomer.js";
|
||||||
import { fetchDepartmentOrderBookingCounts } from "@/components/models/navigation/items/adminBookingCount.js";
|
import { fetchDepartmentOrderBookingCounts } from "@/components/models/navigation/items/adminBookingCount.js";
|
||||||
import { fetchDepartmentDraftCount } from "@/components/models/navigation/items/adminDraftCount.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 { 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);
|
const t = (key: string) => i18n.global.t(key);
|
||||||
|
|
||||||
@@ -70,6 +72,8 @@ let queuedBookingLoadingIndicator = false;
|
|||||||
let queuedDraftLoadingIndicator = false;
|
let queuedDraftLoadingIndicator = false;
|
||||||
const getDepartmentIdNumber = () => Number.parseInt(String(department_id.value), 10);
|
const getDepartmentIdNumber = () => Number.parseInt(String(department_id.value), 10);
|
||||||
const canUseAdminNavigationCounts = () => SessionUser.canAccessAdmin();
|
const canUseAdminNavigationCounts = () => SessionUser.canAccessAdmin();
|
||||||
|
const canUseAdminBookingNavigationCounts = () =>
|
||||||
|
canUseAdminNavigationCounts() && hasExplicitBookingCountPermission(SessionUser);
|
||||||
const getDepartmentById = (id: number) => {
|
const getDepartmentById = (id: number) => {
|
||||||
return departments_cache.value?.find((department: any) => Number(department?.id) === Number(id)) || null;
|
return departments_cache.value?.find((department: any) => Number(department?.id) === Number(id)) || null;
|
||||||
};
|
};
|
||||||
@@ -148,6 +152,19 @@ const getDepartmentDraftBadge = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchDepartmentBookingCount = async ({ showLoadingIndicator = false } = {}) => {
|
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) {
|
if (!hasValidDepartmentId.value) {
|
||||||
department_booking_counts.value = {
|
department_booking_counts.value = {
|
||||||
past: 0,
|
past: 0,
|
||||||
@@ -376,17 +393,26 @@ const items = computed<NavigationItemProps[]>(() => [
|
|||||||
hidden: isDepartmentSet.value,
|
hidden: isDepartmentSet.value,
|
||||||
classes: [ADMIN_DEPARTMENT_SELECTION_CLASS],
|
classes: [ADMIN_DEPARTMENT_SELECTION_CLASS],
|
||||||
permissions: ["admin", "list_departments"],
|
permissions: ["admin", "list_departments"],
|
||||||
children: SessionUser.functions.getAccessibleDepartments().map((departmentId: any) => {
|
children: SessionUser.functions
|
||||||
const department = getDepartmentById(departmentId);
|
.getAccessibleDepartments()
|
||||||
return {
|
.map((departmentId: any) => {
|
||||||
label: getDepartmentName(departmentId),
|
const label = getDepartmentName(departmentId);
|
||||||
to: `/admin/${departmentId}`,
|
const department = getDepartmentById(departmentId);
|
||||||
type: "department",
|
|
||||||
permissions: ["admin", "view_department"],
|
if (!isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment)) {
|
||||||
order_priority: department?.order_priority ?? department?.priority_order ?? null,
|
return null;
|
||||||
priority_order: department?.priority_order ?? department?.order_priority ?? 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
|
// Kassesystem
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/composables/useDraftTransactionCustomer.js";
|
} from "@/composables/useDraftTransactionCustomer.js";
|
||||||
import { fetchSuperUserDraftCount } from "@/components/models/navigation/items/superUserDraftCount.js";
|
import { fetchSuperUserDraftCount } from "@/components/models/navigation/items/superUserDraftCount.js";
|
||||||
import { fetchSuperUserBookingCounts } from "@/components/models/navigation/items/superUserBookingCount.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";
|
import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js";
|
||||||
|
|
||||||
const firstToUpperCase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
const firstToUpperCase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
@@ -37,23 +38,29 @@ let queuedSuperUserBookingLoadingIndicator = false;
|
|||||||
let queuedSuperUserDraftLoadingIndicator = false;
|
let queuedSuperUserDraftLoadingIndicator = false;
|
||||||
|
|
||||||
const canUseSuperUserNavigationCounts = () => SessionUser.canAccessSuperUser();
|
const canUseSuperUserNavigationCounts = () => SessionUser.canAccessSuperUser();
|
||||||
|
const canUseSuperUserBookingNavigationCounts = () =>
|
||||||
|
canUseSuperUserNavigationCounts() && hasExplicitBookingCountPermission(SessionUser);
|
||||||
|
|
||||||
const resetSuperUserNavigationCounts = () => {
|
const resetSuperUserBookingCounts = () => {
|
||||||
bookingCountRequestId += 1;
|
bookingCountRequestId += 1;
|
||||||
draftCountRequestId += 1;
|
|
||||||
superuser_booking_counts.value = {
|
superuser_booking_counts.value = {
|
||||||
past: 0,
|
past: 0,
|
||||||
current: 0,
|
current: 0,
|
||||||
future: 0,
|
future: 0,
|
||||||
};
|
};
|
||||||
superuser_booking_count_loading.value = false;
|
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.value = 0;
|
||||||
superuser_draft_count_loading.value = false;
|
superuser_draft_count_loading.value = false;
|
||||||
superUserBookingCountFetchInFlight = false;
|
|
||||||
superUserDraftCountFetchInFlight = false;
|
superUserDraftCountFetchInFlight = false;
|
||||||
superUserBookingCountRefreshQueued = false;
|
|
||||||
superUserDraftCountRefreshQueued = false;
|
superUserDraftCountRefreshQueued = false;
|
||||||
queuedSuperUserBookingLoadingIndicator = false;
|
|
||||||
queuedSuperUserDraftLoadingIndicator = false;
|
queuedSuperUserDraftLoadingIndicator = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,8 +132,8 @@ const getSuperUserDraftsBadge = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchCurrentSuperUserBookingCount = async ({ showLoadingIndicator = false } = {}) => {
|
const fetchCurrentSuperUserBookingCount = async ({ showLoadingIndicator = false } = {}) => {
|
||||||
if (!canUseSuperUserNavigationCounts()) {
|
if (!canUseSuperUserBookingNavigationCounts()) {
|
||||||
resetSuperUserNavigationCounts();
|
resetSuperUserBookingCounts();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||||
|
import {
|
||||||
|
emptyBookingCounts,
|
||||||
|
isExpectedBookingCountRequestFailure,
|
||||||
|
} from "@/components/models/navigation/items/bookingCountGuards.js";
|
||||||
|
|
||||||
const normalizePositiveInteger = (value) => {
|
const normalizePositiveInteger = (value) => {
|
||||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||||
@@ -24,11 +28,7 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
|
|||||||
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
|
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
|
||||||
|
|
||||||
if (!normalizedDepartmentId) {
|
if (!normalizedDepartmentId) {
|
||||||
return {
|
return emptyBookingCounts();
|
||||||
past: 0,
|
|
||||||
current: 0,
|
|
||||||
future: 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -38,12 +38,11 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
|
|||||||
|
|
||||||
return normalizeBookingCounts(response);
|
return normalizeBookingCounts(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching department order-booking counts:", error);
|
if (!isExpectedBookingCountRequestFailure(error)) {
|
||||||
return {
|
console.error("Error fetching department order-booking counts:", error);
|
||||||
past: 0,
|
}
|
||||||
current: 0,
|
|
||||||
future: 0,
|
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 { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||||
|
import {
|
||||||
|
emptyBookingCounts,
|
||||||
|
isExpectedBookingCountRequestFailure,
|
||||||
|
} from "@/components/models/navigation/items/bookingCountGuards.js";
|
||||||
|
|
||||||
const toNonNegativeInteger = (value) => {
|
const toNonNegativeInteger = (value) => {
|
||||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||||
@@ -20,12 +24,11 @@ export const fetchSuperUserBookingCounts = async () => {
|
|||||||
const response = await authenticatedRequest("/order-bookings/counts", "GET", {});
|
const response = await authenticatedRequest("/order-bookings/counts", "GET", {});
|
||||||
return normalizeBookingCounts(response);
|
return normalizeBookingCounts(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching superuser booking counts:", error);
|
if (!isExpectedBookingCountRequestFailure(error)) {
|
||||||
return {
|
console.error("Error fetching superuser booking counts:", error);
|
||||||
past: 0,
|
}
|
||||||
current: 0,
|
|
||||||
future: 0,
|
return emptyBookingCounts();
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import Swal from "sweetalert2";
|
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";
|
import { pingApiServer } from "@/services/apiHealth.js";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -42,12 +41,12 @@ const handleSlowSessionBootstrap = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await Swal.fire({
|
const result = await Swal.fire({
|
||||||
title: 'Error',
|
title: "Error",
|
||||||
text: 'The user session could not be initiated.',
|
text: "The user session could not be initiated.",
|
||||||
icon: 'error',
|
icon: "error",
|
||||||
confirmButtonText: 'Clear session, and try again',
|
confirmButtonText: "Clear session, and try again",
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
cancelButtonText: 'Retry',
|
cancelButtonText: "Retry",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.isConfirmed) {
|
if (result.isConfirmed) {
|
||||||
@@ -60,10 +59,16 @@ const handleSlowSessionBootstrap = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
watch(isSessionInitiated, (initiated) => {
|
||||||
|
if (initiated) {
|
||||||
|
clearSessionTimeout();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
sessionTimeout = setTimeout(() => {
|
sessionTimeout = setTimeout(() => {
|
||||||
void handleSlowSessionBootstrap();
|
void handleSlowSessionBootstrap();
|
||||||
}, 5000);
|
}, 15000);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(clearSessionTimeout);
|
onBeforeUnmount(clearSessionTimeout);
|
||||||
@@ -89,9 +94,18 @@ onBeforeUnmount(clearSessionTimeout);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Simple checks -->
|
<!-- 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 === '/user'">
|
||||||
<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>
|
You are on the /user page. <br />You do {{ SessionUser.hasPermission("user") ? "" : "not" }} have permission (
|
||||||
<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>
|
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 v-if="showDebugInfo">
|
||||||
<div class="message mb-6">
|
<div class="message mb-6">
|
||||||
<div class="message-header">
|
<div class="message-header">
|
||||||
@@ -108,7 +122,9 @@ onBeforeUnmount(clearSessionTimeout);
|
|||||||
<!-- Button to go back to the previous page -->
|
<!-- Button to go back to the previous page -->
|
||||||
<button class="button is-dark" @click="router.go(-1)">Go back</button>
|
<button class="button is-dark" @click="router.go(-1)">Go back</button>
|
||||||
<!-- Button to see the debug information -->
|
<!-- 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 to hide the debug information -->
|
||||||
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-else>Hide debug information</button>
|
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-else>Hide debug information</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,6 +132,4 @@ onBeforeUnmount(clearSessionTimeout);
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,42 +1,21 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, inject, ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue";
|
import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue";
|
||||||
import ReleaseUpdateWidget from "@/components/release/ReleaseUpdateWidget.vue";
|
import ReleaseUpdateWidget from "@/components/release/ReleaseUpdateWidget.vue";
|
||||||
import {
|
import { releaseChannelSelectorVisible, switchSelectedReleaseChannel } from "@/services/releaseChannelAvailability.js";
|
||||||
clearSelectedReleaseChannel,
|
|
||||||
releaseChannelSelectorVisible,
|
|
||||||
switchSelectedReleaseChannel,
|
|
||||||
} from "@/services/releaseChannelAvailability.js";
|
|
||||||
import {
|
|
||||||
isReleaseSourceOverrideAvailable,
|
|
||||||
RELEASE_SOURCE_MODES,
|
|
||||||
setReleaseSourceOverride,
|
|
||||||
} from "@/services/releaseBootstrap.js";
|
|
||||||
import { inspectReleaseRuntimeForUpdate } from "@/services/releaseUpdate.js";
|
import { inspectReleaseRuntimeForUpdate } from "@/services/releaseUpdate.js";
|
||||||
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
|
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
|
||||||
|
|
||||||
const switchingSlug = ref("");
|
const switchingSlug = ref("");
|
||||||
const switchError = ref("");
|
const switchError = ref("");
|
||||||
const { t, te } = useI18n({ useScope: "global" });
|
const { t, te } = useI18n({ useScope: "global" });
|
||||||
const reloadWindow = inject("releaseSourceReload", () => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const tr = (key, fallback) => {
|
const tr = (key, fallback) => {
|
||||||
const path = `configuration.release_manager.channel_selector.${key}`;
|
const path = `configuration.release_manager.channel_selector.${key}`;
|
||||||
return te(path) ? t(path) : fallback;
|
return te(path) ? t(path) : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const showUseLocalFrontend = computed(
|
|
||||||
() =>
|
|
||||||
releaseChannelSelectorVisible.value &&
|
|
||||||
isReleaseSourceOverrideAvailable() &&
|
|
||||||
String(releaseRuntimeState.source || "").toLowerCase() !== RELEASE_SOURCE_MODES.LOCAL
|
|
||||||
);
|
|
||||||
|
|
||||||
const switchChannel = async (option) => {
|
const switchChannel = async (option) => {
|
||||||
if (switchingSlug.value) {
|
if (switchingSlug.value) {
|
||||||
return;
|
return;
|
||||||
@@ -48,21 +27,14 @@ const switchChannel = async (option) => {
|
|||||||
await switchSelectedReleaseChannel(option.channel, SessionUser.refreshReleaseRuntime);
|
await switchSelectedReleaseChannel(option.channel, SessionUser.refreshReleaseRuntime);
|
||||||
void inspectReleaseRuntimeForUpdate(releaseRuntimeState, { autoDownload: true });
|
void inspectReleaseRuntimeForUpdate(releaseRuntimeState, { autoDownload: true });
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
switchingSlug.value = "";
|
switchingSlug.value = "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const useLocalFrontend = () => {
|
|
||||||
if (switchingSlug.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setReleaseSourceOverride(RELEASE_SOURCE_MODES.LOCAL);
|
|
||||||
clearSelectedReleaseChannel();
|
|
||||||
reloadWindow();
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -74,17 +46,6 @@ const useLocalFrontend = () => {
|
|||||||
:disabled="Boolean(switchingSlug)"
|
:disabled="Boolean(switchingSlug)"
|
||||||
@select="switchChannel"
|
@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">
|
<p v-if="switchError" class="release-channel-sidebar-selector__error" role="alert">
|
||||||
{{ switchError }}
|
{{ switchError }}
|
||||||
</p>
|
</p>
|
||||||
@@ -93,37 +54,6 @@ const useLocalFrontend = () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.release-channel-sidebar-selector__error {
|
||||||
margin: 0 16px 14px;
|
margin: 0 16px 14px;
|
||||||
color: #b42318;
|
color: #b42318;
|
||||||
|
|||||||
@@ -295,10 +295,23 @@ const branchWarning = computed(() =>
|
|||||||
min-width: 12rem;
|
min-width: 12rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.release-context-bar__status {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-context-bar__status .tag {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.release-context-bar__warning {
|
.release-context-bar__warning {
|
||||||
color: #9f1f17;
|
color: #9f1f17;
|
||||||
|
flex: 1 1 12rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
overflow-wrap: anywhere;
|
line-height: 1.25;
|
||||||
|
min-width: min(12rem, 100%);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-context-bar__endpoints {
|
.release-context-bar__endpoints {
|
||||||
|
|||||||
@@ -13,7 +13,59 @@ const getSelectedCustomerNumber = () => {
|
|||||||
return stored ? parseInt(stored) : null;
|
return stored ? parseInt(stored) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null) => {
|
const MY_ACTIVE_WASH_ENDPOINT = '/modules/self-serve/lane/wash/my-active-wash';
|
||||||
|
const ACTIVE_WASH_STARTED_STATUSES = new Set(['MACHINE_RELAY_ENABLED', 'MACHINE_STARTED']);
|
||||||
|
|
||||||
|
const normalizeStatus = (status) => String(status || '').trim().toUpperCase();
|
||||||
|
|
||||||
|
const parseDateTimeMs = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Date.parse(String(value));
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveWashStartEvidence = (details) => {
|
||||||
|
const session = details?.session || {};
|
||||||
|
const status = normalizeStatus(session?.status ?? details?.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
ACTIVE_WASH_STARTED_STATUSES.has(status) ||
|
||||||
|
session?.machine_relay_enabled === true ||
|
||||||
|
session?.machine_start_triggered === true ||
|
||||||
|
details?.machine_relay_enabled === true ||
|
||||||
|
details?.machine_start_triggered === true ||
|
||||||
|
parseDateTimeMs(session?.wash_started_at) !== null ||
|
||||||
|
parseDateTimeMs(session?.machine_start_triggered_at) !== null ||
|
||||||
|
parseDateTimeMs(session?.machine_relay_enabled_at) !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeActiveWashResponse = (url, method, response) => {
|
||||||
|
if (String(method || '').toUpperCase() !== 'GET' || !String(url || '').includes(MY_ACTIVE_WASH_ENDPOINT)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = response?.data?.data;
|
||||||
|
if (!details?.in_progress || hasActiveWashStartEvidence(details)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
data: {
|
||||||
|
...response.data,
|
||||||
|
data: {
|
||||||
|
...details,
|
||||||
|
in_progress: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null, options = {}) => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
//throw new Error('No token was found, unable to make authenticated request');
|
//throw new Error('No token was found, unable to make authenticated request');
|
||||||
@@ -42,6 +94,7 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
|
|||||||
method,
|
method,
|
||||||
url: requestUrl,
|
url: requestUrl,
|
||||||
...(method === 'GET' ? { params: data } : { data }),
|
...(method === 'GET' ? { params: data } : { data }),
|
||||||
|
...(options?.signal ? { signal: options.signal } : {}),
|
||||||
__skipRequestQueue: true,
|
__skipRequestQueue: true,
|
||||||
headers
|
headers
|
||||||
}),
|
}),
|
||||||
@@ -63,6 +116,7 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
|
|||||||
throw error;
|
throw error;
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
response = normalizeActiveWashResponse(url, method, response);
|
||||||
if (thenCallable) {
|
if (thenCallable) {
|
||||||
// Call the then callable
|
// Call the then callable
|
||||||
thenCallable(response);
|
thenCallable(response);
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ const resetSessionState = () => {
|
|||||||
SessionUser.user.notifications.wash_certificate_email.value = null;
|
SessionUser.user.notifications.wash_certificate_email.value = null;
|
||||||
SessionUser.user.notifications.email_notifications_enabled.value = null;
|
SessionUser.user.notifications.email_notifications_enabled.value = null;
|
||||||
SessionUser.user.notifications.sms_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.created_at.value = null;
|
||||||
SessionUser.user.updated_at.value = null;
|
SessionUser.user.updated_at.value = null;
|
||||||
SessionUser.user.cached_at.value = null;
|
SessionUser.user.cached_at.value = null;
|
||||||
@@ -528,6 +529,8 @@ export const getSessionData = async () => {
|
|||||||
SessionUser.user.notifications.email_notifications_enabled.value =
|
SessionUser.user.notifications.email_notifications_enabled.value =
|
||||||
session.notifications.email_notifications_enabled;
|
session.notifications.email_notifications_enabled;
|
||||||
SessionUser.user.notifications.sms_notifications_enabled.value = session.notifications.sms_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.created_at.value = session.created_at;
|
||||||
SessionUser.user.updated_at.value = session.updated_at;
|
SessionUser.user.updated_at.value = session.updated_at;
|
||||||
SessionUser.user.display_name.value = session.display_name;
|
SessionUser.user.display_name.value = session.display_name;
|
||||||
@@ -623,6 +626,7 @@ export const SessionUser = {
|
|||||||
wash_certificate_email: ref(null),
|
wash_certificate_email: ref(null),
|
||||||
email_notifications_enabled: ref(null),
|
email_notifications_enabled: ref(null),
|
||||||
sms_notifications_enabled: ref(null),
|
sms_notifications_enabled: ref(null),
|
||||||
|
superuser_new_customer_email_notifications_enabled: ref(null),
|
||||||
setEmailNotificationsEnabled: (enabled) => {
|
setEmailNotificationsEnabled: (enabled) => {
|
||||||
return SessionUser.request("/account/notifications", "PUT", {
|
return SessionUser.request("/account/notifications", "PUT", {
|
||||||
email_notifications_enabled: enabled,
|
email_notifications_enabled: enabled,
|
||||||
@@ -647,6 +651,18 @@ export const SessionUser = {
|
|||||||
console.error(error);
|
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) => {
|
setWashCertificateEmail: (email) => {
|
||||||
return SessionUser.request("/account/notifications", "PUT", {
|
return SessionUser.request("/account/notifications", "PUT", {
|
||||||
wash_certificate_email: email,
|
wash_certificate_email: email,
|
||||||
|
|||||||
@@ -456,6 +456,29 @@ export const CollectedOrderInvoices = {
|
|||||||
throw error;
|
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,
|
showCreateNewCustom: showCreateCustomInvoiceCollectionForm,
|
||||||
showInvoiceCollectionPickerForm: showInvoiceCollectionPickerModal,
|
showInvoiceCollectionPickerForm: showInvoiceCollectionPickerModal,
|
||||||
add_vehicle_subscriptions: async (id) => {
|
add_vehicle_subscriptions: async (id) => {
|
||||||
@@ -820,4 +843,3 @@ export const CollectedOrderInvoices = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export const SelfServeVehicleConditions = {
|
|||||||
single: async (id) => {
|
single: async (id) => {
|
||||||
return ObjectsGlobal.get.object(SelfServeVehicleConditions.meta.endpoint, id);
|
return ObjectsGlobal.get.object(SelfServeVehicleConditions.meta.endpoint, id);
|
||||||
},
|
},
|
||||||
previewAllowed: async (laneId, reg, vehicleTypeId = null) => {
|
previewAllowed: async (laneId, reg, vehicleTypeId, options = {}) => {
|
||||||
const params = {
|
const params = {
|
||||||
lane_id: parseInt(laneId),
|
lane_id: parseInt(laneId),
|
||||||
reg: reg,
|
reg: reg,
|
||||||
@@ -184,11 +184,11 @@ export const SelfServeVehicleConditions = {
|
|||||||
params.vehicle_type = normalizedVehicleTypeId;
|
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);
|
.then((response) => response.data.data || response.data);
|
||||||
},
|
},
|
||||||
washSummary: async (params = {}) => {
|
washSummary: async (params, options) => {
|
||||||
return authenticatedRequest("/department/selfserve/washes/summary", "GET", params)
|
return authenticatedRequest("/department/selfserve/washes/summary", "GET", params || {}, null, null, options || {})
|
||||||
.then((response) => response.data.data || response.data);
|
.then((response) => response.data.data || response.data);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ export const Config = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
keys: {
|
keys: {
|
||||||
|
dynamic_image_size: {
|
||||||
|
get: async () => {
|
||||||
|
return Config.get("dynamic_image_size");
|
||||||
|
},
|
||||||
|
set: async (value) => {
|
||||||
|
return Config.set("dynamic_image_size", value);
|
||||||
|
},
|
||||||
|
},
|
||||||
minute_product: {
|
minute_product: {
|
||||||
get: async () => {
|
get: async () => {
|
||||||
return Config.get("minute_product");
|
return Config.get("minute_product");
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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 { Failover } from "@/components/session/token/superUser/modules/failover/Failover.vue";
|
||||||
import { Coolify } from "@/components/session/token/superUser/modules/coolify/Coolify.vue";
|
import { Coolify } from "@/components/session/token/superUser/modules/coolify/Coolify.vue";
|
||||||
import { ReleaseManager } from "@/components/session/token/superUser/modules/releasemanager/ReleaseManager.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;
|
let qrCodeModulePromise;
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ export const SuperUserObject = {
|
|||||||
get failover() { return Failover; },
|
get failover() { return Failover; },
|
||||||
get coolify() { return Coolify; },
|
get coolify() { return Coolify; },
|
||||||
get releasemanager() { return ReleaseManager; },
|
get releasemanager() { return ReleaseManager; },
|
||||||
|
get slack() { return Slack; },
|
||||||
},
|
},
|
||||||
/** Intimidate a user */
|
/** Intimidate a user */
|
||||||
intimidate: {
|
intimidate: {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { computed, ref, watch } from "vue";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { API_URL } from "@/config.js";
|
import { API_URL } from "@/config.js";
|
||||||
import { getScansDepartmentPagination } from "@/components/numberplatescanners/Scans.vue";
|
import { getScansDepartmentPagination } from "@/components/numberplatescanners/Scans.vue";
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { parseError, clearErrors } from "@/components/request/HandleGlobalError.vue";
|
import { parseError, clearErrors } from "@/components/request/HandleGlobalError.vue";
|
||||||
import { getNotes, createNote, deleteNote } from "@/components/shop/CustomerNotes.vue";
|
import { getNotes, createNote, deleteNote } from "@/components/shop/CustomerNotes.vue";
|
||||||
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.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
|
// If the current step is 2, set the product category to addons
|
||||||
setProductsCategory(4);
|
setProductsCategory(4);
|
||||||
// Set the step to 3 in the query parameters
|
// 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 (step.value === 3) {
|
||||||
if (normalizedOptions.isMobile === false && doesOrderContainMaterial()) {
|
if (normalizedOptions.isMobile === false && doesOrderContainMaterial()) {
|
||||||
step.value = 4;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,11 +230,7 @@ const toPositiveInteger = (value) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getRouteDepartmentId = () => {
|
const getRouteDepartmentId = () => {
|
||||||
try {
|
return getWindowPathDepartmentId();
|
||||||
return toPositiveInteger(useRouter().currentRoute.value.params.departmentId);
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSessionUrlDepartmentId = () => {
|
const getSessionUrlDepartmentId = () => {
|
||||||
@@ -757,7 +752,7 @@ export const reset_all_values = () => {
|
|||||||
// Reset all values to their initial state
|
// Reset all values to their initial state
|
||||||
clearActivePosOrderContext({ clearStep: true });
|
clearActivePosOrderContext({ clearStep: true });
|
||||||
// Clear the query parameters
|
// 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 */
|
/** 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;
|
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], () => {
|
watch([reg_1, reg_2, reg_3], () => {
|
||||||
reg_1.value = normalizeRegistrationValue(reg_1.value);
|
reg_1.value = normalizeRegistrationValue(reg_1.value);
|
||||||
reg_2.value = normalizeRegistrationValue(reg_2.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)
|
// Set the order id in the local storage (To be make F5 safe)
|
||||||
localStorage.setItem("pos_order_id", order_id.value);
|
localStorage.setItem("pos_order_id", order_id.value);
|
||||||
// Set the query parameters
|
// 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;
|
return true;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -1631,7 +1641,7 @@ export const showDeleteEverythingDialog = () => {
|
|||||||
/** Clear cache */
|
/** Clear cache */
|
||||||
export const clearCache = () => {
|
export const clearCache = () => {
|
||||||
// Reset the query parameters
|
// Reset the query parameters
|
||||||
window.history.pushState({}, "", `?step=1`);
|
pushPosRouteState("step=1");
|
||||||
// Clear the customer data
|
// Clear the customer data
|
||||||
clearCustomerSelection();
|
clearCustomerSelection();
|
||||||
// Clear the order data
|
// Clear the order data
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import {
|
|||||||
getDepartmentSelectionVisibility,
|
getDepartmentSelectionVisibility,
|
||||||
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
|
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
|
||||||
import {
|
import {
|
||||||
isAccessibleVisibleDepartment,
|
isAccessibleVisibleNamedDepartment,
|
||||||
isDepartmentLabelValid,
|
|
||||||
sortByDepartmentPriorityOrder,
|
sortByDepartmentPriorityOrder,
|
||||||
} from "@/services/departmentVisibility.js";
|
} from "@/services/departmentVisibility.js";
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@ const options = computed(() => {
|
|||||||
// Filter out departments the user does not have access to
|
// Filter out departments the user does not have access to
|
||||||
return departments.value
|
return departments.value
|
||||||
.filter((department: department) => {
|
.filter((department: department) => {
|
||||||
return isAccessibleVisibleDepartment(department, SessionUser.canAccessAssignedDepartment);
|
return isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment);
|
||||||
})
|
})
|
||||||
.map((department: department) => {
|
.map((department: department) => {
|
||||||
return {
|
return {
|
||||||
@@ -56,7 +55,7 @@ const mobileOptions = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const desktopOptions = computed(() => {
|
const desktopOptions = computed(() => {
|
||||||
return sortByDepartmentPriorityOrder(options.value.filter((option) => isDepartmentLabelValid(option.label)));
|
return sortByDepartmentPriorityOrder(options.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectDepartment = (departmentId: number | string) => {
|
const selectDepartment = (departmentId: number | string) => {
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const emits = defineEmits(['camera-toggled', 'scanner-toggled', 'update:frame']);
|
const emits = defineEmits(['camera-toggled', 'scanner-toggled', 'update:frame']);
|
||||||
|
const { t } = useI18n();
|
||||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
const cameraStream = ref<MediaStream | null>(null);
|
const cameraStream = ref<MediaStream | null>(null);
|
||||||
const isCameraActive = ref(false);
|
const isCameraActive = ref(false);
|
||||||
|
const cameraErrorKey = ref('pos.camera_permission_denied');
|
||||||
import { isCameraMounted, camera } from '@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue';
|
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() {
|
function startCamera() {
|
||||||
const constraints = {
|
const constraints = {
|
||||||
video: {
|
video: {
|
||||||
@@ -45,6 +58,7 @@ function startCamera() {
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
isCameraActive.value = false;
|
isCameraActive.value = false;
|
||||||
|
cameraErrorKey.value = getCameraErrorKey(err);
|
||||||
console.error('Camera access error:', err);
|
console.error('Camera access error:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -171,8 +185,7 @@ watch(() => isCameraActive.value, (newVal) => {
|
|||||||
></canvas>
|
></canvas>
|
||||||
|
|
||||||
<div v-if="!isCameraActive" class="camera-inactive">
|
<div v-if="!isCameraActive" class="camera-inactive">
|
||||||
<p>Camera is not active.</p>
|
<p>{{ t(cameraErrorKey) }}</p>
|
||||||
<p>Please enable camera access.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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 { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
|
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
|
||||||
|
|
||||||
@@ -271,6 +271,55 @@ const mergeByNumericId = (existingItems, incomingItems) => {
|
|||||||
return merged;
|
return merged;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const taskStableKey = (task) =>
|
||||||
|
String(task?.task ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
const mergeTaskSets = (existingTasks, incomingTasks) => {
|
||||||
|
if (!Array.isArray(existingTasks) || existingTasks.length === 0) {
|
||||||
|
return Array.isArray(incomingTasks) ? [...incomingTasks] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(incomingTasks) || incomingTasks.length === 0) {
|
||||||
|
return [...existingTasks];
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedExistingIndexes = new Set();
|
||||||
|
|
||||||
|
return incomingTasks.map((incomingTask) => {
|
||||||
|
const incomingId = extractTaskId(incomingTask);
|
||||||
|
let existingIndex = -1;
|
||||||
|
|
||||||
|
if (incomingId) {
|
||||||
|
existingIndex = existingTasks.findIndex((task) => extractTaskId(task) === incomingId);
|
||||||
|
} else {
|
||||||
|
const incomingKey = taskStableKey(incomingTask);
|
||||||
|
existingIndex = existingTasks.findIndex(
|
||||||
|
(task, index) => !usedExistingIndexes.has(index) && taskStableKey(task) === incomingKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
return incomingTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
usedExistingIndexes.add(existingIndex);
|
||||||
|
const existingTask = existingTasks[existingIndex];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...existingTask,
|
||||||
|
...incomingTask,
|
||||||
|
id: incomingTask.id || existingTask.id,
|
||||||
|
task_id: incomingTask.task_id || existingTask.task_id,
|
||||||
|
attachments: Array.isArray(incomingTask.attachments) && incomingTask.attachments.length > 0
|
||||||
|
? incomingTask.attachments
|
||||||
|
: existingTask.attachments,
|
||||||
|
_attachmentsLoaded: incomingTask._attachmentsLoaded || existingTask._attachmentsLoaded,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export function useSelfServeLogic() {
|
export function useSelfServeLogic() {
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const requestError = ref(null);
|
const requestError = ref(null);
|
||||||
@@ -296,19 +345,94 @@ export function useSelfServeLogic() {
|
|||||||
const lastResolvedVehicleTypeId = ref(null);
|
const lastResolvedVehicleTypeId = ref(null);
|
||||||
const summaryVisibleQuestionIds = ref([]);
|
const summaryVisibleQuestionIds = ref([]);
|
||||||
const summaryQuestionOrder = ref({});
|
const summaryQuestionOrder = ref({});
|
||||||
const latestFetchRequestId = ref(0);
|
const activeFetchAbortController = ref(null);
|
||||||
|
const latestMutationRequestId = ref(0);
|
||||||
|
const latestSuccessfulFetchKey = ref(null);
|
||||||
|
const inFlightFetchKey = ref(null);
|
||||||
|
|
||||||
const beginFetchRequest = () => {
|
const createFetchKey = (departmentId, vehicleTypeId, laneId, reg) => {
|
||||||
latestFetchRequestId.value += 1;
|
const normalizedDepartmentId = parseInt(departmentId);
|
||||||
return latestFetchRequestId.value;
|
const normalizedLaneId = parseInt(laneId);
|
||||||
|
const normalizedReg = String(reg || "").trim().toUpperCase();
|
||||||
|
const normalizedVehicleTypeId = parseInt(vehicleTypeId);
|
||||||
|
const vehicleTypeKey = !Number.isNaN(normalizedVehicleTypeId) && normalizedVehicleTypeId > 0
|
||||||
|
? normalizedVehicleTypeId
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (
|
||||||
|
Number.isNaN(normalizedDepartmentId)
|
||||||
|
|| normalizedDepartmentId <= 0
|
||||||
|
|| Number.isNaN(normalizedLaneId)
|
||||||
|
|| normalizedLaneId <= 0
|
||||||
|
|| normalizedReg.length < 2
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [normalizedDepartmentId, normalizedLaneId, normalizedReg, vehicleTypeKey].join("|");
|
||||||
};
|
};
|
||||||
|
|
||||||
const isFetchRequestActive = (requestId) => (
|
const isCanceledRequest = (error) => (
|
||||||
requestId === null
|
error?.code === "ERR_CANCELED"
|
||||||
|| requestId === undefined
|
|| error?.name === "CanceledError"
|
||||||
|| requestId === latestFetchRequestId.value
|
|| 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 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 setSummaryVisibleQuestions = (questionList = []) => {
|
||||||
const normalizedIds = (Array.isArray(questionList) ? questionList : [])
|
const normalizedIds = (Array.isArray(questionList) ? questionList : [])
|
||||||
.map((question) => parseInt(question?.id ?? 0))
|
.map((question) => parseInt(question?.id ?? 0))
|
||||||
@@ -415,8 +539,8 @@ export function useSelfServeLogic() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyPreviewData = async (previewData, options = {}) => {
|
const applyPreviewData = async (previewData, options = {}) => {
|
||||||
const { mergeQuestions = false, requestId = null } = options;
|
const { mergeQuestions = false, signal = null } = options;
|
||||||
if (!isFetchRequestActive(requestId)) {
|
if (!isFetchSignalActive(signal)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +549,10 @@ export function useSelfServeLogic() {
|
|||||||
machineType.value = previewData?.machine_type || machineType.value;
|
machineType.value = previewData?.machine_type || machineType.value;
|
||||||
vehicle.value = previewData?.vehicle || null;
|
vehicle.value = previewData?.vehicle || null;
|
||||||
session.value = previewData?.session || session.value;
|
session.value = previewData?.session || session.value;
|
||||||
|
if (!previewData?.session?.id) {
|
||||||
|
summary.value = null;
|
||||||
|
events.value = [];
|
||||||
|
}
|
||||||
if (Object.prototype.hasOwnProperty.call(previewData || {}, "config_version_id")) {
|
if (Object.prototype.hasOwnProperty.call(previewData || {}, "config_version_id")) {
|
||||||
configVersionId.value = previewData?.config_version_id ?? null;
|
configVersionId.value = previewData?.config_version_id ?? null;
|
||||||
}
|
}
|
||||||
@@ -443,6 +571,7 @@ export function useSelfServeLogic() {
|
|||||||
|
|
||||||
questions.value = nextQuestions;
|
questions.value = nextQuestions;
|
||||||
answers.value = buildAnswersMap(nextQuestions);
|
answers.value = buildAnswersMap(nextQuestions);
|
||||||
|
setSummaryVisibleQuestions(nextQuestions);
|
||||||
|
|
||||||
const normalizedConditions = Array.isArray(previewData?.conditions)
|
const normalizedConditions = Array.isArray(previewData?.conditions)
|
||||||
? previewData.conditions.map(normalizeCondition)
|
? previewData.conditions.map(normalizeCondition)
|
||||||
@@ -462,19 +591,21 @@ export function useSelfServeLogic() {
|
|||||||
? previewData.tasks.map(normalizeTask).sort((a, b) => a.order_priority - b.order_priority)
|
? previewData.tasks.map(normalizeTask).sort((a, b) => a.order_priority - b.order_priority)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
tasks.value = normalizedTasks;
|
||||||
|
applyAllowedServices(previewData);
|
||||||
|
|
||||||
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
|
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
|
||||||
if (!isFetchRequestActive(requestId)) {
|
if (!isFetchSignalActive(signal)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.value = hydratedTasks;
|
tasks.value = hydratedTasks;
|
||||||
applyAllowedServices(previewData);
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const applySummaryData = async (summaryData, options = {}) => {
|
const applySummaryData = async (summaryData, options = {}) => {
|
||||||
const { requestId = null } = options;
|
const { signal = null } = options;
|
||||||
if (!isFetchRequestActive(requestId)) {
|
if (!isFetchSignalActive(signal)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,9 +644,10 @@ export function useSelfServeLogic() {
|
|||||||
const normalizedTasks = summaryData.tasks
|
const normalizedTasks = summaryData.tasks
|
||||||
.map(normalizeTask)
|
.map(normalizeTask)
|
||||||
.sort((a, b) => a.order_priority - b.order_priority);
|
.sort((a, b) => a.order_priority - b.order_priority);
|
||||||
|
const mergedTasks = mergeTaskSets(tasks.value, normalizedTasks);
|
||||||
|
|
||||||
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
|
const hydratedTasks = await hydrateTaskAttachments(mergedTasks);
|
||||||
if (!isFetchRequestActive(requestId)) {
|
if (!isFetchSignalActive(signal)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,7 +677,7 @@ export function useSelfServeLogic() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchWashSummary = async (params = {}, manageLoading = true, options = {}) => {
|
const fetchWashSummary = async (params = {}, manageLoading = true, options = {}) => {
|
||||||
const { requestId = null } = options;
|
const { signal = null } = options;
|
||||||
if (!params.session_id && !(params.lane_id && params.reg)) {
|
if (!params.session_id && !(params.lane_id && params.reg)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -555,30 +687,39 @@ export function useSelfServeLogic() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const summaryData = await SessionUser.objects.self_serve_vehicle_conditions.get.washSummary(params);
|
const summaryData = await requestWashSummary(params, signal);
|
||||||
const didApply = await applySummaryData(summaryData, { requestId });
|
const didApply = await applySummaryData(summaryData, { signal });
|
||||||
if (!didApply) {
|
if (!didApply) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
requestError.value = null;
|
requestError.value = null;
|
||||||
return summaryData;
|
return summaryData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isCanceledRequest(error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
console.error("Error fetching self-serve summary:", error);
|
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.");
|
requestError.value = extractErrorMessage(error, "Kunne ikke hente vaskestatus. Prøv igen.");
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
if (manageLoading) {
|
if (manageLoading && isFetchSignalActive(signal)) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null) => {
|
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null, options = {}) => {
|
||||||
const requestId = beginFetchRequest();
|
const fetchKey = createFetchKey(_departmentId, _vehicleTypeId, laneId, reg);
|
||||||
|
if (!options.force && fetchKey && (fetchKey === latestSuccessfulFetchKey.value || fetchKey === inFlightFetchKey.value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!laneId || !reg || reg.trim().length < 2) {
|
if (!laneId || !reg || reg.trim().length < 2) {
|
||||||
|
abortActiveFetchRequest();
|
||||||
|
latestSuccessfulFetchKey.value = null;
|
||||||
|
inFlightFetchKey.value = null;
|
||||||
preview.value = null;
|
preview.value = null;
|
||||||
summary.value = null;
|
summary.value = null;
|
||||||
session.value = null;
|
session.value = null;
|
||||||
@@ -604,8 +745,11 @@ export function useSelfServeLogic() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const abortController = beginFetchRequest(options.signal || null);
|
||||||
|
const signal = abortController?.signal || null;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
requestError.value = null;
|
requestError.value = null;
|
||||||
|
inFlightFetchKey.value = fetchKey;
|
||||||
try {
|
try {
|
||||||
const normalizedReg = reg.trim().toUpperCase();
|
const normalizedReg = reg.trim().toUpperCase();
|
||||||
const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
|
const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
|
||||||
@@ -620,18 +764,18 @@ export function useSelfServeLogic() {
|
|||||||
const contextKey = `${parseInt(laneId)}:${normalizedReg}:${contextVehicleTypeKey}`;
|
const contextKey = `${parseInt(laneId)}:${normalizedReg}:${contextVehicleTypeKey}`;
|
||||||
const shouldMergeQuestions = lastPreviewContextKey.value === contextKey;
|
const shouldMergeQuestions = lastPreviewContextKey.value === contextKey;
|
||||||
const previewData = hasVehicleTypeOverride
|
const previewData = hasVehicleTypeOverride
|
||||||
? await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg, normalizedVehicleTypeId)
|
? await requestPreviewAllowed(laneId, normalizedReg, normalizedVehicleTypeId, signal)
|
||||||
: await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg);
|
: await requestPreviewAllowed(laneId, normalizedReg, null, signal);
|
||||||
|
|
||||||
if (!isFetchRequestActive(requestId)) {
|
if (!isFetchSignalActive(signal)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const didApplyPreview = await applyPreviewData(previewData, {
|
const didApplyPreview = await applyPreviewData(previewData, {
|
||||||
mergeQuestions: shouldMergeQuestions,
|
mergeQuestions: shouldMergeQuestions,
|
||||||
requestId,
|
signal,
|
||||||
});
|
});
|
||||||
if (!didApplyPreview || !isFetchRequestActive(requestId)) {
|
if (!didApplyPreview || !isFetchSignalActive(signal)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,11 +790,11 @@ export function useSelfServeLogic() {
|
|||||||
summaryData = await fetchWashSummary(
|
summaryData = await fetchWashSummary(
|
||||||
{ session_id: previewData.session.id },
|
{ session_id: previewData.session.id },
|
||||||
false,
|
false,
|
||||||
{ requestId }
|
{ signal }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isFetchRequestActive(requestId)) {
|
if (!isFetchSignalActive(signal)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,9 +816,9 @@ export function useSelfServeLogic() {
|
|||||||
const fallbackSummary = await fetchWashSummary(
|
const fallbackSummary = await fetchWashSummary(
|
||||||
fallbackSummaryParams,
|
fallbackSummaryParams,
|
||||||
false,
|
false,
|
||||||
{ requestId }
|
{ signal }
|
||||||
);
|
);
|
||||||
if (!isFetchRequestActive(requestId)) {
|
if (!isFetchSignalActive(signal)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,19 +827,31 @@ export function useSelfServeLogic() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If summary is not available yet, keep the preview list visible so the Questions step is not empty.
|
// 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);
|
setSummaryVisibleQuestions(questions.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFetchSignalActive(signal)) {
|
||||||
|
latestSuccessfulFetchKey.value = fetchKey;
|
||||||
|
}
|
||||||
|
|
||||||
return previewData;
|
return previewData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isCanceledRequest(error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
console.error("Error fetching self-serve preview:", error);
|
console.error("Error fetching self-serve preview:", error);
|
||||||
if (isFetchRequestActive(requestId)) {
|
if (isFetchSignalActive(signal)) {
|
||||||
requestError.value = extractErrorMessage(error);
|
requestError.value = extractErrorMessage(error);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
if (isFetchRequestActive(requestId)) {
|
const isCurrentFetch = !abortController || activeFetchAbortController.value === abortController;
|
||||||
|
if (isCurrentFetch) {
|
||||||
|
if (inFlightFetchKey.value === fetchKey) {
|
||||||
|
inFlightFetchKey.value = null;
|
||||||
|
}
|
||||||
|
activeFetchAbortController.value = null;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -706,6 +862,7 @@ export function useSelfServeLogic() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mutationRequestId = beginMutationRequest();
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const normalizedReg = reg.trim().toUpperCase();
|
const normalizedReg = reg.trim().toUpperCase();
|
||||||
@@ -733,6 +890,10 @@ export function useSelfServeLogic() {
|
|||||||
const payload = response?.data?.data || response?.data || response || {};
|
const payload = response?.data?.data || response?.data || response || {};
|
||||||
const responseSummary = payload?.selfserve || null;
|
const responseSummary = payload?.selfserve || null;
|
||||||
|
|
||||||
|
if (!isMutationRequestActive(mutationRequestId)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
answers.value = {
|
answers.value = {
|
||||||
...answers.value,
|
...answers.value,
|
||||||
[parseInt(questionId)]: value,
|
[parseInt(questionId)]: value,
|
||||||
@@ -749,7 +910,10 @@ export function useSelfServeLogic() {
|
|||||||
updateResolvedVehicleTypeId(responseSummary, responseSummary?.session);
|
updateResolvedVehicleTypeId(responseSummary, responseSummary?.session);
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg);
|
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg, { force: true });
|
||||||
|
if (!isMutationRequestActive(mutationRequestId)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
answers.value = {
|
answers.value = {
|
||||||
...answers.value,
|
...answers.value,
|
||||||
[parseInt(questionId)]: value,
|
[parseInt(questionId)]: value,
|
||||||
@@ -757,10 +921,14 @@ export function useSelfServeLogic() {
|
|||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error synchronizing vehicle answer:", 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;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (isMutationRequestActive(mutationRequestId)) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -769,6 +937,7 @@ export function useSelfServeLogic() {
|
|||||||
return { deletedCount: 0 };
|
return { deletedCount: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mutationRequestId = beginMutationRequest();
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const normalizedReg = reg.trim().toUpperCase();
|
const normalizedReg = reg.trim().toUpperCase();
|
||||||
@@ -804,6 +973,10 @@ export function useSelfServeLogic() {
|
|||||||
SessionUser.objects.self_serve_vehicle_conditions.delete(conditionId)
|
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.
|
// Force a non-merge refresh so cleared answers are not kept from local state.
|
||||||
lastPreviewContextKey.value = null;
|
lastPreviewContextKey.value = null;
|
||||||
const refreshVehicleTypeCandidate = vehicleTypeId !== null && vehicleTypeId !== undefined
|
const refreshVehicleTypeCandidate = vehicleTypeId !== null && vehicleTypeId !== undefined
|
||||||
@@ -814,15 +987,19 @@ export function useSelfServeLogic() {
|
|||||||
? normalizedRefreshVehicleTypeId
|
? normalizedRefreshVehicleTypeId
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg);
|
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg, { force: true });
|
||||||
|
|
||||||
return { deletedCount: conditionIdsToDelete.length };
|
return { deletedCount: conditionIdsToDelete.length };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error clearing self-serve answers:", 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;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (isMutationRequestActive(mutationRequestId)) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -984,9 +1161,27 @@ export function useSelfServeLogic() {
|
|||||||
return activeTaskServices.value.includes(serviceName);
|
return activeTaskServices.value.includes(serviceName);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateLaneAllowedServices = async (laneId) => {
|
const updateLaneAllowedServices = async (laneId, options = {}) => {
|
||||||
if (!laneId) return;
|
if (!laneId) return;
|
||||||
const taskIds = activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
|
const hasTaskIdsOverride = Array.isArray(options.taskIds);
|
||||||
|
const activeTaskIds = hasTaskIdsOverride
|
||||||
|
? options.taskIds.map((taskId) => parseInt(taskId)).filter(Boolean)
|
||||||
|
: activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
|
||||||
|
const taskIds = hasTaskIdsOverride || activeTaskIds.length > 0
|
||||||
|
? activeTaskIds
|
||||||
|
: tasks.value.map((task) => extractTaskId(task)).filter(Boolean);
|
||||||
|
|
||||||
|
if (taskIds.length === 0) {
|
||||||
|
requestError.value = null;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
lane_id: parseInt(laneId),
|
||||||
|
allowed_services: allowedServices.value,
|
||||||
|
skipped_empty_task_sync: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await SessionUser.request('/modules/self-serve/lane/services/allowed', 'post', {
|
const response = await SessionUser.request('/modules/self-serve/lane/services/allowed', 'post', {
|
||||||
@@ -1032,6 +1227,12 @@ export function useSelfServeLogic() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (getCurrentInstance()) {
|
||||||
|
onUnmounted(() => {
|
||||||
|
abortActiveFetchRequest();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
error: requestError,
|
error: requestError,
|
||||||
@@ -1049,6 +1250,7 @@ export function useSelfServeLogic() {
|
|||||||
answers,
|
answers,
|
||||||
completedTasks,
|
completedTasks,
|
||||||
allowedServices,
|
allowedServices,
|
||||||
|
resolvedVehicleTypeId: lastResolvedVehicleTypeId,
|
||||||
configVersionId,
|
configVersionId,
|
||||||
evaluationTrace,
|
evaluationTrace,
|
||||||
visibleQuestions,
|
visibleQuestions,
|
||||||
@@ -1060,6 +1262,7 @@ export function useSelfServeLogic() {
|
|||||||
allowed,
|
allowed,
|
||||||
fetchSelfServeData,
|
fetchSelfServeData,
|
||||||
fetchWashSummary,
|
fetchWashSummary,
|
||||||
|
cancelSelfServeFetch: abortActiveFetchRequest,
|
||||||
syncVehicleAnswer,
|
syncVehicleAnswer,
|
||||||
clearVehicleAnswers,
|
clearVehicleAnswers,
|
||||||
evaluateRule,
|
evaluateRule,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const isLaneSelfServeEnabled = (lane) =>
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasSelfServeEnabledLane = (department) =>
|
||||||
|
department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled);
|
||||||
|
|
||||||
const toDepartmentViewModel = (department, distance = null) => ({
|
const toDepartmentViewModel = (department, distance = null) => ({
|
||||||
id: department.id,
|
id: department.id,
|
||||||
distance,
|
distance,
|
||||||
@@ -47,6 +50,7 @@ export function useWashDepartments(options = {}) {
|
|||||||
const isSearchingDepartments = ref(false);
|
const isSearchingDepartments = ref(false);
|
||||||
const lastDepartmentFetchTime = ref(null);
|
const lastDepartmentFetchTime = ref(null);
|
||||||
const departmentFetchError = ref(null);
|
const departmentFetchError = ref(null);
|
||||||
|
const departmentSelectionStrategy = ref(null);
|
||||||
|
|
||||||
let refreshInterval = null;
|
let refreshInterval = null;
|
||||||
|
|
||||||
@@ -73,6 +77,12 @@ export function useWashDepartments(options = {}) {
|
|||||||
return nearestDepartment.value.self_serve_enabled === true;
|
return nearestDepartment.value.self_serve_enabled === true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isDepartmentSelectionDistanceBased = computed(() => departmentSelectionStrategy.value === "distance");
|
||||||
|
|
||||||
|
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
|
||||||
|
|
||||||
|
const hasLocationCoordinates = (locationValue = locations.location.value) => !!locationValue?.coords;
|
||||||
|
|
||||||
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
|
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
|
||||||
|
|
||||||
const fetchDepartments = async () => {
|
const fetchDepartments = async () => {
|
||||||
@@ -110,20 +120,30 @@ export function useWashDepartments(options = {}) {
|
|||||||
const forcedDepartment = getForcedDepartment();
|
const forcedDepartment = getForcedDepartment();
|
||||||
if (forcedDepartment) {
|
if (forcedDepartment) {
|
||||||
nearestDepartment.value = toDepartmentViewModel(forcedDepartment);
|
nearestDepartment.value = toDepartmentViewModel(forcedDepartment);
|
||||||
|
departmentSelectionStrategy.value = "forced";
|
||||||
return nearestDepartment.value;
|
return nearestDepartment.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (guestDepartments.value.length === 0) {
|
||||||
|
nearestDepartment.value = null;
|
||||||
|
departmentSelectionStrategy.value = null;
|
||||||
|
return nearestDepartment.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasLocationCoordinates(locationValue)) {
|
||||||
|
const fallbackDepartment = guestDepartments.value.find(hasSelfServeEnabledLane);
|
||||||
|
nearestDepartment.value = fallbackDepartment ? toDepartmentViewModel(fallbackDepartment) : null;
|
||||||
|
departmentSelectionStrategy.value = fallbackDepartment ? "fallback" : null;
|
||||||
|
return nearestDepartment.value;
|
||||||
|
}
|
||||||
|
|
||||||
let currentNearestDepartment = {
|
let currentNearestDepartment = {
|
||||||
id: null,
|
id: null,
|
||||||
distance: Infinity,
|
distance: Infinity,
|
||||||
};
|
};
|
||||||
|
|
||||||
guestDepartments.value.forEach((department) => {
|
guestDepartments.value.forEach((department) => {
|
||||||
if (!locationValue?.coords) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const from = {
|
const from = {
|
||||||
latitude: locationValue.coords.latitude,
|
latitude: locationValue.coords.latitude,
|
||||||
longitude: locationValue.coords.longitude,
|
longitude: locationValue.coords.longitude,
|
||||||
@@ -141,6 +161,18 @@ export function useWashDepartments(options = {}) {
|
|||||||
|
|
||||||
if (currentNearestDepartment.id) {
|
if (currentNearestDepartment.id) {
|
||||||
nearestDepartment.value = currentNearestDepartment;
|
nearestDepartment.value = currentNearestDepartment;
|
||||||
|
departmentSelectionStrategy.value = "distance";
|
||||||
|
return nearestDepartment.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackDepartment =
|
||||||
|
guestDepartments.value.find((department) => department.self_serve_enabled === true) || guestDepartments.value[0];
|
||||||
|
if (fallbackDepartment) {
|
||||||
|
nearestDepartment.value = toDepartmentViewModel(fallbackDepartment);
|
||||||
|
departmentSelectionStrategy.value = "fallback";
|
||||||
|
} else {
|
||||||
|
nearestDepartment.value = null;
|
||||||
|
departmentSelectionStrategy.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nearestDepartment.value;
|
return nearestDepartment.value;
|
||||||
@@ -167,7 +199,7 @@ export function useWashDepartments(options = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startDepartmentSearch = () => {
|
const startDepartmentSearch = () => {
|
||||||
if (!canAccessSuperUser()) {
|
if (!canAccessSuperUser() && hasLocationCoordinates()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +277,9 @@ export function useWashDepartments(options = {}) {
|
|||||||
isSearchingDepartments,
|
isSearchingDepartments,
|
||||||
lastDepartmentFetchTime,
|
lastDepartmentFetchTime,
|
||||||
departmentFetchError,
|
departmentFetchError,
|
||||||
|
departmentSelectionStrategy,
|
||||||
|
isDepartmentSelectionDistanceBased,
|
||||||
|
isDepartmentSelectionFallbackBased,
|
||||||
availableProductIds,
|
availableProductIds,
|
||||||
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
||||||
fetchDepartments,
|
fetchDepartments,
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export const WASH_STEPS = {
|
|||||||
COMPLETED: 5,
|
COMPLETED: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeLaneId = (value) => {
|
||||||
|
const normalized = parseInt(String(value ?? ""), 10);
|
||||||
|
return Number.isNaN(normalized) || normalized <= 0 ? null : normalized;
|
||||||
|
};
|
||||||
|
|
||||||
export function useWashFlowState(options) {
|
export function useWashFlowState(options) {
|
||||||
const {
|
const {
|
||||||
currentStep,
|
currentStep,
|
||||||
@@ -14,7 +19,7 @@ export function useWashFlowState(options) {
|
|||||||
customerNumberInput,
|
customerNumberInput,
|
||||||
licensePlateInput,
|
licensePlateInput,
|
||||||
vehicleTypeSelect,
|
vehicleTypeSelect,
|
||||||
availableProductIds,
|
isCustomerNumberRequired,
|
||||||
radioLaneOption,
|
radioLaneOption,
|
||||||
nearestDepartment,
|
nearestDepartment,
|
||||||
allVisibleQuestionsAnswered,
|
allVisibleQuestionsAnswered,
|
||||||
@@ -34,6 +39,17 @@ export function useWashFlowState(options) {
|
|||||||
activeTasks.value.length > 0 ? steps.TASKS : steps.WASH_IN_PROGRESS
|
activeTasks.value.length > 0 ? steps.TASKS : steps.WASH_IN_PROGRESS
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getSelectedLaneId = () => normalizeLaneId(radioLaneOption.value);
|
||||||
|
|
||||||
|
const getSelectedLane = () => {
|
||||||
|
const selectedLaneId = getSelectedLaneId();
|
||||||
|
if (selectedLaneId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nearestDepartment.value?.lanes.find((lane) => normalizeLaneId(lane.id) === selectedLaneId) || null;
|
||||||
|
};
|
||||||
|
|
||||||
const clickableSteps = {
|
const clickableSteps = {
|
||||||
[steps.VEHICLE]: () => {
|
[steps.VEHICLE]: () => {
|
||||||
if (washInProgress.value) {
|
if (washInProgress.value) {
|
||||||
@@ -41,16 +57,15 @@ export function useWashFlowState(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedVehicleTypeId = vehicleTypeSelect.value;
|
const selectedVehicleTypeId = vehicleTypeSelect.value;
|
||||||
const allowedProductIds = new Set((availableProductIds.value || []).map((productId) => String(productId)));
|
const hasSelectedVehicleType = selectedVehicleTypeId !== null && selectedVehicleTypeId !== undefined;
|
||||||
const hasAllowedVehicleType = selectedVehicleTypeId !== null
|
|
||||||
&& selectedVehicleTypeId !== undefined
|
|
||||||
&& allowedProductIds.has(String(selectedVehicleTypeId));
|
|
||||||
const customerNumberValue = customerNumberInput.value;
|
const customerNumberValue = customerNumberInput.value;
|
||||||
const customerNumberValid = customerNumberValue === null
|
const requiresCustomerNumber = isCustomerNumberRequired?.value ?? false;
|
||||||
|
const customerNumberValid = !requiresCustomerNumber
|
||||||
|
|| customerNumberValue === null
|
||||||
|| customerNumberValue === undefined
|
|| customerNumberValue === undefined
|
||||||
|| String(customerNumberValue).trim() !== "";
|
|| String(customerNumberValue).trim() !== "";
|
||||||
const licensePlateValid = !!(licensePlateInput.value && licensePlateInput.value.trim() !== "");
|
const licensePlateValid = !!(licensePlateInput.value && licensePlateInput.value.trim() !== "");
|
||||||
if (!hasAllowedVehicleType) {
|
if (!hasSelectedVehicleType) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +118,7 @@ export function useWashFlowState(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (currentStep.value === steps.SELECT_LANE) {
|
if (currentStep.value === steps.SELECT_LANE) {
|
||||||
const selectedLane = nearestDepartment.value?.lanes.find((lane) => lane.id === radioLaneOption.value);
|
const selectedLane = getSelectedLane();
|
||||||
if (!selectedLane || !isLaneAvailable(selectedLane)) {
|
if (!selectedLane || !isLaneAvailable(selectedLane)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -133,9 +148,10 @@ export function useWashFlowState(options) {
|
|||||||
const handleConfirmNext = async () => {
|
const handleConfirmNext = async () => {
|
||||||
if (currentStep.value === steps.QUESTIONS) {
|
if (currentStep.value === steps.QUESTIONS) {
|
||||||
editAnswers.value = false;
|
editAnswers.value = false;
|
||||||
if (radioLaneOption.value && radioLaneOption.value !== "Any") {
|
const selectedLaneId = getSelectedLaneId();
|
||||||
|
if (selectedLaneId !== null) {
|
||||||
try {
|
try {
|
||||||
await updateLaneAllowedServices(radioLaneOption.value);
|
await updateLaneAllowedServices(selectedLaneId);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -150,8 +166,9 @@ export function useWashFlowState(options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedLaneId = getSelectedLaneId() ?? radioLaneOption.value;
|
||||||
await onStartWash(
|
await onStartWash(
|
||||||
radioLaneOption.value,
|
selectedLaneId,
|
||||||
licensePlateInput.value,
|
licensePlateInput.value,
|
||||||
customerNumberInput.value,
|
customerNumberInput.value,
|
||||||
targetStepForStart()
|
targetStepForStart()
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ export function useWashSessionActions(options) {
|
|||||||
|
|
||||||
const isAlreadyStoppedStopError = (message) => {
|
const isAlreadyStoppedStopError = (message) => {
|
||||||
const normalized = String(message || "").toLowerCase();
|
const normalized = String(message || "").toLowerCase();
|
||||||
return normalized.includes("cannot stop lane") && normalized.includes("not occupied");
|
return (
|
||||||
|
normalized.includes("not occupied") &&
|
||||||
|
(normalized.includes("cannot stop lane") || normalized.includes("cannot invoice"))
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearActiveWashState = () => {
|
const clearActiveWashState = () => {
|
||||||
@@ -247,16 +250,18 @@ export function useWashSessionActions(options) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedWashType = radioWashType.value === "Machine" ? "Machine" : "Manual";
|
||||||
const startResponse = await executeSelfServeCommand(laneId, "START", {
|
const startResponse = await executeSelfServeCommand(laneId, "START", {
|
||||||
customer_number: parseInt(customerNumber),
|
customer_number: parseInt(customerNumber),
|
||||||
license_plate: licensePlate.trim().toUpperCase(),
|
license_plate: licensePlate.trim().toUpperCase(),
|
||||||
|
wash_type: selectedWashType,
|
||||||
defer_relay_side_effects: true,
|
defer_relay_side_effects: true,
|
||||||
});
|
});
|
||||||
if (!startResponse) {
|
if (!startResponse) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) {
|
if (selectedWashType === "Machine" && isServiceAllowed("MACHINE")) {
|
||||||
let machineRelayResponse = null;
|
let machineRelayResponse = null;
|
||||||
try {
|
try {
|
||||||
machineRelayResponse = await enableMachineRelay(laneId);
|
machineRelayResponse = await enableMachineRelay(laneId);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS = String(
|
|||||||
export const ALLOWED_ORIGINS = [
|
export const ALLOWED_ORIGINS = [
|
||||||
"https://truckwash.io",
|
"https://truckwash.io",
|
||||||
"https://www.truckwash.io",
|
"https://www.truckwash.io",
|
||||||
|
new URL(DEFAULT_PUBLIC_GATEWAY_API_URL).origin,
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
"http://localhost:4173",
|
"http://localhost:4173",
|
||||||
"http://localhost:4174",
|
"http://localhost:4174",
|
||||||
|
|||||||
@@ -1845,6 +1845,21 @@
|
|||||||
"week": "@:{'templates.generated.compat.common.week'}"
|
"week": "@:{'templates.generated.compat.common.week'}"
|
||||||
},
|
},
|
||||||
"collected_invoice": {
|
"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": {
|
"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_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'}",
|
"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 +2330,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'} @:{'words.generated.produkt'}?",
|
"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'}"
|
"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.",
|
||||||
|
"subtitle": "Konfiguration af Slack-notifikationer",
|
||||||
|
"title": "Slack-konfiguration",
|
||||||
|
"unavailable": "Slack-konfigurationen er ikke tilgængelig i denne API-udgivelse."
|
||||||
|
},
|
||||||
"shelly": {
|
"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_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'}.",
|
"api_settings_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'} @:{'words.generated.forbindelsesindstillingerne'}.",
|
||||||
@@ -3784,6 +3808,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_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'}"
|
"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": {
|
"messages": {
|
||||||
"are_you_sure": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'}?",
|
"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'}.",
|
"cannot_undo": "@.capitalize:{'words.generated.denne'} @:{'words.generated.handling'} @:{'words.generated.kan'} @:{'words.generated.ikke'} @:{'words.generated.fortrydes'}.",
|
||||||
@@ -4595,7 +4626,6 @@
|
|||||||
"loading_vehicle_types": "@.capitalize:{'words.generated.indlæser'} @:{'words.generated.tilgængelige'} @:{'words.generated.køretøjstyper'}...",
|
"loading_vehicle_types": "@.capitalize:{'words.generated.indlæser'} @:{'words.generated.tilgængelige'} @:{'words.generated.køretøjstyper'}...",
|
||||||
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
||||||
"machine": "@.capitalize:{'words.generated.maskine'}",
|
"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'}",
|
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||||
"occupied": "Optaget",
|
"occupied": "Optaget",
|
||||||
"open_property_access_gate": "@:{'words.generated.abn'} adgangsport",
|
"open_property_access_gate": "@:{'words.generated.abn'} adgangsport",
|
||||||
@@ -5479,7 +5509,9 @@
|
|||||||
"email_notifications_desc": "@:{'words.generated.modtag'} @:{'words.generated.e'}-mails @:{'words.generated.med'} bookingbekræftelser @:{'words.generated.og'} @:{'words.generated.vaskecertifikater'}.",
|
"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_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": "@.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": {
|
"security": {
|
||||||
"description": "@.capitalize:{'words.generated.dine'} sikkerhedsindstillinger",
|
"description": "@.capitalize:{'words.generated.dine'} sikkerhedsindstillinger",
|
||||||
|
|||||||
@@ -1955,6 +1955,21 @@
|
|||||||
"week": "Uke"
|
"week": "Uke"
|
||||||
},
|
},
|
||||||
"collected_invoice": {
|
"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": {
|
"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_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'}",
|
"booked_with_economic": "@:{'words.generated.rechnung'} @:{'words.generated.mit'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.gebucht'}",
|
||||||
@@ -2425,6 +2440,15 @@
|
|||||||
"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'}?",
|
"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'}"
|
"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.",
|
||||||
|
"subtitle": "Konfiguration von Slack-Benachrichtigungen",
|
||||||
|
"title": "Slack-Konfiguration",
|
||||||
|
"unavailable": "Die Slack-Konfiguration ist in dieser API-Version nicht verfuegbar."
|
||||||
|
},
|
||||||
"shelly": {
|
"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_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'}.",
|
"api_settings_desc": "@:{'words.generated.api'}-@:{'words.generated.verbindungseinstellungen'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.shelly'}.",
|
||||||
@@ -3894,6 +3918,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_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'}"
|
"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": {
|
"messages": {
|
||||||
"are_you_sure": "@.capitalize:{'words.generated.sind'} @.capitalize:{'words.generated.sie'} @:{'words.generated.sicher'}?",
|
"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'}.",
|
"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 +4736,6 @@
|
|||||||
"loading_vehicle_types": "@.capitalize:{'words.generated.verf'}?@:{'words.generated.gbare'} @:{'words.generated.fahrzeugtypen'} @:{'words.generated.werden'} @:{'words.generated.geladen'}...",
|
"loading_vehicle_types": "@.capitalize:{'words.generated.verf'}?@:{'words.generated.gbare'} @:{'words.generated.fahrzeugtypen'} @:{'words.generated.werden'} @:{'words.generated.geladen'}...",
|
||||||
"lane_unavailable": "Waschspur nicht verfugbar",
|
"lane_unavailable": "Waschspur nicht verfugbar",
|
||||||
"machine": "@:{'words.generated.maschine'}",
|
"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'}",
|
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||||
"occupied": "Belegt",
|
"occupied": "Belegt",
|
||||||
"open_property_access_gate": "Grundstueckszugangstor @:{'words.generated.oeffnen'}",
|
"open_property_access_gate": "Grundstueckszugangstor @:{'words.generated.oeffnen'}",
|
||||||
@@ -5589,7 +5619,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'}.",
|
"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_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": "@:{'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": {
|
"security": {
|
||||||
"description": "@.capitalize:{'words.generated.ihre'} Sicherheitseinstellungen",
|
"description": "@.capitalize:{'words.generated.ihre'} Sicherheitseinstellungen",
|
||||||
|
|||||||
@@ -1679,6 +1679,21 @@
|
|||||||
"week": "@:{'templates.generated.compat.common.week'}"
|
"week": "@:{'templates.generated.compat.common.week'}"
|
||||||
},
|
},
|
||||||
"collected_invoice": {
|
"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": {
|
"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_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'}",
|
"booked_with_economic": "@.capitalize:{'words.generated.invoice'} @:{'words.generated.booked'} @:{'words.generated.with'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}",
|
||||||
@@ -2149,6 +2164,15 @@
|
|||||||
"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'}?",
|
"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'}"
|
"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.",
|
||||||
|
"subtitle": "Configuration of Slack notifications",
|
||||||
|
"title": "Slack configuration",
|
||||||
|
"unavailable": "Slack configuration is not available on this API release."
|
||||||
|
},
|
||||||
"shelly": {
|
"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_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'}.",
|
"api_settings_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.shelly'} @:{'words.generated.api'} @:{'words.generated.connection'} @:{'words.generated.settings'}.",
|
||||||
@@ -3618,6 +3642,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_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'}"
|
"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": {
|
"messages": {
|
||||||
"are_you_sure": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.sure'}?",
|
"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'}.",
|
"cannot_undo": "@.capitalize:{'words.generated.this'} decision @:{'words.generated.cannot'} @:{'words.generated.be'} @:{'words.generated.undone'}.",
|
||||||
@@ -4429,7 +4460,6 @@
|
|||||||
"loading_vehicle_types": "@.capitalize:{'words.generated.loading'} @:{'words.generated.available'} @:{'words.generated.vehicle'} @:{'words.generated.types'}...",
|
"loading_vehicle_types": "@.capitalize:{'words.generated.loading'} @:{'words.generated.available'} @:{'words.generated.vehicle'} @:{'words.generated.types'}...",
|
||||||
"lane_unavailable": "Lane unavailable",
|
"lane_unavailable": "Lane unavailable",
|
||||||
"machine": "@.capitalize:{'words.generated.machine'}",
|
"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'}",
|
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||||
"occupied": "Occupied",
|
"occupied": "Occupied",
|
||||||
"open_property_access_gate": "@.capitalize:{'words.generated.open'} @:{'words.generated.property'} @:{'words.generated.access'} @:{'words.generated.gate'}",
|
"open_property_access_gate": "@.capitalize:{'words.generated.open'} @:{'words.generated.property'} @:{'words.generated.access'} @:{'words.generated.gate'}",
|
||||||
@@ -5313,7 +5343,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'}.",
|
"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_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": "@.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": {
|
"security": {
|
||||||
"description": "@.capitalize:{'words.generated.your'} @:{'words.generated.security'} @:{'words.generated.settings'}",
|
"description": "@.capitalize:{'words.generated.your'} @:{'words.generated.security'} @:{'words.generated.settings'}",
|
||||||
|
|||||||
@@ -892,6 +892,21 @@
|
|||||||
"year": "@:common.year"
|
"year": "@:common.year"
|
||||||
},
|
},
|
||||||
"collected_invoice": {
|
"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": {
|
"economic": {
|
||||||
"booked_desc": "@:{'templates.generated.compat.collected_invoice.economic.booked_desc'}",
|
"booked_desc": "@:{'templates.generated.compat.collected_invoice.economic.booked_desc'}",
|
||||||
"booked_with_economic": "@:{'templates.generated.compat.collected_invoice.economic.booked_with_economic'}",
|
"booked_with_economic": "@:{'templates.generated.compat.collected_invoice.economic.booked_with_economic'}",
|
||||||
@@ -1347,6 +1362,15 @@
|
|||||||
"subtitle": "@:products.subtitle",
|
"subtitle": "@:products.subtitle",
|
||||||
"title": "@:common.products"
|
"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": {
|
"shelly": {
|
||||||
"api_key": "@:configuration.limble.api_key",
|
"api_key": "@:configuration.limble.api_key",
|
||||||
"api_key_desc": "@:{'templates.generated.compat.configuration.shelly.api_key_desc'}",
|
"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_text": "@:{'templates.generated.compat.login_qr.unknown_qr_text'}",
|
||||||
"unknown_qr_title": "@:{'templates.generated.compat.login_qr.unknown_qr_title'}"
|
"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": {
|
"messages": {
|
||||||
"are_you_sure": "@:{'templates.generated.compat.messages.are_you_sure'}",
|
"are_you_sure": "@:{'templates.generated.compat.messages.are_you_sure'}",
|
||||||
"cancelled": "@:{'templates.generated.compat.global.cancelled'}",
|
"cancelled": "@:{'templates.generated.compat.global.cancelled'}",
|
||||||
@@ -4224,7 +4255,6 @@
|
|||||||
"loading_vehicle_types": "@:{'templates.generated.compat.self_wash.loading_vehicle_types'}",
|
"loading_vehicle_types": "@:{'templates.generated.compat.self_wash.loading_vehicle_types'}",
|
||||||
"lane_unavailable": "@:{'templates.generated.compat.self_wash.lane_unavailable'}",
|
"lane_unavailable": "@:{'templates.generated.compat.self_wash.lane_unavailable'}",
|
||||||
"machine": "@:{'templates.generated.compat.self_wash.machine'}",
|
"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_departments_found": "@:{'templates.generated.compat.self_wash.no_departments_found'}",
|
||||||
"no_vehicles_found": "@:vehicles.no_vehicles",
|
"no_vehicles_found": "@:vehicles.no_vehicles",
|
||||||
"occupied": "@:{'templates.generated.compat.self_wash.occupied'}",
|
"occupied": "@:{'templates.generated.compat.self_wash.occupied'}",
|
||||||
@@ -4732,6 +4762,7 @@
|
|||||||
"department_relays": "@:{'templates.generated.compat.superuser.nav.department_relays'}",
|
"department_relays": "@:{'templates.generated.compat.superuser.nav.department_relays'}",
|
||||||
"departments": "@:common.departments",
|
"departments": "@:common.departments",
|
||||||
"employees": "@:{'templates.generated.compat.common.roless.employee'}",
|
"employees": "@:{'templates.generated.compat.common.roless.employee'}",
|
||||||
|
"error_reports": "@:{'templates.generated.compat.superuser.nav.error_reports'}",
|
||||||
"invoices": "@:common.invoices",
|
"invoices": "@:common.invoices",
|
||||||
"invoicing": "@:{'templates.generated.compat.superuser.nav.invoicing'}",
|
"invoicing": "@:{'templates.generated.compat.superuser.nav.invoicing'}",
|
||||||
"orders": "@:{'templates.generated.compat.superuser.nav.orders'}",
|
"orders": "@:{'templates.generated.compat.superuser.nav.orders'}",
|
||||||
@@ -5556,6 +5587,8 @@
|
|||||||
"sms_phone": "@:{'templates.generated.compat.user_dashboard.profile.notifications.sms_phone'}",
|
"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_phone_desc": "@:{'templates.generated.compat.user_dashboard.profile.notifications.sms_phone_desc'}",
|
||||||
"sms_subtitle": "@:time_booking_flow.sms",
|
"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"
|
"title": "@:nav.notifications"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -1956,6 +1956,21 @@
|
|||||||
"week": "@:{'templates.generated.compat.common.week'}"
|
"week": "@:{'templates.generated.compat.common.week'}"
|
||||||
},
|
},
|
||||||
"collected_invoice": {
|
"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": {
|
"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_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'}",
|
"booked_with_economic": "@.capitalize:{'words.generated.faktura'} @:{'words.generated.bestilles'} @:{'words.generated.hos'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}",
|
||||||
@@ -2426,6 +2441,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?",
|
"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'}"
|
"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": {
|
"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_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'}.",
|
"api_settings_desc": "@:{'words.generated.shelly'} @:{'words.generated.api'}-@:{'words.generated.tilkoblingsinnstillingene'}.",
|
||||||
@@ -3895,6 +3919,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_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'}"
|
"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": {
|
"messages": {
|
||||||
"are_you_sure": "@:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'}?",
|
"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.",
|
"cannot_undo": "@.capitalize:{'words.generated.denne'} avgjørelsen @:{'words.generated.kan'} @:{'words.generated.ikke'} omgjøres.",
|
||||||
@@ -4706,7 +4737,6 @@
|
|||||||
"loading_vehicle_types": "@.capitalize:{'words.generated.laster'} @:{'words.generated.inn'} @:{'words.generated.tilgjengelige'} @:{'words.generated.kjøretøytyper'}...",
|
"loading_vehicle_types": "@.capitalize:{'words.generated.laster'} @:{'words.generated.inn'} @:{'words.generated.tilgjengelige'} @:{'words.generated.kjøretøytyper'}...",
|
||||||
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
||||||
"machine": "@.capitalize:{'words.generated.maskin'}",
|
"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'}",
|
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
|
||||||
"occupied": "Okkupert",
|
"occupied": "Okkupert",
|
||||||
"open_property_access_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} inngangsport",
|
"open_property_access_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} inngangsport",
|
||||||
@@ -5590,7 +5620,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'}.",
|
"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_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": "@.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": {
|
"security": {
|
||||||
"description": "Sikkerhetsinnstillingene @:{'words.generated.dine'}",
|
"description": "Sikkerhetsinnstillingene @:{'words.generated.dine'}",
|
||||||
|
|||||||
@@ -2006,6 +2006,21 @@
|
|||||||
"week": "Uke"
|
"week": "Uke"
|
||||||
},
|
},
|
||||||
"collected_invoice": {
|
"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": {
|
"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_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'}",
|
"booked_with_economic": "@.capitalize:{'words.generated.faktura'} @:{'words.generated.bokford'} @:{'words.generated.i'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}",
|
||||||
@@ -2476,6 +2491,15 @@
|
|||||||
"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'}?",
|
"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'}"
|
"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.",
|
||||||
|
"subtitle": "Konfiguration av Slack-aviseringar",
|
||||||
|
"title": "Slack-konfiguration",
|
||||||
|
"unavailable": "Slack-konfigurationen är inte tillgänglig i den här API-versionen."
|
||||||
|
},
|
||||||
"shelly": {
|
"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_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'}.",
|
"api_settings_desc": "@.capitalize:{'words.generated.anslutningsinstallningar'} @:{'words.generated.for'} @:{'words.generated.shelly'} @:{'words.generated.api'}.",
|
||||||
@@ -3945,6 +3969,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_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'}"
|
"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": {
|
"messages": {
|
||||||
"are_you_sure": "@.capitalize:{'words.generated.ar'} @:{'words.generated.du'} @:{'words.generated.saker'}?",
|
"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'}.",
|
"cannot_undo": "@.capitalize:{'words.generated.denna'} @:{'words.generated.atgard'} @:{'words.generated.kan'} @:{'words.generated.inte'} @:{'words.generated.angras'}.",
|
||||||
@@ -4756,7 +4787,6 @@
|
|||||||
"loading_vehicle_types": "@.capitalize:{'words.generated.laser'} @:{'words.generated.in'} @:{'words.generated.tillgangliga'} @:{'words.generated.fordonstyper'}...",
|
"loading_vehicle_types": "@.capitalize:{'words.generated.laser'} @:{'words.generated.in'} @:{'words.generated.tillgangliga'} @:{'words.generated.fordonstyper'}...",
|
||||||
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
||||||
"machine": "@.capitalize:{'words.generated.machine'}",
|
"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'}",
|
"no_departments_found": "@.capitalize:{'words.generated.inga'} @:{'words.generated.avdelningar'} @:{'words.generated.hittades'}",
|
||||||
"occupied": "Upptagen",
|
"occupied": "Upptagen",
|
||||||
"open_property_access_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} ingangsgrind",
|
"open_property_access_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} ingangsgrind",
|
||||||
@@ -5640,7 +5670,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'}.",
|
"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_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": "@.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": {
|
"security": {
|
||||||
"description": "@.capitalize:{'words.generated.dina'} säkerhetsinställningar",
|
"description": "@.capitalize:{'words.generated.dina'} säkerhetsinställningar",
|
||||||
|
|||||||
+88
-84
@@ -342,7 +342,7 @@
|
|||||||
"attachments": "Vedhæftninger",
|
"attachments": "Vedhæftninger",
|
||||||
"attachments_add": "Tilføj vedhæftning",
|
"attachments_add": "Tilføj vedhæftning",
|
||||||
"attachments_no_preview": "Ingen forhåndsvisning tilgængelig for denne filtype.",
|
"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_description": "Upload en ny fil som vedhæftning til denne transaktion.",
|
||||||
"attachments_upload_action": "Upload",
|
"attachments_upload_action": "Upload",
|
||||||
"attachments_upload_error": "Fejl ved upload af vedhæftet fil.",
|
"attachments_upload_error": "Fejl ved upload af vedhæftet fil.",
|
||||||
@@ -369,7 +369,7 @@
|
|||||||
"customer_creation_error_generic": "Der opstod en fejl under oprettelse af kunden. Prøv igen.",
|
"customer_creation_error_generic": "Der opstod en fejl under oprettelse af kunden. Prøv igen.",
|
||||||
"delete": "Slet",
|
"delete": "Slet",
|
||||||
"details": "Detaljer",
|
"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.",
|
"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",
|
"hide_order_details": "Skjul ordredetaljer",
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
@@ -572,7 +572,7 @@
|
|||||||
"reference_required_title": "Referencenummer påkrævet",
|
"reference_required_title": "Referencenummer påkrævet",
|
||||||
"safety_seal": "Sikkerhedsforsegling",
|
"safety_seal": "Sikkerhedsforsegling",
|
||||||
"scanned": "Scannet",
|
"scanned": "Scannet",
|
||||||
"scanner": "Skanner",
|
"scanner": "Scanner",
|
||||||
"search_license_plate": "Søg efter nummerplade",
|
"search_license_plate": "Søg efter nummerplade",
|
||||||
"see_note": "Se note",
|
"see_note": "Se note",
|
||||||
"see_reference": "Se reference",
|
"see_reference": "Se reference",
|
||||||
@@ -859,24 +859,8 @@
|
|||||||
},
|
},
|
||||||
"day": "Dag",
|
"day": "Dag",
|
||||||
"dayHeaderFormat": "ddd D/M",
|
"dayHeaderFormat": "ddd D/M",
|
||||||
"dayNames": [
|
"dayNames": ["Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag"],
|
||||||
"Søndag",
|
"dayNamesShort": ["Søn", "Man", "Tir", "Ons", "Tor", "Fre", "Lør"],
|
||||||
"Mandag",
|
|
||||||
"Tirsdag",
|
|
||||||
"Onsdag",
|
|
||||||
"Torsdag",
|
|
||||||
"Fredag",
|
|
||||||
"Lørdag"
|
|
||||||
],
|
|
||||||
"dayNamesShort": [
|
|
||||||
"Søn",
|
|
||||||
"Man",
|
|
||||||
"Tir",
|
|
||||||
"Ons",
|
|
||||||
"Tor",
|
|
||||||
"Fre",
|
|
||||||
"Lør"
|
|
||||||
],
|
|
||||||
"eventTimeFormat": "HH:mm",
|
"eventTimeFormat": "HH:mm",
|
||||||
"list": "Liste",
|
"list": "Liste",
|
||||||
"month": "Måned",
|
"month": "Måned",
|
||||||
@@ -894,20 +878,7 @@
|
|||||||
"November",
|
"November",
|
||||||
"Desember"
|
"Desember"
|
||||||
],
|
],
|
||||||
"monthNamesShort": [
|
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "Maj", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"],
|
||||||
"Jan",
|
|
||||||
"Feb",
|
|
||||||
"Mar",
|
|
||||||
"Apr",
|
|
||||||
"Maj",
|
|
||||||
"Jun",
|
|
||||||
"Jul",
|
|
||||||
"Aug",
|
|
||||||
"Sep",
|
|
||||||
"Okt",
|
|
||||||
"Nov",
|
|
||||||
"Des"
|
|
||||||
],
|
|
||||||
"next": "I denne",
|
"next": "I denne",
|
||||||
"prev": "Forrige",
|
"prev": "Forrige",
|
||||||
"slotLabelFormat": "HH:mm",
|
"slotLabelFormat": "HH:mm",
|
||||||
@@ -936,6 +907,21 @@
|
|||||||
"year": "År"
|
"year": "År"
|
||||||
},
|
},
|
||||||
"collected_invoice": {
|
"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": {
|
"economic": {
|
||||||
"booked_desc": "Fakturaen er bogført i E-conomic, ingen yderligere handlinger er nødvendige.",
|
"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",
|
"booked_with_economic": "Fakturaen er bogført med E-conomic",
|
||||||
@@ -1264,7 +1250,7 @@
|
|||||||
"reply_to_name_desc": "Navnet som emailen besvares til. F.eks. Support",
|
"reply_to_name_desc": "Navnet som emailen besvares til. F.eks. Support",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"send_test_email": "Send test email",
|
"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": "Afsender identitet",
|
||||||
"sender_identity_desc": "Afsender identitet indstillinger for email systemet.",
|
"sender_identity_desc": "Afsender identitet indstillinger for email systemet.",
|
||||||
"smtp_encryption": "SMTP kryptering",
|
"smtp_encryption": "SMTP kryptering",
|
||||||
@@ -1282,13 +1268,13 @@
|
|||||||
"smtp_username_desc": "SMTP brugernavnet er brugernavnet som SMTP hosten bruger til at sende emailen.",
|
"smtp_username_desc": "SMTP brugernavnet er brugernavnet som SMTP hosten bruger til at sende emailen.",
|
||||||
"subtitle": "Konfiguration af email systemet.",
|
"subtitle": "Konfiguration af email systemet.",
|
||||||
"test_email_error": "Der opstod en fejl under afsendelse af test emailen.",
|
"test_email_error": "Der opstod en fejl under afsendelse af test emailen.",
|
||||||
"test_email_sent": "Test email sendt",
|
"test_email_sent": "Test-e-mail sendt",
|
||||||
"test_email_sent_success": "Test emailen er blevet sendt til email adressen.",
|
"test_email_sent_success": "Test-e-mailen er blevet sendt til e-mailadressen.",
|
||||||
"title": "Email konfiguration"
|
"title": "Email konfiguration"
|
||||||
},
|
},
|
||||||
"fxrates": {
|
"fxrates": {
|
||||||
"api_key": "API-nøkkel",
|
"api_key": "API-Nøgle",
|
||||||
"api_key_desc": "Valutakurser API-nøkkelen bruges til å hente ut valutakurser",
|
"api_key_desc": "API-nøglen til valutakurser bruges til at hente valutakurser",
|
||||||
"api_settings": "API forbindelsesindstillinger",
|
"api_settings": "API forbindelsesindstillinger",
|
||||||
"api_settings_desc": "FX Rates API forbindelsesindstillingerne.",
|
"api_settings_desc": "FX Rates API forbindelsesindstillingerne.",
|
||||||
"conversion_rates": "Valutakurser",
|
"conversion_rates": "Valutakurser",
|
||||||
@@ -1312,7 +1298,7 @@
|
|||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"currency": "Valuta",
|
"currency": "Valuta",
|
||||||
"language": "Språk",
|
"language": "Sprog",
|
||||||
"subtitle": "Generelle indstillinger",
|
"subtitle": "Generelle indstillinger",
|
||||||
"timezone": "Tidszone",
|
"timezone": "Tidszone",
|
||||||
"title": "Generelle indstillinger"
|
"title": "Generelle indstillinger"
|
||||||
@@ -1322,13 +1308,13 @@
|
|||||||
"title": "Integrationer"
|
"title": "Integrationer"
|
||||||
},
|
},
|
||||||
"limble": {
|
"limble": {
|
||||||
"api_key": "API-nøkkel",
|
"api_key": "API-Nøgle",
|
||||||
"api_key_desc": "Limble API-nøkkelen bruges til å autentisere Limble-integrationen",
|
"api_key_desc": "Limble API-nøglen bruges til at autentificere Limble-integrationen",
|
||||||
"api_settings": "API forbindelsesindstillinger",
|
"api_settings": "API forbindelsesindstillinger",
|
||||||
"api_settings_desc": "Limble API forbindelsesindstillingerne.",
|
"api_settings_desc": "Limble API forbindelsesindstillingerne.",
|
||||||
"client_id": "Klient-ID",
|
"client_id": "Klient-ID",
|
||||||
"client_id_desc": "Client ID bruges til at autentificere API forbindelsen.",
|
"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.",
|
"client_secret_desc": "Client secret bruges til at autentificere API forbindelsen.",
|
||||||
"connection_failed_desc": "Forbindelsen til Limble API fejlede!",
|
"connection_failed_desc": "Forbindelsen til Limble API fejlede!",
|
||||||
"connection_success": "Forbindelse succesfuld",
|
"connection_success": "Forbindelse succesfuld",
|
||||||
@@ -1344,13 +1330,13 @@
|
|||||||
"title": "Limble konfiguration"
|
"title": "Limble konfiguration"
|
||||||
},
|
},
|
||||||
"mailersend": {
|
"mailersend": {
|
||||||
"api_key": "API-nøkkel",
|
"api_key": "API-Nøgle",
|
||||||
"api_key_desc": "Mailersend API-nøkkelen bruges til å autentisere Mailersend-integrationen",
|
"api_key_desc": "Mailersend API-nøglen bruges til at autentificere Mailersend-integrationen",
|
||||||
"title": "Mailersend-konfiguration"
|
"title": "Mailersend-konfiguration"
|
||||||
},
|
},
|
||||||
"motor_api": {
|
"motor_api": {
|
||||||
"api_key": "API-nøkkel",
|
"api_key": "API-Nøgle",
|
||||||
"api_key_desc": "Motor API-nøkkelen bruges til å autentisere Motor API-integrationen",
|
"api_key_desc": "Motor API-nøglen bruges til at autentificere Motor API-integrationen",
|
||||||
"title": "Motor API-konfiguration"
|
"title": "Motor API-konfiguration"
|
||||||
},
|
},
|
||||||
"motorapi": {
|
"motorapi": {
|
||||||
@@ -1369,7 +1355,7 @@
|
|||||||
"title": "MotorAPI konfiguration"
|
"title": "MotorAPI konfiguration"
|
||||||
},
|
},
|
||||||
"products": {
|
"products": {
|
||||||
"add": "Legg til produkt",
|
"add": "Tilføj produkt",
|
||||||
"delete": "Slet produkt",
|
"delete": "Slet produkt",
|
||||||
"delete_confirm": "Er du sikker på, at du vil slette dette produkt?",
|
"delete_confirm": "Er du sikker på, at du vil slette dette produkt?",
|
||||||
"edit": "Rediger produkt",
|
"edit": "Rediger produkt",
|
||||||
@@ -1377,9 +1363,18 @@
|
|||||||
"subtitle": "Administrer produkter",
|
"subtitle": "Administrer produkter",
|
||||||
"title": "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": {
|
"shelly": {
|
||||||
"api_key": "API-nøkkel",
|
"api_key": "API-Nøgle",
|
||||||
"api_key_desc": "Shelly API-nøkkelen bruges til å autentisere Shelly-integrationen",
|
"api_key_desc": "Shelly API-nøglen bruges til at autentificere Shelly-integrationen",
|
||||||
"api_settings": "API forbindelsesindstillinger",
|
"api_settings": "API forbindelsesindstillinger",
|
||||||
"api_settings_desc": "Shelly API forbindelsesindstillingerne.",
|
"api_settings_desc": "Shelly API forbindelsesindstillingerne.",
|
||||||
"connection_failed_desc": "Forbindelsen til Shelly API fejlede!",
|
"connection_failed_desc": "Forbindelsen til Shelly API fejlede!",
|
||||||
@@ -1398,8 +1393,8 @@
|
|||||||
"title": "Shelly konfiguration"
|
"title": "Shelly konfiguration"
|
||||||
},
|
},
|
||||||
"stripe": {
|
"stripe": {
|
||||||
"api_key": "API-nøkkel",
|
"api_key": "API-Nøgle",
|
||||||
"api_key_desc": "Din Stripe API-nøkkel for betalingsbehandling",
|
"api_key_desc": "Din Stripe API-nøgle for betalingsbehandling",
|
||||||
"api_settings": "API forbindelsesindstillinger",
|
"api_settings": "API forbindelsesindstillinger",
|
||||||
"api_settings_desc": "Stripe API forbindelsesindstillingerne.",
|
"api_settings_desc": "Stripe API forbindelsesindstillingerne.",
|
||||||
"customers": "Stripe kunder",
|
"customers": "Stripe kunder",
|
||||||
@@ -1434,8 +1429,8 @@
|
|||||||
"title": "Brugere"
|
"title": "Brugere"
|
||||||
},
|
},
|
||||||
"virkdata": {
|
"virkdata": {
|
||||||
"api_key": "API-nøkkel",
|
"api_key": "API-Nøgle",
|
||||||
"api_key_desc": "Virk Data API-nøkkelen bruges til å autentisere Virk Data-integrationen",
|
"api_key_desc": "Virk Data API-nøglen bruges til at autentificere Virk Data-integrationen",
|
||||||
"api_settings": "API forbindelsesindstillinger",
|
"api_settings": "API forbindelsesindstillinger",
|
||||||
"api_settings_desc": "VirkData API forbindelsesindstillingerne.",
|
"api_settings_desc": "VirkData API forbindelsesindstillingerne.",
|
||||||
"company_selected": "Virksomhed valgt",
|
"company_selected": "Virksomhed valgt",
|
||||||
@@ -1458,8 +1453,8 @@
|
|||||||
"title": "VirkData konfiguration"
|
"title": "VirkData konfiguration"
|
||||||
},
|
},
|
||||||
"xlvask": {
|
"xlvask": {
|
||||||
"api_key": "API-nøkkel",
|
"api_key": "API-Nøgle",
|
||||||
"api_key_desc": "XL Vask API-nøkkelen bruges til å autentisere XL Vask-integrationen",
|
"api_key_desc": "XL Vask API-nøglen bruges til at autentificere XL Vask-integrationen",
|
||||||
"api_settings": "API forbindelsesindstillinger",
|
"api_settings": "API forbindelsesindstillinger",
|
||||||
"api_settings_desc": "XLVask API forbindelsesindstillingerne.",
|
"api_settings_desc": "XLVask API forbindelsesindstillingerne.",
|
||||||
"connection_failed": "Forbindelse fejlede",
|
"connection_failed": "Forbindelse fejlede",
|
||||||
@@ -2213,7 +2208,7 @@
|
|||||||
"list": "Liste",
|
"list": "Liste",
|
||||||
"subtitle": "Se hvordan dine afdelinger klarer sig",
|
"subtitle": "Se hvordan dine afdelinger klarer sig",
|
||||||
"title": "Overblik",
|
"title": "Overblik",
|
||||||
"charts": "Charts"
|
"charts": "Grafer"
|
||||||
},
|
},
|
||||||
"pos": {
|
"pos": {
|
||||||
"subtitle": "Registrer salg",
|
"subtitle": "Registrer salg",
|
||||||
@@ -2312,15 +2307,15 @@
|
|||||||
"country": "Land",
|
"country": "Land",
|
||||||
"email": "E-mail",
|
"email": "E-mail",
|
||||||
"latitude": "Breddegrad",
|
"latitude": "Breddegrad",
|
||||||
"longitude": "Lengdegrad",
|
"longitude": "Længdegrad",
|
||||||
"name": "Navn",
|
"name": "Navn",
|
||||||
"phone": "Telefon",
|
"phone": "Telefon",
|
||||||
"submit": "Lagre",
|
"submit": "Gem",
|
||||||
"zip": "Postnummer"
|
"zip": "Postnummer"
|
||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
"mailersend_api_key": "Mailersend API-nøkkel",
|
"mailersend_api_key": "Mailersend API-Nøgle",
|
||||||
"mailersend_api_key_desc": "Mailersend API-nøkkelen bruges til å autentisere Mailersend-integrationen",
|
"mailersend_api_key_desc": "Mailersend API-nøglen bruges til at autentificere Mailersend-integrationen",
|
||||||
"subtitle": "Konfigurer integrationer for afdelingen",
|
"subtitle": "Konfigurer integrationer for afdelingen",
|
||||||
"title": "Integrationer"
|
"title": "Integrationer"
|
||||||
},
|
},
|
||||||
@@ -2328,9 +2323,9 @@
|
|||||||
"new_department": "Ny afdeling",
|
"new_department": "Ny afdeling",
|
||||||
"no_departments": "Ingen afdelinger fundet",
|
"no_departments": "Ingen afdelinger fundet",
|
||||||
"opening_hours": {
|
"opening_hours": {
|
||||||
"add": "Legg til",
|
"add": "Tilføj",
|
||||||
"closed": "Stengt",
|
"closed": "Lukket",
|
||||||
"title": "Åpningstider"
|
"title": "Åbningstider"
|
||||||
},
|
},
|
||||||
"phone": "Telefon",
|
"phone": "Telefon",
|
||||||
"products": {
|
"products": {
|
||||||
@@ -2342,7 +2337,7 @@
|
|||||||
"tab": {
|
"tab": {
|
||||||
"info": "Info",
|
"info": "Info",
|
||||||
"integrations": "Integrationer",
|
"integrations": "Integrationer",
|
||||||
"opening_hours": "Åpningstider",
|
"opening_hours": "Åbningstider",
|
||||||
"product_addons": "Produkttilvalg",
|
"product_addons": "Produkttilvalg",
|
||||||
"wash_products": "Vaskeprodukter"
|
"wash_products": "Vaskeprodukter"
|
||||||
},
|
},
|
||||||
@@ -2363,7 +2358,7 @@
|
|||||||
"name": "Navn",
|
"name": "Navn",
|
||||||
"phone": "Telefon",
|
"phone": "Telefon",
|
||||||
"role": "Rolle",
|
"role": "Rolle",
|
||||||
"submit": "Lagre"
|
"submit": "Gem"
|
||||||
},
|
},
|
||||||
"name": "Navn",
|
"name": "Navn",
|
||||||
"new_employee": "Ny medarbejder",
|
"new_employee": "Ny medarbejder",
|
||||||
@@ -2657,17 +2652,17 @@
|
|||||||
"yes": "Ja"
|
"yes": "Ja"
|
||||||
},
|
},
|
||||||
"guest": {
|
"guest": {
|
||||||
"about": "Om oss",
|
"about": "Om os",
|
||||||
"access_code": "Tilgangskode",
|
"access_code": "Adgangskode",
|
||||||
"app_store": "App Store",
|
"app_store": "App Store",
|
||||||
"book_wash": "Book vask",
|
"book_wash": "Book vask",
|
||||||
"contact": "Kontakta oss",
|
"contact": "Kontakt os",
|
||||||
"continue": "Fortsett",
|
"continue": "Fortsæt",
|
||||||
"download_app": "Ladda ner app",
|
"download_app": "Download app",
|
||||||
"driver": {
|
"driver": {
|
||||||
"coming_soon": "Denne funktion er på vej!"
|
"coming_soon": "Denne funktion er på vej!"
|
||||||
},
|
},
|
||||||
"enter_access_code": "Skriv inn tilgangskode",
|
"enter_access_code": "Indtast adgangskode",
|
||||||
"find_wash": "Find vask",
|
"find_wash": "Find vask",
|
||||||
"home": {
|
"home": {
|
||||||
"contact_or_call": "eller ring til os på",
|
"contact_or_call": "eller ring til os på",
|
||||||
@@ -2676,12 +2671,12 @@
|
|||||||
"subtitle": "Velkommen til PLENO",
|
"subtitle": "Velkommen til PLENO",
|
||||||
"title": "Forside"
|
"title": "Forside"
|
||||||
},
|
},
|
||||||
"invalid_code": "Ugyldig tilgangskode",
|
"invalid_code": "Ugyldig adgangskode",
|
||||||
"play_store": "Google Play",
|
"play_store": "Google Play",
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"already_installed": "Pleno er allerede installeret på din enhed.",
|
"already_installed": "Pleno er allerede installeret på din enhed.",
|
||||||
"android_instructions": "Tryk på menuknappen og vælg \"Installer app\"",
|
"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.",
|
"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.",
|
"description": "Pleno er en Progressive Web App (PWA), der kan installeres på din enhed for en bedre oplevelse.",
|
||||||
"install": "Installer",
|
"install": "Installer",
|
||||||
@@ -2691,14 +2686,14 @@
|
|||||||
"instructions": "Instruktioner",
|
"instructions": "Instruktioner",
|
||||||
"ios_instructions": "Tryk på delingsknappen og vælg \"Tilføj til hjemmeskærm\"",
|
"ios_instructions": "Tryk på delingsknappen og vælg \"Tilføj til hjemmeskærm\"",
|
||||||
"subtitle": "Download appen på din enhed",
|
"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."
|
"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": "Start vask",
|
||||||
"start_wash_subtitle": "Skriv inn tilgangskoden for å fortsette",
|
"start_wash_subtitle": "Indtast adgangskoden for at fortsætte",
|
||||||
"start_wash_title": "Start vask som gjest",
|
"start_wash_title": "Start vask som gæst",
|
||||||
"title": "Gjesttilgang",
|
"title": "Gæsteadgang",
|
||||||
"welcome": "Velkommen",
|
"welcome": "Velkommen",
|
||||||
"welcome_message": "Velkommen til Truck Wash"
|
"welcome_message": "Velkommen til Truck Wash"
|
||||||
},
|
},
|
||||||
@@ -2795,6 +2790,13 @@
|
|||||||
"unknown_qr_text": "Den scannede QR kode er ikke gyldig for denne side.",
|
"unknown_qr_text": "Den scannede QR kode er ikke gyldig for denne side.",
|
||||||
"unknown_qr_title": "Ukendt QR kode"
|
"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": {
|
"messages": {
|
||||||
"are_you_sure": "Er du sikker?",
|
"are_you_sure": "Er du sikker?",
|
||||||
"cancelled": "Annulleret",
|
"cancelled": "Annulleret",
|
||||||
@@ -3145,7 +3147,7 @@
|
|||||||
"invoices": {
|
"invoices": {
|
||||||
"columns": {
|
"columns": {
|
||||||
"amount": "Beløb",
|
"amount": "Beløb",
|
||||||
"download": "Last ned",
|
"download": "Download",
|
||||||
"due_date": "Forfaldsdato",
|
"due_date": "Forfaldsdato",
|
||||||
"invoice_number": "Fakturanummer",
|
"invoice_number": "Fakturanummer",
|
||||||
"paid": "Betalt"
|
"paid": "Betalt"
|
||||||
@@ -3663,7 +3665,6 @@
|
|||||||
"loading_vehicle_types": "Indlæser tilgængelige køretøjstyper...",
|
"loading_vehicle_types": "Indlæser tilgængelige køretøjstyper...",
|
||||||
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
||||||
"machine": "Maskine",
|
"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_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_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.",
|
"vehicle_step_missing_registration": "Indtast registreringsnummer for at fortsætte.",
|
||||||
@@ -3759,7 +3760,7 @@
|
|||||||
"days": "dage",
|
"days": "dage",
|
||||||
"export": "Eksporter",
|
"export": "Eksporter",
|
||||||
"growth": "Vækst",
|
"growth": "Vækst",
|
||||||
"no_data": "Ingen data tilgjengelig",
|
"no_data": "Ingen data tilgængelige",
|
||||||
"none": "Ingen",
|
"none": "Ingen",
|
||||||
"orders": "Ordrer",
|
"orders": "Ordrer",
|
||||||
"overview": {
|
"overview": {
|
||||||
@@ -3863,6 +3864,7 @@
|
|||||||
"xlvask": "XLVask",
|
"xlvask": "XLVask",
|
||||||
"department_gates": "Afdelingsporte",
|
"department_gates": "Afdelingsporte",
|
||||||
"department_relays": "Afdelingsreleer",
|
"department_relays": "Afdelingsreleer",
|
||||||
|
"error_reports": "Fejlrapporter",
|
||||||
"selfserve": "Selvvask"
|
"selfserve": "Selvvask"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
@@ -4656,6 +4658,8 @@
|
|||||||
"sms_phone": "Telefonnummer til SMS notifikationer",
|
"sms_phone": "Telefonnummer til SMS notifikationer",
|
||||||
"sms_phone_desc": "Det telefonnummer, der modtager SMS notifikationer.",
|
"sms_phone_desc": "Det telefonnummer, der modtager SMS notifikationer.",
|
||||||
"sms_subtitle": "SMS",
|
"sms_subtitle": "SMS",
|
||||||
|
"superuser_notifications": "Superuser-notifikationer",
|
||||||
|
"superuser_notifications_desc": "Modtag e-mailnotifikationer, når en ny kunde registrerer sig på siden.",
|
||||||
"title": "Notifikationer"
|
"title": "Notifikationer"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
@@ -5192,7 +5196,7 @@
|
|||||||
"fixed_pricing": "Fastprisaftaler",
|
"fixed_pricing": "Fastprisaftaler",
|
||||||
"subscriptions": "Abonnementer",
|
"subscriptions": "Abonnementer",
|
||||||
"customer_prices": "Kundepriser",
|
"customer_prices": "Kundepriser",
|
||||||
"other": "Annen bokført inntekt",
|
"other": "Anden bogført indtægt",
|
||||||
"months": "Måneder i visning"
|
"months": "Måneder i visning"
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|||||||
+38
-34
@@ -859,24 +859,8 @@
|
|||||||
},
|
},
|
||||||
"day": "Tag",
|
"day": "Tag",
|
||||||
"dayHeaderFormat": "ddd D/M",
|
"dayHeaderFormat": "ddd D/M",
|
||||||
"dayNames": [
|
"dayNames": ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"],
|
||||||
"Sonntag",
|
"dayNamesShort": ["Søn", "Man", "Tir", "Mi", "Do", "Fre", "Lør"],
|
||||||
"Montag",
|
|
||||||
"Dienstag",
|
|
||||||
"Mittwoch",
|
|
||||||
"Donnerstag",
|
|
||||||
"Freitag",
|
|
||||||
"Samstag"
|
|
||||||
],
|
|
||||||
"dayNamesShort": [
|
|
||||||
"Søn",
|
|
||||||
"Man",
|
|
||||||
"Tir",
|
|
||||||
"Mi",
|
|
||||||
"Do",
|
|
||||||
"Fre",
|
|
||||||
"Lør"
|
|
||||||
],
|
|
||||||
"eventTimeFormat": "HH:mm",
|
"eventTimeFormat": "HH:mm",
|
||||||
"list": "Liste",
|
"list": "Liste",
|
||||||
"month": "Monat",
|
"month": "Monat",
|
||||||
@@ -894,20 +878,7 @@
|
|||||||
"November",
|
"November",
|
||||||
"Desember"
|
"Desember"
|
||||||
],
|
],
|
||||||
"monthNamesShort": [
|
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"],
|
||||||
"Jan",
|
|
||||||
"Feb",
|
|
||||||
"Mar",
|
|
||||||
"Apr",
|
|
||||||
"Mai",
|
|
||||||
"Jun",
|
|
||||||
"Jul",
|
|
||||||
"Aug",
|
|
||||||
"Sep",
|
|
||||||
"Okt",
|
|
||||||
"Nov",
|
|
||||||
"Des"
|
|
||||||
],
|
|
||||||
"next": "Neste",
|
"next": "Neste",
|
||||||
"prev": "Vorherige",
|
"prev": "Vorherige",
|
||||||
"slotLabelFormat": "HH:mm",
|
"slotLabelFormat": "HH:mm",
|
||||||
@@ -936,6 +907,21 @@
|
|||||||
"year": "Jahr"
|
"year": "Jahr"
|
||||||
},
|
},
|
||||||
"collected_invoice": {
|
"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": {
|
"economic": {
|
||||||
"booked_desc": "Die Rechnung ist in E-conomic gebucht, keine weiteren Aktionen sind erforderlich.",
|
"booked_desc": "Die Rechnung ist in E-conomic gebucht, keine weiteren Aktionen sind erforderlich.",
|
||||||
"booked_with_economic": "Rechnung mit E-conomic gebucht",
|
"booked_with_economic": "Rechnung mit E-conomic gebucht",
|
||||||
@@ -1377,6 +1363,15 @@
|
|||||||
"subtitle": "Produkte verwalten",
|
"subtitle": "Produkte verwalten",
|
||||||
"title": "Produkte"
|
"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": {
|
"shelly": {
|
||||||
"api_key": "API-Schl?ssel",
|
"api_key": "API-Schl?ssel",
|
||||||
"api_key_desc": "Der Shelly API-Schl?ssel wird zur Authentifizierung der Shelly-Integration verwendet",
|
"api_key_desc": "Der Shelly API-Schl?ssel wird zur Authentifizierung der Shelly-Integration verwendet",
|
||||||
@@ -2795,6 +2790,13 @@
|
|||||||
"unknown_qr_text": "Der gescannte QR-Code ist f?r diese Seite nicht g?ltig.",
|
"unknown_qr_text": "Der gescannte QR-Code ist f?r diese Seite nicht g?ltig.",
|
||||||
"unknown_qr_title": "Unbekannter QR-Code"
|
"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": {
|
"messages": {
|
||||||
"are_you_sure": "Sind Sie sicher?",
|
"are_you_sure": "Sind Sie sicher?",
|
||||||
"cancelled": "Erfolgreich storniert",
|
"cancelled": "Erfolgreich storniert",
|
||||||
@@ -3663,7 +3665,6 @@
|
|||||||
"loading_vehicle_types": "Verf?gbare Fahrzeugtypen werden geladen...",
|
"loading_vehicle_types": "Verf?gbare Fahrzeugtypen werden geladen...",
|
||||||
"lane_unavailable": "Waschspur nicht verfugbar",
|
"lane_unavailable": "Waschspur nicht verfugbar",
|
||||||
"machine": "Maschine",
|
"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_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_department": "Warten Sie, bis die nachste Abteilung geladen ist, bevor Sie fortfahren.",
|
||||||
"vehicle_step_missing_registration": "Geben Sie das Kennzeichen ein, um fortzufahren.",
|
"vehicle_step_missing_registration": "Geben Sie das Kennzeichen ein, um fortzufahren.",
|
||||||
@@ -3861,7 +3862,8 @@
|
|||||||
"vehicles": "Fahrzeuge",
|
"vehicles": "Fahrzeuge",
|
||||||
"xlvask": "XLVask",
|
"xlvask": "XLVask",
|
||||||
"department_gates": "Abteilungstore",
|
"department_gates": "Abteilungstore",
|
||||||
"department_relays": "Abteilungsrelais"
|
"department_relays": "Abteilungsrelais",
|
||||||
|
"error_reports": "Fehlerberichte"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -4642,6 +4644,8 @@
|
|||||||
"sms_phone": "Telefonnummer f?r SMS-Benachrichtigungen",
|
"sms_phone": "Telefonnummer f?r SMS-Benachrichtigungen",
|
||||||
"sms_phone_desc": "Die Telefonnummer, die SMS-Benachrichtigungen erh?lt.",
|
"sms_phone_desc": "Die Telefonnummer, die SMS-Benachrichtigungen erh?lt.",
|
||||||
"sms_subtitle": "SMS",
|
"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"
|
"title": "Benachrichtigungen"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
+38
-34
@@ -859,24 +859,8 @@
|
|||||||
},
|
},
|
||||||
"day": "Day",
|
"day": "Day",
|
||||||
"dayHeaderFormat": "ddd D/M",
|
"dayHeaderFormat": "ddd D/M",
|
||||||
"dayNames": [
|
"dayNames": ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
|
||||||
"Sunday",
|
"dayNamesShort": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
|
||||||
"Monday",
|
|
||||||
"Tuesday",
|
|
||||||
"Wednesday",
|
|
||||||
"Thursday",
|
|
||||||
"Friday",
|
|
||||||
"Saturday"
|
|
||||||
],
|
|
||||||
"dayNamesShort": [
|
|
||||||
"Sun",
|
|
||||||
"Mon",
|
|
||||||
"Tue",
|
|
||||||
"Wed",
|
|
||||||
"Thu",
|
|
||||||
"Fri",
|
|
||||||
"Sat"
|
|
||||||
],
|
|
||||||
"eventTimeFormat": "HH:mm",
|
"eventTimeFormat": "HH:mm",
|
||||||
"list": "List",
|
"list": "List",
|
||||||
"month": "Month",
|
"month": "Month",
|
||||||
@@ -894,20 +878,7 @@
|
|||||||
"November",
|
"November",
|
||||||
"December"
|
"December"
|
||||||
],
|
],
|
||||||
"monthNamesShort": [
|
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||||
"Jan",
|
|
||||||
"Feb",
|
|
||||||
"Mar",
|
|
||||||
"Apr",
|
|
||||||
"May",
|
|
||||||
"Jun",
|
|
||||||
"Jul",
|
|
||||||
"Aug",
|
|
||||||
"Sep",
|
|
||||||
"Oct",
|
|
||||||
"Nov",
|
|
||||||
"Dec"
|
|
||||||
],
|
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"prev": "Previous",
|
"prev": "Previous",
|
||||||
"slotLabelFormat": "HH:mm",
|
"slotLabelFormat": "HH:mm",
|
||||||
@@ -936,6 +907,21 @@
|
|||||||
"year": "Year"
|
"year": "Year"
|
||||||
},
|
},
|
||||||
"collected_invoice": {
|
"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": {
|
"economic": {
|
||||||
"booked_desc": "The invoice is booked in E-conomic, no further actions are required.",
|
"booked_desc": "The invoice is booked in E-conomic, no further actions are required.",
|
||||||
"booked_with_economic": "Invoice booked with E-conomic",
|
"booked_with_economic": "Invoice booked with E-conomic",
|
||||||
@@ -1377,6 +1363,15 @@
|
|||||||
"subtitle": "Manage products",
|
"subtitle": "Manage products",
|
||||||
"title": "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": {
|
"shelly": {
|
||||||
"api_key": "API-nyckel",
|
"api_key": "API-nyckel",
|
||||||
"api_key_desc": "The Shelly API key is used to authenticate the Shelly integration",
|
"api_key_desc": "The Shelly API key is used to authenticate the Shelly integration",
|
||||||
@@ -2795,6 +2790,13 @@
|
|||||||
"unknown_qr_text": "The scanned QR code is not valid for this page.",
|
"unknown_qr_text": "The scanned QR code is not valid for this page.",
|
||||||
"unknown_qr_title": "Unknown QR Code"
|
"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": {
|
"messages": {
|
||||||
"are_you_sure": "Are you sure?",
|
"are_you_sure": "Are you sure?",
|
||||||
"cancelled": "Canceled successfully",
|
"cancelled": "Canceled successfully",
|
||||||
@@ -3662,7 +3664,6 @@
|
|||||||
"loading_vehicle_types": "Loading available vehicle types...",
|
"loading_vehicle_types": "Loading available vehicle types...",
|
||||||
"lane_unavailable": "Lane unavailable",
|
"lane_unavailable": "Lane unavailable",
|
||||||
"machine": "Machine",
|
"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_customer_number": "Enter your customer number to continue.",
|
||||||
"vehicle_step_missing_department": "Wait for the nearest department to load before continuing.",
|
"vehicle_step_missing_department": "Wait for the nearest department to load before continuing.",
|
||||||
"vehicle_step_missing_registration": "Enter your registration number to continue.",
|
"vehicle_step_missing_registration": "Enter your registration number to continue.",
|
||||||
@@ -3861,7 +3862,8 @@
|
|||||||
"vehicles": "Vehicles",
|
"vehicles": "Vehicles",
|
||||||
"xlvask": "XLVask",
|
"xlvask": "XLVask",
|
||||||
"department_gates": "Department Gates",
|
"department_gates": "Department Gates",
|
||||||
"department_relays": "Department Relays"
|
"department_relays": "Department Relays",
|
||||||
|
"error_reports": "Error reports"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -4654,6 +4656,8 @@
|
|||||||
"sms_phone": "Phone number for SMS notifications",
|
"sms_phone": "Phone number for SMS notifications",
|
||||||
"sms_phone_desc": "The phone number that receives SMS notifications.",
|
"sms_phone_desc": "The phone number that receives SMS notifications.",
|
||||||
"sms_subtitle": "SMS",
|
"sms_subtitle": "SMS",
|
||||||
|
"superuser_notifications": "Superuser notifications",
|
||||||
|
"superuser_notifications_desc": "Receive email notifications when a new customer registers on the site.",
|
||||||
"title": "Notifications"
|
"title": "Notifications"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
+38
-34
@@ -859,24 +859,8 @@
|
|||||||
},
|
},
|
||||||
"day": "Dag",
|
"day": "Dag",
|
||||||
"dayHeaderFormat": "ddd D/M",
|
"dayHeaderFormat": "ddd D/M",
|
||||||
"dayNames": [
|
"dayNames": ["Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag"],
|
||||||
"Søndag",
|
"dayNamesShort": ["Søn", "Mann", "Tir", "Ons", "Tor", "Fre", "Lør"],
|
||||||
"Mandag",
|
|
||||||
"Tirsdag",
|
|
||||||
"Onsdag",
|
|
||||||
"Torsdag",
|
|
||||||
"Fredag",
|
|
||||||
"Lørdag"
|
|
||||||
],
|
|
||||||
"dayNamesShort": [
|
|
||||||
"Søn",
|
|
||||||
"Mann",
|
|
||||||
"Tir",
|
|
||||||
"Ons",
|
|
||||||
"Tor",
|
|
||||||
"Fre",
|
|
||||||
"Lør"
|
|
||||||
],
|
|
||||||
"eventTimeFormat": "HH:mm",
|
"eventTimeFormat": "HH:mm",
|
||||||
"list": "Liste",
|
"list": "Liste",
|
||||||
"month": "Måned",
|
"month": "Måned",
|
||||||
@@ -894,20 +878,7 @@
|
|||||||
"november",
|
"november",
|
||||||
"desember"
|
"desember"
|
||||||
],
|
],
|
||||||
"monthNamesShort": [
|
"monthNamesShort": ["Jan", "feb", "Mar", "apr", "mai", "jun", "jul", "august", "sep", "Okt", "nov", "Av"],
|
||||||
"Jan",
|
|
||||||
"feb",
|
|
||||||
"Mar",
|
|
||||||
"apr",
|
|
||||||
"mai",
|
|
||||||
"jun",
|
|
||||||
"jul",
|
|
||||||
"august",
|
|
||||||
"sep",
|
|
||||||
"Okt",
|
|
||||||
"nov",
|
|
||||||
"Av"
|
|
||||||
],
|
|
||||||
"next": "I dette",
|
"next": "I dette",
|
||||||
"prev": "Forrige",
|
"prev": "Forrige",
|
||||||
"slotLabelFormat": "HH:mm",
|
"slotLabelFormat": "HH:mm",
|
||||||
@@ -936,6 +907,21 @@
|
|||||||
"year": "År"
|
"year": "År"
|
||||||
},
|
},
|
||||||
"collected_invoice": {
|
"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": {
|
"economic": {
|
||||||
"booked_desc": "Fakturaen er bokført i E-conomic, ingen ytterligere handlinger er nødvendig.",
|
"booked_desc": "Fakturaen er bokført i E-conomic, ingen ytterligere handlinger er nødvendig.",
|
||||||
"booked_with_economic": "Faktura bestilles hos E-conomic",
|
"booked_with_economic": "Faktura bestilles hos E-conomic",
|
||||||
@@ -1377,6 +1363,15 @@
|
|||||||
"subtitle": "Administrer produkter",
|
"subtitle": "Administrer produkter",
|
||||||
"title": "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": {
|
"shelly": {
|
||||||
"api_key": "API-nøkkel",
|
"api_key": "API-nøkkel",
|
||||||
"api_key_desc": "Shelly API-nøkkelen brukes til å autentisere Shelly-integrasjonen",
|
"api_key_desc": "Shelly API-nøkkelen brukes til å autentisere Shelly-integrasjonen",
|
||||||
@@ -2795,6 +2790,13 @@
|
|||||||
"unknown_qr_text": "Den skannede QR-koden er ikke gyldig for denne siden.",
|
"unknown_qr_text": "Den skannede QR-koden er ikke gyldig for denne siden.",
|
||||||
"unknown_qr_title": "Ukjent QR-kode"
|
"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": {
|
"messages": {
|
||||||
"are_you_sure": "er du sikker?",
|
"are_you_sure": "er du sikker?",
|
||||||
"cancelled": "Avbrutt",
|
"cancelled": "Avbrutt",
|
||||||
@@ -3663,7 +3665,6 @@
|
|||||||
"loading_vehicle_types": "Laster inn tilgjengelige kjøretøytyper...",
|
"loading_vehicle_types": "Laster inn tilgjengelige kjøretøytyper...",
|
||||||
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
|
||||||
"machine": "Maskin",
|
"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_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_department": "Vent til naermeste avdeling er lastet inn for du fortsetter.",
|
||||||
"vehicle_step_missing_registration": "Skriv inn registreringsnummer for a fortsette.",
|
"vehicle_step_missing_registration": "Skriv inn registreringsnummer for a fortsette.",
|
||||||
@@ -3861,7 +3862,8 @@
|
|||||||
"vehicles": "Kjøretøy",
|
"vehicles": "Kjøretøy",
|
||||||
"xlvask": "XLVask",
|
"xlvask": "XLVask",
|
||||||
"department_gates": "Avdelingsporter",
|
"department_gates": "Avdelingsporter",
|
||||||
"department_relays": "Avdelingsreleer"
|
"department_relays": "Avdelingsreleer",
|
||||||
|
"error_reports": "Feilrapporter"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -4642,6 +4644,8 @@
|
|||||||
"sms_phone": "Telefonnummer for SMS-varsler",
|
"sms_phone": "Telefonnummer for SMS-varsler",
|
||||||
"sms_phone_desc": "Telefonnummeret som mottar SMS-varsler.",
|
"sms_phone_desc": "Telefonnummeret som mottar SMS-varsler.",
|
||||||
"sms_subtitle": "SMS",
|
"sms_subtitle": "SMS",
|
||||||
|
"superuser_notifications": "Superbrukervarsler",
|
||||||
|
"superuser_notifications_desc": "Motta e-postvarsler når en ny kunde registrerer seg på siden.",
|
||||||
"title": "Varsler"
|
"title": "Varsler"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
+38
-34
@@ -859,24 +859,8 @@
|
|||||||
},
|
},
|
||||||
"day": "Dag",
|
"day": "Dag",
|
||||||
"dayHeaderFormat": "ddd D/M",
|
"dayHeaderFormat": "ddd D/M",
|
||||||
"dayNames": [
|
"dayNames": ["Söndag", "Måndag", "Tisdag", "Onsdag", "Torsdag", "Fredag", "Lördag"],
|
||||||
"Söndag",
|
"dayNamesShort": ["Sön", "Man", "Tir", "Ons", "Tor", "Fre", "Lör"],
|
||||||
"Måndag",
|
|
||||||
"Tisdag",
|
|
||||||
"Onsdag",
|
|
||||||
"Torsdag",
|
|
||||||
"Fredag",
|
|
||||||
"Lördag"
|
|
||||||
],
|
|
||||||
"dayNamesShort": [
|
|
||||||
"Sön",
|
|
||||||
"Man",
|
|
||||||
"Tir",
|
|
||||||
"Ons",
|
|
||||||
"Tor",
|
|
||||||
"Fre",
|
|
||||||
"Lör"
|
|
||||||
],
|
|
||||||
"eventTimeFormat": "HH:mm",
|
"eventTimeFormat": "HH:mm",
|
||||||
"list": "Liste",
|
"list": "Liste",
|
||||||
"month": "Månad",
|
"month": "Månad",
|
||||||
@@ -894,20 +878,7 @@
|
|||||||
"November",
|
"November",
|
||||||
"Desember"
|
"Desember"
|
||||||
],
|
],
|
||||||
"monthNamesShort": [
|
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"],
|
||||||
"Jan",
|
|
||||||
"Feb",
|
|
||||||
"Mar",
|
|
||||||
"Apr",
|
|
||||||
"Mai",
|
|
||||||
"Jun",
|
|
||||||
"Jul",
|
|
||||||
"Aug",
|
|
||||||
"Sep",
|
|
||||||
"Okt",
|
|
||||||
"Nov",
|
|
||||||
"Des"
|
|
||||||
],
|
|
||||||
"next": "Neste",
|
"next": "Neste",
|
||||||
"prev": "Föregående",
|
"prev": "Föregående",
|
||||||
"slotLabelFormat": "HH:mm",
|
"slotLabelFormat": "HH:mm",
|
||||||
@@ -936,6 +907,21 @@
|
|||||||
"year": "År"
|
"year": "År"
|
||||||
},
|
},
|
||||||
"collected_invoice": {
|
"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": {
|
"economic": {
|
||||||
"booked_desc": "Fakturan är bokförd i E-conomic, inga ytterligare åtgärder krävs.",
|
"booked_desc": "Fakturan är bokförd i E-conomic, inga ytterligare åtgärder krävs.",
|
||||||
"booked_with_economic": "Faktura bokförd i E-conomic",
|
"booked_with_economic": "Faktura bokförd i E-conomic",
|
||||||
@@ -1377,6 +1363,15 @@
|
|||||||
"subtitle": "Administrer produkter",
|
"subtitle": "Administrer produkter",
|
||||||
"title": "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": {
|
"shelly": {
|
||||||
"api_key": "API-nyckel",
|
"api_key": "API-nyckel",
|
||||||
"api_key_desc": "Shelly API-nyckeln används för att autentisera Shelly-integrationen",
|
"api_key_desc": "Shelly API-nyckeln används för att autentisera Shelly-integrationen",
|
||||||
@@ -2795,6 +2790,13 @@
|
|||||||
"unknown_qr_text": "The scanned QR code is not valid for this page.",
|
"unknown_qr_text": "The scanned QR code is not valid for this page.",
|
||||||
"unknown_qr_title": "Okänd QR-kod"
|
"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": {
|
"messages": {
|
||||||
"are_you_sure": "Är du säker?",
|
"are_you_sure": "Är du säker?",
|
||||||
"cancelled": "Avbruten framgångsrikt",
|
"cancelled": "Avbruten framgångsrikt",
|
||||||
@@ -3663,7 +3665,6 @@
|
|||||||
"loading_vehicle_types": "Läser in tillgängliga fordonstyper...",
|
"loading_vehicle_types": "Läser in tillgängliga fordonstyper...",
|
||||||
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
"lane_unavailable": "Tvattbanan ar inte tillganglig",
|
||||||
"machine": "Machine",
|
"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_customer_number": "Ange kundnummer for att fortsatta.",
|
||||||
"vehicle_step_missing_department": "Vanta tills narmaste avdelning har laddats innan du fortsatter.",
|
"vehicle_step_missing_department": "Vanta tills narmaste avdelning har laddats innan du fortsatter.",
|
||||||
"vehicle_step_missing_registration": "Ange registreringsnummer for att fortsatta.",
|
"vehicle_step_missing_registration": "Ange registreringsnummer for att fortsatta.",
|
||||||
@@ -3861,7 +3862,8 @@
|
|||||||
"vehicles": "Fordon",
|
"vehicles": "Fordon",
|
||||||
"xlvask": "XLVask",
|
"xlvask": "XLVask",
|
||||||
"department_gates": "Avdelningsgrindar",
|
"department_gates": "Avdelningsgrindar",
|
||||||
"department_relays": "Avdelningsreläer"
|
"department_relays": "Avdelningsreläer",
|
||||||
|
"error_reports": "Felrapporter"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -4642,6 +4644,8 @@
|
|||||||
"sms_phone": "Telefonnummer för SMS-aviseringar",
|
"sms_phone": "Telefonnummer för SMS-aviseringar",
|
||||||
"sms_phone_desc": "Telefonnumret som för SMS-aviseringar.",
|
"sms_phone_desc": "Telefonnumret som för SMS-aviseringar.",
|
||||||
"sms_subtitle": "SMS",
|
"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"
|
"title": "Aviseringar"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
{
|
{
|
||||||
"compat": {
|
"compat": {
|
||||||
"collected_invoice": {
|
"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": {
|
"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_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'}",
|
"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,15 @@
|
|||||||
"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'}?",
|
"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'}"
|
"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.",
|
||||||
|
"subtitle": "Konfiguration af Slack-notifikationer",
|
||||||
|
"title": "Slack-konfiguration",
|
||||||
|
"unavailable": "Slack-konfigurationen er ikke tilgængelig i denne API-udgivelse."
|
||||||
|
},
|
||||||
"shelly": {
|
"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_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'}.",
|
"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'}...",
|
"loading_vehicle_types": "@.capitalize:{'terms.glossary.indlæser'} @:{'terms.glossary.tilgængelige'} @:{'terms.glossary.køretøjstyper'}...",
|
||||||
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
|
||||||
"machine": "@.capitalize:{'terms.glossary.maskine'}",
|
"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'}",
|
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
|
||||||
"occupied": "Optaget",
|
"occupied": "Optaget",
|
||||||
"open_property_access_gate": "@:{'terms.glossary.abn'} adgangsport",
|
"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'}.",
|
"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_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": "@.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": {
|
"security": {
|
||||||
"description": "@.capitalize:{'terms.glossary.dine'} sikkerhedsindstillinger",
|
"description": "@.capitalize:{'terms.glossary.dine'} sikkerhedsindstillinger",
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
{
|
{
|
||||||
"compat": {
|
"compat": {
|
||||||
"collected_invoice": {
|
"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": {
|
"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_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'}",
|
"booked_with_economic": "@:{'terms.glossary.rechnung'} @:{'terms.glossary.mit'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.gebucht'}",
|
||||||
|
|||||||
@@ -158,6 +158,15 @@
|
|||||||
"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'}?",
|
"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'}"
|
"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.",
|
||||||
|
"subtitle": "Konfiguration von Slack-Benachrichtigungen",
|
||||||
|
"title": "Slack-Konfiguration",
|
||||||
|
"unavailable": "Die Slack-Konfiguration ist in dieser API-Version nicht verfuegbar."
|
||||||
|
},
|
||||||
"shelly": {
|
"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_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'}.",
|
"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'}...",
|
"loading_vehicle_types": "@.capitalize:{'terms.glossary.verf'}?@:{'terms.glossary.gbare'} @:{'terms.glossary.fahrzeugtypen'} @:{'terms.glossary.werden'} @:{'terms.glossary.geladen'}...",
|
||||||
"lane_unavailable": "Waschspur nicht verfugbar",
|
"lane_unavailable": "Waschspur nicht verfugbar",
|
||||||
"machine": "@:{'terms.glossary.maschine'}",
|
"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'}",
|
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
|
||||||
"occupied": "Belegt",
|
"occupied": "Belegt",
|
||||||
"open_property_access_gate": "Grundstueckszugangstor @:{'terms.glossary.oeffnen'}",
|
"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'}.",
|
"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_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": "@:{'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": {
|
"security": {
|
||||||
"description": "@.capitalize:{'terms.glossary.ihre'} Sicherheitseinstellungen",
|
"description": "@.capitalize:{'terms.glossary.ihre'} Sicherheitseinstellungen",
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
{
|
{
|
||||||
"compat": {
|
"compat": {
|
||||||
"collected_invoice": {
|
"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": {
|
"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_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'}",
|
"booked_with_economic": "@.capitalize:{'terms.glossary.invoice'} @:{'terms.glossary.booked'} @:{'terms.glossary.with'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'}",
|
||||||
|
|||||||
@@ -158,6 +158,15 @@
|
|||||||
"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'}?",
|
"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'}"
|
"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.",
|
||||||
|
"subtitle": "Configuration of Slack notifications",
|
||||||
|
"title": "Slack configuration",
|
||||||
|
"unavailable": "Slack configuration is not available on this API release."
|
||||||
|
},
|
||||||
"shelly": {
|
"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_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'}.",
|
"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'}...",
|
"loading_vehicle_types": "@.capitalize:{'terms.glossary.loading'} @:{'terms.glossary.available'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.types'}...",
|
||||||
"lane_unavailable": "Lane unavailable",
|
"lane_unavailable": "Lane unavailable",
|
||||||
"machine": "@.capitalize:{'terms.glossary.machine'}",
|
"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'}",
|
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
|
||||||
"occupied": "Occupied",
|
"occupied": "Occupied",
|
||||||
"open_property_access_gate": "@.capitalize:{'terms.glossary.open'} @:{'terms.glossary.property'} @:{'terms.glossary.access'} @:{'terms.glossary.gate'}",
|
"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'}.",
|
"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_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": "@.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": {
|
"security": {
|
||||||
"description": "@.capitalize:{'terms.glossary.your'} @:{'terms.glossary.security'} @:{'terms.glossary.settings'}",
|
"description": "@.capitalize:{'terms.glossary.your'} @:{'terms.glossary.security'} @:{'terms.glossary.settings'}",
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
{
|
{
|
||||||
"collected_invoice": {
|
"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": {
|
"economic": {
|
||||||
"booked_desc": "@:{'phrases.compat.collected_invoice.economic.booked_desc'}",
|
"booked_desc": "@:{'phrases.compat.collected_invoice.economic.booked_desc'}",
|
||||||
"booked_with_economic": "@:{'phrases.compat.collected_invoice.economic.booked_with_economic'}",
|
"booked_with_economic": "@:{'phrases.compat.collected_invoice.economic.booked_with_economic'}",
|
||||||
|
|||||||
@@ -184,6 +184,15 @@
|
|||||||
"subtitle": "@:products.subtitle",
|
"subtitle": "@:products.subtitle",
|
||||||
"title": "@:common.products"
|
"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": {
|
"shelly": {
|
||||||
"api_key": "@:configuration.limble.api_key",
|
"api_key": "@:configuration.limble.api_key",
|
||||||
"api_key_desc": "@:{'phrases.compat.configuration.shelly.api_key_desc'}",
|
"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'}",
|
"loading_vehicle_types": "@:{'phrases.compat.self_wash.loading_vehicle_types'}",
|
||||||
"lane_unavailable": "@:{'phrases.compat.self_wash.lane_unavailable'}",
|
"lane_unavailable": "@:{'phrases.compat.self_wash.lane_unavailable'}",
|
||||||
"machine": "@:{'phrases.compat.self_wash.machine'}",
|
"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_departments_found": "@:{'phrases.compat.self_wash.no_departments_found'}",
|
||||||
"no_vehicles_found": "@:vehicles.no_vehicles",
|
"no_vehicles_found": "@:vehicles.no_vehicles",
|
||||||
"occupied": "@:{'phrases.compat.self_wash.occupied'}",
|
"occupied": "@:{'phrases.compat.self_wash.occupied'}",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user