Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fdbd22879 | ||
|
|
0392672a2d | ||
|
|
3d229a2de2 | ||
|
|
7e090ab405 | ||
|
|
b8c3758714 | ||
|
|
298fc0e04b | ||
|
|
1c1bffaee4 | ||
|
|
14506e545a | ||
|
|
89dff75416 | ||
|
|
8a790582f4 | ||
|
|
e6aff8f512 | ||
|
|
1c277d1944 | ||
|
|
26cc166282 | ||
|
|
30e878979c | ||
|
|
0f335e0984 | ||
|
|
4083c05b72 | ||
|
|
7b4d24a212 | ||
|
|
02c31c8bb0 | ||
|
|
6c0278c6e9 | ||
|
|
41e0320ce1 | ||
|
|
d97b02bace | ||
|
|
11bbe953f3 | ||
|
|
ecdd895a5a | ||
|
|
07bb815754 | ||
|
|
2a64fcdf19 | ||
|
|
f8b7bda74a | ||
|
|
5a0fc5f36b | ||
|
|
9e378b32ac | ||
|
|
968587d847 | ||
|
|
fa809ccd0b | ||
|
|
a8800a3be1 | ||
|
|
1f23b58d1e | ||
|
|
c6b266b0de | ||
|
|
3eba32c235 | ||
|
|
ec76ae426e | ||
|
|
e35d59c652 | ||
|
|
73fc89557b | ||
|
|
be98715045 | ||
|
|
4392b7915f | ||
|
|
b1fb92549c | ||
|
|
9bba666f3a | ||
|
|
dcf0fd61ba | ||
|
|
3d02146911 | ||
|
|
10bc822233 | ||
|
|
baac1a243a | ||
|
|
7c233d7c03 | ||
|
|
7c19dbddf0 | ||
|
|
f4b248573a | ||
|
|
14e454e9d9 | ||
|
|
ebab7d8804 | ||
|
|
78a3fb1879 | ||
|
|
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 | ||
|
|
5170139a32 | ||
|
|
90952a68b7 | ||
|
|
1cc14d7b41 | ||
|
|
aa4d6576f7 | ||
|
|
2a46984f9c | ||
|
|
4a9c721c55 | ||
|
|
7be9995c87 | ||
|
|
85529de3f8 | ||
|
|
114ca9d956 | ||
|
|
29f858685a | ||
|
|
4112faf682 | ||
|
|
a13b73c414 | ||
|
|
667e93af8f | ||
|
|
54a888026e | ||
|
|
a0fb02baa1 | ||
|
|
c445b932db | ||
|
|
ffccb1683b | ||
|
|
0d590fc9d6 | ||
|
|
1d4910f947 | ||
|
|
7e457ed961 | ||
|
|
34722c6985 | ||
|
|
c5a13b50ab | ||
|
|
387bd50281 | ||
|
|
81016fd898 | ||
|
|
39fc1612ac | ||
|
|
7ab12a3fff | ||
|
|
b4d869c469 | ||
|
|
799b667828 | ||
|
|
ae3513edf3 | ||
|
|
6db1861b78 | ||
|
|
a8e839ba18 | ||
|
|
b224cf67d1 | ||
|
|
222237ab34 | ||
|
|
433cfe521e | ||
|
|
1e4a776fa5 | ||
|
|
e88a424a7d | ||
|
|
9450c7902d | ||
|
|
75b5883afe | ||
|
|
c26f0184d9 | ||
|
|
cb74b4bbe7 | ||
|
|
326eca656f | ||
|
|
f0a5058b68 | ||
|
|
d39cd36995 | ||
|
|
0548018b5c | ||
|
|
699e10856e | ||
|
|
36b399cb2f | ||
|
|
f613d58727 | ||
|
|
feccfada77 | ||
|
|
4127e66114 | ||
|
|
b2a9b59399 | ||
|
|
6ffed9c2f2 | ||
|
|
53e193d8d1 | ||
|
|
7835691bc9 | ||
|
|
91715541ec | ||
|
|
e3f9e99129 | ||
|
|
4634cbad89 | ||
|
|
5db2a36da0 | ||
|
|
cd7b273da4 | ||
|
|
cbbad1a6e2 | ||
|
|
9661d4efa7 | ||
|
|
31762c23c5 | ||
|
|
c71a6b4d14 | ||
|
|
e8ddb33f21 | ||
|
|
ea368d19bb | ||
|
|
7ab50b6290 | ||
|
|
fd7cfbd3c3 | ||
|
|
90725af10a | ||
|
|
d63a62a921 | ||
|
|
4bc5481941 | ||
|
|
9feede3b0d | ||
|
|
5aad9eda8d | ||
|
|
91b15faba6 | ||
|
|
98c1c61cbd | ||
|
|
4463a5326a | ||
|
|
bdb23513a4 | ||
|
|
ee40f16232 | ||
|
|
a76944b00c | ||
|
|
cf074ff893 | ||
|
|
712eb19db5 | ||
|
|
de9bc9b170 | ||
|
|
95e2404307 | ||
|
|
2c01feb3be | ||
|
|
f416e0d462 | ||
|
|
7f1308c8ce | ||
|
|
fd8d45e437 | ||
|
|
20390342d5 | ||
|
|
c47bf229f0 | ||
|
|
3616e197e1 | ||
|
|
3d8544de21 | ||
|
|
504abb7a15 | ||
|
|
37021533b9 | ||
|
|
59eab84f03 | ||
|
|
e67033711c | ||
|
|
85fd00f020 | ||
|
|
4f064a1625 | ||
|
|
3bf7ada226 | ||
|
|
3b90e17a1e | ||
|
|
c1fb5b72ee | ||
|
|
29da26cc08 | ||
|
|
807fc09b1e | ||
|
|
528e4994db | ||
|
|
c339122f4f | ||
|
|
feeef46354 | ||
|
|
066bd14554 | ||
|
|
bffaa2ab88 | ||
|
|
31495ea67b | ||
|
|
8f2533f1b2 | ||
|
|
13b0742deb | ||
|
|
e2b2d50e5e | ||
|
|
55d61e37f3 | ||
|
|
fa19f5a704 | ||
|
|
b995031255 | ||
|
|
d096444ca0 | ||
|
|
10358d4f53 | ||
|
|
78f41b8380 | ||
|
|
90d4bc7c04 | ||
|
|
b59c17f120 | ||
|
|
c279e7fd38 | ||
|
|
8a4fcb3e62 | ||
|
|
680ef7088b | ||
|
|
83935af973 | ||
|
|
37973f8fcb | ||
|
|
acf19c281a | ||
|
|
683fce32da | ||
|
|
4c9ba7c30d | ||
|
|
ca1f3ab477 |
@@ -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: ubuntu-latest
|
||||
|
||||
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]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upload-qodana-config:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -20,9 +24,9 @@ jobs:
|
||||
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v $(pwd):/workspace \
|
||||
-v "$(pwd):/workspace" \
|
||||
-w /workspace \
|
||||
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
|
||||
jetbrains/qodana-configuration-uploader:latest \
|
||||
-e QODANA_CONFIGURATIONS_TOKEN \
|
||||
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \
|
||||
--global-configs-file qodana-global-configurations.yaml \
|
||||
--qodana-host https://qodana.cloud
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
name: Frontend Release
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Automated Tests
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
group: frontend-release-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-upload-and-verify:
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
env:
|
||||
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||
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_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_EXPECTED_COMMIT: ${{ github.sha }}
|
||||
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
RELEASE_WAIT_INITIAL_SECONDS: 45
|
||||
RELEASE_WAIT_TIMEOUT_SECONDS: 600
|
||||
RELEASE_POLL_INTERVAL_SECONDS: 10
|
||||
@@ -32,54 +38,114 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
|
||||
- name: Check release commit is current
|
||||
id: branch-head
|
||||
run: |
|
||||
latest_sha="$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | awk '{print $1}')"
|
||||
if [[ -z "$latest_sha" ]]; then
|
||||
echo "Could not resolve origin/$RELEASE_BRANCH." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$latest_sha" != "$RELEASE_EXPECTED_COMMIT" ]]; then
|
||||
echo "current=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping stale release for $RELEASE_EXPECTED_COMMIT; origin/$RELEASE_BRANCH is $latest_sha."
|
||||
exit 0
|
||||
fi
|
||||
echo "current=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Release commit is current for $RELEASE_BRANCH."
|
||||
env:
|
||||
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Check AI workflow sync
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: node scripts/sync-ai-workflow.mjs --check
|
||||
|
||||
- name: Source and i18n checks
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: |
|
||||
npm run text:check-encoding
|
||||
npm run i18n:v2:source-check
|
||||
|
||||
- name: Unit tests
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: npm run test:unit
|
||||
env:
|
||||
VITEST_BATCH_SIZE: 5
|
||||
|
||||
- name: Build release artifact
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: npm run build
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: node scripts/install-playwright-browsers.mjs chromium
|
||||
|
||||
- name: Production Playwright gate
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: npm run test:e2e:prod
|
||||
|
||||
- name: Upload dist artifact
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
|
||||
path: dist
|
||||
retention-days: 14
|
||||
retention-days: 3
|
||||
|
||||
- name: Request Release Manager auto sync
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: |
|
||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||
response_file="$(mktemp)"
|
||||
status_code="$(curl --show-error --silent \
|
||||
--output "$response_file" \
|
||||
--write-out "%{http_code}" \
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
|
||||
if [[ "$status_code" =~ ^2 ]]; then
|
||||
cat "$response_file"
|
||||
elif [[ "$status_code" == "504" ]]; then
|
||||
echo "Release Manager auto sync request reached the gateway timeout; continuing to artifact wait."
|
||||
else
|
||||
cat "$response_file" >&2
|
||||
echo "Release Manager auto sync request failed with HTTP $status_code." >&2
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
- name: Wait for Coolify release artifact
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: npm run release:verify-upload
|
||||
|
||||
- name: Public live Playwright gate
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: npm run test:e2e:live:public
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
|
||||
- name: Credentialed live Playwright gate
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: npm run test:e2e:live:roles
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
@@ -92,31 +158,35 @@ jobs:
|
||||
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
||||
|
||||
- name: Record Release Manager gate
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: |
|
||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||
release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}"
|
||||
curl --fail --show-error --silent \
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
|
||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
|
||||
env:
|
||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_BRANCH: ${{ github.ref_name }}
|
||||
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
- name: Update server version after verification
|
||||
if: steps.branch-head.outputs.current == 'true'
|
||||
run: npm run release:update-server-version
|
||||
env:
|
||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||
RELEASE_VERSION: ${{ github.sha }}
|
||||
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
if: failure() && steps.branch-head.outputs.current == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
|
||||
path: output/playwright
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
retention-days: 3
|
||||
|
||||
+261
-34
@@ -3,6 +3,8 @@ name: Automated Tests
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
@@ -11,14 +13,29 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.ref }}
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
format-tests:
|
||||
# Match the labels exposed by the Coolify-managed GitHub runner.
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
# CI runs on the repository's self-hosted runner pool.
|
||||
runs-on: [self-hosted, Linux, X64, pleno, frontend]
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Repair self-hosted workspace permissions
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
|
||||
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
|
||||
if [[ -n "$foreign_entry" ]]; then
|
||||
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
|
||||
rm -rf "$trash" 2>/dev/null || true
|
||||
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
|
||||
mkdir -p "$GITHUB_WORKSPACE"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
@@ -26,7 +43,6 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Check AI workflow sync
|
||||
run: node scripts/sync-ai-workflow.mjs --check
|
||||
@@ -39,8 +55,23 @@ jobs:
|
||||
|
||||
build-and-unit:
|
||||
needs: format-tests
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: [self-hosted, Linux, X64, pleno, frontend]
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Repair self-hosted workspace permissions
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
|
||||
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
|
||||
if [[ -n "$foreign_entry" ]]; then
|
||||
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
|
||||
rm -rf "$trash" 2>/dev/null || true
|
||||
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
|
||||
mkdir -p "$GITHUB_WORKSPACE"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
@@ -48,11 +79,16 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Check i18n source consistency
|
||||
run: npm run i18n:v2:check
|
||||
|
||||
- name: Build sanity check
|
||||
run: npm run build
|
||||
|
||||
@@ -64,8 +100,33 @@ jobs:
|
||||
e2e-pr:
|
||||
if: github.event_name != 'schedule'
|
||||
needs: build-and-unit
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
suite: [core, changed]
|
||||
project: [chromium-desktop, chromium-mobile]
|
||||
env:
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||
PLAYWRIGHT_REPORTER_MODE: line-html
|
||||
steps:
|
||||
- name: Repair self-hosted workspace permissions
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
|
||||
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
|
||||
if [[ -n "$foreign_entry" ]]; then
|
||||
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
|
||||
rm -rf "$trash" 2>/dev/null || true
|
||||
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
|
||||
mkdir -p "$GITHUB_WORKSPACE"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
@@ -98,33 +159,112 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright PR tests
|
||||
run: npm run test:e2e:pr -- --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
|
||||
- name: Run Playwright PR suite in container
|
||||
shell: bash
|
||||
env:
|
||||
DIFF_BASE_REF: ${{ steps.playwright-diff.outputs.base }}
|
||||
DIFF_HEAD_REF: ${{ steps.playwright-diff.outputs.head }}
|
||||
MATRIX_SUITE: ${{ matrix.suite }}
|
||||
MATRIX_PROJECT: ${{ matrix.project }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
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
|
||||
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
|
||||
playwright_dev_port="$(
|
||||
PORT_SEED="$port_seed" node - <<'NODE'
|
||||
const net = require("node:net");
|
||||
const start = Number(process.env.PORT_SEED || 20000);
|
||||
const candidates = Array.from({ length: 200 }, (_, index) => start + index);
|
||||
function tryPort(index) {
|
||||
if (index >= candidates.length) {
|
||||
console.error("Unable to find a free Playwright dev-server port.");
|
||||
process.exit(1);
|
||||
}
|
||||
const port = candidates[index];
|
||||
const server = net.createServer();
|
||||
server.once("error", () => tryPort(index + 1));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => {
|
||||
console.log(port);
|
||||
});
|
||||
});
|
||||
}
|
||||
tryPort(0);
|
||||
NODE
|
||||
)"
|
||||
if docker info >/dev/null 2>&1; then
|
||||
docker_cmd=(docker)
|
||||
elif sudo -n docker info >/dev/null 2>&1; then
|
||||
docker_cmd=(sudo docker)
|
||||
else
|
||||
echo "Docker is not available to the runner user, and sudo docker is not available." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p output/playwright
|
||||
"${docker_cmd[@]}" run --rm --ipc=host --network host \
|
||||
--volume "$PWD:/source:ro" \
|
||||
--volume "$PWD/output/playwright:/work/output/playwright" \
|
||||
--workdir /work \
|
||||
--env HOME=/tmp \
|
||||
--env CI="${CI:-}" \
|
||||
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
|
||||
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
|
||||
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
|
||||
--env MATRIX_SUITE="$MATRIX_SUITE" \
|
||||
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
|
||||
--env DIFF_BASE_REF="$DIFF_BASE_REF" \
|
||||
--env DIFF_HEAD_REF="$DIFF_HEAD_REF" \
|
||||
mcr.microsoft.com/playwright:v1.58.2-noble \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
tar --exclude=./output/playwright -C /source -cf - . | tar -C /work -xf -
|
||||
git config --global --add safe.directory /work
|
||||
npm ci --legacy-peer-deps
|
||||
ulimit -n 16384 || true
|
||||
if [[ "$MATRIX_SUITE" == "core" ]]; then
|
||||
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
|
||||
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
|
||||
else
|
||||
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF"
|
||||
fi
|
||||
'
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-pr
|
||||
path: output/playwright
|
||||
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||
path: |
|
||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
|
||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
retention-days: 1
|
||||
|
||||
e2e-full:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
|
||||
needs: build-and-unit
|
||||
if: >
|
||||
always() &&
|
||||
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
|
||||
needs.build-and-unit.result == 'success' &&
|
||||
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
|
||||
needs: [build-and-unit, e2e-pr]
|
||||
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
role: [customer, subuser, admin, superuser]
|
||||
browser: [chromium, firefox, webkit]
|
||||
@@ -143,6 +283,20 @@ jobs:
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
|
||||
PLAYWRIGHT_REPORTER_MODE: line-html
|
||||
steps:
|
||||
- name: Repair self-hosted workspace permissions
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
|
||||
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
|
||||
if [[ -n "$foreign_entry" ]]; then
|
||||
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
|
||||
rm -rf "$trash" 2>/dev/null || true
|
||||
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
|
||||
mkdir -p "$GITHUB_WORKSPACE"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
@@ -150,19 +304,92 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps ${{ matrix.browser_install }}
|
||||
|
||||
- name: Run full Playwright slice
|
||||
run: npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
|
||||
- name: Run full Playwright slice in container
|
||||
shell: bash
|
||||
env:
|
||||
MATRIX_ROLE: ${{ matrix.role }}
|
||||
MATRIX_BROWSER: ${{ matrix.browser }}
|
||||
MATRIX_DEVICE: ${{ matrix.device }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
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
|
||||
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
|
||||
playwright_dev_port="$(
|
||||
PORT_SEED="$port_seed" node - <<'NODE'
|
||||
const net = require("node:net");
|
||||
const start = Number(process.env.PORT_SEED || 20000);
|
||||
const candidates = Array.from({ length: 200 }, (_, index) => start + index);
|
||||
function tryPort(index) {
|
||||
if (index >= candidates.length) {
|
||||
console.error("Unable to find a free Playwright dev-server port.");
|
||||
process.exit(1);
|
||||
}
|
||||
const port = candidates[index];
|
||||
const server = net.createServer();
|
||||
server.once("error", () => tryPort(index + 1));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => {
|
||||
console.log(port);
|
||||
});
|
||||
});
|
||||
}
|
||||
tryPort(0);
|
||||
NODE
|
||||
)"
|
||||
if docker info >/dev/null 2>&1; then
|
||||
docker_cmd=(docker)
|
||||
elif sudo -n docker info >/dev/null 2>&1; then
|
||||
docker_cmd=(sudo docker)
|
||||
else
|
||||
echo "Docker is not available to the runner user, and sudo docker is not available." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p output/playwright
|
||||
"${docker_cmd[@]}" run --rm --ipc=host --network host \
|
||||
--volume "$PWD:/source:ro" \
|
||||
--volume "$PWD/output/playwright:/work/output/playwright" \
|
||||
--workdir /work \
|
||||
--env HOME=/tmp \
|
||||
--env CI="${CI:-}" \
|
||||
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
|
||||
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
|
||||
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
|
||||
--env MATRIX_ROLE="$MATRIX_ROLE" \
|
||||
--env MATRIX_BROWSER="$MATRIX_BROWSER" \
|
||||
--env MATRIX_DEVICE="$MATRIX_DEVICE" \
|
||||
mcr.microsoft.com/playwright:v1.58.2-noble \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
tar --exclude=./output/playwright -C /source -cf - . | tar -C /work -xf -
|
||||
git config --global --add safe.directory /work
|
||||
npm ci --legacy-peer-deps
|
||||
ulimit -n 16384 || true
|
||||
npm run test:e2e:full:slice -- --role="$MATRIX_ROLE" --project="$MATRIX_BROWSER-$MATRIX_DEVICE"
|
||||
'
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
if: failure() || cancelled()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
|
||||
@@ -171,4 +398,4 @@ jobs:
|
||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
|
||||
output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
retention-days: 1
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": 1,
|
||||
"setupCompletedAt": "2026-06-07T11:37:04.444Z"
|
||||
}
|
||||
@@ -38,6 +38,14 @@ To use another remote API route:
|
||||
$env:VITE_API_PROXY_BASE_PATH="/canary/api"; npm run dev
|
||||
```
|
||||
|
||||
TLS certificate validation is enabled for proxied HTTPS APIs by default. If you
|
||||
are using a trusted local HTTPS API with a self-signed certificate, you can opt
|
||||
out explicitly:
|
||||
|
||||
```powershell
|
||||
$env:VITE_API_PROXY_TARGET="https://local-api.test"; $env:VITE_API_PROXY_SECURE="false"; npm run dev
|
||||
```
|
||||
|
||||
For compatible local gateways that expect the `/api` prefix to be preserved:
|
||||
|
||||
```powershell
|
||||
|
||||
+3
-3
@@ -56,9 +56,9 @@ android {
|
||||
defaultConfig {
|
||||
applicationId "io.truckwash.twa"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 35
|
||||
versionCode 6
|
||||
versionName "6"
|
||||
targetSdkVersion 36
|
||||
versionCode 7
|
||||
versionName "7"
|
||||
|
||||
// The name for the application
|
||||
resValue "string", "appName", twaManifest.name
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"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">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
|
||||
<script>
|
||||
(function () {
|
||||
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/);
|
||||
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/';
|
||||
var base = document.createElement('base');
|
||||
base.href = href;
|
||||
document.currentScript.after(base);
|
||||
})();
|
||||
</script>
|
||||
<link rel="icon" type="image/png" href="%BASE_URL%assets/favicons/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="%BASE_URL%assets/favicons/favicon.svg" />
|
||||
<link rel="shortcut icon" href="%BASE_URL%assets/favicons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%BASE_URL%assets/favicons/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
|
||||
@@ -5,22 +5,39 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
|
||||
location ~ ^/(?:.+/)?(?<webmanifest_path>(?:assets/)?manifest\.webmanifest)$ {
|
||||
types { application/manifest+json webmanifest; }
|
||||
default_type application/manifest+json;
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
try_files /$webmanifest_path =404;
|
||||
}
|
||||
|
||||
location ~ ^/(release-entry|release-manifest)\.json$ {
|
||||
add_header Cache-Control "no-store";
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
|
||||
add_header Cache-Control "no-store";
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
try_files /$2.json =404;
|
||||
}
|
||||
|
||||
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -30,11 +47,15 @@ server {
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
+115
-19
@@ -3056,6 +3056,8 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- Orders
|
||||
x-api-coverage:
|
||||
happy: true
|
||||
summary: List orders
|
||||
description: Retrieve a paginated list of orders
|
||||
operationId: listOrders
|
||||
@@ -4622,7 +4624,7 @@ paths:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Add vehicle condition
|
||||
description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles.
|
||||
description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow.
|
||||
operationId: addSelfserveVehicleCondition
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -4657,14 +4659,6 @@ paths:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Alias for vehicle_type.
|
||||
activate_machine:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the session synchronization may enable the machine relay. User wash-start saves answers with false.
|
||||
sync_relay_state:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the answer mutation should synchronize live relay state.
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully added vehicle condition
|
||||
@@ -4681,7 +4675,7 @@ paths:
|
||||
tags:
|
||||
- Self-Serve
|
||||
summary: Update vehicle condition
|
||||
description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles.
|
||||
description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow.
|
||||
operationId: updateSelfserveVehicleCondition
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -4717,14 +4711,6 @@ paths:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Alias for vehicle_type.
|
||||
activate_machine:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the session synchronization may enable the machine relay.
|
||||
sync_relay_state:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the mutation should synchronize live relay state.
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully updated vehicle condition
|
||||
@@ -5400,7 +5386,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [department, gateway_id, action]
|
||||
required: [department, gateway_id, action, confirm]
|
||||
properties:
|
||||
department: { type: integer }
|
||||
gateway_id: { type: integer }
|
||||
@@ -8702,6 +8688,116 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfServeLaneStatus'
|
||||
|
||||
/modules/self-serve/lane/wash/my-active-wash:
|
||||
get:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Get the authenticated customer's active self-serve wash
|
||||
description: |
|
||||
Returns the current authenticated customer's open self-serve wash session,
|
||||
if one exists. Regular customers must only receive their own active wash
|
||||
details from this endpoint.
|
||||
operationId: getMyActiveSelfServeWash
|
||||
responses:
|
||||
'200':
|
||||
description: Authenticated customer's active wash details resolved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
lane_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
in_progress:
|
||||
type: boolean
|
||||
session:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
lane_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
department_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
status:
|
||||
type: string
|
||||
reg:
|
||||
type: string
|
||||
customer_number:
|
||||
type: integer
|
||||
nullable: true
|
||||
vehicle_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
vehicle_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
included_minutes:
|
||||
type: integer
|
||||
nullable: true
|
||||
machine_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
machine_relay_enabled:
|
||||
type: boolean
|
||||
machine_relay_enabled_at:
|
||||
type: string
|
||||
nullable: true
|
||||
machine_start_triggered:
|
||||
type: boolean
|
||||
machine_start_triggered_at:
|
||||
type: string
|
||||
nullable: true
|
||||
wash_started_at:
|
||||
type: string
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
nullable: true
|
||||
customer:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
nullable: true
|
||||
customer_number:
|
||||
type: integer
|
||||
nullable: true
|
||||
display_name:
|
||||
type: string
|
||||
nullable: true
|
||||
email:
|
||||
type: string
|
||||
nullable: true
|
||||
phone_country_code:
|
||||
type: integer
|
||||
nullable: true
|
||||
phone:
|
||||
type: string
|
||||
nullable: true
|
||||
vehicle:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
customer_id:
|
||||
type: integer
|
||||
type:
|
||||
type: integer
|
||||
reg:
|
||||
type: string
|
||||
reference:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
/modules/self-serve/lane/wash/in-progress:
|
||||
get:
|
||||
tags:
|
||||
|
||||
@@ -56,9 +56,9 @@ android {
|
||||
defaultConfig {
|
||||
applicationId "io.truckwash.twa.staging"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 35
|
||||
versionCode 5
|
||||
versionName "5"
|
||||
targetSdkVersion 36
|
||||
versionCode 6
|
||||
versionName "6"
|
||||
|
||||
// The name for the application
|
||||
resValue "string", "appName", twaManifest.name
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"pleno-pwa-test"}
|
||||
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"/"}
|
||||
|
||||
Generated
+1120
-42
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -7,11 +7,13 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"lint": "eslint eslint.config.js src tests scripts vite.config.js vitest.config.js playwright*.ts --quiet",
|
||||
"lint:strict": "npm run lint:report -- --max-warnings=0",
|
||||
"lint:report": "eslint eslint.config.js src tests scripts vite.config.js vitest.config.js playwright*.ts",
|
||||
"format:tests": "prettier --write \"tests/**/*.{js,ts}\"",
|
||||
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
|
||||
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
|
||||
"prepare": "node scripts/prepare-husky.mjs",
|
||||
"postinstall": "node scripts/postinstall-sync-playwright-root-links.mjs",
|
||||
"preview": "vite preview",
|
||||
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
|
||||
"text:fix-encoding": "node scripts/text-encoding.mjs fix",
|
||||
@@ -109,17 +111,23 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@creativebulma/bulma-divider": "^1.1.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@event-calendar/core": "^4.1.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/event-calendar__core": "^3.7.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-plugin-vue": "^10.9.2",
|
||||
"globals": "^17.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.0.0",
|
||||
"otpauth": "^9.5.0",
|
||||
"prettier": "2.8.8",
|
||||
"sass-embedded": "^1.81.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"vite": "7.1.11",
|
||||
"vite-plugin-pwa": "^1.0.2",
|
||||
"vite-plugin-vue-devtools": "^7.5.4",
|
||||
|
||||
@@ -38,10 +38,14 @@ function buildProject(name: string, browserName: "chromium" | "firefox" | "webki
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
testIgnore: ["**/release/**"],
|
||||
snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}-win32{ext}",
|
||||
timeout: 60_000,
|
||||
fullyParallel: true,
|
||||
forbidOnly: isCI,
|
||||
retries: isCI ? 2 : 0,
|
||||
expect: {
|
||||
timeout: 15_000,
|
||||
},
|
||||
workers,
|
||||
...(process.env.PLAYWRIGHT_BASE_URL
|
||||
? {}
|
||||
|
||||
@@ -19,6 +19,16 @@ const stderrLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}
|
||||
const viteCliPath = path.resolve(process.cwd(), "node_modules/vite/bin/vite.js");
|
||||
const serverOutputLimit = 80;
|
||||
const activeOutputReaders = [];
|
||||
const viteServerEnv = {
|
||||
...process.env,
|
||||
PLAYWRIGHT: "1",
|
||||
...(process.env.CI
|
||||
? {
|
||||
CHOKIDAR_INTERVAL: process.env.CHOKIDAR_INTERVAL || "300",
|
||||
CHOKIDAR_USEPOLLING: process.env.CHOKIDAR_USEPOLLING || "true",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
// Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot.
|
||||
const viteDevArgs = [
|
||||
...(process.env.PLAYWRIGHT_VITE_FORCE === "1" ? ["--force"] : []),
|
||||
@@ -449,10 +459,7 @@ export default async function globalSetup() {
|
||||
const serverProcess = spawn(process.execPath, [viteCliPath, ...viteDevArgs], {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT: "1",
|
||||
},
|
||||
env: viteServerEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
+51
-20
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = "http://127.0.0.1:4173";
|
||||
@@ -5,6 +6,55 @@ const isCI = !!process.env.CI;
|
||||
|
||||
process.env.PLAYWRIGHT_BASE_URL = baseURL;
|
||||
|
||||
const osReleaseValue = (key: string) => {
|
||||
try {
|
||||
const body = fs.readFileSync("/etc/os-release", "utf8");
|
||||
const match = body.match(new RegExp(`^${key}=(.*)$`, "m"));
|
||||
return String(match?.[1] || "").replace(/^"|"$/g, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const isUnsupportedWebKitHost = () => {
|
||||
if (process.platform !== "linux") {
|
||||
return false;
|
||||
}
|
||||
const id = osReleaseValue("ID").toLowerCase();
|
||||
const version = Number.parseFloat(osReleaseValue("VERSION_ID"));
|
||||
return id === "ubuntu" && Number.isFinite(version) && version >= 26.04;
|
||||
};
|
||||
|
||||
const webKitOverride = String(process.env.PLAYWRIGHT_PROD_WEBKIT || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const includeWebKit =
|
||||
webKitOverride === "1" || (webKitOverride !== "0" && webKitOverride !== "false" && !isUnsupportedWebKitHost());
|
||||
|
||||
const projects = [
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chromium-mobile",
|
||||
use: {
|
||||
...devices["Pixel 5"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (includeWebKit) {
|
||||
projects.push({
|
||||
name: "webkit-desktop",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e/release",
|
||||
testMatch: /.*\.local-prod\.spec\.ts/,
|
||||
@@ -28,24 +78,5 @@ export default defineConfig({
|
||||
timeout: 240_000,
|
||||
reuseExistingServer: !isCI,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chromium-mobile",
|
||||
use: {
|
||||
...devices["Pixel 5"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "webkit-desktop",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
},
|
||||
},
|
||||
],
|
||||
projects,
|
||||
});
|
||||
|
||||
+8
-4
@@ -2,6 +2,10 @@
|
||||
Options -MultiViews
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_mime.c>
|
||||
AddType application/manifest+json .webmanifest
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
@@ -23,17 +27,17 @@
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
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} !-d
|
||||
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} !-d
|
||||
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} !-d
|
||||
@@ -41,7 +45,7 @@
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
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} !-d
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "Truck Wash Kundeportal",
|
||||
"short_name": "Truck Wash",
|
||||
"description": "Access your Truck Wash accounts and transactions from anywhere.",
|
||||
"id": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/favicons/web-app-manifest-192x192.png",
|
||||
|
||||
@@ -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,22 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const helperScriptPath = path.resolve(process.cwd(), "..", "scripts", "sync-playwright-root-links.mjs");
|
||||
|
||||
if (!fs.existsSync(helperScriptPath)) {
|
||||
console.log(
|
||||
`Skipping root Playwright link sync: helper script not found at ${helperScriptPath}.`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, [helperScriptPath], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (typeof result.status === "number") {
|
||||
process.exit(result.status);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
@@ -23,20 +23,52 @@ if [[ -z "$DEPLOY_URL" ]]; then
|
||||
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
|
||||
exit 1
|
||||
fi
|
||||
DEPLOY_URL="ftp://${USER_NAME}:${PASSWORD}@${HOST}"
|
||||
DEPLOY_URL="ftp://${HOST}"
|
||||
elif [[ -n "$USER_NAME" || -n "$PASSWORD" ]]; then
|
||||
if [[ -z "$USER_NAME" || -z "$PASSWORD" ]]; then
|
||||
echo "Set both RELEASE_DEPLOY_USER and RELEASE_DEPLOY_PASSWORD when providing deploy credentials separately." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
lftp_quote() {
|
||||
local value="${1//\'/\'\\\'\'}"
|
||||
printf "'%s'" "$value"
|
||||
}
|
||||
|
||||
run_lftp() {
|
||||
local transfer_command="$1"
|
||||
|
||||
{
|
||||
printf 'set ftp:ssl-allow true\n'
|
||||
printf 'set ftp:ssl-force true\n'
|
||||
printf 'set ftp:ssl-protect-data true\n'
|
||||
printf 'set net:max-retries 3\n'
|
||||
printf 'set net:timeout 20\n'
|
||||
|
||||
if [[ -n "$USER_NAME" && -n "$PASSWORD" ]]; then
|
||||
printf 'open -u %s,%s %s\n' "$(lftp_quote "$USER_NAME")" "$(lftp_quote "$PASSWORD")" "$(lftp_quote "$DEPLOY_URL")"
|
||||
else
|
||||
printf 'open %s\n' "$(lftp_quote "$DEPLOY_URL")"
|
||||
fi
|
||||
|
||||
printf 'cd %s\n' "$(lftp_quote "$REMOTE_ROOT")"
|
||||
printf '%s\n' "$transfer_command"
|
||||
printf 'bye\n'
|
||||
} | lftp -f /dev/stdin
|
||||
}
|
||||
|
||||
upload_file() {
|
||||
local source_file="$1"
|
||||
local remote_file="$2"
|
||||
if [[ -f "$source_file" ]]; then
|
||||
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; put -O \"$(dirname "$remote_file")\" \"$source_file\" -o \"$(basename "$remote_file")\"; bye"
|
||||
run_lftp "put -O $(lftp_quote "$(dirname "$remote_file")") $(lftp_quote "$source_file") -o $(lftp_quote "$(basename "$remote_file")")"
|
||||
fi
|
||||
}
|
||||
|
||||
for directory in assets resources favicons icons img sounds .well-known; do
|
||||
if [[ -d "$DIST_DIR/$directory" ]]; then
|
||||
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; mirror -R --only-newer --parallel=4 \"$DIST_DIR/$directory\" \"$directory\"; bye"
|
||||
run_lftp "mirror -R --only-newer --parallel=4 $(lftp_quote "$DIST_DIR/$directory") $(lftp_quote "$directory")"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -7,6 +8,24 @@ function numberEnv(name, fallback) {
|
||||
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function booleanEnv(name) {
|
||||
return /^(1|true|yes)$/i.test(process.env[name] || "");
|
||||
}
|
||||
|
||||
function appendGithubEnv(values) {
|
||||
const envFile = process.env.GITHUB_ENV;
|
||||
if (!envFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = Object.entries(values)
|
||||
.filter(([, value]) => value)
|
||||
.map(([key, value]) => `${key}=${String(value).replace(/\r?\n/g, "")}`);
|
||||
if (lines.length > 0) {
|
||||
fs.appendFileSync(envFile, `${lines.join("\n")}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function requiredUrl() {
|
||||
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
|
||||
if (!value) {
|
||||
@@ -120,7 +139,8 @@ async function verifyShell(baseUrl, shellPath) {
|
||||
|
||||
async function verifyRelease(baseUrl) {
|
||||
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
|
||||
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || "";
|
||||
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
|
||||
const strictBuildId = booleanEnv("RELEASE_STRICT_BUILD_ID");
|
||||
const manifest = await fetchJson(baseUrl, "release-manifest.json");
|
||||
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
|
||||
|
||||
@@ -130,7 +150,7 @@ async function verifyRelease(baseUrl) {
|
||||
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
|
||||
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
|
||||
}
|
||||
if (expectedBuildId && manifest.build_id !== expectedBuildId) {
|
||||
if (strictBuildId && expectedBuildId && manifest.build_id !== expectedBuildId) {
|
||||
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
|
||||
}
|
||||
if (releaseEntry.entry !== manifest.entry) {
|
||||
@@ -191,6 +211,10 @@ async function main() {
|
||||
attempt += 1;
|
||||
try {
|
||||
const result = await verifyRelease(baseUrl);
|
||||
appendGithubEnv({
|
||||
RELEASE_VERIFIED_BUILD_ID: result.build_id,
|
||||
RELEASE_VERIFIED_COMMIT: result.commit_sha,
|
||||
});
|
||||
console.log(
|
||||
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
|
||||
);
|
||||
|
||||
@@ -1,31 +1,41 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const workingDirectory = process.cwd();
|
||||
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
|
||||
const execFileAsync = promisify(execFile);
|
||||
const roles = ["customer", "subuser", "admin", "superuser"];
|
||||
export const roles = ["customer", "subuser", "admin", "superuser"];
|
||||
const listEntryPattern = /^\s+\[[^\]]+\]\s+›\s+(.+?):(\d+):(\d+)\s+›\s+(.+)\s*$/u;
|
||||
|
||||
const ownedFilesByRole = {
|
||||
export const ownedFilesByRole = {
|
||||
customer: [
|
||||
"auth.smoke.spec.js",
|
||||
"booking-selfserve.smoke.spec.js",
|
||||
"connectivityIssue.spec.ts",
|
||||
"example.spec.ts",
|
||||
"guest-book-wash-mobile.spec.ts",
|
||||
"i18n-catalog-switch.spec.ts",
|
||||
"i18n-v2-integrity.spec.ts",
|
||||
"i18n.smoke.spec.ts",
|
||||
"i18n.views.spec.ts",
|
||||
"navigation.smoke.spec.js",
|
||||
"qr-new-customer-layout.spec.ts",
|
||||
"release-bootstrap.spec.js",
|
||||
"release-channel-switched.spec.js",
|
||||
"release-channel-unavailable.spec.js",
|
||||
"release-update-widget.spec.js",
|
||||
"self-serve-wash.spec.js",
|
||||
"session-release-runtime.spec.ts",
|
||||
"user-orders.spec.ts",
|
||||
"userBookings.spec.ts",
|
||||
"userBookWash.spec.ts",
|
||||
"userHome.spec.ts",
|
||||
"userInvoices.spec.ts",
|
||||
"userMyWashStart.spec.ts",
|
||||
"userMyWashStartFlow.spec.ts",
|
||||
"userProfileInvoicing.spec.ts",
|
||||
"userProfileNotifications.spec.ts",
|
||||
"userProfileSecurity.spec.ts",
|
||||
@@ -53,6 +63,7 @@ const ownedFilesByRole = {
|
||||
"assign-draft-order-modal-layout.spec.ts",
|
||||
"change-invoice-collection.spec.ts",
|
||||
"change-customer.spec.ts",
|
||||
"default-mobile-redirect.spec.ts",
|
||||
"economic-queue-workflow.spec.js",
|
||||
"pos-customer-rules.spec.js",
|
||||
"pos-desktop-card-payments.spec.js",
|
||||
@@ -62,18 +73,24 @@ const ownedFilesByRole = {
|
||||
"pos.visual.spec.js",
|
||||
],
|
||||
superuser: [
|
||||
"coolify-infrastructure.spec.js",
|
||||
"edge-gateways.fleet-outline.spec.js",
|
||||
"edge-gateways.routes.spec.js",
|
||||
"edge-gateways.smoke.spec.js",
|
||||
"edge-gateways.visual.spec.js",
|
||||
"errorReports.spec.ts",
|
||||
"failover-config.source.spec.ts",
|
||||
"collected-invoice-move-customer.spec.ts",
|
||||
"invoice-distribution.smoke.spec.js",
|
||||
"invoice-transfer-monitor.spec.ts",
|
||||
"invoice-transfer-queue-history.spec.js",
|
||||
"invoicing-period.smoke.spec.js",
|
||||
"issue-repro-duplicates-date.spec.js",
|
||||
"release-manager.spec.js",
|
||||
"self-serve-sessions.spec.js",
|
||||
"self-serve-studio-audit-navigation.spec.js",
|
||||
"self-serve-studio-flow.spec.js",
|
||||
"session-bootstrap.spec.ts",
|
||||
"superuser-bookings.spec.ts",
|
||||
"superuser-customer-complaints.spec.ts",
|
||||
"superuser-customers-mass-import.spec.ts",
|
||||
@@ -84,12 +101,13 @@ const ownedFilesByRole = {
|
||||
"superuser-drafts.spec.ts",
|
||||
"superuser-products-layout.spec.ts",
|
||||
"superuser-system-status.smoke.spec.js",
|
||||
"superuser-users.spec.ts",
|
||||
"superuser-vehicles.smoke.spec.js",
|
||||
"workfeed-config.smoke.spec.js",
|
||||
],
|
||||
};
|
||||
|
||||
const titleRules = [
|
||||
export const titleRules = [
|
||||
{ role: "customer", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[User\]/u] },
|
||||
{ role: "subuser", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Subuser\]/u] },
|
||||
{ role: "admin", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Operator\]/u] },
|
||||
@@ -116,6 +134,11 @@ const titleRules = [
|
||||
file: "subuser-management.spec.ts",
|
||||
patterns: [/^authorized subuser managers/i, /^subuser self-service/i, /^subusers without /i],
|
||||
},
|
||||
{
|
||||
role: "superuser",
|
||||
file: "subuser-management.spec.ts",
|
||||
patterns: [/^superusers can list and invite chauffeurs/i],
|
||||
},
|
||||
{ role: "subuser", file: "userProfileVisibility.spec.ts", patterns: [/\[PROFILE\]\[Subuser\]\[Visibility\]/u] },
|
||||
];
|
||||
|
||||
@@ -200,7 +223,7 @@ function toBaseName(filePath) {
|
||||
return filePath.split(/[\\/]/u).pop() || filePath;
|
||||
}
|
||||
|
||||
function parseListedTests(listOutput) {
|
||||
export function parseListedTests(listOutput) {
|
||||
return listOutput
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => {
|
||||
@@ -222,7 +245,7 @@ function parseListedTests(listOutput) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function classifyTest(testEntry) {
|
||||
export function classifyTest(testEntry) {
|
||||
const matches = new Set();
|
||||
const directOwner = ownedFileToRole.get(testEntry.fileName);
|
||||
|
||||
@@ -309,8 +332,8 @@ async function runPlaywright(project, testListPath, forwardedArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { options, forwardedArgs } = parseCliArgs(process.argv.slice(2));
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const { options, forwardedArgs } = parseCliArgs(argv);
|
||||
validateOptions(options, forwardedArgs);
|
||||
|
||||
const listOutput = await listProjectTests(options.project, forwardedArgs);
|
||||
@@ -342,4 +365,17 @@ async function main() {
|
||||
await runPlaywright(options.project, testListPath, forwardedArgs);
|
||||
}
|
||||
|
||||
await main();
|
||||
async function isDirectRun() {
|
||||
if (!process.argv[1]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentPath = await fs.realpath(fileURLToPath(import.meta.url));
|
||||
const invokedPath = await fs.realpath(process.argv[1]).catch(() => path.resolve(process.argv[1]));
|
||||
|
||||
return currentPath === invokedPath;
|
||||
}
|
||||
|
||||
if (await isDirectRun()) {
|
||||
await main();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
function parseArgs(rawArgs) {
|
||||
const parsed = {
|
||||
help: false,
|
||||
coreOnly: false,
|
||||
changedOnly: false,
|
||||
listOnly: false,
|
||||
base: "",
|
||||
@@ -43,6 +44,11 @@ function parseArgs(rawArgs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "--core-only") {
|
||||
parsed.coreOnly = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "--changed-only") {
|
||||
parsed.changedOnly = true;
|
||||
continue;
|
||||
@@ -103,12 +109,14 @@ Options:
|
||||
--base <ref> Base ref for changed-area detection
|
||||
--head <ref> Head ref for changed-area detection. Default: HEAD
|
||||
--project <name> Restrict to one Chromium Playwright project. Can be repeated.
|
||||
--core-only Run only the core @pr gate
|
||||
--changed-only Run changed-area selection without the core @pr gate
|
||||
--list-only List selected tests instead of running them
|
||||
-h, --help Show help
|
||||
|
||||
Examples:
|
||||
npm run test:e2e:pr
|
||||
npm run test:e2e:pr -- --core-only --project=chromium-desktop
|
||||
npm run test:e2e:changed -- --base=HEAD~1 --head=HEAD
|
||||
`);
|
||||
}
|
||||
@@ -418,6 +426,10 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.coreOnly && args.changedOnly) {
|
||||
throw new Error("--core-only and --changed-only cannot be used together.");
|
||||
}
|
||||
|
||||
await fs.access(playwrightCliPath);
|
||||
|
||||
if (!args.changedOnly) {
|
||||
@@ -428,6 +440,10 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
if (args.coreOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = await getChangedFiles();
|
||||
if (changed.unavailable) {
|
||||
console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`);
|
||||
|
||||
@@ -20,6 +20,7 @@ const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/L
|
||||
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
|
||||
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
|
||||
const ErrorReportLauncher = defineAsyncComponent(() => import("@/components/global/ErrorReportLauncher.vue"));
|
||||
const FrontendMaintenanceMenu = defineAsyncComponent(() => import("@/components/global/FrontendMaintenanceMenu.vue"));
|
||||
const ReleaseChannelUnavailable = defineAsyncComponent(() =>
|
||||
import("@/components/release/ReleaseChannelUnavailable.vue")
|
||||
);
|
||||
@@ -128,6 +129,7 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
|
||||
</template>
|
||||
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
||||
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
||||
<FrontendMaintenanceMenu />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -63,6 +63,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
reg_3: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
order_booking_id: {
|
||||
type: Number,
|
||||
default: null,
|
||||
@@ -93,6 +97,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowBookingDeletion: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
department_lane_id: {
|
||||
type: Number,
|
||||
default: null,
|
||||
@@ -917,6 +925,15 @@ const getAttachmentLabel = (attachment) => {
|
||||
);
|
||||
};
|
||||
|
||||
const isWashCertificateAttachment = (attachment) => {
|
||||
const marker = String(getAttachmentOtherPayload(attachment) || "").trim().toUpperCase();
|
||||
if (marker === "WASH_CERTIFICATE") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /(?:^|[/\\])wash[_-]?certificate.*\.pdf$/i.test(getAttachmentLabel(attachment));
|
||||
};
|
||||
|
||||
const getAttachmentExtension = (attachment) => {
|
||||
const match = String(getAttachmentLabel(attachment))
|
||||
.toLowerCase()
|
||||
@@ -1045,6 +1062,10 @@ const activeAttachmentPreviewSource = computed(() => {
|
||||
return previewSourcesById.value[activeAttachment.value.id] ?? null;
|
||||
});
|
||||
|
||||
const hasWashCertificateAttachment = computed(() =>
|
||||
attachmentsFromOrder.value.some((attachment) => isWashCertificateAttachment(attachment))
|
||||
);
|
||||
|
||||
const hasCachedPreviewSource = (attachmentId) => {
|
||||
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
|
||||
};
|
||||
@@ -1609,6 +1630,48 @@ const showCompleteOrderBookingConfirmation = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const showEmailNotificationActionResult = async (requestAction, successKey, errorKey) => {
|
||||
try {
|
||||
await requestAction();
|
||||
await Swal.fire({
|
||||
title: t(successKey),
|
||||
icon: "success",
|
||||
showConfirmButton: false,
|
||||
timer: 2000,
|
||||
heightAuto: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await Swal.fire({
|
||||
title: t("common.error"),
|
||||
text: [t(errorKey), SessionUser.functions.parseErrorMessage?.(error)].filter(Boolean).join(": "),
|
||||
icon: "error",
|
||||
heightAuto: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resendBookingConfirmation = () =>
|
||||
showEmailNotificationActionResult(
|
||||
() => SessionUser.objects.order_bookings.functions.resendBookingConfirmation(props.order_booking_id),
|
||||
"admin.pos.settings_wheel.resend_booking_confirmation_success",
|
||||
"admin.pos.settings_wheel.resend_booking_confirmation_error"
|
||||
);
|
||||
|
||||
const resendBookingCompletionConfirmation = () =>
|
||||
showEmailNotificationActionResult(
|
||||
() => SessionUser.objects.order_bookings.functions.resendBookingCompletionConfirmation(props.order_booking_id),
|
||||
"admin.pos.settings_wheel.resend_booking_completion_confirmation_success",
|
||||
"admin.pos.settings_wheel.resend_booking_completion_confirmation_error"
|
||||
);
|
||||
|
||||
const resendWashCertificate = () =>
|
||||
showEmailNotificationActionResult(
|
||||
() => SessionUser.objects.orders.functions.resendWashCertificate(props.order_id),
|
||||
"admin.pos.settings_wheel.resend_wash_certificate_success",
|
||||
"admin.pos.settings_wheel.resend_wash_certificate_error"
|
||||
);
|
||||
|
||||
const getAvailableInvoiceCollectionsForCustomer = async (customerNumber) => {
|
||||
const normalizedCustomerNumber = normalizePositiveInteger(customerNumber);
|
||||
if (!normalizedCustomerNumber) {
|
||||
@@ -1693,6 +1756,11 @@ const acceptSelfServeWashDraft = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const canDeleteOrderBooking = computed(() =>
|
||||
!props.order_id &&
|
||||
(props.allowBookingDeletion || SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser())
|
||||
);
|
||||
|
||||
const flatBuiltInMenuSections = computed(() => {
|
||||
const sections = [];
|
||||
|
||||
@@ -1747,7 +1815,7 @@ const flatBuiltInMenuSections = computed(() => {
|
||||
),
|
||||
})
|
||||
: null,
|
||||
!props.order_id
|
||||
canDeleteOrderBooking.value
|
||||
? buildMenuAction("booking-delete", {
|
||||
icon: "fas fa-trash-alt",
|
||||
label: t("admin.pos.settings_wheel.delete_booking"),
|
||||
@@ -1835,6 +1903,40 @@ const flatBuiltInMenuSections = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
if (props.order_booking_id || hasWashCertificateAttachment.value) {
|
||||
const emailNotificationsSection = buildMenuSection(
|
||||
"email-notifications",
|
||||
t("admin.pos.settings_wheel.email_notifications_section"),
|
||||
[
|
||||
props.order_booking_id
|
||||
? buildMenuAction("email-notifications-resend-booking-confirmation", {
|
||||
icon: "fas fa-envelope",
|
||||
label: t("admin.pos.settings_wheel.resend_booking_confirmation"),
|
||||
clickAction: resendBookingConfirmation,
|
||||
})
|
||||
: null,
|
||||
props.order_booking_id && hasWashCertificateAttachment.value
|
||||
? buildMenuAction("email-notifications-resend-booking-completion-confirmation", {
|
||||
icon: "fas fa-envelope-open-text",
|
||||
label: t("admin.pos.settings_wheel.resend_booking_completion_confirmation"),
|
||||
clickAction: resendBookingCompletionConfirmation,
|
||||
})
|
||||
: null,
|
||||
!props.order_booking_id && hasWashCertificateAttachment.value
|
||||
? buildMenuAction("email-notifications-resend-wash-certificate", {
|
||||
icon: "fas fa-file-pdf",
|
||||
label: t("admin.pos.settings_wheel.resend_wash_certificate"),
|
||||
clickAction: resendWashCertificate,
|
||||
})
|
||||
: null,
|
||||
]
|
||||
);
|
||||
|
||||
if (emailNotificationsSection) {
|
||||
sections.push(emailNotificationsSection);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.invoice_collection_id && SessionUser.canAccessSuperUser()) {
|
||||
const invoiceCollectionLinkSection = buildMenuSection(
|
||||
"invoice-collection-link",
|
||||
@@ -2136,10 +2238,10 @@ const flatBuiltInMenuSections = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
if (props.reg_1 || props.reg_2) {
|
||||
if (props.reg_1 || props.reg_2 || props.reg_3) {
|
||||
const vehicleSection = buildMenuSection(
|
||||
"vehicle",
|
||||
props.reg_1 && props.reg_2
|
||||
[props.reg_1, props.reg_2, props.reg_3].filter(Boolean).length > 1
|
||||
? SessionUser.objects.vehicles.meta.labels.multiple
|
||||
: SessionUser.objects.vehicles.meta.labels.single,
|
||||
[
|
||||
@@ -2157,6 +2259,13 @@ const flatBuiltInMenuSections = computed(() => {
|
||||
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_2, true),
|
||||
})
|
||||
: null,
|
||||
props.reg_3 && SessionUser.canAccessSuperUser()
|
||||
? buildMenuAction("vehicle-reg-3", {
|
||||
icon: "fas fa-car",
|
||||
label: t("admin.pos.settings_wheel.view_vehicle_new_tab", { reg: props.reg_3 }),
|
||||
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_3, true),
|
||||
})
|
||||
: null,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -2426,11 +2535,13 @@ const syncDesktopFlyoutPosition = () => {
|
||||
dropdownContentEl.style.top = "";
|
||||
}
|
||||
|
||||
const padding = 12;
|
||||
const viewportInsets = getViewportInsets();
|
||||
const viewportTop = viewportInsets.top;
|
||||
const viewportBottom = viewportHeight - viewportInsets.bottom;
|
||||
let top = triggerRect.bottom;
|
||||
|
||||
if (top + contentHeight > viewportHeight - padding) {
|
||||
top = Math.max(padding, triggerRect.top - contentHeight);
|
||||
if (top + contentHeight > viewportBottom) {
|
||||
top = Math.max(viewportTop, triggerRect.top - contentHeight);
|
||||
}
|
||||
|
||||
const nextFixedStyles = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import {computed, onMounted, ref, watch} from 'vue';
|
||||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
import {BSwitch} from "buefy";
|
||||
@@ -18,6 +18,39 @@ const selfServeEnabled = ref(false);
|
||||
const isLoadingSelfServeEnabled = ref(false);
|
||||
const isSavingSelfServeEnabled = ref(false);
|
||||
const bookingsCount = ref(0);
|
||||
const selfServeLoadingMinimumMs = import.meta.env.MODE === "test" ? 0 : import.meta.env.VITE_IS_PLAYWRIGHT ? 5000 : 250;
|
||||
const selfServeLoadingStartedAt = ref(0);
|
||||
let selfServeLoadingTimer = null;
|
||||
|
||||
const clearSelfServeLoadingTimer = () => {
|
||||
if (selfServeLoadingTimer !== null) {
|
||||
clearTimeout(selfServeLoadingTimer);
|
||||
selfServeLoadingTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const setSelfServeLoading = (isLoading) => {
|
||||
if (isLoading) {
|
||||
clearSelfServeLoadingTimer();
|
||||
selfServeLoadingStartedAt.value = Date.now();
|
||||
isLoadingSelfServeEnabled.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - selfServeLoadingStartedAt.value;
|
||||
const remaining = Math.max(0, selfServeLoadingMinimumMs - elapsed);
|
||||
clearSelfServeLoadingTimer();
|
||||
|
||||
if (remaining === 0) {
|
||||
isLoadingSelfServeEnabled.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
selfServeLoadingTimer = setTimeout(() => {
|
||||
isLoadingSelfServeEnabled.value = false;
|
||||
selfServeLoadingTimer = null;
|
||||
}, remaining);
|
||||
};
|
||||
|
||||
const getDepartmentId = () => {
|
||||
return parseInt(router.currentRoute.value.params.departmentId);
|
||||
@@ -190,13 +223,13 @@ const getSelfServeStatus = async () => {
|
||||
const departmentId = getDepartmentId();
|
||||
if (!departmentId) return;
|
||||
|
||||
isLoadingSelfServeEnabled.value = true;
|
||||
setSelfServeLoading(true);
|
||||
try {
|
||||
selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch self-serve status", error);
|
||||
} finally {
|
||||
isLoadingSelfServeEnabled.value = false;
|
||||
setSelfServeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -204,6 +237,10 @@ onMounted(() => {
|
||||
getSelfServeStatus();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearSelfServeLoadingTimer();
|
||||
});
|
||||
|
||||
// Watch the departmentId
|
||||
watch(() => router.currentRoute.value.params.departmentId, () => {
|
||||
getTodaysBookings();
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
selectPreferredStripeTerminalReaderId,
|
||||
STRIPE_TERMINAL_STATUS,
|
||||
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
|
||||
import { normalizeStripeInvoice } from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
|
||||
|
||||
const POLLING_INTERVAL_MS = 5000;
|
||||
const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required';
|
||||
@@ -87,29 +88,6 @@ const selectedTaxRate = ref(1);
|
||||
const paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
|
||||
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent.value));
|
||||
|
||||
const normalizeStripeInvoice = (value) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const invoiceId = value.invoice_id || value.id || null;
|
||||
if (!invoiceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: value.id ?? props.order_id,
|
||||
invoice_id: invoiceId,
|
||||
customer_id: value.customer_id ?? null,
|
||||
url: value.url || value.hosted_invoice_url || null,
|
||||
created_at: value.created_at || null,
|
||||
paid: Boolean(value.paid),
|
||||
status: value.status || 'unknown',
|
||||
amount_due: Number(value.amount_due ?? 0),
|
||||
amount_paid: Number(value.amount_paid ?? 0),
|
||||
};
|
||||
};
|
||||
|
||||
const hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null);
|
||||
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
|
||||
const isStripeEmailInvoiceTerminalState = computed(() => {
|
||||
@@ -202,7 +180,7 @@ const loadStripeInvoiceState = async () => {
|
||||
|
||||
try {
|
||||
const response = await getOrder(props.order_id, true);
|
||||
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders);
|
||||
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders, props.order_id);
|
||||
stripeInvoice.value = nextInvoice;
|
||||
if (nextInvoice) {
|
||||
emailPanelState.value = 'tracking';
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
const STRIPE_INVOICE_PAID_STATUS = 'paid';
|
||||
|
||||
export const parseStripeInvoicePaidFlag = (value) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value === 1;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalizedValue = value.trim().toLowerCase();
|
||||
if (['true', '1'].includes(normalizedValue)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (['false', '0', ''].includes(normalizedValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const toFiniteNumber = (value, fallback = 0) => {
|
||||
const parsedValue = Number(value ?? fallback);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : fallback;
|
||||
};
|
||||
|
||||
export const normalizeStripeInvoice = (value, fallbackOrderId = null) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const invoiceId = value.invoice_id || value.id || null;
|
||||
if (!invoiceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = String(value.status || 'unknown').toLowerCase();
|
||||
const amountDue = toFiniteNumber(value.amount_due);
|
||||
const amountPaid = toFiniteNumber(value.amount_paid);
|
||||
const hasCoveredAmountDue = amountDue <= 0 || amountPaid >= amountDue;
|
||||
const isPaid = status === STRIPE_INVOICE_PAID_STATUS
|
||||
&& parseStripeInvoicePaidFlag(value.paid)
|
||||
&& hasCoveredAmountDue;
|
||||
|
||||
return {
|
||||
id: value.id ?? fallbackOrderId,
|
||||
invoice_id: invoiceId,
|
||||
customer_id: value.customer_id ?? null,
|
||||
url: value.url || value.hosted_invoice_url || null,
|
||||
created_at: value.created_at || null,
|
||||
paid: isPaid,
|
||||
status,
|
||||
amount_due: amountDue,
|
||||
amount_paid: amountPaid,
|
||||
};
|
||||
};
|
||||
@@ -10,6 +10,16 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
const acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
|
||||
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
|
||||
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
|
||||
const safePreviewBlobTypesByKind = {
|
||||
image: {
|
||||
fallback: "image/png",
|
||||
allowed: new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/svg+xml"]),
|
||||
},
|
||||
document: {
|
||||
fallback: "application/pdf",
|
||||
allowed: new Set(["application/pdf"]),
|
||||
},
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
order: {
|
||||
@@ -324,7 +334,21 @@ const hasCachedPreviewSource = (attachmentId) => {
|
||||
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
|
||||
};
|
||||
|
||||
const createEmbeddablePreviewUrl = async (downloadLink) => {
|
||||
const createSafePreviewBlob = (fileBlob, previewKind) => {
|
||||
const previewBlobTypes = safePreviewBlobTypesByKind[previewKind];
|
||||
if (!previewBlobTypes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedBlobType = String(fileBlob.type || "").toLowerCase();
|
||||
const safeBlobType = previewBlobTypes.allowed.has(normalizedBlobType)
|
||||
? normalizedBlobType
|
||||
: previewBlobTypes.fallback;
|
||||
|
||||
return new Blob([fileBlob], { type: safeBlobType });
|
||||
};
|
||||
|
||||
const createEmbeddablePreviewUrl = async (downloadLink, previewKind) => {
|
||||
if (!downloadLink) {
|
||||
return null;
|
||||
}
|
||||
@@ -340,7 +364,12 @@ const createEmbeddablePreviewUrl = async (downloadLink) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(fileBlob);
|
||||
const safePreviewBlob = createSafePreviewBlob(fileBlob, previewKind);
|
||||
if (!safePreviewBlob) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(safePreviewBlob);
|
||||
generatedObjectUrls.add(objectUrl);
|
||||
return objectUrl;
|
||||
} catch (error) {
|
||||
@@ -372,7 +401,7 @@ const ensurePreviewSource = async (attachment) => {
|
||||
attachment.id,
|
||||
false
|
||||
);
|
||||
const previewSource = await createEmbeddablePreviewUrl(downloadLink);
|
||||
const previewSource = await createEmbeddablePreviewUrl(downloadLink, previewKind);
|
||||
previewSourcesById.value = {
|
||||
...previewSourcesById.value,
|
||||
[attachment.id]: previewSource,
|
||||
@@ -517,6 +546,7 @@ const toggleDropdown = async () => {
|
||||
:src="activePreviewSource"
|
||||
class="order-attachments-preview-panel__document"
|
||||
title="Attachment preview"
|
||||
sandbox
|
||||
></iframe>
|
||||
<a
|
||||
v-else-if="activePreviewKind === 'link'"
|
||||
|
||||
@@ -275,6 +275,10 @@ const getInvoiceCollectionResponseOrders = (response) => {
|
||||
return response.data.orders;
|
||||
}
|
||||
|
||||
if (Array.isArray(response?.data?.data?.orders)) {
|
||||
return response.data.data.orders;
|
||||
}
|
||||
|
||||
if (Array.isArray(response?.includes?.orders)) {
|
||||
return response.includes.orders;
|
||||
}
|
||||
@@ -649,6 +653,41 @@ const getInvoiceCollectionDetails = (invoiceCollectionId) => {
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const getInvoiceCollectionDetailOrders = (invoiceCollectionId) => {
|
||||
const details = getInvoiceCollectionDetails(invoiceCollectionId);
|
||||
return details ? getInvoiceCollectionResponseOrders(details) : [];
|
||||
};
|
||||
const getHiddenOrdersByInvoiceCollection = (invoiceCollectionId) => {
|
||||
const visibleOrderIds = new Set(getOrdersByInvoiceCollection(invoiceCollectionId).map((order) => Number(order?.id)));
|
||||
return getInvoiceCollectionDetailOrders(invoiceCollectionId).filter((order) => !visibleOrderIds.has(Number(order?.id)));
|
||||
};
|
||||
const getHiddenOrderIdsByInvoiceCollection = (invoiceCollectionId) => (
|
||||
getHiddenOrdersByInvoiceCollection(invoiceCollectionId).map((order) => Number(order?.id))
|
||||
);
|
||||
const getHiddenInvoiceCollectionFlags = (invoiceCollectionId) => {
|
||||
const hiddenOrderIds = new Set(getHiddenOrderIdsByInvoiceCollection(invoiceCollectionId));
|
||||
if (hiddenOrderIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
|
||||
if (!isActiveInvoicePeriodFlag(flag)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetType = String(flag?.target_type || "");
|
||||
if (!["order", "order_field", "order_item", "order_item_field"].includes(targetType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flagOrderId = Number(
|
||||
flag?.order_id
|
||||
|| flag?.context?.order_id
|
||||
|| (["order", "order_field"].includes(targetType) ? flag?.target_id : 0)
|
||||
);
|
||||
return flagOrderId > 0 && hiddenOrderIds.has(flagOrderId);
|
||||
}));
|
||||
};
|
||||
watch(
|
||||
[() => props.orders, () => props.excludedOrderIds, () => props.groupInvoiceCollection],
|
||||
() => {
|
||||
@@ -999,6 +1038,13 @@ const formatCashierName = (order) => {
|
||||
}}
|
||||
er ikke vist, men vil muligvis blive faktureret alligevel.</span
|
||||
>
|
||||
<InvoicingPeriodFlagList
|
||||
v-if="getHiddenInvoiceCollectionFlags(order.invoice_collection_id).length > 0"
|
||||
class="mt-3"
|
||||
compact
|
||||
:flags="getHiddenInvoiceCollectionFlags(order.invoice_collection_id)"
|
||||
@statusChanged="emitFlagStatusChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -1263,6 +1309,9 @@ const formatCashierName = (order) => {
|
||||
v-bind:customer_number="order.customer_id"
|
||||
v-bind:department_id="order.department_id"
|
||||
v-bind:reg_1="order.reg_1"
|
||||
v-bind:reg_2="order.reg_2"
|
||||
v-bind:reg_3="order.reg_3"
|
||||
v-bind:order_booking_id="order.booking_id"
|
||||
:refreshFunction="loadList"
|
||||
@deleted="loadList()"
|
||||
@flag-created="emitFlagCreated"
|
||||
@@ -1506,6 +1555,9 @@ const formatCashierName = (order) => {
|
||||
v-bind:customer_number="order.customer_id"
|
||||
v-bind:department_id="order.department_id"
|
||||
v-bind:reg_1="order.reg_1"
|
||||
v-bind:reg_2="order.reg_2"
|
||||
v-bind:reg_3="order.reg_3"
|
||||
v-bind:order_booking_id="order.booking_id"
|
||||
:refreshFunction="loadList"
|
||||
@deleted="loadList()"
|
||||
@flag-created="emitFlagCreated"
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
step,
|
||||
setDesktopStep1PreflightHandler,
|
||||
clearDesktopStep1PreflightHandler,
|
||||
pushPosRouteState,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue";
|
||||
import { POS_STEP_1_VERSION } from "@/config.js";
|
||||
@@ -92,6 +93,7 @@ const duplicateDetailsExpanded = ref(false);
|
||||
const pendingNextResolution = ref(false);
|
||||
const isDesktopLastWashCopying = ref(false);
|
||||
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
|
||||
let focusOnReg1TimeoutId = null;
|
||||
const isDesktopStep1Active = computed(() => getCurrentStep() === 1);
|
||||
|
||||
const setTab = (tab) => {
|
||||
@@ -117,7 +119,16 @@ watch(
|
||||
);
|
||||
|
||||
const focusOnReg1 = () => {
|
||||
setTimeout(() => {
|
||||
if (focusOnReg1TimeoutId !== null) {
|
||||
clearTimeout(focusOnReg1TimeoutId);
|
||||
}
|
||||
|
||||
focusOnReg1TimeoutId = setTimeout(() => {
|
||||
focusOnReg1TimeoutId = null;
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const reg1Input = document.getElementById("reg_1");
|
||||
if (reg1Input) {
|
||||
reg1Input.focus();
|
||||
@@ -267,12 +278,8 @@ const bookingSelectionObjects = computed(() => {
|
||||
const contentSegments = [
|
||||
`${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`,
|
||||
`${t("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`,
|
||||
`${t("common.reference")}: ${
|
||||
getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")
|
||||
}`,
|
||||
`${t("common.services")}: ${
|
||||
getOrderBookingServiceText(booking) || t("admin.pos.not_found")
|
||||
}`,
|
||||
`${t("common.reference")}: ${getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")}`,
|
||||
`${t("common.services")}: ${getOrderBookingServiceText(booking) || t("admin.pos.not_found")}`,
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -639,7 +646,7 @@ const pushDesktopStepTwoRoute = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
|
||||
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
|
||||
};
|
||||
|
||||
const handleDesktopLastWashCopy = async (payload = {}) => {
|
||||
@@ -681,6 +688,10 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (focusOnReg1TimeoutId !== null) {
|
||||
clearTimeout(focusOnReg1TimeoutId);
|
||||
focusOnReg1TimeoutId = null;
|
||||
}
|
||||
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
|
||||
});
|
||||
|
||||
@@ -750,13 +761,9 @@ watch(
|
||||
</div>
|
||||
<div class="pos-shell-actions">
|
||||
<ButtonsBox class="pos-actions pos-actions--stacked">
|
||||
<Cancel tabindex="2" class="is-fullwidth" />
|
||||
<Cancel tabindex="5" class="is-fullwidth" />
|
||||
</ButtonsBox>
|
||||
<div
|
||||
v-if="shouldShowActionRailControls"
|
||||
class="pos-shell-actions__rail"
|
||||
data-testid="pos-step-1-action-rail"
|
||||
>
|
||||
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail">
|
||||
<PosDesktopDuplicateWarning
|
||||
v-if="shouldShowDuplicateWarningInActionRail"
|
||||
:title="t('admin.pos.warning')"
|
||||
|
||||
@@ -24,70 +24,79 @@ import {
|
||||
} from "./objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { PosSearchResult } from "./objects/PosSearchResult.vue";
|
||||
import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue";
|
||||
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
|
||||
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
|
||||
import BookedCustomer from "@/components/viewport/elements/icons/BookedCustomer.vue";
|
||||
import KnownCustomer from "@/components/viewport/elements/icons/KnownCustomer.vue";
|
||||
import CardPaymentCustomer from "@/components/viewport/elements/icons/CardPaymentCustomer.vue";
|
||||
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||
import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
|
||||
import PosDepartmentStepMobile1Debug from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue";
|
||||
import PosDepartmentStepMobileAttachments from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.vue";
|
||||
import PosDepartmentStep1MobileTransactionHistory from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue";
|
||||
import { attachments } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
import {
|
||||
LPR_FRAME_CLIENT_BYTES_FIELD,
|
||||
LPR_FRAME_CLIENT_CAPTURE_MS_FIELD,
|
||||
LPR_FRAME_CLIENT_DRAW_MS_FIELD,
|
||||
LPR_FRAME_CLIENT_ENCODE_MS_FIELD,
|
||||
LPR_FRAME_CLIENT_HEIGHT_FIELD,
|
||||
LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD,
|
||||
LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD,
|
||||
LPR_FRAME_CLIENT_WIDTH_FIELD,
|
||||
getVisualFingerprintDistance,
|
||||
type LPRFrameEncodeCandidate,
|
||||
type LPRFramePayload,
|
||||
type LPRFrameViewportRect,
|
||||
} from "@/components/viewport/page/templates/scanner/lprFrameCapture";
|
||||
// Debug mode flag
|
||||
const debug_mode = ref(false);
|
||||
// Debug array to store request results
|
||||
const debug_request_results = ref([]);
|
||||
const debug_request_results = ref<unknown[]>([]);
|
||||
|
||||
const lastParsedImage = ref(null);
|
||||
const setLastCapturedImage = (image: string) => {
|
||||
camera.latestImage.value = image;
|
||||
type ParsedFrameFingerprint = {
|
||||
content: string | null;
|
||||
contentFingerprintPromise: Promise<string> | null;
|
||||
getContentFingerprint: (() => Promise<string>) | null;
|
||||
getVisualFingerprint: (() => string | null) | null;
|
||||
outcome: "pending" | "miss" | "success";
|
||||
quick: string;
|
||||
visual: string | null;
|
||||
};
|
||||
|
||||
const lastParsedImage = ref<ParsedFrameFingerprint | null>(null);
|
||||
const scannerFocusRef = ref<HTMLElement | null>(null);
|
||||
|
||||
type LPRResponse = {
|
||||
success: boolean;
|
||||
license_plate_number: string;
|
||||
};
|
||||
|
||||
const latestLPRResponse = ref<LPRResponse | null>(null);
|
||||
const LPR_IMAGE_MAX_WIDTH = 1280;
|
||||
const LPR_IMAGE_MAX_HEIGHT = 720;
|
||||
const LPR_IMAGE_JPEG_QUALITY = 0.72;
|
||||
|
||||
const compressImageForLPR = (image: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const sourceWidth = img.naturalWidth || img.width;
|
||||
const sourceHeight = img.naturalHeight || img.height;
|
||||
if (!sourceWidth || !sourceHeight) {
|
||||
resolve(image);
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = Math.min(1, LPR_IMAGE_MAX_WIDTH / sourceWidth, LPR_IMAGE_MAX_HEIGHT / sourceHeight);
|
||||
const targetWidth = Math.max(1, Math.round(sourceWidth * scale));
|
||||
const targetHeight = Math.max(1, Math.round(sourceHeight * scale));
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
resolve(image);
|
||||
return;
|
||||
}
|
||||
|
||||
context.drawImage(img, 0, 0, targetWidth, targetHeight);
|
||||
resolve(canvas.toDataURL("image/jpeg", LPR_IMAGE_JPEG_QUALITY));
|
||||
};
|
||||
img.onerror = () => resolve(image);
|
||||
img.src = image;
|
||||
});
|
||||
type LPRScanContext = {
|
||||
activeVehicleIndex: number;
|
||||
attachmentView: boolean;
|
||||
manualInput: boolean;
|
||||
registrationNumbers: string[];
|
||||
transactionHistoryView: boolean;
|
||||
};
|
||||
|
||||
const latestLPRResponse = ref<LPRResponse | null>(null);
|
||||
const isLPRFrameProcessing = ref(false);
|
||||
const isLPRRequestInFlight = ref(false);
|
||||
const isNoPlateBackoffActive = ref(false);
|
||||
const isDuplicateFrameBackoffActive = ref(false);
|
||||
const isSuccessCooldownActive = ref(false);
|
||||
let lprRequestAbortController: AbortController | null = null;
|
||||
let noPlateBackoffTimerId: ReturnType<typeof window.setTimeout> | null = null;
|
||||
let duplicateFrameBackoffTimerId: ReturnType<typeof window.setTimeout> | null = null;
|
||||
let successCooldownTimerId: ReturnType<typeof window.setTimeout> | null = null;
|
||||
|
||||
const NO_PLATE_BACKOFF_DELAYS_MS = [1500, 2500, 4000];
|
||||
const LPR_VISUAL_DUPLICATE_DISTANCE_THRESHOLD = 4;
|
||||
const LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS = 700;
|
||||
const LPR_ENDPOINT = "/modules/scanner/lpr";
|
||||
let consecutiveNoPlateResponses = 0;
|
||||
|
||||
const nowMs = (): number =>
|
||||
typeof performance !== "undefined" && typeof performance.now === "function"
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
|
||||
const activeVehicleIndexNext = () => {
|
||||
// Increment the active vehicle index, wrapping around if necessary
|
||||
if (vehicles.activeVehicleIndex.value < 3) {
|
||||
@@ -115,55 +124,518 @@ const handleLPRResult = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const parseImage = async (image: string) => {
|
||||
type LPRFrameInput = string | LPRFramePayload;
|
||||
|
||||
const isLPRFramePayload = (image: LPRFrameInput): image is LPRFramePayload =>
|
||||
typeof image === "object" && image !== null && image.blob instanceof Blob;
|
||||
|
||||
const getLPRFrameFingerprint = (image: LPRFrameInput): string =>
|
||||
isLPRFramePayload(image) ? image.fingerprint : image;
|
||||
|
||||
const getLPRFrameContentFingerprint = (image: LPRFrameInput): (() => Promise<string>) | null =>
|
||||
isLPRFramePayload(image) ? image.getContentFingerprint ?? null : null;
|
||||
|
||||
const getLPRFrameVisualFingerprint = (image: LPRFrameInput): string | null =>
|
||||
isLPRFramePayload(image) ? image.visualFingerprint ?? null : null;
|
||||
|
||||
const getLPRFrameVisualFingerprintGetter = (image: LPRFrameInput): (() => string | null) | null =>
|
||||
isLPRFramePayload(image) ? image.getVisualFingerprint ?? null : null;
|
||||
|
||||
const rememberParsedImageFingerprint = (image: LPRFrameInput) => {
|
||||
const quick = getLPRFrameFingerprint(image);
|
||||
const getContentFingerprint = getLPRFrameContentFingerprint(image);
|
||||
const entry: ParsedFrameFingerprint = {
|
||||
content: getContentFingerprint === null ? quick : null,
|
||||
contentFingerprintPromise: null,
|
||||
getContentFingerprint,
|
||||
getVisualFingerprint: getLPRFrameVisualFingerprintGetter(image),
|
||||
outcome: "pending",
|
||||
quick,
|
||||
visual: getLPRFrameVisualFingerprint(image),
|
||||
};
|
||||
|
||||
lastParsedImage.value = entry;
|
||||
};
|
||||
|
||||
const markLastParsedImageOutcome = (outcome: ParsedFrameFingerprint["outcome"]) => {
|
||||
if (lastParsedImage.value !== null) {
|
||||
if (outcome === "miss" && lastParsedImage.value.visual === null) {
|
||||
lastParsedImage.value.visual = lastParsedImage.value.getVisualFingerprint?.() ?? null;
|
||||
}
|
||||
|
||||
lastParsedImage.value.outcome = outcome;
|
||||
}
|
||||
};
|
||||
|
||||
const resetParsedImageFingerprint = () => {
|
||||
lastParsedImage.value = null;
|
||||
};
|
||||
|
||||
const resolveParsedFrameContentFingerprint = (entry: ParsedFrameFingerprint): Promise<string> | null => {
|
||||
if (entry.content !== null) {
|
||||
return Promise.resolve(entry.content);
|
||||
}
|
||||
|
||||
if (entry.getContentFingerprint === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
entry.contentFingerprintPromise ??= entry.getContentFingerprint().then((content) => {
|
||||
if (lastParsedImage.value === entry) {
|
||||
entry.content = content;
|
||||
}
|
||||
|
||||
return content;
|
||||
}).catch((error) => {
|
||||
if (lastParsedImage.value === entry) {
|
||||
entry.contentFingerprintPromise = null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
return entry.contentFingerprintPromise;
|
||||
};
|
||||
|
||||
const isVisuallySimilarToLastMiss = (visualFingerprint: string | null | undefined): boolean => {
|
||||
const lastParsed = lastParsedImage.value;
|
||||
|
||||
return lastParsed !== null
|
||||
&& lastParsed.outcome === "miss"
|
||||
&& getVisualFingerprintDistance(lastParsed.visual, visualFingerprint) <= LPR_VISUAL_DUPLICATE_DISTANCE_THRESHOLD;
|
||||
};
|
||||
|
||||
const hasLastMissVisualFingerprint = (): boolean =>
|
||||
lastParsedImage.value !== null
|
||||
&& lastParsedImage.value.outcome === "miss"
|
||||
&& lastParsedImage.value.visual !== null;
|
||||
|
||||
const shouldBuildLPRVisualFingerprint = (): boolean =>
|
||||
hasLastMissVisualFingerprint();
|
||||
|
||||
const shouldSkipDuplicateFrame = async (image: LPRFrameInput): Promise<boolean> => {
|
||||
const quick = getLPRFrameFingerprint(image);
|
||||
const lastParsed = lastParsedImage.value;
|
||||
|
||||
if (lastParsed === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isVisuallySimilarToLastMiss(getLPRFrameVisualFingerprint(image))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (lastParsed.quick !== quick) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentContentFingerprint = getLPRFrameContentFingerprint(image);
|
||||
const lastContentFingerprint = resolveParsedFrameContentFingerprint(lastParsed);
|
||||
if (lastContentFingerprint === null || currentContentFingerprint === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const [lastContent, currentContent] = await Promise.all([
|
||||
lastContentFingerprint,
|
||||
currentContentFingerprint(),
|
||||
]);
|
||||
|
||||
return lastContent === currentContent;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const appendFiniteTimingParam = (
|
||||
queryParts: string[],
|
||||
field: string,
|
||||
value: number | null | undefined
|
||||
) => {
|
||||
if (value === null || value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numericValue = Number(value);
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roundedValue = numericValue.toFixed(3);
|
||||
if (Number(roundedValue) > 0) {
|
||||
queryParts.push(`${field}=${roundedValue}`);
|
||||
}
|
||||
};
|
||||
|
||||
const appendPositiveIntegerParam = (
|
||||
queryParts: string[],
|
||||
field: string,
|
||||
value: number | null | undefined
|
||||
) => {
|
||||
const numericValue = Number(value);
|
||||
if (!Number.isFinite(numericValue) || numericValue <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryParts.push(`${field}=${Math.round(numericValue)}`);
|
||||
};
|
||||
|
||||
type LPRRequestPayload = Blob | { base64_image: string };
|
||||
type LPRRequestBuildResult = {
|
||||
headers?: Record<string, string>;
|
||||
payload: LPRRequestPayload;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const buildLPRRequestPayload = (
|
||||
image: LPRFrameInput,
|
||||
clientPreflightDurationMs: number | null = null
|
||||
): LPRRequestBuildResult => {
|
||||
if (!isLPRFramePayload(image)) {
|
||||
return {
|
||||
payload: { base64_image: image },
|
||||
url: LPR_ENDPOINT,
|
||||
};
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": image.mimeType || image.blob.type || "image/jpeg",
|
||||
};
|
||||
const queryParts: string[] = [];
|
||||
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_CAPTURE_MS_FIELD, image.captureDurationMs);
|
||||
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD, clientPreflightDurationMs);
|
||||
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_DRAW_MS_FIELD, image.captureTimings?.drawMs);
|
||||
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_ENCODE_MS_FIELD, image.captureTimings?.encodeMs);
|
||||
appendFiniteTimingParam(
|
||||
queryParts,
|
||||
LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD,
|
||||
image.captureTimings?.visualFingerprintMs
|
||||
);
|
||||
appendPositiveIntegerParam(queryParts, LPR_FRAME_CLIENT_WIDTH_FIELD, image.width);
|
||||
appendPositiveIntegerParam(queryParts, LPR_FRAME_CLIENT_HEIGHT_FIELD, image.height);
|
||||
appendPositiveIntegerParam(queryParts, LPR_FRAME_CLIENT_BYTES_FIELD, image.blob.size);
|
||||
const queryString = queryParts.join("&");
|
||||
|
||||
return {
|
||||
headers,
|
||||
payload: image.blob,
|
||||
url: queryString ? `${LPR_ENDPOINT}?${queryString}` : LPR_ENDPOINT,
|
||||
};
|
||||
};
|
||||
|
||||
type LPRCurrentStateSkipOptions = {
|
||||
ignoreNoPlateBackoff?: boolean;
|
||||
};
|
||||
|
||||
const shouldEncodeLPRFrame = (candidate: LPRFrameEncodeCandidate): boolean => {
|
||||
if (views.attachmentView.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
shouldSkipLPRForCurrentState({ ignoreNoPlateBackoff: true })
|
||||
|| isLPRFrameProcessing.value
|
||||
|| isLPRRequestInFlight.value
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isNoPlateBackoffActive.value) {
|
||||
if (!hasLastMissVisualFingerprint() || isVisuallySimilarToLastMiss(candidate.visualFingerprint)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearNoPlateBackoff();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isVisuallySimilarToLastMiss(candidate.visualFingerprint)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
scheduleDuplicateFrameBackoff();
|
||||
return false;
|
||||
};
|
||||
|
||||
const rememberLatestCameraImage = (image: LPRFrameInput) => {
|
||||
if (isLPRFramePayload(image)) {
|
||||
camera.setLatestImageBlob(image.blob);
|
||||
return;
|
||||
}
|
||||
|
||||
camera.setLatestImage(image);
|
||||
};
|
||||
|
||||
const hasRegistrationNumber = (registrationNumber: string | null | undefined): boolean =>
|
||||
String(registrationNumber ?? "").trim().length > 0;
|
||||
|
||||
const isActiveRegistrationSlotFilled = (): boolean =>
|
||||
hasRegistrationNumber(vehicles.getActiveVehicle()?.reg);
|
||||
|
||||
const areAllRegistrationSlotsFilled = (): boolean =>
|
||||
[1, 2, 3].every((vehicleIndex) => hasRegistrationNumber(vehicles.get(vehicleIndex)?.reg));
|
||||
|
||||
const shouldSkipLPRForCurrentState = (options: LPRCurrentStateSkipOptions = {}): boolean =>
|
||||
views.attachmentView.value
|
||||
|| isActiveRegistrationSlotFilled()
|
||||
|| areAllRegistrationSlotsFilled()
|
||||
|| (!options.ignoreNoPlateBackoff && isNoPlateBackoffActive.value)
|
||||
|| isDuplicateFrameBackoffActive.value
|
||||
|| isSuccessCooldownActive.value;
|
||||
|
||||
const getScannerFocusViewportRect = (): LPRFrameViewportRect | null => {
|
||||
if (views.attachmentView.value || scannerFocusRef.value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = scannerFocusRef.value.getBoundingClientRect();
|
||||
if (
|
||||
!Number.isFinite(rect.width) ||
|
||||
!Number.isFinite(rect.height) ||
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
height: rect.height,
|
||||
width: rect.width,
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
};
|
||||
};
|
||||
|
||||
const isCameraFrameCaptureEnabled = computed(() => {
|
||||
if (views.attachmentView.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !isLPRFrameProcessing.value
|
||||
&& !isLPRRequestInFlight.value
|
||||
&& (!isNoPlateBackoffActive.value || hasLastMissVisualFingerprint())
|
||||
&& !isDuplicateFrameBackoffActive.value
|
||||
&& !isSuccessCooldownActive.value
|
||||
&& !isActiveRegistrationSlotFilled()
|
||||
&& !areAllRegistrationSlotsFilled();
|
||||
});
|
||||
|
||||
const shouldPauseScannerPreview = computed(() =>
|
||||
!views.attachmentView.value
|
||||
&& (
|
||||
isLPRFrameProcessing.value
|
||||
|| isLPRRequestInFlight.value
|
||||
|| isDuplicateFrameBackoffActive.value
|
||||
|| isSuccessCooldownActive.value
|
||||
|| isActiveRegistrationSlotFilled()
|
||||
|| areAllRegistrationSlotsFilled()
|
||||
|| (isNoPlateBackoffActive.value && !hasLastMissVisualFingerprint())
|
||||
)
|
||||
);
|
||||
|
||||
const lprCameraCaptureIntervalMs = computed(() =>
|
||||
!views.attachmentView.value && isNoPlateBackoffActive.value && hasLastMissVisualFingerprint()
|
||||
? LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS
|
||||
: null
|
||||
);
|
||||
|
||||
const abortLPRRequest = () => {
|
||||
lprRequestAbortController?.abort();
|
||||
lprRequestAbortController = null;
|
||||
};
|
||||
|
||||
const isDocumentHidden = (): boolean =>
|
||||
typeof document !== "undefined" && document.visibilityState === "hidden";
|
||||
|
||||
const handleDocumentVisibilityChange = () => {
|
||||
if (!isDocumentHidden()) {
|
||||
return;
|
||||
}
|
||||
|
||||
abortLPRRequest();
|
||||
resetParsedImageFingerprint();
|
||||
resetNoPlateBackoff();
|
||||
};
|
||||
|
||||
const clearNoPlateBackoff = () => {
|
||||
if (noPlateBackoffTimerId !== null) {
|
||||
window.clearTimeout(noPlateBackoffTimerId);
|
||||
noPlateBackoffTimerId = null;
|
||||
}
|
||||
isNoPlateBackoffActive.value = false;
|
||||
};
|
||||
|
||||
const clearDuplicateFrameBackoff = () => {
|
||||
if (duplicateFrameBackoffTimerId !== null) {
|
||||
window.clearTimeout(duplicateFrameBackoffTimerId);
|
||||
duplicateFrameBackoffTimerId = null;
|
||||
}
|
||||
isDuplicateFrameBackoffActive.value = false;
|
||||
};
|
||||
|
||||
const clearSuccessCooldown = () => {
|
||||
if (successCooldownTimerId !== null) {
|
||||
window.clearTimeout(successCooldownTimerId);
|
||||
successCooldownTimerId = null;
|
||||
}
|
||||
isSuccessCooldownActive.value = false;
|
||||
};
|
||||
|
||||
const resetNoPlateBackoff = () => {
|
||||
consecutiveNoPlateResponses = 0;
|
||||
clearNoPlateBackoff();
|
||||
clearDuplicateFrameBackoff();
|
||||
clearSuccessCooldown();
|
||||
};
|
||||
|
||||
const scheduleNoPlateBackoff = () => {
|
||||
consecutiveNoPlateResponses += 1;
|
||||
const delay = NO_PLATE_BACKOFF_DELAYS_MS[
|
||||
Math.min(consecutiveNoPlateResponses - 1, NO_PLATE_BACKOFF_DELAYS_MS.length - 1)
|
||||
];
|
||||
|
||||
clearNoPlateBackoff();
|
||||
isNoPlateBackoffActive.value = true;
|
||||
noPlateBackoffTimerId = window.setTimeout(() => {
|
||||
noPlateBackoffTimerId = null;
|
||||
isNoPlateBackoffActive.value = false;
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const scheduleDuplicateFrameBackoff = () => {
|
||||
clearDuplicateFrameBackoff();
|
||||
isDuplicateFrameBackoffActive.value = true;
|
||||
duplicateFrameBackoffTimerId = window.setTimeout(() => {
|
||||
duplicateFrameBackoffTimerId = null;
|
||||
isDuplicateFrameBackoffActive.value = false;
|
||||
}, Math.min(camera.getImageCaptureDelay(false), LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS));
|
||||
};
|
||||
|
||||
const scheduleSuccessCooldown = () => {
|
||||
clearSuccessCooldown();
|
||||
isSuccessCooldownActive.value = true;
|
||||
successCooldownTimerId = window.setTimeout(() => {
|
||||
successCooldownTimerId = null;
|
||||
isSuccessCooldownActive.value = false;
|
||||
}, camera.getImageCaptureDelayAfterSuccess());
|
||||
};
|
||||
|
||||
const isAbortError = (error: unknown): boolean => {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeof error === "object" && error !== null && (error as { name?: string; code?: string }).code === "ERR_CANCELED";
|
||||
};
|
||||
|
||||
const isSameLPRScanContext = (first: LPRScanContext, second: LPRScanContext): boolean =>
|
||||
first.activeVehicleIndex === second.activeVehicleIndex
|
||||
&& first.attachmentView === second.attachmentView
|
||||
&& first.manualInput === second.manualInput
|
||||
&& first.transactionHistoryView === second.transactionHistoryView
|
||||
&& first.registrationNumbers.length === second.registrationNumbers.length
|
||||
&& first.registrationNumbers.every((registrationNumber, index) => registrationNumber === second.registrationNumbers[index]);
|
||||
|
||||
const parseImage = async (image: LPRFrameInput) => {
|
||||
if (views.attachmentView.value) {
|
||||
rememberLatestCameraImage(image);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSkipLPRForCurrentState() || isLPRFrameProcessing.value || isLPRRequestInFlight.value) {
|
||||
return;
|
||||
}
|
||||
// Check if the time since the last successful parse is enough
|
||||
if (!camera.hasDelayAfterSuccessPassed()) {
|
||||
return;
|
||||
}
|
||||
if (lastParsedImage.value === image) {
|
||||
// If the image is the same as the last parsed one, skip parsing
|
||||
|
||||
isLPRFrameProcessing.value = true;
|
||||
try {
|
||||
const clientPreflightStartedAt = nowMs();
|
||||
const isDuplicateFrame = await shouldSkipDuplicateFrame(image);
|
||||
const clientPreflightDurationMs = Math.max(0, nowMs() - clientPreflightStartedAt);
|
||||
|
||||
if (shouldSkipLPRForCurrentState()) {
|
||||
return;
|
||||
}
|
||||
lastParsedImage.value = image; // Update the last parsed image
|
||||
camera.setLatestImage(image); // Update the latest image in the camera object
|
||||
// Function to parse the image data
|
||||
const compressedImage = await compressImageForLPR(image);
|
||||
SessionUser.request("/modules/scanner/lpr", "POST", {
|
||||
base64_image: compressedImage,
|
||||
})
|
||||
.then((response) => {
|
||||
|
||||
if (isDuplicateFrame) {
|
||||
// If the image is the same as the last parsed one, skip parsing
|
||||
scheduleDuplicateFrameBackoff();
|
||||
return;
|
||||
}
|
||||
|
||||
rememberParsedImageFingerprint(image); // Update the last parsed image
|
||||
isLPRRequestInFlight.value = true;
|
||||
const abortController = new AbortController();
|
||||
lprRequestAbortController = abortController;
|
||||
const requestScanContext = getLPRScanContext();
|
||||
const lprRequest = buildLPRRequestPayload(image, clientPreflightDurationMs);
|
||||
|
||||
try {
|
||||
const response = await SessionUser.request(
|
||||
lprRequest.url,
|
||||
"POST",
|
||||
lprRequest.payload,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
...(lprRequest.headers ? { headers: lprRequest.headers } : {}),
|
||||
signal: abortController.signal,
|
||||
transport: "fetch",
|
||||
}
|
||||
);
|
||||
|
||||
if (debug_mode.value) {
|
||||
debug_request_results.value.push(response);
|
||||
}
|
||||
// If the response is not successful, stop here.
|
||||
if (!response.data.success) {
|
||||
|
||||
if (!isSameLPRScanContext(requestScanContext, getLPRScanContext())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the response is not successful, stop here.
|
||||
if (!response.data.success) {
|
||||
markLastParsedImageOutcome("miss");
|
||||
scheduleNoPlateBackoff();
|
||||
return;
|
||||
}
|
||||
resetNoPlateBackoff();
|
||||
markLastParsedImageOutcome("success");
|
||||
rememberLatestCameraImage(image);
|
||||
latestLPRResponse.value = response.data.data as LPRResponse;
|
||||
// Set the last successful capture time
|
||||
camera.setLastSuccess();
|
||||
scheduleSuccessCooldown();
|
||||
// Handle parsed result.
|
||||
handleLPRResult();
|
||||
})
|
||||
.catch((error) => {
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug_mode.value) {
|
||||
debug_request_results.value.push(error);
|
||||
}
|
||||
markLastParsedImageOutcome("miss");
|
||||
scheduleNoPlateBackoff();
|
||||
//console.error("Error parsing image:", error);
|
||||
});
|
||||
} finally {
|
||||
if (lprRequestAbortController === abortController) {
|
||||
lprRequestAbortController = null;
|
||||
}
|
||||
isLPRRequestInFlight.value = false;
|
||||
}
|
||||
} finally {
|
||||
isLPRFrameProcessing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(manualInput, (newValue) => {
|
||||
// Update the header transparency when manualInput changes
|
||||
setTransparency(!newValue);
|
||||
});
|
||||
type statusIcon =
|
||||
| typeof VerifiedCustomer
|
||||
| typeof KnownCustomer
|
||||
| typeof UnknownCustomer
|
||||
| typeof CardPaymentCustomer
|
||||
| typeof BookedCustomer;
|
||||
|
||||
const registrationNumbers = computed(() => {
|
||||
// Return the registration numbers of all vehicles
|
||||
return [
|
||||
@@ -173,6 +645,14 @@ const registrationNumbers = computed(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const getLPRScanContext = (): LPRScanContext => ({
|
||||
activeVehicleIndex: vehicles.activeVehicleIndex.value,
|
||||
attachmentView: views.attachmentView.value,
|
||||
manualInput: manualInput.value,
|
||||
registrationNumbers: [...registrationNumbers.value],
|
||||
transactionHistoryView: transactionHistoryView.value,
|
||||
});
|
||||
|
||||
function getSearchResultIndex(object: PosSearchResult) {
|
||||
let targetIndex = vehicles.activeVehicleIndex.value; // Default to the current active vehicle index
|
||||
// Check if the registration number already exists in the vehicles (And update the vehicle if it does)
|
||||
@@ -228,27 +708,49 @@ const getQuery = computed(() => {
|
||||
return activeVehicle ? activeVehicle.reg : "";
|
||||
});
|
||||
|
||||
const activeSelectionHasRegistrationNumber = computed(() => {
|
||||
return vehicles.getActiveVehicle()?.reg && vehicles.getActiveVehicle()?.reg.length > 0;
|
||||
});
|
||||
|
||||
const shouldCustomerBeModified = computed(() => {
|
||||
// Check if the customer should be modified based on the active vehicle index
|
||||
return vehicles.activeVehicleIndex.value === 1;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("visibilitychange", handleDocumentVisibilityChange);
|
||||
// Set the header to be transparent
|
||||
setTransparency(!views.isAnyActive.value);
|
||||
setBackgroundColor(backgroundColors.default); // Set the default background color
|
||||
setOverflow(false); // Prevent scrolling
|
||||
});
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("visibilitychange", handleDocumentVisibilityChange);
|
||||
abortLPRRequest();
|
||||
clearNoPlateBackoff();
|
||||
clearDuplicateFrameBackoff();
|
||||
clearSuccessCooldown();
|
||||
// Reset the header settings when the component is unmounted
|
||||
setTransparency(false); // Reset transparency
|
||||
setBackgroundColor(backgroundColors.default); // Reset to default background color
|
||||
setOverflow(true); // Allow scrolling again
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [manualInput.value, transactionHistoryView.value, views.attachmentView.value],
|
||||
([isManualInputActive, isTransactionHistoryActive, isAttachmentViewActive]) => {
|
||||
if (isManualInputActive || isTransactionHistoryActive || isAttachmentViewActive) {
|
||||
abortLPRRequest();
|
||||
resetParsedImageFingerprint();
|
||||
resetNoPlateBackoff();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [vehicles.activeVehicleIndex.value, ...registrationNumbers.value],
|
||||
() => {
|
||||
abortLPRRequest();
|
||||
resetParsedImageFingerprint();
|
||||
resetNoPlateBackoff();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -263,7 +765,16 @@ onUnmounted(() => {
|
||||
<!-- Default: Scanner view -->
|
||||
<template v-else>
|
||||
<div class="background-fixed">
|
||||
<ScannerCamera @update:frame="parseImage" />
|
||||
<ScannerCamera
|
||||
:capture-enabled="isCameraFrameCaptureEnabled"
|
||||
:capture-interval-ms="lprCameraCaptureIntervalMs"
|
||||
:capture-mode="views.attachmentView.value ? 'preview' : 'lpr'"
|
||||
:get-focus-viewport-rect="getScannerFocusViewportRect"
|
||||
:pause-preview="shouldPauseScannerPreview"
|
||||
:should-build-visual-fingerprint="shouldBuildLPRVisualFingerprint"
|
||||
:should-encode-frame="shouldEncodeLPRFrame"
|
||||
@update:frame="parseImage"
|
||||
/>
|
||||
</div>
|
||||
<div class="custom-content" data-testid="pos-mobile-step-1">
|
||||
<!-- Meta objects, registration number auto-lookup -->
|
||||
@@ -289,7 +800,13 @@ onUnmounted(() => {
|
||||
/>
|
||||
<!-- Scanner outline object -->
|
||||
<div class="is-align-content-center is-flex is-justify-content-center">
|
||||
<ScannerOutline :loading="false" v-if="!views.attachmentView.value" />
|
||||
<div
|
||||
v-if="!views.attachmentView.value"
|
||||
ref="scannerFocusRef"
|
||||
class="scanner-focus-target"
|
||||
>
|
||||
<ScannerOutline :loading="false" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reg. 1, Reg. 2, Reg. 3 -->
|
||||
<div
|
||||
@@ -303,7 +820,10 @@ onUnmounted(() => {
|
||||
<!-- Location -->
|
||||
<PosDepartmentStepMobile1Location />
|
||||
<!-- Buttons -->
|
||||
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
|
||||
<PosDepartmentStepMobileFixedBottomControl
|
||||
variant="pos-step"
|
||||
:use-backdrop-blur="views.attachmentView.value"
|
||||
>
|
||||
<!-- Attachments -->
|
||||
<div class="is-flex is-justify-content-center">
|
||||
<PosDepartmentStepMobileAttachments :showDefaultControls="false" v-show="views.attachmentView.value" />
|
||||
@@ -429,4 +949,10 @@ onUnmounted(() => {
|
||||
.custom-content > * {
|
||||
width: min(100%, 48rem);
|
||||
}
|
||||
|
||||
.scanner-focus-target {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
width: fit-content;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,6 +53,9 @@ import PosDepartmentStepMobile2AdditionalItems from "@/components/displays/depar
|
||||
import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
onMounted(() => {
|
||||
// Set the header to be transparent
|
||||
@@ -477,6 +480,8 @@ const updateLastOrderItemPrices = (items: PosOrderItem[]) => {
|
||||
const layout = {
|
||||
classes: <string[]>[],
|
||||
};
|
||||
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
|
||||
|
||||
const onCopyLastOrder = (vehicleIndex: number) => {
|
||||
lastOrders.select(vehicleIndex);
|
||||
@@ -777,15 +782,18 @@ const buildDesiredOrderItemShapes = () => {
|
||||
|
||||
const addonShapes = (transactionItems.primaryItem.value.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||
.map((addon: any) => ({
|
||||
.map((addon: any) => {
|
||||
const addonProduct = addon?.product ?? addon;
|
||||
return {
|
||||
kind: "addon",
|
||||
relatedKey: "primary",
|
||||
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
|
||||
product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
|
||||
quantity: Number(addon?.quantity ?? 0),
|
||||
related_item_id: "__PRIMARY__",
|
||||
price: Number(addon?.product?.price ?? addon?.price ?? 0),
|
||||
notes: String(addon?.product?.notes ?? ""),
|
||||
}));
|
||||
price: Number(addonProduct?.price ?? addon?.price ?? 0),
|
||||
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
|
||||
};
|
||||
});
|
||||
|
||||
const additionalShapes = (transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||
@@ -952,16 +960,17 @@ const syncCurrentTransactionToOrder = async () => {
|
||||
|
||||
const addonPromises = (transactionItems.primaryItem.value.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||
.map((addon: any) =>
|
||||
createOrderItem(
|
||||
.map((addon: any) => {
|
||||
const addonProduct = addon?.product ?? addon;
|
||||
return createOrderItem(
|
||||
normalizedOrderId,
|
||||
addon.product.id,
|
||||
addonProduct.id,
|
||||
Number(addon.quantity),
|
||||
createdPrimaryItemId,
|
||||
addon.product?.notes || "",
|
||||
addon.product.price
|
||||
)
|
||||
addonProduct?.notes || addon?.notes || "",
|
||||
addonProduct.price ?? addon.price
|
||||
);
|
||||
});
|
||||
|
||||
const additionalPromises = (transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||
@@ -973,11 +982,146 @@ const syncCurrentTransactionToOrder = async () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const normalizeText = (value: unknown) => String(value ?? "").trim();
|
||||
|
||||
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
|
||||
|
||||
const getProductId = (product: any) =>
|
||||
Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
|
||||
|
||||
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
|
||||
|
||||
const productRequiresOrderItemNote = (product: any) => {
|
||||
if (!product) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
|
||||
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
|
||||
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
|
||||
);
|
||||
};
|
||||
|
||||
const productHasOrderItemNote = (product: any) =>
|
||||
normalizeText(product?.notes ?? product?.product?.notes).length > 0;
|
||||
|
||||
const getSelectedProductsMissingRequiredNotes = () => {
|
||||
const missingProducts: any[] = [];
|
||||
const primaryProduct = transactionItems.primaryItem.value;
|
||||
|
||||
if (productRequiresOrderItemNote(primaryProduct) && !productHasOrderItemNote(primaryProduct)) {
|
||||
missingProducts.push(primaryProduct);
|
||||
}
|
||||
|
||||
(primaryProduct?.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||
.forEach((addon: any) => {
|
||||
const addonProduct = addon?.product ?? addon;
|
||||
if (
|
||||
(productRequiresOrderItemNote(addonProduct) || productRequiresOrderItemNote(addon)) &&
|
||||
!productHasOrderItemNote(addonProduct) &&
|
||||
!productHasOrderItemNote(addon)
|
||||
) {
|
||||
missingProducts.push(addonProduct);
|
||||
}
|
||||
});
|
||||
|
||||
(transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||
.forEach((item: any) => {
|
||||
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
|
||||
missingProducts.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return missingProducts;
|
||||
};
|
||||
|
||||
const promptForRequiredProductNote = (product: any) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const hadOriginalNote = Object.prototype.hasOwnProperty.call(product, "notes");
|
||||
const originalNote = product?.notes;
|
||||
|
||||
const resolveAndClose = (didConfirm: boolean) => {
|
||||
if (!didConfirm) {
|
||||
if (hadOriginalNote) {
|
||||
product.notes = originalNote;
|
||||
} else {
|
||||
delete product.notes;
|
||||
}
|
||||
}
|
||||
if (popups.get()?.id === "add_product_note") {
|
||||
popups.clear();
|
||||
}
|
||||
resolve(didConfirm);
|
||||
};
|
||||
|
||||
popups.select("add_product_note", {
|
||||
title: `${t("common.note")}: ${getProductName(product) || `#${getProductId(product)}`}`,
|
||||
message: t("objects.products.columns.requires_note"),
|
||||
component: "add_product_note",
|
||||
hideHeader: true,
|
||||
style: { maxHeight: "40vh" },
|
||||
props: {
|
||||
product,
|
||||
validationMessage: "",
|
||||
},
|
||||
actionButtons: [
|
||||
{
|
||||
label: t("common.confirm"),
|
||||
description: t("common.confirm"),
|
||||
color: "primary",
|
||||
testId: "pos-mobile-product-note-confirm",
|
||||
onClick: () => {
|
||||
const activePopup = popups.get();
|
||||
const normalizedNote = normalizeText(activePopup?.props?.product?.notes);
|
||||
if (!normalizedNote) {
|
||||
if (activePopup?.props) {
|
||||
activePopup.props.validationMessage = t("objects.products.columns.requires_note");
|
||||
}
|
||||
return;
|
||||
}
|
||||
product.notes = normalizedNote;
|
||||
resolveAndClose(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("common.cancel"),
|
||||
description: t("common.cancel"),
|
||||
color: "light",
|
||||
testId: "pos-mobile-product-note-cancel",
|
||||
onClick: () => resolveAndClose(false),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const ensureRequiredOrderItemNotes = async () => {
|
||||
const missingProducts = getSelectedProductsMissingRequiredNotes();
|
||||
for (const product of missingProducts) {
|
||||
if (productHasOrderItemNote(product)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const didConfirm = await promptForRequiredProductNote(product);
|
||||
if (!didConfirm) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const onBeforeComplete = async () => {
|
||||
if (!transactionItems.primaryItem.value) {
|
||||
throw new Error("No primary item selected");
|
||||
}
|
||||
|
||||
if (!(await ensureRequiredOrderItemNotes())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await syncCurrentTransactionToOrder();
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
reg_1,
|
||||
reset_all_values,
|
||||
setStep,
|
||||
pushPosRouteState,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { StripeModule } from "@/components/stripe/StripeModule.vue";
|
||||
@@ -70,10 +71,10 @@ const formatCurrency = (amount) => {
|
||||
const navigateToItems = () => {
|
||||
setStep(2);
|
||||
if (normalizedOrderId.value) {
|
||||
window.history.pushState({}, '', `?id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
|
||||
pushPosRouteState(`id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
|
||||
return;
|
||||
}
|
||||
window.history.pushState({}, '', `?step=2`);
|
||||
pushPosRouteState("step=2");
|
||||
};
|
||||
|
||||
const clearSuccessfulMobileCardFlow = () => {
|
||||
|
||||
+29
-11
@@ -2,25 +2,43 @@
|
||||
import { useGeolocation } from "@vueuse/core";
|
||||
import { watch } from "vue";
|
||||
import { locations } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
const emits = defineEmits<{
|
||||
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
|
||||
}>();
|
||||
const { coords, locatedAt, error, resume, pause } = useGeolocation({
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
enableHighAccuracy?: boolean;
|
||||
maximumAge?: number;
|
||||
timeout?: number;
|
||||
}>(), {
|
||||
enableHighAccuracy: true,
|
||||
maximumAge: 30000,
|
||||
timeout: 27000,
|
||||
});
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
|
||||
}>();
|
||||
|
||||
const { coords, locatedAt, error, resume, pause } = useGeolocation({
|
||||
enableHighAccuracy: props.enableHighAccuracy,
|
||||
maximumAge: props.maximumAge,
|
||||
timeout: props.timeout,
|
||||
});
|
||||
|
||||
const getLocationTimestamp = () => {
|
||||
const timestamp = Number(locatedAt.value);
|
||||
return Number.isFinite(timestamp) ? timestamp : Date.now();
|
||||
};
|
||||
|
||||
const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => {
|
||||
if (newCoords.latitude && newCoords.longitude) {
|
||||
const normalizedCoords = locations.normalizeCoordinatePair(newCoords);
|
||||
if (normalizedCoords) {
|
||||
const timestamp = getLocationTimestamp();
|
||||
locations.set({
|
||||
coords: {
|
||||
latitude: newCoords.latitude,
|
||||
longitude: newCoords.longitude,
|
||||
},
|
||||
timestamp: new Date(),
|
||||
coords: normalizedCoords,
|
||||
timestamp: new Date(timestamp),
|
||||
locatedAt: timestamp,
|
||||
errorMessage: error.value ? error.value.message : null,
|
||||
})
|
||||
emits('location-updated', newCoords);
|
||||
emits('location-updated', normalizedCoords);
|
||||
}
|
||||
};
|
||||
watch(coords, (newCoords) => {
|
||||
|
||||
+6
-10
@@ -299,14 +299,12 @@ const getSelectedBookingId = () => {
|
||||
|
||||
const completeStep2Order = async ({
|
||||
bookingSafetySeal = null,
|
||||
markOrderCompleted = false,
|
||||
}: {
|
||||
bookingSafetySeal?: string | null;
|
||||
markOrderCompleted?: boolean;
|
||||
} = {}) => {
|
||||
await finalizeCurrentMobileOrder({
|
||||
bookingSafetySeal,
|
||||
markOrderCompleted,
|
||||
markOrderCompleted: true,
|
||||
});
|
||||
popups.select("completed_transaction", {
|
||||
message: `Order #${order_id.value} successfully created.`,
|
||||
@@ -327,14 +325,12 @@ const step2 = async () => {
|
||||
const resolvedSafetySeal = getResolvedMobileSafetySeal();
|
||||
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
|
||||
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
|
||||
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
|
||||
|
||||
try {
|
||||
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
|
||||
syncMobileSafetySealState(resolvedSafetySeal);
|
||||
await completeStep2Order({
|
||||
bookingSafetySeal: resolvedSafetySeal,
|
||||
markOrderCompleted: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -344,7 +340,6 @@ const step2 = async () => {
|
||||
syncMobileSafetySealState(safetySeal);
|
||||
await completeStep2Order({
|
||||
bookingSafetySeal: String(safetySeal ?? ""),
|
||||
markOrderCompleted: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -358,9 +353,7 @@ const step2 = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
await completeStep2Order({
|
||||
markOrderCompleted: shouldMarkOrderAsCompleted,
|
||||
});
|
||||
await completeStep2Order();
|
||||
} catch (error: any) {
|
||||
errors.value.push(error);
|
||||
console.warn("An error occurred while completing the mobile order:", error);
|
||||
@@ -412,7 +405,10 @@ const onClick = async () => {
|
||||
|
||||
isProcessingClick.value = true;
|
||||
try {
|
||||
await props.onBeforeStep();
|
||||
const beforeStepResult = await props.onBeforeStep();
|
||||
if (beforeStepResult === false) {
|
||||
return;
|
||||
}
|
||||
// Proceed to the next step
|
||||
switch (step.value) {
|
||||
case 1:
|
||||
|
||||
+13
-1
@@ -11,6 +11,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
useBackdropBlur: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
@@ -25,6 +29,13 @@ const props = defineProps({
|
||||
const FIXED_BOTTOM_HEIGHT_CSS_VAR = '--pos-mobile-fixed-bottom-height';
|
||||
|
||||
const style = computed(() => {
|
||||
const background = `linear-gradient(to top, rgba(255, 255, 255, ${props.blurAmount * 0.8}) 0%, rgba(255, 255, 255, ${props.blurAmount * 0.4}) 100%)`;
|
||||
if (!props.useBackdropBlur) {
|
||||
return {
|
||||
background,
|
||||
};
|
||||
}
|
||||
|
||||
// Smooth blur transition
|
||||
if (props.smoothBlur) {
|
||||
return {
|
||||
@@ -32,12 +43,13 @@ const style = computed(() => {
|
||||
WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`,
|
||||
transition: 'backdrop-filter 0.3s ease, -webkit-backdrop-filter 0.3s ease',
|
||||
// Add gradient from bottom
|
||||
background: `linear-gradient(to top, rgba(255, 255, 255, ${props.blurAmount * 0.8}) 0%, rgba(255, 255, 255, ${props.blurAmount * 0.4}) 100%)`,
|
||||
background,
|
||||
// Add gradient from top
|
||||
|
||||
}
|
||||
}
|
||||
return {
|
||||
background,
|
||||
backdropFilter: `blur(${props.blurAmount * 10}px)`,
|
||||
WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`
|
||||
}
|
||||
|
||||
+1
@@ -28,6 +28,7 @@ watch(note, (newNote) => {
|
||||
class="input is-searched"
|
||||
v-model="note"
|
||||
type="text"
|
||||
data-testid="pos-mobile-product-note-input"
|
||||
placeholder="Indtast note"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+74
-8
@@ -100,18 +100,57 @@ const getLocation = () => {
|
||||
const clearLocation = () => {
|
||||
location.value = null;
|
||||
};
|
||||
type CoordinatePair = {
|
||||
latitude?: number | string | null;
|
||||
longitude?: number | string | null;
|
||||
};
|
||||
|
||||
export const normalizeCoordinatePair = (
|
||||
coordinates: CoordinatePair | null | undefined,
|
||||
{ allowZeroPair = true }: { allowZeroPair?: boolean } = {}
|
||||
): { latitude: number; longitude: number } | null => {
|
||||
const latitude = Number(coordinates?.latitude);
|
||||
const longitude = Number(coordinates?.longitude);
|
||||
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!allowZeroPair && latitude === 0 && longitude === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { latitude, longitude };
|
||||
};
|
||||
|
||||
export const hasValidCoordinatePair = (
|
||||
coordinates: CoordinatePair | null | undefined,
|
||||
options: { allowZeroPair?: boolean } = {}
|
||||
): boolean => normalizeCoordinatePair(coordinates, options) !== null;
|
||||
|
||||
// Get distance in kilometers between two locations
|
||||
const getDistance = (
|
||||
from: { latitude: number; longitude: number },
|
||||
to: { latitude: number; longitude: number }
|
||||
from: CoordinatePair,
|
||||
to: CoordinatePair
|
||||
): number => {
|
||||
const normalizedFrom = normalizeCoordinatePair(from);
|
||||
const normalizedTo = normalizeCoordinatePair(to);
|
||||
|
||||
if (!normalizedFrom || !normalizedTo) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
const toRad = (value: number) => (value * Math.PI) / 180;
|
||||
|
||||
const R = 6371; // Radius of the Earth in kilometers
|
||||
const dLat = toRad(to.latitude - from.latitude);
|
||||
const dLon = toRad(to.longitude - from.longitude);
|
||||
const lat1 = toRad(from.latitude);
|
||||
const lat2 = toRad(to.latitude);
|
||||
const dLat = toRad(normalizedTo.latitude - normalizedFrom.latitude);
|
||||
const dLon = toRad(normalizedTo.longitude - normalizedFrom.longitude);
|
||||
const lat1 = toRad(normalizedFrom.latitude);
|
||||
const lat2 = toRad(normalizedTo.latitude);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
|
||||
@@ -124,6 +163,8 @@ const locations = {
|
||||
set: setLocation,
|
||||
get: getLocation,
|
||||
clear: clearLocation,
|
||||
normalizeCoordinatePair,
|
||||
hasValidCoordinatePair,
|
||||
getDistance,
|
||||
defaultTimeout: locationTimeout,
|
||||
};
|
||||
@@ -1024,9 +1065,9 @@ const getTotalAttachmentsCount = () => {
|
||||
);
|
||||
};
|
||||
// Function to take a picture as a base64 attachment
|
||||
const takePicture = () => {
|
||||
const takePicture = async () => {
|
||||
// Save the last picture to the base64 attachments
|
||||
const lastPicture = latestImage.value;
|
||||
const lastPicture = await getLatestImage();
|
||||
if (lastPicture) {
|
||||
addAttachmentBase64({
|
||||
filename: "last_picture.jpg",
|
||||
@@ -1077,6 +1118,7 @@ watch(
|
||||
/** Camera */
|
||||
// Define the reactive properties
|
||||
const latestImage = ref<string | null>(null);
|
||||
const latestImageBlob = ref<Blob | null>(null);
|
||||
const isCameraMounted = ref<boolean>(false);
|
||||
const cameraImageCaptureDelayInitial = ref<number>(500); // Initial delay for camera image capture in milliseconds (First capture)
|
||||
const cameraImageCaptureDelaySubsequent = ref<number>(1005); // Further captures delay in milliseconds (Every capture after the first one)
|
||||
@@ -1118,13 +1160,34 @@ const hasCameraImageCaptureDelayAfterSuccessPassed = (): boolean => {
|
||||
const currentTime = Date.now();
|
||||
return currentTime - cameraImageCaptureLastSuccess.value > cameraImageCaptureDelayAfterSuccess.value;
|
||||
};
|
||||
const blobToDataUrl = (blob: Blob): Promise<string | null> => new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve(typeof reader.result === "string" ? reader.result : null);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
resolve(null);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
// Function to retrieve the latest image frame.
|
||||
const getLatestImage = async () => {
|
||||
if (!latestImage.value && latestImageBlob.value) {
|
||||
latestImage.value = await blobToDataUrl(latestImageBlob.value);
|
||||
}
|
||||
|
||||
return latestImage.value;
|
||||
};
|
||||
// Function to set the latest image frame.
|
||||
const setLatestImage = (image: string | null) => {
|
||||
latestImage.value = image;
|
||||
latestImageBlob.value = null;
|
||||
};
|
||||
// Function to set the latest image frame as a Blob.
|
||||
const setLatestImageBlob = (image: Blob | null) => {
|
||||
latestImage.value = null;
|
||||
latestImageBlob.value = image;
|
||||
};
|
||||
// Function to set the camera mounted state.
|
||||
const setCameraMounted = (mounted: boolean) => {
|
||||
@@ -1133,6 +1196,7 @@ const setCameraMounted = (mounted: boolean) => {
|
||||
// Function to clear the latest image.
|
||||
const clearLatestImage = () => {
|
||||
latestImage.value = null;
|
||||
latestImageBlob.value = null;
|
||||
};
|
||||
// Function to clear the camera mounted state.
|
||||
const clearCameraMounted = () => {
|
||||
@@ -1153,10 +1217,12 @@ const setCameraImageCaptureDelay = (isFirstCapture: boolean, delay: number) => {
|
||||
|
||||
const camera = {
|
||||
latestImage,
|
||||
latestImageBlob,
|
||||
get: getLatestImage,
|
||||
mounted: isCameraMounted,
|
||||
setMounted: setCameraMounted,
|
||||
setLatestImage,
|
||||
setLatestImageBlob,
|
||||
clearLatestImage,
|
||||
clearMounted: clearCameraMounted,
|
||||
getImageCaptureDelay: getCameraImageCaptureDelay,
|
||||
|
||||
@@ -18,6 +18,8 @@ export type PosLocation = {
|
||||
coords?: PosLocationCoords | null;
|
||||
/** Timestamp */
|
||||
timestamp?: Date | null;
|
||||
/** Browser geolocation timestamp in milliseconds */
|
||||
locatedAt?: number | null;
|
||||
/** Error message */
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,45 @@ export const XLVASK_USAGE_AMOUNT_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const CACHE_PREFIX = "xlvask-usage-amount:";
|
||||
const memoryCache = new Map();
|
||||
|
||||
const getStorageValue = (key) => {
|
||||
try {
|
||||
if (typeof window === "undefined" || !window.localStorage) {
|
||||
return "";
|
||||
}
|
||||
return window.localStorage.getItem(key) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const hashCacheScopePart = (value) => {
|
||||
let hash = 5381;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
};
|
||||
|
||||
const getAuthenticatedCacheScope = () => {
|
||||
const token = getStorageValue("token");
|
||||
if (!token) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
hashCacheScopePart(token),
|
||||
getStorageValue("is_subuser") === "true" ? "subuser" : "user",
|
||||
getStorageValue("selected_customer_number"),
|
||||
]
|
||||
.map((part) => encodeURIComponent(String(part ?? "")))
|
||||
.join("|");
|
||||
};
|
||||
|
||||
const getScopedCacheKey = (cacheKey) => {
|
||||
const scope = getAuthenticatedCacheScope();
|
||||
return scope ? `${scope}:${cacheKey}` : "";
|
||||
};
|
||||
|
||||
const safeSessionStorage = () => {
|
||||
try {
|
||||
if (typeof window === "undefined" || !window.sessionStorage) {
|
||||
@@ -15,9 +54,7 @@ const safeSessionStorage = () => {
|
||||
};
|
||||
|
||||
const normalizeUsageLogId = (objectOrId) => {
|
||||
const value = typeof objectOrId === "object"
|
||||
? objectOrId?.usage_log_id ?? objectOrId?.id
|
||||
: objectOrId;
|
||||
const value = typeof objectOrId === "object" ? objectOrId?.usage_log_id ?? objectOrId?.id : objectOrId;
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||
};
|
||||
@@ -34,17 +71,18 @@ export const buildXlvaskUsageAmountCacheKey = (objectOrId) => {
|
||||
|
||||
export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
||||
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||
if (!cacheKey) {
|
||||
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||
if (!scopedCacheKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memoryEntry = memoryCache.get(cacheKey);
|
||||
const memoryEntry = memoryCache.get(scopedCacheKey);
|
||||
if (isFreshEntry(memoryEntry)) {
|
||||
return memoryEntry.data;
|
||||
}
|
||||
|
||||
if (memoryEntry) {
|
||||
memoryCache.delete(cacheKey);
|
||||
memoryCache.delete(scopedCacheKey);
|
||||
}
|
||||
|
||||
const storage = safeSessionStorage();
|
||||
@@ -52,14 +90,14 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storageKey = `${CACHE_PREFIX}${cacheKey}`;
|
||||
const storageKey = `${CACHE_PREFIX}${scopedCacheKey}`;
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
|
||||
if (!isFreshEntry(parsed)) {
|
||||
storage.removeItem(storageKey);
|
||||
return null;
|
||||
}
|
||||
memoryCache.set(cacheKey, parsed);
|
||||
memoryCache.set(scopedCacheKey, parsed);
|
||||
return parsed.data;
|
||||
} catch {
|
||||
storage.removeItem(storageKey);
|
||||
@@ -69,7 +107,8 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
||||
|
||||
export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||
if (!cacheKey) {
|
||||
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||
if (!scopedCacheKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,7 +116,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||
storedAt: Date.now(),
|
||||
data,
|
||||
};
|
||||
memoryCache.set(cacheKey, entry);
|
||||
memoryCache.set(scopedCacheKey, entry);
|
||||
|
||||
const storage = safeSessionStorage();
|
||||
if (!storage) {
|
||||
@@ -85,7 +124,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
|
||||
storage.setItem(`${CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
|
||||
} catch {
|
||||
// Best-effort cache. Quota errors should not block XL Vask usage rows.
|
||||
}
|
||||
@@ -94,11 +133,12 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||
export const clearCachedXlvaskUsageAmount = (objectOrId = null) => {
|
||||
const storage = safeSessionStorage();
|
||||
const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||
|
||||
if (cacheKey) {
|
||||
memoryCache.delete(cacheKey);
|
||||
if (scopedCacheKey) {
|
||||
memoryCache.delete(scopedCacheKey);
|
||||
try {
|
||||
storage?.removeItem(`${CACHE_PREFIX}${cacheKey}`);
|
||||
storage?.removeItem(`${CACHE_PREFIX}${scopedCacheKey}`);
|
||||
} catch {
|
||||
// Best-effort cache cleanup.
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
type="is-link"
|
||||
:disabled="!isMachineAvailable(selectedLaneId)"
|
||||
:title="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
|
||||
:aria-label="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
|
||||
data-testid="self-serve-wash-type-machine"
|
||||
@update:model-value="emit('update:washType', 'Machine')"
|
||||
@input="emit('update:washType', 'Machine')"
|
||||
@@ -117,15 +115,6 @@ const emit = defineEmits<{
|
||||
</b-radio-button>
|
||||
</div>
|
||||
</div>
|
||||
<b-message
|
||||
v-if="selectedLaneId && !isMachineAvailable(selectedLaneId)"
|
||||
type="is-warning"
|
||||
has-icon
|
||||
:closable="false"
|
||||
data-testid="self-serve-machine-unavailable-guidance"
|
||||
>
|
||||
{{ $t("self_wash.machine_unavailable_for_lane") }}
|
||||
</b-message>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,16 +21,6 @@ const isImageAttachment = (attachment: any) => (
|
||||
&& attachment.content.other.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)
|
||||
);
|
||||
|
||||
const hasTaskDescription = (task: any) => {
|
||||
const description = task?.description;
|
||||
if (typeof description !== "string") {
|
||||
return !!description;
|
||||
}
|
||||
|
||||
const trimmedDescription = description.trim();
|
||||
return trimmedDescription.length > 0 && trimmedDescription !== "-";
|
||||
};
|
||||
|
||||
const getImageAttachments = (task: any) => {
|
||||
if (!Array.isArray(task?.attachments)) {
|
||||
return [];
|
||||
@@ -83,7 +73,7 @@ const formatTaskTitle = (task: any) => {
|
||||
const title = String(task?.task || "");
|
||||
const programWheelSelection = getProgramWheelSelection(task);
|
||||
|
||||
if (programWheelSelection === null) {
|
||||
if (programWheelSelection === null || /\bprogram\s*#\d+\b/i.test(title)) {
|
||||
return title;
|
||||
}
|
||||
|
||||
@@ -127,13 +117,11 @@ const formatTaskTitle = (task: any) => {
|
||||
type="is-success"
|
||||
:data-testid="`self-serve-task-${task.id}-toggle`"
|
||||
@update:model-value="emit('toggle-task', task.id, $event)"
|
||||
@input="emit('toggle-task', task.id, $event)"
|
||||
/>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="self-serve-task-image-overlay-text">
|
||||
<p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p>
|
||||
<p v-if="hasTaskDescription(task)" class="self-serve-task-description">{{ task.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,22 +144,21 @@ const formatTaskTitle = (task: any) => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="columns is-mobile is-vcentered">
|
||||
<div v-if="props.showCheckboxes" class="column is-narrow">
|
||||
<div v-else class="self-serve-task-row">
|
||||
<div v-if="props.showCheckboxes" class="self-serve-task-checkbox">
|
||||
<b-field>
|
||||
<b-checkbox
|
||||
class="mr-0 pr-0"
|
||||
size="is-large"
|
||||
:model-value="completedTasks[task.id] === true"
|
||||
type="is-success"
|
||||
:data-testid="`self-serve-task-${task.id}-toggle`"
|
||||
@update:model-value="emit('toggle-task', task.id, $event)"
|
||||
@input="emit('toggle-task', task.id, $event)"
|
||||
/>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="self-serve-task-content">
|
||||
<p><strong>{{ formatTaskTitle(task) }}</strong></p>
|
||||
<p v-if="hasTaskDescription(task)" class="is-size-7">{{ task.description }}</p>
|
||||
<div v-if="getVisibleServices(task).length > 0" class="tags mt-2">
|
||||
<span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span>
|
||||
</div>
|
||||
@@ -239,7 +226,7 @@ const formatTaskTitle = (task: any) => {
|
||||
padding: 0.65rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
gap: 0.35rem;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -247,12 +234,30 @@ const formatTaskTitle = (task: any) => {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.self-serve-task-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.self-serve-task-checkbox {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.self-serve-task-checkbox :deep(.field) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.self-serve-task-content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.self-serve-task-content p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.self-serve-task-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.self-serve-task-description {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { BIcon, BSkeleton } from "buefy";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { BIcon } from "buefy";
|
||||
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -19,10 +19,21 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
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(
|
||||
() => props.dynamicImageUrl,
|
||||
(dynamicImageUrl) => {
|
||||
isDynamicImageLoading.value = !!dynamicImageUrl;
|
||||
}, { immediate: true });
|
||||
failedDynamicImageUrl.value = null;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const emitToggleTask = (taskId: number, value: boolean) => {
|
||||
emit("toggle-task", taskId, value);
|
||||
@@ -38,6 +49,7 @@ const onDynamicImageLoad = () => {
|
||||
|
||||
const onDynamicImageError = () => {
|
||||
isDynamicImageLoading.value = false;
|
||||
failedDynamicImageUrl.value = props.dynamicImageUrl;
|
||||
emit("clear-dynamic-image");
|
||||
};
|
||||
</script>
|
||||
@@ -58,33 +70,43 @@ const onDynamicImageError = () => {
|
||||
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
||||
</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
|
||||
v-if="dynamicImageUrl"
|
||||
v-if="shouldRenderDynamicImageFrame"
|
||||
class="self-serve-dynamic-image-frame mb-4"
|
||||
:class="{ 'is-loading': isDynamicImageLoading }"
|
||||
data-testid="self-serve-dynamic-image-frame"
|
||||
>
|
||||
<b-skeleton
|
||||
<div
|
||||
v-if="isDynamicImageLoading"
|
||||
class="self-serve-dynamic-image-skeleton"
|
||||
width="100%"
|
||||
height="100%"
|
||||
data-testid="self-serve-dynamic-image-skeleton"
|
||||
/>
|
||||
>
|
||||
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-large" />
|
||||
<span class="is-sr-only">{{ $t("self_wash.loading_data") }}</span>
|
||||
</div>
|
||||
<img
|
||||
v-if="showDynamicImageFrame"
|
||||
:key="dynamicImageUrl"
|
||||
:src="dynamicImageUrl"
|
||||
alt="Machine status"
|
||||
data-testid="self-serve-dynamic-image"
|
||||
class="self-serve-dynamic-image"
|
||||
:class="{ 'is-loading': isDynamicImageLoading }"
|
||||
@load="onDynamicImageLoad"
|
||||
@error="onDynamicImageError"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
|
||||
<h1 class="title has-text-centered mb-2" v-if="activeTasks.length > 0">{{ $t("self_wash.start_machine") }}</h1>
|
||||
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
|
||||
<h1 class="title has-text-centered mb-2">{{ $t("self_wash.start_machine") }}</h1>
|
||||
<SelfServeTaskList
|
||||
:tasks="activeTasks"
|
||||
:completedTasks="completedTasks"
|
||||
@@ -107,19 +129,31 @@ const onDynamicImageError = () => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image-skeleton {
|
||||
align-items: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #edf2f7;
|
||||
color: #112f5f;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image-preload {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 769px) {
|
||||
.self-serve-dynamic-image-frame {
|
||||
max-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image-skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -128,8 +162,4 @@ const onDynamicImageError = () => {
|
||||
object-fit: contain;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image.is-loading {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
|
||||
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
|
||||
|
||||
@@ -21,8 +21,11 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:customerNumber", value: string): void;
|
||||
(e: "update:customer-number", value: string): void;
|
||||
(e: "update:registrationNumber", value: string): void;
|
||||
(e: "update:registration-number", value: string): void;
|
||||
(e: "select-vehicle-type", selection: any): void;
|
||||
(e: "selected", selection: any): void;
|
||||
}>();
|
||||
|
||||
const extractRegistrationValue = (value: unknown): string => {
|
||||
@@ -49,6 +52,19 @@ const normalizeRegistration = (value: unknown) => extractRegistrationValue(value
|
||||
|
||||
const typedRegistration = ref(normalizeRegistration(props.registrationNumber));
|
||||
|
||||
const filteredRegistrationOptions = computed(() => {
|
||||
const query = normalizeRegistration(typedRegistration.value);
|
||||
const options = props.registrationOptions || [];
|
||||
if (!query) {
|
||||
return options;
|
||||
}
|
||||
|
||||
return options.filter((option) => {
|
||||
const normalizedOption = normalizeRegistration(option);
|
||||
return normalizedOption !== query && normalizedOption.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
watch(() => props.registrationNumber, (newValue) => {
|
||||
typedRegistration.value = normalizeRegistration(newValue);
|
||||
});
|
||||
@@ -57,6 +73,18 @@ const emitRegistration = (value: unknown) => {
|
||||
const normalized = normalizeRegistration(value);
|
||||
typedRegistration.value = normalized;
|
||||
emit("update:registrationNumber", normalized);
|
||||
emit("update:registration-number", normalized);
|
||||
};
|
||||
|
||||
const emitCustomerNumber = (value: unknown) => {
|
||||
const normalized = String(value || "");
|
||||
emit("update:customerNumber", normalized);
|
||||
emit("update:customer-number", normalized);
|
||||
};
|
||||
|
||||
const emitVehicleTypeSelection = (selection: any) => {
|
||||
emit("select-vehicle-type", selection);
|
||||
emit("selected", selection);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -68,38 +96,41 @@ const emitRegistration = (value: unknown) => {
|
||||
:model-value="customerNumber || ''"
|
||||
data-testid="self-serve-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 :label="$t('self_wash.registration_number')">
|
||||
<b-field :label="$t('self_wash.registration_number')" class="self-serve-registration-field">
|
||||
<div class="self-serve-registration-control">
|
||||
<b-autocomplete
|
||||
:model-value="typedRegistration"
|
||||
data-testid="self-serve-registration"
|
||||
:data="registrationOptions"
|
||||
:data="filteredRegistrationOptions"
|
||||
:placeholder="$t('self_wash.enter_registration_number')"
|
||||
:debounce="300"
|
||||
:min-length="1"
|
||||
:filter="(option, query) => option.toLowerCase().includes(query.toLowerCase())"
|
||||
icon-pack="fas"
|
||||
icon="car-side"
|
||||
clearable
|
||||
:loading="registrationOptions.length === 0 && isCustomerVehiclesLoading"
|
||||
:selectable-header="true"
|
||||
:loading="filteredRegistrationOptions.length === 0 && isCustomerVehiclesLoading"
|
||||
@select="emitRegistration($event)"
|
||||
@typing="emitRegistration($event)"
|
||||
@update:modelValue="emitRegistration($event)"
|
||||
@select-header="emitRegistration(typedRegistration)"
|
||||
>
|
||||
<template #header>
|
||||
</b-autocomplete>
|
||||
<p
|
||||
v-if="typedRegistration"
|
||||
class="help self-serve-registration-guidance"
|
||||
data-testid="self-serve-registration-guidance"
|
||||
>
|
||||
<template v-if="hasMatchingVehicle">
|
||||
{{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }}
|
||||
</template>
|
||||
</template>
|
||||
<template #empty>{{ $t("self_wash.no_vehicles_found") }}</template>
|
||||
</b-autocomplete>
|
||||
</p>
|
||||
</div>
|
||||
</b-field>
|
||||
<b-message
|
||||
v-if="props.vehicleStepError"
|
||||
@@ -124,7 +155,7 @@ const emitRegistration = (value: unknown) => {
|
||||
<SelfServeVehicleTypeSelector
|
||||
:selectedVehicleTypeId="selectedVehicleTypeId"
|
||||
:restrictToProductIds="availableProductIds"
|
||||
@selected="emit('select-vehicle-type', $event)"
|
||||
@selected="emitVehicleTypeSelection"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="vehicleTypes.length === 0">
|
||||
@@ -140,3 +171,34 @@ const emitRegistration = (value: unknown) => {
|
||||
</b-field>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.self-serve-registration-control {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.self-serve-registration-guidance {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
line-height: 1.35;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.self-serve-registration-field :deep(.dropdown),
|
||||
.self-serve-registration-field :deep(.autocomplete) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.self-serve-registration-field :deep(.dropdown-menu) {
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.self-serve-registration-field :deep(.dropdown-item) {
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
|
||||
const getOrderItemFlagIconClass = (orderItem) => (
|
||||
getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : ""
|
||||
);
|
||||
|
||||
const getOrderItemFlagColorClass = (orderItem) => {
|
||||
const flags = getOrderItemFlags(orderItem);
|
||||
if (flags.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning";
|
||||
};
|
||||
|
||||
const displayColumnCount = computed(() => (
|
||||
2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
|
||||
));
|
||||
@@ -162,7 +175,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
||||
<template v-if="!isOrderItemAddon(item)">
|
||||
<!-- If the item is included in the invoice, show it normally -->
|
||||
<tr v-if="item.include_in_invoice">
|
||||
<td>{{ item.product.name }}</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="getOrderItemFlags(item).length > 0"
|
||||
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||
:class="getOrderItemFlagColorClass(item)"
|
||||
:data-testid="`order-content-item-flag-indicator-${item.id}`"
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(item)"></i>
|
||||
</span>
|
||||
{{ item.product.name }}
|
||||
</td>
|
||||
<td v-if="props.displayReference">{{ item.reference }}</td>
|
||||
<td v-if="props.displayNotes">{{ item.notes }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
@@ -170,7 +193,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
||||
</tr>
|
||||
<!-- If the item is not included in the invoice, show it with a strikethrough -->
|
||||
<tr v-else class="has-background-warning-light">
|
||||
<td><span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="getOrderItemFlags(item).length > 0"
|
||||
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||
:class="getOrderItemFlagColorClass(item)"
|
||||
:data-testid="`order-content-item-flag-indicator-${item.id}`"
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(item)"></i>
|
||||
</span>
|
||||
<span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
|
||||
</td>
|
||||
<td v-if="props.displayReference">{{ item.reference }}</td>
|
||||
<td v-if="props.displayNotes">{{ item.notes }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
@@ -193,7 +226,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
||||
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
|
||||
<!-- If the add-on item is included in the invoice, show it normally -->
|
||||
<tr v-if="addon_item.include_in_invoice">
|
||||
<td>+ {{ addon_item.product.name }}</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="getOrderItemFlags(addon_item).length > 0"
|
||||
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||
:class="getOrderItemFlagColorClass(addon_item)"
|
||||
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
|
||||
</span>
|
||||
+ {{ addon_item.product.name }}
|
||||
</td>
|
||||
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
|
||||
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
|
||||
<td>{{ addon_item.quantity }}</td>
|
||||
@@ -201,7 +244,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
||||
</tr>
|
||||
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
|
||||
<tr v-else class="has-background-warning">
|
||||
<td>+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="getOrderItemFlags(addon_item).length > 0"
|
||||
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||
:class="getOrderItemFlagColorClass(addon_item)"
|
||||
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
|
||||
</span>
|
||||
+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
|
||||
</td>
|
||||
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
|
||||
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
|
||||
<td>{{ addon_item.quantity }}</td>
|
||||
@@ -237,6 +290,14 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
||||
<!-- If the item is a primary item, show it -->
|
||||
<template v-if="!isOrderItemAddon(item)">
|
||||
<div>
|
||||
<span
|
||||
v-if="getOrderItemFlags(item).length > 0"
|
||||
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||
:class="getOrderItemFlagColorClass(item)"
|
||||
:data-testid="`order-content-item-flag-indicator-${item.id}`"
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(item)"></i>
|
||||
</span>
|
||||
<strong>{{ item.product.name }}</strong> x {{ item.quantity }}
|
||||
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span>
|
||||
</div>
|
||||
@@ -248,6 +309,14 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
||||
<!-- Show the add-on items -->
|
||||
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
|
||||
<small>
|
||||
<span
|
||||
v-if="getOrderItemFlags(addon_item).length > 0"
|
||||
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||
:class="getOrderItemFlagColorClass(addon_item)"
|
||||
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
|
||||
</span>
|
||||
+ {{ addon_item.product.name }} x {{ addon_item.quantity }}
|
||||
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
|
||||
</small>
|
||||
@@ -265,5 +334,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.invoice-period-item-flag-indicator {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import Swal from "sweetalert2";
|
||||
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||
|
||||
const props = defineProps({
|
||||
objects: {
|
||||
@@ -23,6 +25,8 @@ const canDisableAccess = () =>
|
||||
const canResendInvite = (subuser) =>
|
||||
(props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
|
||||
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
|
||||
const hasRowActions = (subuser) =>
|
||||
(canEditPermissions() && subuser?.grant_id) || canResendInvite(subuser) || (canDisableAccess() && subuser?.grant_id);
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
if (!dateString) {
|
||||
@@ -241,51 +245,49 @@ const onResendInvite = async (subuser) => {
|
||||
|
||||
<td>
|
||||
<div>{{ subuser.grant_note || "-" }}</div>
|
||||
<button
|
||||
v-if="canEditPermissions() && subuser.grant_id"
|
||||
class="button is-text is-small px-0 mt-1"
|
||||
type="button"
|
||||
@click="onEditNote(subuser)"
|
||||
>
|
||||
Redigér note
|
||||
</button>
|
||||
</td>
|
||||
|
||||
<td>{{ formatDateTime(subuser.created_at) }}</td>
|
||||
<td>{{ formatDateTime(subuser.updated_at) }}</td>
|
||||
|
||||
<td>
|
||||
<div class="buttons is-justify-content-flex-end action-buttons">
|
||||
<button
|
||||
<div class="buttons is-justify-content-flex-end action-buttons" :data-testid="`subuser-actions-${subuser.id}`">
|
||||
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
|
||||
<template #actions>
|
||||
<ActionSettingsWheelItem
|
||||
v-if="canEditPermissions() && subuser.grant_id"
|
||||
class="button is-small"
|
||||
type="button"
|
||||
:data-testid="`subuser-permissions-${subuser.id}`"
|
||||
@click="onEditPermissions(subuser)"
|
||||
>
|
||||
Tilladelser
|
||||
</button>
|
||||
icon="fas fa-pen"
|
||||
label="Redigér note"
|
||||
:click-action="() => onEditNote(subuser)"
|
||||
:test-id="`subuser-note-${subuser.id}`"
|
||||
/>
|
||||
|
||||
<button
|
||||
<ActionSettingsWheelItem
|
||||
v-if="canEditPermissions() && subuser.grant_id"
|
||||
icon="fas fa-user-shield"
|
||||
label="Tilladelser"
|
||||
:click-action="() => onEditPermissions(subuser)"
|
||||
:test-id="`subuser-permissions-${subuser.id}`"
|
||||
/>
|
||||
|
||||
<ActionSettingsWheelItem
|
||||
v-if="canResendInvite(subuser)"
|
||||
class="button is-small"
|
||||
type="button"
|
||||
:data-testid="`subuser-resend-${subuser.id}`"
|
||||
@click="onResendInvite(subuser)"
|
||||
>
|
||||
Gensend
|
||||
</button>
|
||||
icon="fas fa-paper-plane"
|
||||
label="Gensend"
|
||||
:click-action="() => onResendInvite(subuser)"
|
||||
:test-id="`subuser-resend-${subuser.id}`"
|
||||
/>
|
||||
|
||||
<button
|
||||
<ActionSettingsWheelItem
|
||||
v-if="canDisableAccess() && subuser.grant_id"
|
||||
class="button is-small"
|
||||
:class="subuser.grant_enabled ? 'is-danger is-light' : 'is-success is-light'"
|
||||
type="button"
|
||||
:data-testid="`subuser-toggle-${subuser.id}`"
|
||||
@click="onToggleEnabled(subuser, !subuser.grant_enabled)"
|
||||
>
|
||||
{{ subuser.grant_enabled ? "Deaktivér" : "Aktivér" }}
|
||||
</button>
|
||||
:icon="subuser.grant_enabled ? 'fas fa-ban' : 'fas fa-check'"
|
||||
:label="subuser.grant_enabled ? 'Deaktivér' : 'Aktivér'"
|
||||
:template="subuser.grant_enabled ? 'danger' : 'success'"
|
||||
:click-action="() => onToggleEnabled(subuser, !subuser.grant_enabled)"
|
||||
:test-id="`subuser-toggle-${subuser.id}`"
|
||||
/>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -10,6 +10,7 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
||||
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
|
||||
const employees = ref([]);
|
||||
const { t } = useI18n();
|
||||
@@ -52,6 +53,7 @@ const login = async () => {
|
||||
}
|
||||
|
||||
// Save the token in the local storage
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', data.token);
|
||||
// Redirect to the dashboard
|
||||
window.location.href = '/admin';
|
||||
@@ -72,6 +74,7 @@ const loginWithPasskey = async () => {
|
||||
const result = await authenticateWithPasskey('employee', null, recaptchaToken);
|
||||
|
||||
if (result.token) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', result.token);
|
||||
window.location.href = '/admin';
|
||||
return;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { parseError, clearErrors, addError, getError } from "@/components/reques
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
||||
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -68,6 +69,7 @@ const login = async () => {
|
||||
}
|
||||
|
||||
// Save the token in the local storage
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', data.token);
|
||||
// Set the success message
|
||||
successMessage.value = "Du er nu logget ind!"
|
||||
@@ -103,6 +105,7 @@ const loginWithPasskey = async () => {
|
||||
const result = await authenticateWithPasskey('user', customerNum, recaptchaToken);
|
||||
console.log('Passkey authentication result:', result);
|
||||
if (result.token) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', result.token);
|
||||
successMessage.value = "Du er nu logget ind!";
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
||||
import { getSubuserPasswordPolicyError } from "@/services/subuserPasswordPolicy.js";
|
||||
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -69,6 +70,7 @@ const login = async () => {
|
||||
|
||||
// Save the session token
|
||||
if (data.session) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', data.session);
|
||||
localStorage.setItem('is_subuser', 'true');
|
||||
window.location.reload();
|
||||
@@ -97,6 +99,7 @@ const loginWithPasskey = async () => {
|
||||
// Subuser login returns 'session' token, user login returns 'token'
|
||||
const sessionToken = result.session || result.token;
|
||||
if (sessionToken) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', sessionToken);
|
||||
localStorage.setItem('is_subuser', 'true');
|
||||
// Reload the page to update the UI
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { verify2FA } from "@/services/TwoFactorAuthService.js";
|
||||
import { parseError, getError, clearErrors } from "@/components/request/HandleGlobalError.vue";
|
||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
@@ -43,9 +44,11 @@ const verify = async () => {
|
||||
|
||||
// Handle the response based on user type
|
||||
if (props.userType === 'subuser' && result.session) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', result.session);
|
||||
localStorage.setItem('is_subuser', 'true');
|
||||
} else if (result.token) {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem('token', result.token);
|
||||
localStorage.removeItem('is_subuser');
|
||||
}
|
||||
|
||||
@@ -217,6 +217,54 @@ const getVehicleReferenceValue = () => {
|
||||
return String(vehicleObject.value?.reference ?? "").trim();
|
||||
};
|
||||
|
||||
const getVehicleStateKey = (vehicle) => {
|
||||
if (!vehicle) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
vehicle?.id ?? "",
|
||||
normalizePlateValue(vehicle?.reg),
|
||||
normalizeCustomerNumber(vehicle?.customer_id) ?? "",
|
||||
vehicle?.type ?? "",
|
||||
vehicle?.booking_id ?? "",
|
||||
String(vehicle?.reference ?? "").trim(),
|
||||
].join("|");
|
||||
};
|
||||
|
||||
const getBookingStateKey = (booking) => {
|
||||
if (!booking) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
booking?.id ?? "",
|
||||
getBookingReg1Value(booking),
|
||||
getBookingReg2Value(booking),
|
||||
getBookingCustomerNumber(booking) ?? "",
|
||||
String(booking?.reference_number ?? booking?.reference ?? "").trim(),
|
||||
String(booking?.po ?? "").trim(),
|
||||
].join("|");
|
||||
};
|
||||
|
||||
const getBookingMatchesStateKey = (matches) => {
|
||||
if (!Array.isArray(matches) || matches.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return matches.map((booking) => getBookingStateKey(booking)).join("||");
|
||||
};
|
||||
|
||||
const hasBookingDetailsChanged = (currentBooking, nextBooking) => {
|
||||
const currentKeys = Object.keys(currentBooking || {});
|
||||
const nextKeys = Object.keys(nextBooking || {});
|
||||
if (currentKeys.length !== nextKeys.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return nextKeys.some((key) => currentBooking?.[key] !== nextBooking?.[key]);
|
||||
};
|
||||
|
||||
const setVehicleObject = (emittedVehicleObject, options = {}) => {
|
||||
const normalizedOptions = {
|
||||
preserveManualReference: true,
|
||||
@@ -224,17 +272,26 @@ const setVehicleObject = (emittedVehicleObject, options = {}) => {
|
||||
...options,
|
||||
};
|
||||
|
||||
vehicleObject.value = emittedVehicleObject;
|
||||
|
||||
const emittedVehiclePlate = normalizePlateValue(vehicleObject.value?.reg || reg_1.value);
|
||||
const emittedVehiclePlate = normalizePlateValue(emittedVehicleObject?.reg || reg_1.value);
|
||||
if (
|
||||
emittedVehiclePlate &&
|
||||
skippedDesktopBookingVehiclePlate.value === emittedVehiclePlate &&
|
||||
!isBookingMarkedVehicle(vehicleObject.value)
|
||||
!isBookingMarkedVehicle(emittedVehicleObject)
|
||||
) {
|
||||
skippedDesktopBookingVehiclePlate.value = "";
|
||||
}
|
||||
|
||||
const currentVehicleKey = getVehicleStateKey(vehicleObject.value);
|
||||
const nextVehicleKey = getVehicleStateKey(emittedVehicleObject);
|
||||
if (currentVehicleKey === nextVehicleKey) {
|
||||
if (normalizedOptions.nextSelectionSource) {
|
||||
setSelectionSource(normalizedOptions.nextSelectionSource);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
vehicleObject.value = emittedVehicleObject;
|
||||
|
||||
const matchedVehicleCustomerNumber = normalizeCustomerNumber(vehicleObject.value?.customer_id);
|
||||
const selectedCustomerNumber = normalizeCustomerNumber(customer_id.value);
|
||||
|
||||
@@ -274,6 +331,10 @@ const setBookingObject = (emittedBookingObject, options = {}) => {
|
||||
...options,
|
||||
};
|
||||
|
||||
if (getBookingStateKey(bookingObject.value) === getBookingStateKey(emittedBookingObject)) {
|
||||
return;
|
||||
}
|
||||
|
||||
bookingObject.value = emittedBookingObject;
|
||||
|
||||
if (emittedBookingObject && emittedBookingObject.reference_number) {
|
||||
@@ -284,7 +345,12 @@ const setBookingObject = (emittedBookingObject, options = {}) => {
|
||||
};
|
||||
|
||||
const setBookingMatches = (emittedBookingMatches) => {
|
||||
bookingMatches.value = Array.isArray(emittedBookingMatches) ? emittedBookingMatches : [];
|
||||
const nextBookingMatches = Array.isArray(emittedBookingMatches) ? emittedBookingMatches : [];
|
||||
if (getBookingMatchesStateKey(bookingMatches.value) === getBookingMatchesStateKey(nextBookingMatches)) {
|
||||
return;
|
||||
}
|
||||
|
||||
bookingMatches.value = nextBookingMatches;
|
||||
};
|
||||
|
||||
const mergeBookingMatchDetails = (booking) => {
|
||||
@@ -294,17 +360,23 @@ const mergeBookingMatchDetails = (booking) => {
|
||||
}
|
||||
|
||||
let didMergeBooking = false;
|
||||
bookingMatches.value = bookingMatches.value.map((entry) => {
|
||||
let didChangeBooking = false;
|
||||
const nextBookingMatches = bookingMatches.value.map((entry) => {
|
||||
if (normalizeBookingId(entry?.id) !== normalizedBookingId) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
didMergeBooking = true;
|
||||
return { ...entry, ...booking };
|
||||
const mergedBooking = { ...entry, ...booking };
|
||||
const hasChangedEntry = hasBookingDetailsChanged(entry, mergedBooking);
|
||||
didChangeBooking = didChangeBooking || hasChangedEntry;
|
||||
return hasChangedEntry ? mergedBooking : entry;
|
||||
});
|
||||
|
||||
if (!didMergeBooking) {
|
||||
bookingMatches.value = [...bookingMatches.value, booking];
|
||||
} else if (didChangeBooking) {
|
||||
bookingMatches.value = nextBookingMatches;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -416,6 +416,21 @@ const shouldPreserveCustomerSelection = (plateValue) => {
|
||||
};
|
||||
|
||||
let pendingVehicleCustomerLookup = null;
|
||||
const lastAutoSyncedVehicleKey = ref("");
|
||||
|
||||
const getVehicleAutoSyncKey = (vehicle, plateOverride = null) => {
|
||||
if (!vehicle) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
resolveBookingPlate(vehicle, plateOverride),
|
||||
vehicle?.id ?? "",
|
||||
normalizeCustomerNumber(vehicle?.customer_id) ?? "",
|
||||
normalizeCustomerNumber(customer_id.value) ?? "",
|
||||
].join("|");
|
||||
};
|
||||
|
||||
const syncMatchedVehicleSelection = (vehicle, options = {}) => {
|
||||
const normalizedOptions = {
|
||||
plateOverride: null,
|
||||
@@ -590,6 +605,7 @@ watch(reg_1, (newValue) => {
|
||||
// Define the search ID for this input change
|
||||
const search_id = register_new_search();
|
||||
console.log("reg_1 changed:", newValue);
|
||||
lastAutoSyncedVehicleKey.value = "";
|
||||
const shouldKeepCustomerSelection = shouldPreserveCustomerSelection(newValue);
|
||||
selectedDropdownItem.value = -1;
|
||||
// Check if the new value is empty, if so, clear the vehicles_matching array
|
||||
@@ -847,11 +863,17 @@ watch(vehicles_matching, (newValue) => {
|
||||
const currentValue = reg_1.value;
|
||||
const vehicle = newValue.find((vehicle) => vehicle.reg === currentValue);
|
||||
if (vehicle) {
|
||||
const nextAutoSyncKey = getVehicleAutoSyncKey(vehicle, currentValue);
|
||||
if (lastAutoSyncedVehicleKey.value !== nextAutoSyncKey) {
|
||||
lastAutoSyncedVehicleKey.value = nextAutoSyncKey;
|
||||
syncMatchedVehicleSelection(vehicle);
|
||||
}
|
||||
} else if (getBookingMatchesForSelection(null, currentValue).length > 0) {
|
||||
lastAutoSyncedVehicleKey.value = "";
|
||||
clearCustomerConflict();
|
||||
emitVehicleObject(null, currentValue);
|
||||
} else {
|
||||
lastAutoSyncedVehicleKey.value = "";
|
||||
clearCustomerConflict();
|
||||
}
|
||||
// Check if the selectedDropdownItem index is valid
|
||||
@@ -1015,6 +1037,7 @@ const getCurrentIconColor = () => {
|
||||
@blur="lostfocus"
|
||||
@keydown="arrowKeyHandler"
|
||||
id="reg_1"
|
||||
tabindex="1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</template>
|
||||
@@ -1037,6 +1060,7 @@ const getCurrentIconColor = () => {
|
||||
@blur="lostfocus"
|
||||
@keydown="arrowKeyHandler"
|
||||
id="reg_1"
|
||||
tabindex="1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -394,12 +394,16 @@ onBeforeUnmount(() => {
|
||||
v-model="typedReference"
|
||||
:data="groupedSuggestions"
|
||||
field="reference"
|
||||
input-id="reference"
|
||||
data-testid="pos-desktop-step-1-reference-input"
|
||||
group-field="group"
|
||||
group-options="items"
|
||||
:keep-first="true"
|
||||
open-on-focus
|
||||
expanded
|
||||
:loading="isFetching"
|
||||
:tabindex="props.tabindex"
|
||||
:aria-invalid="props.requiredWarning ? 'true' : undefined"
|
||||
custom-class="has-sharp-edges"
|
||||
icon-pack="fas"
|
||||
max-height="320"
|
||||
|
||||
@@ -52,6 +52,7 @@ const customer_suggestions = ref([
|
||||
const isSettingCustomer = ref(false);
|
||||
const isSettingCustomerToInteger = ref(0);
|
||||
const isSettingCustomerStartTime = ref(null);
|
||||
let customerSuggestionsRequestId = 0;
|
||||
|
||||
const isSettingCustomerTo = (customer_number) => {
|
||||
return isSettingCustomerToInteger.value === parseInt(customer_number);
|
||||
@@ -88,10 +89,17 @@ const getCustomerSuggestions = () => {
|
||||
if (!props.reg_1) {
|
||||
return;
|
||||
}
|
||||
const requestedReg1 = props.reg_1;
|
||||
const requestId = ++customerSuggestionsRequestId;
|
||||
|
||||
SessionUser.request("/department/vehicle/customer-suggestions", "GET", {
|
||||
reg_1: props.reg_1,
|
||||
reg_1: requestedReg1,
|
||||
})
|
||||
.then((response) => {
|
||||
if (requestId !== customerSuggestionsRequestId || requestedReg1 !== props.reg_1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Assuming the response contains an array of customer suggestions
|
||||
console.log("Customer suggestions:", response.data.data);
|
||||
let suggestions = [];
|
||||
@@ -126,6 +134,7 @@ watch(
|
||||
if (newValue) {
|
||||
getCustomerSuggestions();
|
||||
} else {
|
||||
customerSuggestionsRequestId += 1;
|
||||
customer_suggestions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
|
||||
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 isClearConfirmationOpen = 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;
|
||||
isClearConfirmationOpen.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 openClearConfirmation = () => {
|
||||
if (!isBusy.value) {
|
||||
isClearConfirmationOpen.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const closeClearConfirmation = () => {
|
||||
isClearConfirmationOpen.value = false;
|
||||
};
|
||||
|
||||
const forceUpdateAndClearLocal = async () => {
|
||||
if (isBusy.value) {
|
||||
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="openClearConfirmation"
|
||||
>
|
||||
<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>
|
||||
<LocalDataResetDialog
|
||||
v-model="isClearConfirmationOpen"
|
||||
:busy="isBusy"
|
||||
@confirm="forceUpdateAndClearLocal"
|
||||
@dismiss="closeClearConfirmation"
|
||||
/>
|
||||
</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>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
busy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "confirm", "dismiss"]);
|
||||
|
||||
const dismissDialog = () => {
|
||||
emit("dismiss");
|
||||
emit("update:modelValue", false);
|
||||
};
|
||||
|
||||
const confirmDialog = () => {
|
||||
emit("confirm");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="modelValue" class="local-data-reset-dialog" data-testid="local-data-reset-dialog">
|
||||
<button
|
||||
type="button"
|
||||
class="local-data-reset-dialog__backdrop"
|
||||
aria-label="Luk"
|
||||
:disabled="busy"
|
||||
@click="dismissDialog"
|
||||
></button>
|
||||
<section
|
||||
class="local-data-reset-dialog__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="local-data-reset-dialog-title"
|
||||
>
|
||||
<h2 id="local-data-reset-dialog-title">Ryd lokale data?</h2>
|
||||
<p>
|
||||
Dette sletter login, localStorage, sessionStorage, browsercache og lokale appdata på denne enhed. Du bliver
|
||||
logget ud.
|
||||
</p>
|
||||
<footer class="local-data-reset-dialog__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="local-data-reset-dialog__button local-data-reset-dialog__button--secondary"
|
||||
data-testid="local-data-reset-cancel"
|
||||
:disabled="busy"
|
||||
@click="dismissDialog"
|
||||
>
|
||||
Nej
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="local-data-reset-dialog__button local-data-reset-dialog__button--danger"
|
||||
data-testid="local-data-reset-confirm"
|
||||
:disabled="busy"
|
||||
@click="confirmDialog"
|
||||
>
|
||||
Ja, ryd alt
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.local-data-reset-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 11000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: rgba(9, 20, 33, 0.48);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__backdrop:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__panel {
|
||||
position: relative;
|
||||
width: min(430px, calc(100vw - 36px));
|
||||
border: 1px solid #d5dde8;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28);
|
||||
color: #172033;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__panel h2 {
|
||||
margin: 0;
|
||||
padding: 18px 18px 8px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__panel p {
|
||||
margin: 0;
|
||||
padding: 0 18px 16px;
|
||||
color: #475467;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 550;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 14px 18px 18px;
|
||||
border-top: 1px solid #edf1f5;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__button {
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__button--secondary {
|
||||
border: 1px solid #cfd8e3;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.local-data-reset-dialog__button--danger {
|
||||
border: 1px solid #b42318;
|
||||
background: #b42318;
|
||||
color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
@@ -41,9 +41,16 @@ const REQUEST_INSIGHT_DEFINITIONS = Object.freeze([
|
||||
iconClass: "fa-calendar-alt",
|
||||
matcher: (url) => /\/order-bookings(?:[/?#]|$)/i.test(url),
|
||||
},
|
||||
{
|
||||
key: "scanner",
|
||||
label: "Scanner",
|
||||
iconClass: "fa-camera",
|
||||
matcher: (url) => /\/modules\/scanner\/lpr(?:[/?#]|$)/i.test(url),
|
||||
},
|
||||
]);
|
||||
const SHIFT_MULTI_PRESS_WINDOW_MS = 700;
|
||||
const SYSTEM_SEARCH_CLOSE_EVENT = "system-search:close";
|
||||
const FRONTEND_MAINTENANCE_MENU_CLOSE_EVENT = "frontend-maintenance-menu:close";
|
||||
|
||||
const isExpanded = ref(REQUEST_QUEUE_CONFIG.inspector.expandedByDefault);
|
||||
const isShortcutActivated = ref(false);
|
||||
@@ -61,6 +68,7 @@ const processedRequests = computed(() => requestQueueState.batchCompleted + requ
|
||||
const missingPermissions = computed(() => requestQueueState.missingPermissions || []);
|
||||
const activeRequests = computed(() => requestQueueState.activeRequests || []);
|
||||
const recentRequests = computed(() => requestQueueState.recentRequests || []);
|
||||
const queueRequestInsights = computed(() => requestQueueState.requestInsights || {});
|
||||
const errorRequests = computed(() => requestQueueState.errorRequests || []);
|
||||
const networkTotals = computed(() => requestQueueState.networkTotals || {
|
||||
outgoingRequests: 0,
|
||||
@@ -107,7 +115,7 @@ const userTypeLabel = computed(() => (SessionUser.isSubuser.value ? "Subuser" :
|
||||
const hasSuperuserToken = computed(() => {
|
||||
try {
|
||||
return Boolean(localStorage.getItem("superuser_token"));
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -118,6 +126,7 @@ const impersonatedUserRoleId = computed(() => {
|
||||
const canGrantMissingPermissions = computed(() =>
|
||||
hasSuperuserToken.value && !SessionUser.isSubuser.value && impersonatedUserRoleId.value !== null
|
||||
);
|
||||
const canInspectReleaseRuntime = computed(() => hasSuperuserToken.value || SessionUser.canAccessAdmin?.() === true);
|
||||
|
||||
const userDetailRows = computed(() => {
|
||||
if (SessionUser.isSubuser.value) {
|
||||
@@ -189,6 +198,152 @@ const formatBytes = (value) => {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
const parseServerTimingDurations = (value) => {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return value.split(",").reduce((durations, part) => {
|
||||
const [rawMetric, ...rawParams] = part.trim().split(";");
|
||||
const metric = rawMetric.trim();
|
||||
if (!metric) {
|
||||
return durations;
|
||||
}
|
||||
|
||||
const durationParam = rawParams.find((param) => param.trim().toLowerCase().startsWith("dur="));
|
||||
if (!durationParam) {
|
||||
return durations;
|
||||
}
|
||||
|
||||
const duration = Number.parseFloat(durationParam.split("=").slice(1).join("="));
|
||||
if (Number.isFinite(duration)) {
|
||||
durations[metric] = duration;
|
||||
}
|
||||
|
||||
return durations;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const findServerTimingDuration = (durations, metricNames) => {
|
||||
const metricName = metricNames.find((name) => Number.isFinite(durations[name]));
|
||||
|
||||
return metricName ? durations[metricName] : null;
|
||||
};
|
||||
|
||||
const formatScannerFrameSize = (width, height) => {
|
||||
const roundedWidth = Math.round(Number(width) || 0);
|
||||
const roundedHeight = Math.round(Number(height) || 0);
|
||||
|
||||
if (roundedWidth <= 0 || roundedHeight <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `img ${roundedWidth}x${roundedHeight}`;
|
||||
};
|
||||
|
||||
const formatScannerFrameBytes = (bytes) => {
|
||||
const roundedBytes = Math.round(Number(bytes) || 0);
|
||||
|
||||
if (roundedBytes <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `bytes ${formatBytes(roundedBytes)}`;
|
||||
};
|
||||
|
||||
const getBrowserNetworkDuration = (requestDurationMs, serverDurationMs) => {
|
||||
if (serverDurationMs === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const durationMs = Number(requestDurationMs) - Number(serverDurationMs);
|
||||
|
||||
if (!Number.isFinite(durationMs) || durationMs < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return durationMs;
|
||||
};
|
||||
|
||||
const formatRequestLatency = (definition, request) => {
|
||||
const requestDurationMs = Math.max(0, Number(request?.requestDurationMs) || 0);
|
||||
if (definition.key !== "scanner") {
|
||||
return formatDuration(requestDurationMs);
|
||||
}
|
||||
|
||||
const queueDurationMs = Math.max(0, Number(request?.queueDurationMs) || 0);
|
||||
const durations = parseServerTimingDurations(request?.serverTiming);
|
||||
const captureDurationMs = findServerTimingDuration(durations, ["lpr_client_capture"]);
|
||||
const preflightDurationMs = findServerTimingDuration(durations, ["lpr_client_preflight"]);
|
||||
const visualFingerprintDurationMs = findServerTimingDuration(durations, ["lpr_client_visual_fingerprint"]);
|
||||
const drawDurationMs = findServerTimingDuration(durations, ["lpr_client_draw"]);
|
||||
const encodeDurationMs = findServerTimingDuration(durations, ["lpr_client_encode"]);
|
||||
const clientFrameWidth = findServerTimingDuration(durations, ["lpr_client_frame_width"]);
|
||||
const clientFrameHeight = findServerTimingDuration(durations, ["lpr_client_frame_height"]);
|
||||
const clientFrameBytes = findServerTimingDuration(durations, ["lpr_client_frame_bytes"]);
|
||||
const cacheDurationMs = findServerTimingDuration(durations, ["lpr_cache"]);
|
||||
const cacheHit = findServerTimingDuration(durations, ["lpr_cache_hit"]) !== null;
|
||||
const cacheMiss = findServerTimingDuration(durations, ["lpr_cache_miss"]) !== null;
|
||||
const localDurationMs = findServerTimingDuration(durations, ["lpr_local"]);
|
||||
const upstreamProcessingDurationMs = findServerTimingDuration(durations, ["lpr_upstream_processing"]);
|
||||
const upstreamDurationMs = findServerTimingDuration(durations, ["lpr_upstream_total", "lpr_upstream"]);
|
||||
const serverDurationMs = findServerTimingDuration(durations, ["lpr_total", "lpr_route_total", "lpr_request_total"]);
|
||||
const browserNetworkDurationMs = getBrowserNetworkDuration(requestDurationMs, serverDurationMs);
|
||||
const timingParts = [`browser ${formatDuration(requestDurationMs)}`];
|
||||
const frameSize = formatScannerFrameSize(clientFrameWidth, clientFrameHeight);
|
||||
const frameBytes = formatScannerFrameBytes(clientFrameBytes);
|
||||
|
||||
if (queueDurationMs > 0) {
|
||||
timingParts.push(`queue ${formatDuration(queueDurationMs)}`);
|
||||
}
|
||||
if (browserNetworkDurationMs !== null) {
|
||||
timingParts.push(`net ${formatDuration(browserNetworkDurationMs)}`);
|
||||
}
|
||||
if (captureDurationMs !== null) {
|
||||
timingParts.push(`cap ${formatDuration(captureDurationMs)}`);
|
||||
}
|
||||
if (preflightDurationMs !== null) {
|
||||
timingParts.push(`prep ${formatDuration(preflightDurationMs)}`);
|
||||
}
|
||||
if (visualFingerprintDurationMs !== null) {
|
||||
timingParts.push(`vf ${formatDuration(visualFingerprintDurationMs)}`);
|
||||
}
|
||||
if (drawDurationMs !== null) {
|
||||
timingParts.push(`draw ${formatDuration(drawDurationMs)}`);
|
||||
}
|
||||
if (encodeDurationMs !== null) {
|
||||
timingParts.push(`enc ${formatDuration(encodeDurationMs)}`);
|
||||
}
|
||||
if (frameSize !== null) {
|
||||
timingParts.push(frameSize);
|
||||
}
|
||||
if (frameBytes !== null) {
|
||||
timingParts.push(frameBytes);
|
||||
}
|
||||
if (cacheHit) {
|
||||
timingParts.push("cache hit");
|
||||
} else if (cacheMiss) {
|
||||
timingParts.push("cache miss");
|
||||
}
|
||||
if (cacheDurationMs !== null) {
|
||||
timingParts.push(`cache ${formatDuration(cacheDurationMs)}`);
|
||||
}
|
||||
if (localDurationMs !== null) {
|
||||
timingParts.push(`local ${formatDuration(localDurationMs)}`);
|
||||
}
|
||||
if (upstreamProcessingDurationMs !== null) {
|
||||
timingParts.push(`proc ${formatDuration(upstreamProcessingDurationMs)}`);
|
||||
}
|
||||
if (upstreamDurationMs !== null) {
|
||||
timingParts.push(`up ${formatDuration(upstreamDurationMs)}`);
|
||||
}
|
||||
if (serverDurationMs !== null) {
|
||||
timingParts.push(`srv ${formatDuration(serverDurationMs)}`);
|
||||
}
|
||||
|
||||
return timingParts.join(" / ");
|
||||
};
|
||||
|
||||
const formatTimeAgo = (timestamp) => {
|
||||
const value = Number(timestamp || 0);
|
||||
if (!value || Number.isNaN(value)) {
|
||||
@@ -217,43 +372,67 @@ const getActiveRequestElapsedMs = (request) => Math.max(0, nowMs.value - Number(
|
||||
|
||||
const hasInsightEntryChanged = (currentEntry, nextEntry) =>
|
||||
Number(currentEntry?.requestDurationMs || 0) !== Number(nextEntry?.requestDurationMs || 0)
|
||||
|| Number(currentEntry?.queueDurationMs || 0) !== Number(nextEntry?.queueDurationMs || 0)
|
||||
|| Number(currentEntry?.completedAt || 0) !== Number(nextEntry?.completedAt || 0)
|
||||
|| Number(currentEntry?.startedAt || 0) !== Number(nextEntry?.startedAt || 0)
|
||||
|| Number(currentEntry?.queuedAt || 0) !== Number(nextEntry?.queuedAt || 0);
|
||||
|| Number(currentEntry?.queuedAt || 0) !== Number(nextEntry?.queuedAt || 0)
|
||||
|| String(currentEntry?.serverTiming || "") !== String(nextEntry?.serverTiming || "");
|
||||
|
||||
const getInsightEntryTimestamp = (entry) =>
|
||||
Number(entry?.completedAt || entry?.startedAt || entry?.queuedAt || 0);
|
||||
|
||||
const selectNewestInsightEntry = (...entries) =>
|
||||
entries
|
||||
.filter((entry) => entry !== null && entry !== undefined)
|
||||
.sort((first, second) => getInsightEntryTimestamp(second) - getInsightEntryTimestamp(first))[0] || null;
|
||||
|
||||
const requestInsights = computed(() => REQUEST_INSIGHT_DEFINITIONS.map((definition) => {
|
||||
const matchedRequestFromRecent = recentRequests.value.find((request) =>
|
||||
definition.matcher(String(request?.url || ""))
|
||||
) || null;
|
||||
const matchedRequest = matchedRequestFromRecent || requestInsightHistory.value[definition.key] || null;
|
||||
const matchedRequestFromQueueInsight = queueRequestInsights.value[definition.key] || null;
|
||||
const matchedRequest = selectNewestInsightEntry(
|
||||
matchedRequestFromRecent,
|
||||
matchedRequestFromQueueInsight,
|
||||
requestInsightHistory.value[definition.key],
|
||||
);
|
||||
const hasData = matchedRequest !== null;
|
||||
|
||||
return {
|
||||
...definition,
|
||||
latencyText: hasData ? `${Math.max(0, Number(matchedRequest.requestDurationMs) || 0)} ms` : " ",
|
||||
latencyText: hasData ? formatRequestLatency(definition, matchedRequest) : " ",
|
||||
serverTiming: matchedRequest?.serverTiming || "",
|
||||
timeAgoText: hasData
|
||||
? formatTimeAgo(matchedRequest.completedAt || matchedRequest.startedAt || matchedRequest.queuedAt)
|
||||
: "",
|
||||
};
|
||||
}));
|
||||
|
||||
watch(recentRequests, (requests) => {
|
||||
if (!Array.isArray(requests) || requests.length === 0) {
|
||||
watch([recentRequests, queueRequestInsights], ([requests, insights]) => {
|
||||
const recentRequestList = Array.isArray(requests) ? requests : [];
|
||||
const insightEntries = insights && typeof insights === "object" ? insights : {};
|
||||
|
||||
if (recentRequestList.length === 0 && Object.keys(insightEntries).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHistory = { ...requestInsightHistory.value };
|
||||
let hasChanges = false;
|
||||
REQUEST_INSIGHT_DEFINITIONS.forEach((definition) => {
|
||||
const matchedRequest = requests.find((request) =>
|
||||
const matchedRequest = selectNewestInsightEntry(
|
||||
recentRequestList.find((request) =>
|
||||
definition.matcher(String(request?.url || ""))
|
||||
),
|
||||
insightEntries[definition.key],
|
||||
);
|
||||
if (!matchedRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEntry = {
|
||||
serverTiming: matchedRequest.serverTiming || null,
|
||||
requestDurationMs: Math.max(0, Number(matchedRequest.requestDurationMs) || 0),
|
||||
queueDurationMs: Math.max(0, Number(matchedRequest.queueDurationMs) || 0),
|
||||
completedAt: matchedRequest.completedAt || null,
|
||||
startedAt: matchedRequest.startedAt || null,
|
||||
queuedAt: matchedRequest.queuedAt || null,
|
||||
@@ -284,7 +463,12 @@ const resolvePingUrl = () => {
|
||||
};
|
||||
|
||||
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
|
||||
const releaseSessionSummary = computed(() => buildReleaseSessionSummary());
|
||||
const activeApiUrlLabel = computed(() =>
|
||||
canInspectReleaseRuntime.value ? activeApiUrl.value : "Restricted to release operators"
|
||||
);
|
||||
const releaseSessionSummary = computed(() =>
|
||||
buildReleaseSessionSummary(undefined, { includeInfrastructureDetails: canInspectReleaseRuntime.value })
|
||||
);
|
||||
|
||||
const measurePingLatency = async () => {
|
||||
if (typeof fetch !== "function") {
|
||||
@@ -322,7 +506,7 @@ const measurePingLatency = async () => {
|
||||
queuedAt: startedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
pingLatencyMs.value = null;
|
||||
pingIsUnavailable.value = true;
|
||||
} finally {
|
||||
@@ -360,6 +544,9 @@ const handleWindowKeydown = (event) => {
|
||||
}
|
||||
|
||||
if (shiftKeyPressCount.value >= 3) {
|
||||
event.preventDefault?.();
|
||||
event.stopImmediatePropagation?.();
|
||||
window.dispatchEvent(new CustomEvent(FRONTEND_MAINTENANCE_MENU_CLOSE_EVENT));
|
||||
closeSystemSearchForSuperuser();
|
||||
isShortcutActivated.value = true;
|
||||
isExpanded.value = true;
|
||||
@@ -564,7 +751,7 @@ onBeforeUnmount(() => {
|
||||
{{ request.method }}
|
||||
</span>
|
||||
<span class="request-queue-progress__endpoint" :title="request.url">{{ request.url }}</span>
|
||||
<span class="request-queue-progress__time">
|
||||
<span class="request-queue-progress__time" :title="request.serverTiming || ''">
|
||||
{{ formatDuration(request.requestDurationMs) }}
|
||||
</span>
|
||||
</li>
|
||||
@@ -585,7 +772,9 @@ onBeforeUnmount(() => {
|
||||
{{ insight.label }}
|
||||
</span>
|
||||
<span class="request-queue-progress__bottom-request-time">{{ insight.timeAgoText }}</span>
|
||||
<span class="request-queue-progress__bottom-request-latency">{{ insight.latencyText }}</span>
|
||||
<span class="request-queue-progress__bottom-request-latency" :title="insight.serverTiming">
|
||||
{{ insight.latencyText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -646,6 +835,7 @@ onBeforeUnmount(() => {
|
||||
</aside>
|
||||
|
||||
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
|
||||
<template v-if="canInspectReleaseRuntime">
|
||||
<div class="request-queue-progress__section-title">Session release</div>
|
||||
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
|
||||
<ul class="request-queue-progress__meta-list">
|
||||
@@ -771,11 +961,15 @@ onBeforeUnmount(() => {
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="request-queue-progress__subsection-title">Runtime details</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="request-queue-progress__section-title">Runtime details</div>
|
||||
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
|
||||
<ul class="request-queue-progress__meta-list">
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">API URL</span>
|
||||
<span class="request-queue-progress__meta-value" :title="activeApiUrl">{{ activeApiUrl }}</span>
|
||||
<span class="request-queue-progress__meta-value" :title="activeApiUrlLabel">{{ activeApiUrlLabel }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Current host</span>
|
||||
@@ -1466,6 +1660,10 @@ onBeforeUnmount(() => {
|
||||
.request-queue-progress__bottom-request-latency {
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
max-width: 190px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1485,4 +1683,3 @@ onBeforeUnmount(() => {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import MenuDefault from "@/components/menus/MenuDefault.vue";
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { isAccessibleVisibleDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
|
||||
import { isAccessibleVisibleNamedDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
|
||||
// Get the department ID from the URL
|
||||
const route = useRoute();
|
||||
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
|
||||
@@ -26,7 +26,7 @@ const loadDepartments = () => {
|
||||
value: department.id,
|
||||
icon: "fas fa-building",
|
||||
children: [],
|
||||
hidden: !isAccessibleVisibleDepartment(department, SessionUser.canAccessAssignedDepartment),
|
||||
hidden: !isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment),
|
||||
});
|
||||
});
|
||||
menu_items.value[1].options = options;
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
} from "@/composables/useDraftTransactionCustomer.js";
|
||||
import { fetchDepartmentOrderBookingCounts } from "@/components/models/navigation/items/adminBookingCount.js";
|
||||
import { fetchDepartmentDraftCount } from "@/components/models/navigation/items/adminDraftCount.js";
|
||||
import { hasExplicitBookingCountPermission } from "@/components/models/navigation/items/bookingCountGuards.js";
|
||||
import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js";
|
||||
import { isAccessibleVisibleNamedDepartment } from "@/services/departmentVisibility.js";
|
||||
|
||||
const t = (key: string) => i18n.global.t(key);
|
||||
|
||||
@@ -70,6 +72,8 @@ let queuedBookingLoadingIndicator = false;
|
||||
let queuedDraftLoadingIndicator = false;
|
||||
const getDepartmentIdNumber = () => Number.parseInt(String(department_id.value), 10);
|
||||
const canUseAdminNavigationCounts = () => SessionUser.canAccessAdmin();
|
||||
const canUseAdminBookingNavigationCounts = () =>
|
||||
canUseAdminNavigationCounts() && hasExplicitBookingCountPermission(SessionUser);
|
||||
const getDepartmentById = (id: number) => {
|
||||
return departments_cache.value?.find((department: any) => Number(department?.id) === Number(id)) || null;
|
||||
};
|
||||
@@ -148,6 +152,19 @@ const getDepartmentDraftBadge = () => {
|
||||
};
|
||||
|
||||
const fetchDepartmentBookingCount = async ({ showLoadingIndicator = false } = {}) => {
|
||||
if (!canUseAdminBookingNavigationCounts()) {
|
||||
department_booking_counts.value = {
|
||||
past: 0,
|
||||
current: 0,
|
||||
future: 0,
|
||||
};
|
||||
department_booking_counts_loading.value = false;
|
||||
bookingCountFetchInFlight = false;
|
||||
bookingCountRefreshQueued = false;
|
||||
queuedBookingLoadingIndicator = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasValidDepartmentId.value) {
|
||||
department_booking_counts.value = {
|
||||
past: 0,
|
||||
@@ -376,17 +393,26 @@ const items = computed<NavigationItemProps[]>(() => [
|
||||
hidden: isDepartmentSet.value,
|
||||
classes: [ADMIN_DEPARTMENT_SELECTION_CLASS],
|
||||
permissions: ["admin", "list_departments"],
|
||||
children: SessionUser.functions.getAccessibleDepartments().map((departmentId: any) => {
|
||||
children: SessionUser.functions
|
||||
.getAccessibleDepartments()
|
||||
.map((departmentId: any) => {
|
||||
const label = getDepartmentName(departmentId);
|
||||
const department = getDepartmentById(departmentId);
|
||||
|
||||
if (!isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: getDepartmentName(departmentId),
|
||||
to: `/admin/${departmentId}`,
|
||||
label,
|
||||
to: `/admin/${department.id}`,
|
||||
type: "department",
|
||||
permissions: ["admin", "view_department"],
|
||||
order_priority: department?.order_priority ?? department?.priority_order ?? null,
|
||||
priority_order: department?.priority_order ?? department?.order_priority ?? null,
|
||||
};
|
||||
}),
|
||||
})
|
||||
.filter(Boolean) as NavigationItemProps[],
|
||||
},
|
||||
// Kassesystem
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/composables/useDraftTransactionCustomer.js";
|
||||
import { fetchSuperUserDraftCount } from "@/components/models/navigation/items/superUserDraftCount.js";
|
||||
import { fetchSuperUserBookingCounts } from "@/components/models/navigation/items/superUserBookingCount.js";
|
||||
import { hasExplicitBookingCountPermission } from "@/components/models/navigation/items/bookingCountGuards.js";
|
||||
import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js";
|
||||
|
||||
const firstToUpperCase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
@@ -37,23 +38,29 @@ let queuedSuperUserBookingLoadingIndicator = false;
|
||||
let queuedSuperUserDraftLoadingIndicator = false;
|
||||
|
||||
const canUseSuperUserNavigationCounts = () => SessionUser.canAccessSuperUser();
|
||||
const canUseSuperUserBookingNavigationCounts = () =>
|
||||
canUseSuperUserNavigationCounts() && hasExplicitBookingCountPermission(SessionUser);
|
||||
|
||||
const resetSuperUserNavigationCounts = () => {
|
||||
const resetSuperUserBookingCounts = () => {
|
||||
bookingCountRequestId += 1;
|
||||
draftCountRequestId += 1;
|
||||
superuser_booking_counts.value = {
|
||||
past: 0,
|
||||
current: 0,
|
||||
future: 0,
|
||||
};
|
||||
superuser_booking_count_loading.value = false;
|
||||
superUserBookingCountFetchInFlight = false;
|
||||
superUserBookingCountRefreshQueued = false;
|
||||
queuedSuperUserBookingLoadingIndicator = false;
|
||||
};
|
||||
|
||||
const resetSuperUserNavigationCounts = () => {
|
||||
resetSuperUserBookingCounts();
|
||||
draftCountRequestId += 1;
|
||||
superuser_draft_count.value = 0;
|
||||
superuser_draft_count_loading.value = false;
|
||||
superUserBookingCountFetchInFlight = false;
|
||||
superUserDraftCountFetchInFlight = false;
|
||||
superUserBookingCountRefreshQueued = false;
|
||||
superUserDraftCountRefreshQueued = false;
|
||||
queuedSuperUserBookingLoadingIndicator = false;
|
||||
queuedSuperUserDraftLoadingIndicator = false;
|
||||
};
|
||||
|
||||
@@ -125,8 +132,8 @@ const getSuperUserDraftsBadge = () => {
|
||||
};
|
||||
|
||||
const fetchCurrentSuperUserBookingCount = async ({ showLoadingIndicator = false } = {}) => {
|
||||
if (!canUseSuperUserNavigationCounts()) {
|
||||
resetSuperUserNavigationCounts();
|
||||
if (!canUseSuperUserBookingNavigationCounts()) {
|
||||
resetSuperUserBookingCounts();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
import {
|
||||
emptyBookingCounts,
|
||||
isExpectedBookingCountRequestFailure,
|
||||
} from "@/components/models/navigation/items/bookingCountGuards.js";
|
||||
|
||||
const normalizePositiveInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
@@ -24,11 +28,7 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
|
||||
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
|
||||
|
||||
if (!normalizedDepartmentId) {
|
||||
return {
|
||||
past: 0,
|
||||
current: 0,
|
||||
future: 0,
|
||||
};
|
||||
return emptyBookingCounts();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -38,12 +38,11 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
|
||||
|
||||
return normalizeBookingCounts(response);
|
||||
} catch (error) {
|
||||
if (!isExpectedBookingCountRequestFailure(error)) {
|
||||
console.error("Error fetching department order-booking counts:", error);
|
||||
return {
|
||||
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 {
|
||||
emptyBookingCounts,
|
||||
isExpectedBookingCountRequestFailure,
|
||||
} from "@/components/models/navigation/items/bookingCountGuards.js";
|
||||
|
||||
const toNonNegativeInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
@@ -20,12 +24,11 @@ export const fetchSuperUserBookingCounts = async () => {
|
||||
const response = await authenticatedRequest("/order-bookings/counts", "GET", {});
|
||||
return normalizeBookingCounts(response);
|
||||
} catch (error) {
|
||||
if (!isExpectedBookingCountRequestFailure(error)) {
|
||||
console.error("Error fetching superuser booking counts:", error);
|
||||
return {
|
||||
past: 0,
|
||||
current: 0,
|
||||
future: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return emptyBookingCounts();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup>
|
||||
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRouter } from "vue-router";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { pingApiServer } from "@/services/apiHealth.js";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -42,12 +41,12 @@ const handleSlowSessionBootstrap = async () => {
|
||||
}
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: 'Error',
|
||||
text: 'The user session could not be initiated.',
|
||||
icon: 'error',
|
||||
confirmButtonText: 'Clear session, and try again',
|
||||
title: "Error",
|
||||
text: "The user session could not be initiated.",
|
||||
icon: "error",
|
||||
confirmButtonText: "Clear session, and try again",
|
||||
showCancelButton: true,
|
||||
cancelButtonText: 'Retry',
|
||||
cancelButtonText: "Retry",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
@@ -60,10 +59,16 @@ const handleSlowSessionBootstrap = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
watch(isSessionInitiated, (initiated) => {
|
||||
if (initiated) {
|
||||
clearSessionTimeout();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
sessionTimeout = setTimeout(() => {
|
||||
void handleSlowSessionBootstrap();
|
||||
}, 5000);
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(clearSessionTimeout);
|
||||
@@ -89,9 +94,18 @@ onBeforeUnmount(clearSessionTimeout);
|
||||
</div>
|
||||
</div>
|
||||
<!-- Simple checks -->
|
||||
<p v-if="router.currentRoute.value.path === '/user'">You are on the /user page. <br>You do {{ SessionUser.hasPermission('user') ? '' : 'not' }} have permission ( user ) to view this page.</p>
|
||||
<p v-if="router.currentRoute.value.path === '/admin'">You are on the /admin page. <br>You do {{ SessionUser.hasPermission('admin') ? '' : 'not' }} have permission ( admin ) to view this page.</p>
|
||||
<p v-if="router.currentRoute.value.path === '/superuser'">You are on the /superuser <br>page. You do {{ SessionUser.hasPermission('superuser') ? '' : 'not' }} have permission ( superuser ) to view this page.</p>
|
||||
<p v-if="router.currentRoute.value.path === '/user'">
|
||||
You are on the /user page. <br />You do {{ SessionUser.hasPermission("user") ? "" : "not" }} have permission (
|
||||
user ) to view this page.
|
||||
</p>
|
||||
<p v-if="router.currentRoute.value.path === '/admin'">
|
||||
You are on the /admin page. <br />You do {{ SessionUser.hasPermission("admin") ? "" : "not" }} have permission (
|
||||
admin ) to view this page.
|
||||
</p>
|
||||
<p v-if="router.currentRoute.value.path === '/superuser'">
|
||||
You are on the /superuser <br />page. You do {{ SessionUser.hasPermission("superuser") ? "" : "not" }} have
|
||||
permission ( superuser ) to view this page.
|
||||
</p>
|
||||
<div v-if="showDebugInfo">
|
||||
<div class="message mb-6">
|
||||
<div class="message-header">
|
||||
@@ -108,7 +122,9 @@ onBeforeUnmount(clearSessionTimeout);
|
||||
<!-- Button to go back to the previous page -->
|
||||
<button class="button is-dark" @click="router.go(-1)">Go back</button>
|
||||
<!-- Button to see the debug information -->
|
||||
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-if="!showDebugInfo">Show debug information</button>
|
||||
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-if="!showDebugInfo">
|
||||
Show debug information
|
||||
</button>
|
||||
<!-- Button to hide the debug information -->
|
||||
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-else>Hide debug information</button>
|
||||
</div>
|
||||
@@ -116,6 +132,4 @@ onBeforeUnmount(clearSessionTimeout);
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -48,10 +48,13 @@ export const getDepartmentDescription = (id) => {
|
||||
|
||||
export const getDepartmentsGuest = async (queryParams = {}) => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const queryString = new URLSearchParams(queryParams).toString();
|
||||
const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get");
|
||||
departments.value = sortByDepartmentPriorityOrder(request.data.data || []);
|
||||
isLoading.value = false;
|
||||
return departments.value;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,42 +1,21 @@
|
||||
<script setup>
|
||||
import { computed, inject, ref } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue";
|
||||
import ReleaseUpdateWidget from "@/components/release/ReleaseUpdateWidget.vue";
|
||||
import {
|
||||
clearSelectedReleaseChannel,
|
||||
releaseChannelSelectorVisible,
|
||||
switchSelectedReleaseChannel,
|
||||
} from "@/services/releaseChannelAvailability.js";
|
||||
import {
|
||||
isReleaseSourceOverrideAvailable,
|
||||
RELEASE_SOURCE_MODES,
|
||||
setReleaseSourceOverride,
|
||||
} from "@/services/releaseBootstrap.js";
|
||||
import { releaseChannelSelectorVisible, switchSelectedReleaseChannel } from "@/services/releaseChannelAvailability.js";
|
||||
import { inspectReleaseRuntimeForUpdate } from "@/services/releaseUpdate.js";
|
||||
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
|
||||
|
||||
const switchingSlug = ref("");
|
||||
const switchError = ref("");
|
||||
const { t, te } = useI18n({ useScope: "global" });
|
||||
const reloadWindow = inject("releaseSourceReload", () => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
const tr = (key, fallback) => {
|
||||
const path = `configuration.release_manager.channel_selector.${key}`;
|
||||
return te(path) ? t(path) : fallback;
|
||||
};
|
||||
|
||||
const showUseLocalFrontend = computed(
|
||||
() =>
|
||||
releaseChannelSelectorVisible.value &&
|
||||
isReleaseSourceOverrideAvailable() &&
|
||||
String(releaseRuntimeState.source || "").toLowerCase() !== RELEASE_SOURCE_MODES.LOCAL
|
||||
);
|
||||
|
||||
const switchChannel = async (option) => {
|
||||
if (switchingSlug.value) {
|
||||
return;
|
||||
@@ -48,21 +27,14 @@ const switchChannel = async (option) => {
|
||||
await switchSelectedReleaseChannel(option.channel, SessionUser.refreshReleaseRuntime);
|
||||
void inspectReleaseRuntimeForUpdate(releaseRuntimeState, { autoDownload: true });
|
||||
} catch (error) {
|
||||
switchError.value = tr("switch_error", "Release channel could not be switched. The previous channel is still active.");
|
||||
switchError.value = tr(
|
||||
"switch_error",
|
||||
"Release channel could not be switched. The previous channel is still active."
|
||||
);
|
||||
} finally {
|
||||
switchingSlug.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const useLocalFrontend = () => {
|
||||
if (switchingSlug.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReleaseSourceOverride(RELEASE_SOURCE_MODES.LOCAL);
|
||||
clearSelectedReleaseChannel();
|
||||
reloadWindow();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -74,17 +46,6 @@ const useLocalFrontend = () => {
|
||||
:disabled="Boolean(switchingSlug)"
|
||||
@select="switchChannel"
|
||||
/>
|
||||
<button
|
||||
v-if="showUseLocalFrontend"
|
||||
type="button"
|
||||
class="release-channel-sidebar-selector__local-button"
|
||||
data-testid="release-channel-use-local-frontend"
|
||||
:disabled="Boolean(switchingSlug)"
|
||||
@click="useLocalFrontend"
|
||||
>
|
||||
<i class="fas fa-code" aria-hidden="true"></i>
|
||||
<span>{{ tr("use_local_frontend", "Use local frontend") }}</span>
|
||||
</button>
|
||||
<p v-if="switchError" class="release-channel-sidebar-selector__error" role="alert">
|
||||
{{ switchError }}
|
||||
</p>
|
||||
@@ -93,37 +54,6 @@ const useLocalFrontend = () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-channel-sidebar-selector__local-button {
|
||||
width: calc(100% - 32px);
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin: -2px 16px 14px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #b8c7d9;
|
||||
border-radius: 5px;
|
||||
background: #f8fbff;
|
||||
color: #153554;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.release-channel-sidebar-selector__local-button:hover:not(:disabled) {
|
||||
border-color: #6f8fb4;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.release-channel-sidebar-selector__local-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.release-channel-sidebar-selector__error {
|
||||
margin: 0 16px 14px;
|
||||
color: #b42318;
|
||||
|
||||
@@ -295,10 +295,23 @@ const branchWarning = computed(() =>
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.release-context-bar__status {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.release-context-bar__status .tag {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.release-context-bar__warning {
|
||||
color: #9f1f17;
|
||||
flex: 1 1 12rem;
|
||||
font-size: 0.82rem;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.25;
|
||||
min-width: min(12rem, 100%);
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.release-context-bar__endpoints {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import {API_URL} from "@/config.js";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
|
||||
export const currentDepartment = ref(null);
|
||||
export const selectDepartment = (department) => {
|
||||
@@ -31,6 +32,7 @@ export const exitSession = () => {
|
||||
}
|
||||
});
|
||||
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.removeItem('token');
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import axios from 'axios'
|
||||
import { enqueueRequest } from "@/services/requestQueue.js";
|
||||
import { buildCurrentReleaseHeaders, resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
|
||||
import { isTrustedReleaseUrl } from "@/services/releaseTrust.js";
|
||||
|
||||
/**
|
||||
* Get the selected customer number for X-Customer-Number header (used by subusers)
|
||||
@@ -12,33 +13,277 @@ const getSelectedCustomerNumber = () => {
|
||||
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 SELF_SERVE_HARDWARE_QUEUE_GROUP = 'SELF_SERVE_HARDWARE';
|
||||
const POS_SCANNER_QUEUE_GROUP = 'POS_SCANNER';
|
||||
const POS_STRIPE_QUEUE_GROUP = 'POS_STRIPE';
|
||||
const FETCH_TRANSPORT = 'fetch';
|
||||
const SELF_SERVE_HARDWARE_ENDPOINTS = [
|
||||
'/modules/self-serve/lane/command',
|
||||
'/modules/self-serve/lane/relay/',
|
||||
'/modules/self-serve/lane/gate/open',
|
||||
'/modules/self-serve/lane/force/machine',
|
||||
];
|
||||
const POS_LATENCY_QUEUE_RULES = [
|
||||
{
|
||||
endpoints: ['/modules/scanner/lpr'],
|
||||
queueGroup: POS_SCANNER_QUEUE_GROUP,
|
||||
concurrencyLimit: 1,
|
||||
retryByStatusCode: {},
|
||||
skipRequestByteAccounting: true,
|
||||
skipResponseByteAccounting: true,
|
||||
skipNetworkTotals: true,
|
||||
insightKey: 'scanner',
|
||||
recordRecentOnSuccess: false,
|
||||
trackActiveRequest: false,
|
||||
trackProgressCounters: false,
|
||||
},
|
||||
{
|
||||
endpoints: ['/modules/stripe/invoice'],
|
||||
queueGroup: POS_STRIPE_QUEUE_GROUP,
|
||||
concurrencyLimit: 2,
|
||||
retryByStatusCode: {},
|
||||
},
|
||||
];
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const isSelfServeHardwareMutation = (url, method) => {
|
||||
const normalizedMethod = String(method || '').trim().toUpperCase();
|
||||
if (normalizedMethod === 'GET') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedUrl = String(url || '');
|
||||
return SELF_SERVE_HARDWARE_ENDPOINTS.some((endpoint) => normalizedUrl.includes(endpoint));
|
||||
};
|
||||
|
||||
const findPosLatencyQueueRule = (url, method) => {
|
||||
const normalizedMethod = String(method || '').trim().toUpperCase();
|
||||
if (normalizedMethod === 'GET') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedUrl = String(url || '');
|
||||
return POS_LATENCY_QUEUE_RULES.find((rule) =>
|
||||
rule.endpoints.some((endpoint) => normalizedUrl.includes(endpoint))
|
||||
) || null;
|
||||
};
|
||||
|
||||
const hasHeader = (headers, name) => {
|
||||
const normalizedName = String(name || '').trim().toLowerCase();
|
||||
if (!normalizedName || !headers || typeof headers !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.keys(headers).some((headerName) => String(headerName).toLowerCase() === normalizedName);
|
||||
};
|
||||
|
||||
const parseFetchResponseData = async (response) => {
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = response.headers?.get?.('content-type') || '';
|
||||
if (
|
||||
contentType.toLowerCase().includes('application/json') ||
|
||||
text.trim().startsWith('{') ||
|
||||
text.trim().startsWith('[')
|
||||
) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (_error) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
const buildFetchBody = (method, data, headers) => {
|
||||
if (String(method || '').trim().toUpperCase() === 'GET' || data === undefined || data === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof Blob !== 'undefined' && data instanceof Blob ||
|
||||
typeof FormData !== 'undefined' && data instanceof FormData ||
|
||||
typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams ||
|
||||
typeof ArrayBuffer !== 'undefined' && data instanceof ArrayBuffer ||
|
||||
typeof ReadableStream !== 'undefined' && data instanceof ReadableStream ||
|
||||
typeof data === 'string'
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (!hasHeader(headers, 'Content-Type')) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
};
|
||||
|
||||
const executeFetchRequest = async ({ method, url, data, signal, headers }) => {
|
||||
const fetchHeaders = { ...headers };
|
||||
const body = buildFetchBody(method, data, fetchHeaders);
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: fetchHeaders,
|
||||
...(body !== undefined ? { body } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
const responseData = await parseFetchResponseData(response);
|
||||
const axiosLikeResponse = {
|
||||
data: responseData,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
config: {
|
||||
data,
|
||||
headers: fetchHeaders,
|
||||
method,
|
||||
url,
|
||||
},
|
||||
request: null,
|
||||
};
|
||||
|
||||
if (response.ok) {
|
||||
return axiosLikeResponse;
|
||||
}
|
||||
|
||||
const error = new Error(`Request failed with status code ${response.status}`);
|
||||
error.name = 'AxiosError';
|
||||
error.response = axiosLikeResponse;
|
||||
throw error;
|
||||
};
|
||||
|
||||
const buildRequestQueueOptions = (url, method, options = {}) => {
|
||||
const queueOptions = {
|
||||
retryByStatusCode: options?.retryByStatusCode,
|
||||
shouldRetry: options?.shouldRetry,
|
||||
queueGroup: options?.queueGroup,
|
||||
concurrencyLimit: options?.concurrencyLimit,
|
||||
skipRequestByteAccounting: options?.skipRequestByteAccounting,
|
||||
skipResponseByteAccounting: options?.skipResponseByteAccounting,
|
||||
skipNetworkTotals: options?.skipNetworkTotals,
|
||||
insightKey: options?.insightKey,
|
||||
recordRecentOnSuccess: options?.recordRecentOnSuccess,
|
||||
trackActiveRequest: options?.trackActiveRequest,
|
||||
trackProgressCounters: options?.trackProgressCounters,
|
||||
};
|
||||
|
||||
if (isSelfServeHardwareMutation(url, method)) {
|
||||
queueOptions.retryByStatusCode ??= {};
|
||||
queueOptions.queueGroup ??= SELF_SERVE_HARDWARE_QUEUE_GROUP;
|
||||
queueOptions.concurrencyLimit ??= 1;
|
||||
}
|
||||
|
||||
const posQueueRule = findPosLatencyQueueRule(url, method);
|
||||
if (posQueueRule) {
|
||||
queueOptions.retryByStatusCode ??= posQueueRule.retryByStatusCode;
|
||||
queueOptions.queueGroup ??= posQueueRule.queueGroup;
|
||||
queueOptions.concurrencyLimit ??= posQueueRule.concurrencyLimit;
|
||||
queueOptions.skipRequestByteAccounting ??= posQueueRule.skipRequestByteAccounting;
|
||||
queueOptions.skipResponseByteAccounting ??= posQueueRule.skipResponseByteAccounting;
|
||||
queueOptions.skipNetworkTotals ??= posQueueRule.skipNetworkTotals;
|
||||
queueOptions.insightKey ??= posQueueRule.insightKey;
|
||||
queueOptions.recordRecentOnSuccess ??= posQueueRule.recordRecentOnSuccess;
|
||||
queueOptions.trackActiveRequest ??= posQueueRule.trackActiveRequest;
|
||||
queueOptions.trackProgressCounters ??= posQueueRule.trackProgressCounters;
|
||||
}
|
||||
|
||||
return queueOptions;
|
||||
};
|
||||
|
||||
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null, options = {}) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
//throw new Error('No token was found, unable to make authenticated request');
|
||||
}
|
||||
|
||||
const requestUrl = resolveReleaseApiUrl(url);
|
||||
const canSendCredentials = isTrustedReleaseUrl(requestUrl);
|
||||
|
||||
// Build headers
|
||||
const headers = {
|
||||
...buildCurrentReleaseHeaders(),
|
||||
...(options?.headers || {}),
|
||||
};
|
||||
if (token && token.length > 0) {
|
||||
if (canSendCredentials && token && token.length > 0) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Add X-Customer-Number header if subuser has selected a grant
|
||||
const isSubuser = localStorage.getItem('is_subuser') === 'true';
|
||||
const selectedCustomerNumber = getSelectedCustomerNumber();
|
||||
if (isSubuser && selectedCustomerNumber) {
|
||||
if (canSendCredentials && isSubuser && selectedCustomerNumber) {
|
||||
headers['X-Customer-Number'] = selectedCustomerNumber;
|
||||
}
|
||||
|
||||
const requestUrl = resolveReleaseApiUrl(url);
|
||||
const useFetchTransport = options?.transport === FETCH_TRANSPORT;
|
||||
|
||||
return enqueueRequest(
|
||||
() => axios({
|
||||
() => useFetchTransport
|
||||
? executeFetchRequest({
|
||||
method,
|
||||
url: requestUrl,
|
||||
data,
|
||||
signal: options?.signal,
|
||||
headers,
|
||||
})
|
||||
: axios({
|
||||
method,
|
||||
url: requestUrl,
|
||||
...(method === 'GET' ? { params: data } : { data }),
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
__skipRequestQueue: true,
|
||||
headers
|
||||
}),
|
||||
@@ -49,7 +294,9 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
|
||||
params: method === 'GET' ? data : null,
|
||||
data: method === 'GET' ? null : data,
|
||||
headers,
|
||||
}
|
||||
},
|
||||
signal: options?.signal,
|
||||
...buildRequestQueueOptions(requestUrl, method, options),
|
||||
}
|
||||
)
|
||||
.catch((error) => {
|
||||
@@ -60,6 +307,7 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
|
||||
throw error;
|
||||
})
|
||||
.then((response) => {
|
||||
response = normalizeActiveWashResponse(url, method, response);
|
||||
if (thenCallable) {
|
||||
// Call the then callable
|
||||
thenCallable(response);
|
||||
|
||||
@@ -58,6 +58,12 @@ import {
|
||||
releaseChannelRuntimeRequestParams,
|
||||
setReleaseChannelSwitchNoticePrincipal,
|
||||
} from "@/services/releaseChannelAvailability.js";
|
||||
import { clearCachedXlvaskUsageAmount } from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
import {
|
||||
clearPeriodCache,
|
||||
clearSelfWashCountsCache,
|
||||
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js";
|
||||
|
||||
const normalizePositiveInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
@@ -98,7 +104,16 @@ const hydrateSessionFromStorage = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const clearPrivilegedSessionCaches = () => {
|
||||
clearPeriodCache();
|
||||
clearSelfWashCountsCache();
|
||||
clearCachedXlvaskUsageAmount();
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
};
|
||||
|
||||
const clearStoredSession = () => {
|
||||
clearPrivilegedSessionCaches();
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
@@ -115,7 +130,8 @@ const applyReleaseRuntimeConfig = (runtime) => {
|
||||
const normalizedRuntime = runtime || {};
|
||||
configureReleaseRuntime(normalizedRuntime);
|
||||
reconcileSelectedReleaseChannel(normalizedRuntime);
|
||||
const runtimeUrls = normalizedRuntime.urls && typeof normalizedRuntime.urls === "object" ? normalizedRuntime.urls : {};
|
||||
const runtimeUrls =
|
||||
normalizedRuntime.urls && typeof normalizedRuntime.urls === "object" ? normalizedRuntime.urls : {};
|
||||
SessionUser.runtimeConfig.release.traceId.value = normalizedRuntime.trace_id || null;
|
||||
SessionUser.runtimeConfig.release.channel.value = normalizedRuntime.channel || null;
|
||||
SessionUser.runtimeConfig.release.availableChannels.value =
|
||||
@@ -168,6 +184,7 @@ const resetSessionState = () => {
|
||||
SessionUser.user.notifications.wash_certificate_email.value = null;
|
||||
SessionUser.user.notifications.email_notifications_enabled.value = null;
|
||||
SessionUser.user.notifications.sms_notifications_enabled.value = null;
|
||||
SessionUser.user.notifications.superuser_new_customer_email_notifications_enabled.value = null;
|
||||
SessionUser.user.created_at.value = null;
|
||||
SessionUser.user.updated_at.value = null;
|
||||
SessionUser.user.cached_at.value = null;
|
||||
@@ -377,6 +394,7 @@ export const authenticateUser = async (customer_number, password) => {
|
||||
password,
|
||||
})
|
||||
.then((response) => {
|
||||
clearPrivilegedSessionCaches();
|
||||
SessionUser.token.value = response.data.token;
|
||||
SessionUser.authenticated.value = true;
|
||||
localStorage.setItem("token", response.data.token);
|
||||
@@ -423,6 +441,7 @@ export const authenticateSubuser = async (credentials) => {
|
||||
|
||||
return await authenticatedRequest("/subusers/auth/password", "POST", requestBody)
|
||||
.then((response) => {
|
||||
clearPrivilegedSessionCaches();
|
||||
SessionUser.token.value = response.data.data.session;
|
||||
SessionUser.authenticated.value = true;
|
||||
SessionUser.isSubuser.value = true;
|
||||
@@ -510,6 +529,8 @@ export const getSessionData = async () => {
|
||||
SessionUser.user.notifications.email_notifications_enabled.value =
|
||||
session.notifications.email_notifications_enabled;
|
||||
SessionUser.user.notifications.sms_notifications_enabled.value = session.notifications.sms_notifications_enabled;
|
||||
SessionUser.user.notifications.superuser_new_customer_email_notifications_enabled.value =
|
||||
session.notifications.superuser_new_customer_email_notifications_enabled;
|
||||
SessionUser.user.created_at.value = session.created_at;
|
||||
SessionUser.user.updated_at.value = session.updated_at;
|
||||
SessionUser.user.display_name.value = session.display_name;
|
||||
@@ -605,6 +626,7 @@ export const SessionUser = {
|
||||
wash_certificate_email: ref(null),
|
||||
email_notifications_enabled: ref(null),
|
||||
sms_notifications_enabled: ref(null),
|
||||
superuser_new_customer_email_notifications_enabled: ref(null),
|
||||
setEmailNotificationsEnabled: (enabled) => {
|
||||
return SessionUser.request("/account/notifications", "PUT", {
|
||||
email_notifications_enabled: enabled,
|
||||
@@ -629,6 +651,18 @@ export const SessionUser = {
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
setSuperuserNewCustomerEmailNotificationsEnabled: (enabled) => {
|
||||
return SessionUser.request("/account/notifications", "PUT", {
|
||||
superuser_new_customer_email_notifications_enabled: enabled,
|
||||
})
|
||||
.then(() => {
|
||||
SessionUser.user.notifications.superuser_new_customer_email_notifications_enabled.value = enabled;
|
||||
})
|
||||
.catch((error) => {
|
||||
parseError(error, "user_notifications");
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
setWashCertificateEmail: (email) => {
|
||||
return SessionUser.request("/account/notifications", "PUT", {
|
||||
wash_certificate_email: email,
|
||||
@@ -669,6 +703,7 @@ export const SessionUser = {
|
||||
* @param {number|null} customerNumber - The billing_customer_number to select, or null to clear
|
||||
*/
|
||||
selectGrant: (customerNumber) => {
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
if (customerNumber === null) {
|
||||
localStorage.removeItem("selected_customer_number");
|
||||
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
|
||||
@@ -886,6 +921,7 @@ export const SessionUser = {
|
||||
* @param {string} token
|
||||
*/
|
||||
setToken: (token) => {
|
||||
clearPrivilegedSessionCaches();
|
||||
SessionUser.token.value = token;
|
||||
localStorage.setItem("token", token);
|
||||
// Reload the window to make sure the user's session is initiated
|
||||
|
||||
@@ -456,6 +456,29 @@ export const CollectedOrderInvoices = {
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
move_to_customer: async (id, customerNumber) => {
|
||||
const invoiceCollectionId = parseInt(id);
|
||||
const targetCustomerNumber = parseInt(customerNumber);
|
||||
|
||||
if (!Number.isInteger(invoiceCollectionId) || invoiceCollectionId <= 0) {
|
||||
throw new Error('Invalid invoice collection id');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(targetCustomerNumber) || targetCustomerNumber <= 0) {
|
||||
throw new Error('Invalid customer number');
|
||||
}
|
||||
|
||||
return authenticatedRequest('/collected-invoices/move-to-customer', 'POST', {
|
||||
id: invoiceCollectionId,
|
||||
customer_number: targetCustomerNumber,
|
||||
}).then((response) => {
|
||||
console.log(response);
|
||||
return response;
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
showCreateNewCustom: showCreateCustomInvoiceCollectionForm,
|
||||
showInvoiceCollectionPickerForm: showInvoiceCollectionPickerModal,
|
||||
add_vehicle_subscriptions: async (id) => {
|
||||
@@ -820,4 +843,3 @@ export const CollectedOrderInvoices = {
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@ import { getSystemUserIds } from "@/components/session/token/SessionUser/Objects
|
||||
// Helper function to get i18n translation
|
||||
const t = (key) => i18n.global.t(key);
|
||||
|
||||
const escapeHtml = (value = "") => String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
const normalizeDateTimeLocalValue = (value) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
@@ -765,9 +772,11 @@ export const ObjectsGlobal = {
|
||||
console.log(response);
|
||||
// How long are the list of options
|
||||
for (let i = 0; i < response.length; i++) {
|
||||
var tmp = response[i];
|
||||
const tmp = response[i];
|
||||
const optionValue = tmp?.id ?? "";
|
||||
const optionLabel = tmp?.name ? tmp.name : optionValue;
|
||||
console.log(tmp);
|
||||
html += `<option value="${tmp.id}" ${(value == tmp.id || (value === null && tmp.id === 0)) ? 'selected' : ''}>${tmp.name ? tmp.name : tmp.id}</option>`;
|
||||
html += `<option value="${escapeHtml(optionValue)}" ${(value == optionValue || (value === null && optionValue === 0)) ? 'selected' : ''}>${escapeHtml(optionLabel)}</option>`;
|
||||
}
|
||||
});
|
||||
html += `</select>
|
||||
|
||||
@@ -356,6 +356,20 @@ export const OrderBookings = {
|
||||
{ safety_seal: normalizeCompletionSafetySeal(safetySeal) },
|
||||
onAfterComplete
|
||||
);
|
||||
},
|
||||
resendBookingConfirmation: async (id) => {
|
||||
return SessionUser.request(
|
||||
OrderBookings.meta.endpoint + "/booking-confirmation/resend",
|
||||
"POST",
|
||||
{ id: parseInt(id, 10) }
|
||||
).then((response) => response.data.data);
|
||||
},
|
||||
resendBookingCompletionConfirmation: async (id) => {
|
||||
return SessionUser.request(
|
||||
OrderBookings.meta.endpoint + "/completion-confirmation/resend",
|
||||
"POST",
|
||||
{ id: parseInt(id, 10) }
|
||||
).then((response) => response.data.data);
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,6 @@ import {ObjectsGlobal} from "@/components/session/token/SessionUser/Objects/Obje
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import AssignDraftOrderCustomerModal from "@/components/displays/modals/AssignDraftOrderCustomerModal.vue";
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
import { editOrderItem, getOrderItems } from "@/components/shop/OrdersItems.vue";
|
||||
import {createApp} from "vue";
|
||||
import i18n from '@/i18n';
|
||||
import { dispatchNavigationCountRefresh } from "@/components/models/navigation/items/navigationCountEvents.js";
|
||||
@@ -19,6 +18,14 @@ const getProtectedDeletePayload = (error) => {
|
||||
return payload;
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (character) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\"": """,
|
||||
"'": "'",
|
||||
})[character]);
|
||||
|
||||
const formatDeleteProtectionReason = (reason) => {
|
||||
switch (reason) {
|
||||
case "completed":
|
||||
@@ -28,22 +35,23 @@ const formatDeleteProtectionReason = (reason) => {
|
||||
case "attachments":
|
||||
return "ordren har vedhæftninger";
|
||||
default:
|
||||
return String(reason || "").replace(/_/g, " ");
|
||||
return escapeHtml(String(reason || "").replace(/_/g, " "));
|
||||
}
|
||||
};
|
||||
|
||||
const showProtectedOrderDeleteConfirmation = async (id, protectionPayload, onAfterSubmit = null) => {
|
||||
const orderId = String(id);
|
||||
const escapedOrderId = escapeHtml(orderId);
|
||||
const reasons = Array.isArray(protectionPayload?.protected_reasons)
|
||||
? protectionPayload.protected_reasons.map(formatDeleteProtectionReason).filter(Boolean)
|
||||
: [];
|
||||
const reasonText = reasons.length > 0 ? reasons.join(", ") : "ordren indeholder gemte data";
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: `Bekræft sletning af ordre #${orderId}`,
|
||||
titleText: `Bekræft sletning af ordre #${orderId}`,
|
||||
html: `
|
||||
<p>Ordren kan stadig slettes, men kræver ekstra bekræftelse fordi ${reasonText}.</p>
|
||||
<p>Skriv <strong>${orderId}</strong> for at slette ordren.</p>
|
||||
<p>Skriv <strong>${escapedOrderId}</strong> for at slette ordren.</p>
|
||||
`,
|
||||
input: "text",
|
||||
inputAttributes: {
|
||||
@@ -256,6 +264,19 @@ const refreshDraftNavigationCount = () => {
|
||||
dispatchNavigationCountRefresh();
|
||||
};
|
||||
|
||||
|
||||
const getOrderItemsForRepricing = (orderId) => authenticatedRequest('/order/items', 'GET', {
|
||||
order_id: orderId,
|
||||
});
|
||||
|
||||
const editOrderItemForRepricing = ({ id, price, notes, reference, quantity }) => authenticatedRequest('/order/items', 'PUT', {
|
||||
id,
|
||||
price,
|
||||
notes,
|
||||
reference,
|
||||
quantity,
|
||||
});
|
||||
|
||||
const getFinalProductPriceForCustomer = async (productId, departmentId, customerId) => {
|
||||
const normalizedProductId = normalizePositiveInteger(productId);
|
||||
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
|
||||
@@ -289,7 +310,7 @@ const recalculateOrderItemPricesForCustomer = async ({ order_id, department_id,
|
||||
throw new Error("Invalid order repricing context");
|
||||
}
|
||||
|
||||
const response = await getOrderItems(normalizedOrderId);
|
||||
const response = await getOrderItemsForRepricing(normalizedOrderId);
|
||||
const orderItems = Array.isArray(response?.data?.data) ? response.data.data : [];
|
||||
const uniqueProductIds = [...new Set(
|
||||
orderItems
|
||||
@@ -320,13 +341,13 @@ const recalculateOrderItemPricesForCustomer = async ({ order_id, department_id,
|
||||
return null;
|
||||
}
|
||||
|
||||
return editOrderItem(
|
||||
normalizedItemId,
|
||||
finalPriceMap.get(normalizedProductId),
|
||||
item?.notes ?? "",
|
||||
item?.reference ?? "",
|
||||
normalizePositiveInteger(item?.quantity) ?? 1
|
||||
);
|
||||
return editOrderItemForRepricing({
|
||||
id: normalizedItemId,
|
||||
price: finalPriceMap.get(normalizedProductId),
|
||||
notes: item?.notes ?? "",
|
||||
reference: item?.reference ?? "",
|
||||
quantity: normalizePositiveInteger(item?.quantity) ?? 1,
|
||||
});
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -361,11 +382,6 @@ const assignDraftOrderCustomer = async ({
|
||||
normalizedCustomerId
|
||||
);
|
||||
|
||||
const invoiceCollectionResponse = await SessionUser.objects.orders.set.invoice_collection_id(
|
||||
normalizedOrderId,
|
||||
normalizedInvoiceCollectionId
|
||||
);
|
||||
|
||||
let repricingResponse = null;
|
||||
if (recalculate_prices) {
|
||||
repricingResponse = await recalculateOrderItemPricesForCustomer({
|
||||
@@ -375,6 +391,11 @@ const assignDraftOrderCustomer = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const invoiceCollectionResponse = await SessionUser.objects.orders.set.invoice_collection_id(
|
||||
normalizedOrderId,
|
||||
normalizedInvoiceCollectionId
|
||||
);
|
||||
|
||||
return {
|
||||
customerResponse,
|
||||
invoiceCollectionResponse,
|
||||
@@ -924,6 +945,13 @@ const assignDraftOrderCustomer = async ({
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
resendWashCertificate: async (id) => {
|
||||
return SessionUser.request(
|
||||
Orders.meta.endpoint + "/wash-certificate/resend",
|
||||
"POST",
|
||||
{ id: parseInt(id, 10) }
|
||||
).then((response) => response.data.data);
|
||||
},
|
||||
showAttachWashCertificateForm(order_id, onAfterSubmit = null) {
|
||||
const normalizedOrderId = Number.parseInt(order_id, 10);
|
||||
if (!Number.isInteger(normalizedOrderId) || normalizedOrderId <= 0) {
|
||||
|
||||
@@ -127,6 +127,13 @@ export const SelfServeVehicleConditions = {
|
||||
}
|
||||
},
|
||||
add: async (department, lane, customer_id, reg, question, value, options = {}) => {
|
||||
const safeOptions = {};
|
||||
const normalizedVehicleType = parseInt(options.vehicle_type ?? options.vehicle_type_id);
|
||||
|
||||
if (!Number.isNaN(normalizedVehicleType) && normalizedVehicleType > 0) {
|
||||
safeOptions.vehicle_type = normalizedVehicleType;
|
||||
}
|
||||
|
||||
return ObjectsGlobal.add.object(SelfServeVehicleConditions.meta.endpoint, {
|
||||
department: parseInt(department),
|
||||
lane: parseInt(lane),
|
||||
@@ -134,7 +141,9 @@ export const SelfServeVehicleConditions = {
|
||||
reg: reg,
|
||||
question: parseInt(question),
|
||||
value: value === "true" || value === true,
|
||||
...options
|
||||
...safeOptions,
|
||||
activate_machine: false,
|
||||
sync_relay_state: false
|
||||
});
|
||||
},
|
||||
set: {
|
||||
@@ -164,7 +173,7 @@ export const SelfServeVehicleConditions = {
|
||||
single: async (id) => {
|
||||
return ObjectsGlobal.get.object(SelfServeVehicleConditions.meta.endpoint, id);
|
||||
},
|
||||
previewAllowed: async (laneId, reg, vehicleTypeId = null) => {
|
||||
previewAllowed: async (laneId, reg, vehicleTypeId, options = {}) => {
|
||||
const params = {
|
||||
lane_id: parseInt(laneId),
|
||||
reg: reg,
|
||||
@@ -175,11 +184,11 @@ export const SelfServeVehicleConditions = {
|
||||
params.vehicle_type = normalizedVehicleTypeId;
|
||||
}
|
||||
|
||||
return authenticatedRequest("/department/selfserve/vehicle/allowed", "GET", params)
|
||||
return authenticatedRequest("/department/selfserve/vehicle/allowed", "GET", params, null, null, options)
|
||||
.then((response) => response.data.data || response.data);
|
||||
},
|
||||
washSummary: async (params = {}) => {
|
||||
return authenticatedRequest("/department/selfserve/washes/summary", "GET", params)
|
||||
washSummary: async (params, options) => {
|
||||
return authenticatedRequest("/department/selfserve/washes/summary", "GET", params || {}, null, null, options || {})
|
||||
.then((response) => response.data.data || response.data);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,13 +17,15 @@ const normalizeEntries = (entries) => {
|
||||
return [];
|
||||
};
|
||||
|
||||
const SENSITIVE_WRITE_ONLY_KEYS = new Set(["broker_shared_secret"]);
|
||||
|
||||
const toConfigPayload = (entries) => {
|
||||
const payload = {};
|
||||
normalizeEntries(entries).forEach((entry) => {
|
||||
if (!entry || !entry.variable) {
|
||||
return;
|
||||
}
|
||||
payload[entry.variable] = entry.value;
|
||||
payload[entry.variable] = SENSITIVE_WRITE_ONLY_KEYS.has(entry.variable) ? "" : entry.value;
|
||||
});
|
||||
return payload;
|
||||
};
|
||||
@@ -42,6 +44,11 @@ export const Config = {
|
||||
test_broker: async (payload) => testEdgeGatewayBrokerConfig(payload),
|
||||
set: async (variable, value) => {
|
||||
const currentConfig = await Config.get_config();
|
||||
SENSITIVE_WRITE_ONLY_KEYS.forEach((key) => {
|
||||
if (key !== variable) {
|
||||
delete currentConfig[key];
|
||||
}
|
||||
});
|
||||
return Config.set_config({
|
||||
...currentConfig,
|
||||
[variable]: value,
|
||||
@@ -73,7 +80,6 @@ export const Config = {
|
||||
set: async (value) => Config.set("broker_auth_mode", value),
|
||||
},
|
||||
broker_shared_secret: {
|
||||
get: async () => Config.get("broker_shared_secret"),
|
||||
set: async (value) => Config.set("broker_shared_secret", value),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -61,6 +61,14 @@ export const Config = {
|
||||
});
|
||||
},
|
||||
keys: {
|
||||
dynamic_image_size: {
|
||||
get: async () => {
|
||||
return Config.get("dynamic_image_size");
|
||||
},
|
||||
set: async (value) => {
|
||||
return Config.set("dynamic_image_size", value);
|
||||
},
|
||||
},
|
||||
minute_product: {
|
||||
get: async () => {
|
||||
return Config.get("minute_product");
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<script>
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
|
||||
/**
|
||||
* The Slack -> Config object
|
||||
*/
|
||||
export const Config = {
|
||||
get: async (variable) => {
|
||||
return authenticatedRequest("/slack/config?variable=" + variable, "GET");
|
||||
},
|
||||
get_all: async () => {
|
||||
return authenticatedRequest("/slack/config", "GET");
|
||||
},
|
||||
set: async (variable, value) => {
|
||||
return authenticatedRequest("/slack/config", "POST", {
|
||||
variable: variable,
|
||||
value: value,
|
||||
});
|
||||
},
|
||||
test_customer_registration_webhook: async () => {
|
||||
return authenticatedRequest("/slack/config/test", "POST", {});
|
||||
},
|
||||
keys: {
|
||||
customer_registration_webhook_url: {
|
||||
get: async () => {
|
||||
return Config.get("customer_registration_webhook_url");
|
||||
},
|
||||
set: async (value) => {
|
||||
return Config.set("customer_registration_webhook_url", value);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { Config } from "@/components/session/token/superUser/modules/slack/Config.vue";
|
||||
|
||||
/**
|
||||
* The Slack object
|
||||
*/
|
||||
export const Slack = {
|
||||
meta: {
|
||||
title: "Slack",
|
||||
icon: "fab fa-slack",
|
||||
description: "Slack notifications",
|
||||
endpoint: "/slack/config",
|
||||
config_endpoint: "/configuration/slack",
|
||||
labels: {
|
||||
single: "slack",
|
||||
multiple: "slack",
|
||||
},
|
||||
},
|
||||
config: Config,
|
||||
};
|
||||
</script>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
import { parseError } from "@/components/request/HandleGlobalError.vue";
|
||||
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||
import { DatabaseSystemObject } from "@/components/session/token/superUser/systemDatabase.vue";
|
||||
import { Cron } from "@/components/session/token/superUser/cron.vue";
|
||||
import { Economic } from "@/components/session/token/superUser/modules/Economic/Economic.vue";
|
||||
@@ -27,6 +28,7 @@ import { EdgeGateway } from "@/components/session/token/superUser/modules/edgega
|
||||
import { Failover } from "@/components/session/token/superUser/modules/failover/Failover.vue";
|
||||
import { Coolify } from "@/components/session/token/superUser/modules/coolify/Coolify.vue";
|
||||
import { ReleaseManager } from "@/components/session/token/superUser/modules/releasemanager/ReleaseManager.vue";
|
||||
import { Slack } from "@/components/session/token/superUser/modules/slack/Slack.vue";
|
||||
|
||||
let qrCodeModulePromise;
|
||||
|
||||
@@ -71,6 +73,7 @@ export const SuperUserObject = {
|
||||
get failover() { return Failover; },
|
||||
get coolify() { return Coolify; },
|
||||
get releasemanager() { return ReleaseManager; },
|
||||
get slack() { return Slack; },
|
||||
},
|
||||
/** Intimidate a user */
|
||||
intimidate: {
|
||||
@@ -106,6 +109,7 @@ export const SuperUserObject = {
|
||||
const currentToken = localStorage.getItem("token");
|
||||
localStorage.setItem("superuser_token", currentToken);
|
||||
// Set the token in the local storage
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem("token", response.data.data.token);
|
||||
// Redirect the user to the dashboard
|
||||
window.location = "/";
|
||||
@@ -143,6 +147,7 @@ export const SuperUserObject = {
|
||||
// Get the superuser token
|
||||
const superuserToken = localStorage.getItem("superuser_token");
|
||||
// Set the token in the local storage
|
||||
clearEdgeGatewayWorkspaceCache();
|
||||
localStorage.setItem("token", superuserToken);
|
||||
// Clear the superuser token
|
||||
localStorage.removeItem("superuser_token");
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, ref, watch } from "vue";
|
||||
import axios from "axios";
|
||||
import { API_URL } from "@/config.js";
|
||||
import { getScansDepartmentPagination } from "@/components/numberplatescanners/Scans.vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { parseError, clearErrors } from "@/components/request/HandleGlobalError.vue";
|
||||
import { getNotes, createNote, deleteNote } from "@/components/shop/CustomerNotes.vue";
|
||||
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||
@@ -131,12 +130,12 @@ export const nextStep = async (options = { isMobile: false, orderCreation: true
|
||||
// If the current step is 2, set the product category to addons
|
||||
setProductsCategory(4);
|
||||
// Set the step to 3 in the query parameters
|
||||
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=3`);
|
||||
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=3`);
|
||||
}
|
||||
if (step.value === 3) {
|
||||
if (normalizedOptions.isMobile === false && doesOrderContainMaterial()) {
|
||||
step.value = 4;
|
||||
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=4`);
|
||||
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=4`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -231,11 +230,7 @@ const toPositiveInteger = (value) => {
|
||||
};
|
||||
|
||||
const getRouteDepartmentId = () => {
|
||||
try {
|
||||
return toPositiveInteger(useRouter().currentRoute.value.params.departmentId);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
return getWindowPathDepartmentId();
|
||||
};
|
||||
|
||||
const getSessionUrlDepartmentId = () => {
|
||||
@@ -320,20 +315,26 @@ export const ensureCurrentOrderDepartment = async (targetDepartmentId = null) =>
|
||||
}
|
||||
|
||||
try {
|
||||
let currentDepartmentId = null;
|
||||
try {
|
||||
currentDepartmentId = await getCurrentOrderDepartmentId(normalizedOrderId);
|
||||
} catch (error) {
|
||||
currentDepartmentId = null;
|
||||
}
|
||||
const currentDepartmentId = await getCurrentOrderDepartmentId(normalizedOrderId);
|
||||
if (currentDepartmentId === normalizedDepartmentId) {
|
||||
department_id.value = normalizedDepartmentId;
|
||||
return true;
|
||||
}
|
||||
|
||||
await SessionUser.objects.orders.set.department_id(normalizedOrderId, normalizedDepartmentId);
|
||||
department_id.value = normalizedDepartmentId;
|
||||
return true;
|
||||
parseError(
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
data: {
|
||||
message: "Ordren tilhører ikke den valgte afdeling",
|
||||
},
|
||||
message: "Ordren tilhører ikke den valgte afdeling",
|
||||
},
|
||||
},
|
||||
},
|
||||
"stepError"
|
||||
);
|
||||
return false;
|
||||
} catch (error) {
|
||||
parseError(error, "stepError");
|
||||
return false;
|
||||
@@ -727,9 +728,9 @@ export const restoreStoredPosOrderId = async (
|
||||
if (selectedDepartmentId && toPositiveInteger(storedOrder?.department_id) !== selectedDepartmentId) {
|
||||
if (options.syncDepartment === true) {
|
||||
order_id.value = storedOrderId;
|
||||
const didSyncDepartment = await ensureCurrentOrderDepartment(selectedDepartmentId);
|
||||
if (!didSyncDepartment) {
|
||||
throw new Error("Stored order department could not be changed to current department");
|
||||
const isCurrentDepartment = await ensureCurrentOrderDepartment(selectedDepartmentId);
|
||||
if (!isCurrentDepartment) {
|
||||
throw new Error("Stored order department does not match current department");
|
||||
}
|
||||
} else {
|
||||
throw new Error("Stored order department does not match current department");
|
||||
@@ -751,7 +752,7 @@ export const reset_all_values = () => {
|
||||
// Reset all values to their initial state
|
||||
clearActivePosOrderContext({ clearStep: true });
|
||||
// Clear the query parameters
|
||||
window.history.pushState({}, "", `?step=1`);
|
||||
pushPosRouteState("step=1");
|
||||
};
|
||||
|
||||
/** Whenever the reg_1, reg_2 or reg_3 changes, remove any spaces and make the string uppercase */
|
||||
@@ -762,6 +763,21 @@ const normalizeRegistrationValue = (value) =>
|
||||
|
||||
export const isBlankPosMetadataValue = (value) => String(value ?? "").trim().length === 0;
|
||||
|
||||
const getPosRoutePath = () => {
|
||||
const normalizedDepartmentId = Number.parseInt(String(department_id.value || ""), 10);
|
||||
|
||||
if (Number.isInteger(normalizedDepartmentId) && normalizedDepartmentId > 0) {
|
||||
return `/admin/${normalizedDepartmentId}/modules/pos`;
|
||||
}
|
||||
|
||||
return window.location.pathname && window.location.pathname !== "/" ? window.location.pathname : "/admin/modules/pos";
|
||||
};
|
||||
|
||||
export const pushPosRouteState = (queryString) => {
|
||||
const normalizedQueryString = String(queryString || "").replace(/^\?/, "");
|
||||
window.history.pushState({}, "", `${getPosRoutePath()}?${normalizedQueryString}`);
|
||||
};
|
||||
|
||||
watch([reg_1, reg_2, reg_3], () => {
|
||||
reg_1.value = normalizeRegistrationValue(reg_1.value);
|
||||
reg_2.value = normalizeRegistrationValue(reg_2.value);
|
||||
@@ -908,7 +924,7 @@ export const createOrder = async (options = { isMobile: false }) => {
|
||||
// Set the order id in the local storage (To be make F5 safe)
|
||||
localStorage.setItem("pos_order_id", order_id.value);
|
||||
// Set the query parameters
|
||||
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
|
||||
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
|
||||
return true;
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -1188,6 +1204,7 @@ export const selectScan = (scan) => {
|
||||
};
|
||||
|
||||
const cached_customer_names = [];
|
||||
const activeCustomerSelectionRequests = new Map();
|
||||
|
||||
/** Get the customer's name */
|
||||
export const getCustomerName = async (customerNumber) => {
|
||||
@@ -1216,23 +1233,62 @@ export const getCustomerName = async (customerNumber) => {
|
||||
|
||||
/** Search, then select customer by customer number */
|
||||
export const searchAndSelectCustomer = async (customerNumber, options = {}) => {
|
||||
const normalizedOptions = {
|
||||
forceRefresh: false,
|
||||
...options,
|
||||
};
|
||||
const normalizedCustomerNumber = resolveCustomerNumber(customerNumber);
|
||||
if (!normalizedCustomerNumber) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedCustomerNumber = getSelectedCustomerNumber();
|
||||
const hasSelectedCustomerData =
|
||||
customer_data.value &&
|
||||
typeof customer_data.value === "object" &&
|
||||
!Array.isArray(customer_data.value) &&
|
||||
Object.keys(customer_data.value).length > 0;
|
||||
|
||||
if (
|
||||
!normalizedOptions.forceRefresh &&
|
||||
selectedCustomerNumber === normalizedCustomerNumber &&
|
||||
hasSelectedCustomerData
|
||||
) {
|
||||
return customer_data.value;
|
||||
}
|
||||
|
||||
if (!normalizedOptions.forceRefresh && activeCustomerSelectionRequests.has(normalizedCustomerNumber)) {
|
||||
return await activeCustomerSelectionRequests.get(normalizedCustomerNumber);
|
||||
}
|
||||
|
||||
// Get the customer data
|
||||
return await authenticatedRequest(`/users/customer?customer_number=${customerNumber}`, "GET")
|
||||
const request = authenticatedRequest(`/users/customer?customer_number=${normalizedCustomerNumber}`, "GET")
|
||||
.then((response) => {
|
||||
console.log(response);
|
||||
const responseData = response?.data?.data ?? {};
|
||||
const rawEconomicCustomer = responseData.economic_customer ?? null;
|
||||
const normalizedCustomer = normalizeCustomerRecord(rawEconomicCustomer, {
|
||||
...responseData,
|
||||
customerNumber: resolveCustomerNumber(rawEconomicCustomer) ?? resolveCustomerNumber(customerNumber),
|
||||
customerNumber: resolveCustomerNumber(rawEconomicCustomer) ?? normalizedCustomerNumber,
|
||||
});
|
||||
selectCustomer(normalizedCustomer, options);
|
||||
selectCustomer(normalizedCustomer, normalizedOptions);
|
||||
return normalizedCustomer;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeCustomerSelectionRequests.get(normalizedCustomerNumber) === request) {
|
||||
activeCustomerSelectionRequests.delete(normalizedCustomerNumber);
|
||||
}
|
||||
});
|
||||
|
||||
if (!normalizedOptions.forceRefresh) {
|
||||
activeCustomerSelectionRequests.set(normalizedCustomerNumber, request);
|
||||
}
|
||||
|
||||
return await request;
|
||||
};
|
||||
|
||||
/** Get order items */
|
||||
@@ -1585,7 +1641,7 @@ export const showDeleteEverythingDialog = () => {
|
||||
/** Clear cache */
|
||||
export const clearCache = () => {
|
||||
// Reset the query parameters
|
||||
window.history.pushState({}, "", `?step=1`);
|
||||
pushPosRouteState("step=1");
|
||||
// Clear the customer data
|
||||
clearCustomerSelection();
|
||||
// Clear the order data
|
||||
@@ -1972,18 +2028,14 @@ export const getVehiclePlateBookings = (vehiclePlate) => {
|
||||
return sortPendingOrderBookings(matchingBookings);
|
||||
};
|
||||
|
||||
export const ensureVehiclePlateBookingsLoaded = async (vehiclePlate, options = {}) => {
|
||||
const normalizedOptions = {
|
||||
force: false,
|
||||
...options,
|
||||
};
|
||||
export const ensureVehiclePlateBookingsLoaded = async (vehiclePlate, _options = {}) => {
|
||||
const normalizedVehiclePlate = normalizeVehiclePlateForBookingSelection(vehiclePlate);
|
||||
const currentDepartmentKey = syncPendingBookingsDepartmentState();
|
||||
if (!normalizedVehiclePlate || !department_id.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!normalizedOptions.force && loadedPendingBookingPlates.has(normalizedVehiclePlate)) {
|
||||
if (loadedPendingBookingPlates.has(normalizedVehiclePlate)) {
|
||||
return getVehiclePlateBookings(normalizedVehiclePlate);
|
||||
}
|
||||
|
||||
@@ -2127,7 +2179,7 @@ const getFirstAvailableWashProduct = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const hydrateSelectedOrderBookingForDesktop = async () => {
|
||||
export const hydrateSelectedOrderBookingForDesktop = async () => {
|
||||
const normalizedOrderId = toPositiveInteger(order_id.value);
|
||||
const normalizedBookingId = toPositiveInteger(selectedOrderBookingId.value);
|
||||
|
||||
@@ -2210,8 +2262,7 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const forcedPrimaryPrice =
|
||||
typeof primaryRawItem?.price === "number" ? Number(primaryRawItem.price) : primaryProduct.price ?? null;
|
||||
const forcedPrimaryPrice = primaryProduct.price ?? null;
|
||||
const primaryItemResponse = await createOrderItem(
|
||||
normalizedOrderId,
|
||||
primaryProduct.id,
|
||||
@@ -2229,13 +2280,15 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const secondaryProduct = await fetchOrderBookingProductWithPricing(secondaryProductId);
|
||||
|
||||
await createOrderItem(
|
||||
normalizedOrderId,
|
||||
secondaryProductId,
|
||||
Math.max(1, Number.parseInt(String(bookingItem?.quantity ?? 1), 10) || 1),
|
||||
relatedPrimaryItemId,
|
||||
String(bookingItem?.notes ?? "").trim() || null,
|
||||
typeof bookingItem?.price === "number" ? Number(bookingItem.price) : null
|
||||
secondaryProduct?.price ?? null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import {useRouter} from "vue-router";
|
||||
import { showFooterInContent } from "@/components/viewport/conditions/ViewPortFooterOptions.vue";
|
||||
import { isHidden } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
|
||||
import ConnectivityIssue from "@/views/errors/ConnectivityIssue.vue";
|
||||
import { inject } from "vue";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const headerHeight = ref(60); // Default header height in pixels
|
||||
@@ -87,6 +89,7 @@ const isFooterInContent = computed(() => {
|
||||
style="max-height: 28px;"
|
||||
>
|
||||
<p class="is-size-7 has-text-grey-light mb-2 mt-0">© Truckwash ApS. All rights reserved.</p>
|
||||
<p class="is-size-7 has-text-grey-light mb-2 mt-0" v-show="SessionUser.functions.device.isMobile()">{{inject("VERSION")}}</p>
|
||||
</a>
|
||||
</div>
|
||||
</ViewportContent>
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import {BButton, BField, BIcon} from "buefy";
|
||||
import {computed, ref} from "vue";
|
||||
import {computed, onBeforeUnmount, ref, watch} from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { IS_DEV } from '@/config.js';
|
||||
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
|
||||
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
|
||||
|
||||
const router = useRouter();
|
||||
const HOME_LONG_PRESS_MS = 5000;
|
||||
const HOME_CLICK_SUPPRESS_MS = 1200;
|
||||
|
||||
type FooterPage = {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
const show = computed(() => {
|
||||
const path = router.currentRoute.value.path;
|
||||
@@ -12,10 +22,16 @@ const show = computed(() => {
|
||||
return path.startsWith("/user");
|
||||
});
|
||||
|
||||
const currentPath = computed(() => router.currentRoute.value.path);
|
||||
const isLocalDataResetOpen = ref(false);
|
||||
const isClearingLocalData = ref(false);
|
||||
const suppressHomeClickUntil = ref(0);
|
||||
let homeLongPressTimer: ReturnType<typeof window.setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Page navigation footer for mobile devices
|
||||
*/
|
||||
const pages = {
|
||||
const pages: Record<string, FooterPage> = {
|
||||
home: {
|
||||
to: "/user",
|
||||
label: "Hjem",
|
||||
@@ -26,7 +42,7 @@ const pages = {
|
||||
to: "/user/wash/start",
|
||||
label: "Selv vask",
|
||||
icon: "tint",
|
||||
disabled: !IS_DEV,
|
||||
disabled: false,
|
||||
},
|
||||
bookings: {
|
||||
to: "/user/bookings",
|
||||
@@ -35,6 +51,78 @@ const pages = {
|
||||
disabled: false
|
||||
},
|
||||
}
|
||||
|
||||
const cancelHomeLongPress = () => {
|
||||
if (homeLongPressTimer !== null) {
|
||||
window.clearTimeout(homeLongPressTimer);
|
||||
homeLongPressTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startHomeLongPress = (event: PointerEvent) => {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLocalDataResetOpen.value || isClearingLocalData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelHomeLongPress();
|
||||
homeLongPressTimer = window.setTimeout(() => {
|
||||
homeLongPressTimer = null;
|
||||
suppressHomeClickUntil.value = Date.now() + HOME_CLICK_SUPPRESS_MS;
|
||||
isLocalDataResetOpen.value = true;
|
||||
}, HOME_LONG_PRESS_MS);
|
||||
};
|
||||
|
||||
const footerButtonListeners = (key: string) =>
|
||||
key === "home"
|
||||
? {
|
||||
pointerdown: startHomeLongPress,
|
||||
pointerup: cancelHomeLongPress,
|
||||
pointercancel: cancelHomeLongPress,
|
||||
pointerleave: cancelHomeLongPress,
|
||||
}
|
||||
: {};
|
||||
|
||||
const isActiveFooterRoute = (path: string) => currentPath.value.endsWith(path);
|
||||
|
||||
const handlePageClick = (event: MouseEvent, key: string, page: FooterPage) => {
|
||||
if (page.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "home" && Date.now() <= suppressHomeClickUntil.value) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
suppressHomeClickUntil.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(page.to);
|
||||
};
|
||||
|
||||
const closeLocalDataReset = () => {
|
||||
isLocalDataResetOpen.value = false;
|
||||
};
|
||||
|
||||
const confirmLocalDataReset = async () => {
|
||||
if (isClearingLocalData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isClearingLocalData.value = true;
|
||||
try {
|
||||
await forceFrontendUpdateAndClearLocal();
|
||||
} catch (error) {
|
||||
isClearingLocalData.value = false;
|
||||
console.error("Failed to clear local app data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => router.currentRoute.value.fullPath || router.currentRoute.value.path, cancelHomeLongPress);
|
||||
onBeforeUnmount(cancelHomeLongPress);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,9 +134,11 @@ const pages = {
|
||||
<div class="column has-text-centered">
|
||||
<b-button
|
||||
class="no-border-radius"
|
||||
:type="$route.path.endsWith(page.to) ? 'is-info is-outlined is-light p-1' : 'p-1 is-light is-outlined'"
|
||||
:data-testid="`mobile-footer-${key}`"
|
||||
:type="isActiveFooterRoute(page.to) ? 'is-info is-outlined is-light p-1' : 'p-1 is-light is-outlined'"
|
||||
size="is-normal"
|
||||
@click="$router.push(page.to)"
|
||||
v-on="footerButtonListeners(key)"
|
||||
@click="handlePageClick($event, key, page)"
|
||||
iconPack="fas"
|
||||
expanded
|
||||
:disabled="page.disabled"
|
||||
@@ -64,6 +154,12 @@ const pages = {
|
||||
</div>
|
||||
</b-field>
|
||||
</section>
|
||||
<LocalDataResetDialog
|
||||
v-model="isLocalDataResetOpen"
|
||||
:busy="isClearingLocalData"
|
||||
@confirm="confirmLocalDataReset"
|
||||
@dismiss="closeLocalDataReset"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
getDepartmentSelectionVisibility,
|
||||
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
|
||||
import {
|
||||
isAccessibleVisibleDepartment,
|
||||
isDepartmentLabelValid,
|
||||
isAccessibleVisibleNamedDepartment,
|
||||
sortByDepartmentPriorityOrder,
|
||||
} from "@/services/departmentVisibility.js";
|
||||
|
||||
@@ -35,7 +34,7 @@ const options = computed(() => {
|
||||
// Filter out departments the user does not have access to
|
||||
return departments.value
|
||||
.filter((department: department) => {
|
||||
return isAccessibleVisibleDepartment(department, SessionUser.canAccessAssignedDepartment);
|
||||
return isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment);
|
||||
})
|
||||
.map((department: department) => {
|
||||
return {
|
||||
@@ -56,7 +55,7 @@ const mobileOptions = computed(() => {
|
||||
});
|
||||
|
||||
const desktopOptions = computed(() => {
|
||||
return sortByDepartmentPriorityOrder(options.value.filter((option) => isDepartmentLabelValid(option.label)));
|
||||
return sortByDepartmentPriorityOrder(options.value);
|
||||
});
|
||||
|
||||
const selectDepartment = (departmentId: number | string) => {
|
||||
@@ -92,11 +91,17 @@ const getDistanceToDepartment = (departmentId: number | null, optionList = mobil
|
||||
return 0;
|
||||
}
|
||||
const department = optionList.find((dept) => dept.value === departmentId);
|
||||
if (department && locations.location.value?.coords) {
|
||||
return locations.getDistance(
|
||||
{ latitude: locations.location.value.coords.latitude, longitude: locations.location.value.coords.longitude },
|
||||
{ latitude: department.latitude, longitude: department.longitude }
|
||||
const currentCoords = locations.normalizeCoordinatePair(locations.location.value?.coords);
|
||||
const departmentCoords = locations.normalizeCoordinatePair(
|
||||
{ latitude: department?.latitude, longitude: department?.longitude },
|
||||
{ allowZeroPair: false }
|
||||
);
|
||||
if (department && currentCoords && departmentCoords) {
|
||||
const distance = locations.getDistance(
|
||||
currentCoords,
|
||||
departmentCoords
|
||||
);
|
||||
return Number.isFinite(distance) ? distance : 0;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
@@ -1,21 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
captureVideoFrameBlobForLPR,
|
||||
isVideoFrameReadyForLPR,
|
||||
LPR_CAMERA_FRAME_RATE,
|
||||
LPR_CAMERA_VIDEO_HEIGHT,
|
||||
LPR_CAMERA_VIDEO_WIDTH,
|
||||
type LPRFrameEncodeCandidate,
|
||||
type LPRFrameViewportRect,
|
||||
} from "@/components/viewport/page/templates/scanner/lprFrameCapture";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
captureEnabled?: boolean;
|
||||
captureIntervalMs?: number | null;
|
||||
captureMode?: 'lpr' | 'preview';
|
||||
getFocusViewportRect?: () => LPRFrameViewportRect | null;
|
||||
pausePreview?: boolean;
|
||||
shouldBuildVisualFingerprint?: () => boolean;
|
||||
shouldEncodeFrame?: (_candidate: LPRFrameEncodeCandidate) => boolean | Promise<boolean>;
|
||||
}>(), {
|
||||
captureEnabled: true,
|
||||
captureIntervalMs: null,
|
||||
captureMode: 'lpr',
|
||||
getFocusViewportRect: undefined,
|
||||
pausePreview: false,
|
||||
shouldBuildVisualFingerprint: undefined,
|
||||
shouldEncodeFrame: undefined,
|
||||
});
|
||||
type GetUserMediaConstraints = Parameters<typeof navigator.mediaDevices.getUserMedia>[0];
|
||||
type CameraConstraintCaps = {
|
||||
capFrameRate: boolean;
|
||||
capResolution: boolean;
|
||||
};
|
||||
const LPR_VIDEO_NOT_READY_RETRY_MS = 100;
|
||||
const LPR_PREVIEW_RESUME_CAPTURE_DELAY_MS = LPR_VIDEO_NOT_READY_RETRY_MS;
|
||||
const emits = defineEmits(['camera-toggled', 'scanner-toggled', 'update:frame']);
|
||||
const { t } = useI18n();
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const visualFingerprintCanvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const cameraStream = ref<MediaStream | null>(null);
|
||||
const isCameraActive = ref(false);
|
||||
const cameraErrorKey = ref('pos.camera_permission_denied');
|
||||
let captureIntervalId: ReturnType<typeof window.setInterval> | null = null;
|
||||
let firstCaptureTimeoutId: ReturnType<typeof window.setTimeout> | null = null;
|
||||
let isFrameCaptureInProgress = false;
|
||||
let hasRequestedVideoPreviewPlay = false;
|
||||
let lastAppliedTrackEnabled: boolean | null = null;
|
||||
let cachedRelativeFocusViewportRect: LPRFrameViewportRect | null = null;
|
||||
let hasCachedRelativeFocusViewportRect = false;
|
||||
let cachedVideoViewportRect: DOMRect | null = null;
|
||||
let videoResizeObserver: ResizeObserver | null = null;
|
||||
import { isCameraMounted, camera } from '@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue';
|
||||
|
||||
function startCamera() {
|
||||
const constraints = {
|
||||
const isDocumentVisible = (): boolean =>
|
||||
typeof document === 'undefined' || document.visibilityState !== 'hidden';
|
||||
|
||||
const shouldRunLivePreview = (): boolean =>
|
||||
!props.pausePreview && isDocumentVisible();
|
||||
|
||||
const canCaptureFrames = (): boolean =>
|
||||
isCameraActive.value && props.captureEnabled && !props.pausePreview && isDocumentVisible();
|
||||
|
||||
const getCameraErrorName = (err: unknown): string => {
|
||||
if (err instanceof DOMException) {
|
||||
return err.name;
|
||||
}
|
||||
|
||||
return typeof err === 'object' && err !== null
|
||||
? String((err as { name?: unknown }).name ?? '')
|
||||
: '';
|
||||
};
|
||||
|
||||
const getCameraErrorKey = (err: unknown) => {
|
||||
const errorName = getCameraErrorName(err);
|
||||
if (errorName === 'NotFoundError' || errorName === 'DevicesNotFoundError') {
|
||||
return 'pos.no_camera_found';
|
||||
}
|
||||
|
||||
return 'pos.camera_permission_denied';
|
||||
};
|
||||
|
||||
const shouldRetryWithRelaxedCameraConstraints = (err: unknown): boolean => {
|
||||
const errorName = getCameraErrorName(err);
|
||||
|
||||
return errorName === 'OverconstrainedError' || errorName === 'ConstraintNotSatisfiedError';
|
||||
};
|
||||
|
||||
const getRejectedCameraConstraintName = (err: unknown): string => {
|
||||
if (typeof err !== 'object' || err === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String((err as { constraint?: unknown }).constraint ?? '').toLowerCase();
|
||||
};
|
||||
|
||||
const getCameraConstraintFallbacks = (err: unknown): CameraConstraintCaps[] => {
|
||||
const rejectedConstraint = getRejectedCameraConstraintName(err);
|
||||
if (rejectedConstraint === 'framerate') {
|
||||
return [
|
||||
{ capFrameRate: false, capResolution: true },
|
||||
{ capFrameRate: false, capResolution: false },
|
||||
];
|
||||
}
|
||||
|
||||
if (['width', 'height', 'aspectratio', 'resizemode'].includes(rejectedConstraint)) {
|
||||
return [
|
||||
{ capFrameRate: true, capResolution: false },
|
||||
{ capFrameRate: false, capResolution: false },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ capFrameRate: false, capResolution: true },
|
||||
{ capFrameRate: true, capResolution: false },
|
||||
{ capFrameRate: false, capResolution: false },
|
||||
];
|
||||
};
|
||||
|
||||
const getCameraConstraints = ({
|
||||
capFrameRate,
|
||||
capResolution,
|
||||
}: CameraConstraintCaps = { capFrameRate: true, capResolution: true }) => ({
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
zoom: camera.getZoom(),
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
frameRate: { ideal: 30 },
|
||||
width: capResolution
|
||||
? { ideal: LPR_CAMERA_VIDEO_WIDTH, max: LPR_CAMERA_VIDEO_WIDTH }
|
||||
: { ideal: LPR_CAMERA_VIDEO_WIDTH },
|
||||
height: capResolution
|
||||
? { ideal: LPR_CAMERA_VIDEO_HEIGHT, max: LPR_CAMERA_VIDEO_HEIGHT }
|
||||
: { ideal: LPR_CAMERA_VIDEO_HEIGHT },
|
||||
frameRate: capFrameRate
|
||||
? { ideal: LPR_CAMERA_FRAME_RATE, max: LPR_CAMERA_FRAME_RATE }
|
||||
: { ideal: LPR_CAMERA_FRAME_RATE },
|
||||
|
||||
// New spec
|
||||
advanced: [
|
||||
@@ -31,25 +150,254 @@ function startCamera() {
|
||||
//aspectRatio: { ideal: 16/9 },
|
||||
//frameRate: { ideal: 30 }
|
||||
}
|
||||
};
|
||||
} as unknown as GetUserMediaConstraints);
|
||||
|
||||
navigator.mediaDevices.getUserMedia(constraints)
|
||||
.then((stream) => {
|
||||
const requestCameraStream = async (): Promise<MediaStream> => {
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia(getCameraConstraints());
|
||||
} catch (err) {
|
||||
if (!shouldRetryWithRelaxedCameraConstraints(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
let lastError = err;
|
||||
for (const fallback of getCameraConstraintFallbacks(err)) {
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia(getCameraConstraints(fallback));
|
||||
} catch (fallbackError) {
|
||||
if (!shouldRetryWithRelaxedCameraConstraints(fallbackError)) {
|
||||
throw fallbackError;
|
||||
}
|
||||
lastError = fallbackError;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
};
|
||||
|
||||
const playVideoPreview = (video: HTMLVideoElement) => {
|
||||
if (hasRequestedVideoPreviewPlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasRequestedVideoPreviewPlay = true;
|
||||
const playResult = video.play();
|
||||
if (playResult && typeof playResult.catch === 'function') {
|
||||
void playResult.catch(() => {
|
||||
hasRequestedVideoPreviewPlay = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const pauseVideoPreview = (video: HTMLVideoElement) => {
|
||||
if (!hasRequestedVideoPreviewPlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasRequestedVideoPreviewPlay = false;
|
||||
video.pause();
|
||||
};
|
||||
|
||||
const getCameraVideoTracks = (): MediaStreamTrack[] => {
|
||||
if (!cameraStream.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof cameraStream.value.getVideoTracks === 'function') {
|
||||
return cameraStream.value.getVideoTracks();
|
||||
}
|
||||
|
||||
return cameraStream.value.getTracks().filter(track => track.kind === 'video');
|
||||
};
|
||||
|
||||
const syncCameraVideoTracksEnabled = () => {
|
||||
if (!isCameraActive.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldEnableTracks = shouldRunLivePreview();
|
||||
if (lastAppliedTrackEnabled === shouldEnableTracks) {
|
||||
return;
|
||||
}
|
||||
|
||||
getCameraVideoTracks().forEach((track) => {
|
||||
if (track.enabled !== shouldEnableTracks) {
|
||||
track.enabled = shouldEnableTracks;
|
||||
}
|
||||
});
|
||||
lastAppliedTrackEnabled = shouldEnableTracks;
|
||||
};
|
||||
|
||||
const syncVideoPreviewPlayback = () => {
|
||||
syncCameraVideoTracksEnabled();
|
||||
|
||||
if (!videoRef.value || !isCameraActive.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldRunLivePreview()) {
|
||||
pauseVideoPreview(videoRef.value);
|
||||
return;
|
||||
}
|
||||
|
||||
playVideoPreview(videoRef.value);
|
||||
};
|
||||
|
||||
const applyCameraStream = (stream: MediaStream) => {
|
||||
isCameraActive.value = true;
|
||||
isCameraMounted.value = true;
|
||||
cameraStream.value = stream;
|
||||
hasRequestedVideoPreviewPlay = false;
|
||||
lastAppliedTrackEnabled = null;
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = stream;
|
||||
videoRef.value.setAttribute('playsinline', '');
|
||||
videoRef.value.play();
|
||||
syncVideoPreviewPlayback();
|
||||
}
|
||||
})
|
||||
startCaptureTimers();
|
||||
};
|
||||
|
||||
function startCamera() {
|
||||
if (cameraStream.value || isCameraActive.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestCameraStream()
|
||||
.then(applyCameraStream)
|
||||
.catch((err) => {
|
||||
isCameraActive.value = false;
|
||||
cameraErrorKey.value = getCameraErrorKey(err);
|
||||
console.error('Camera access error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function clearCaptureInterval() {
|
||||
if (captureIntervalId !== null) {
|
||||
window.clearInterval(captureIntervalId);
|
||||
captureIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearFirstCaptureTimeout() {
|
||||
if (firstCaptureTimeoutId !== null) {
|
||||
window.clearTimeout(firstCaptureTimeoutId);
|
||||
firstCaptureTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
const pauseCaptureTimers = () => {
|
||||
clearFirstCaptureTimeout();
|
||||
clearCaptureInterval();
|
||||
};
|
||||
|
||||
function captureFrameIfReady(): Promise<void> {
|
||||
if (canCaptureFrames() && !isFrameCaptureInProgress) {
|
||||
if (!videoRef.value || !isVideoFrameReadyForLPR(videoRef.value)) {
|
||||
scheduleFrameReadinessRetry();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
clearFirstCaptureTimeout();
|
||||
return getFrame()
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
if (canCaptureFrames()) {
|
||||
startCaptureInterval();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function startCaptureTimers() {
|
||||
if (!canCaptureFrames()) {
|
||||
pauseCaptureTimers();
|
||||
return;
|
||||
}
|
||||
|
||||
clearCaptureInterval();
|
||||
scheduleFirstCapture();
|
||||
}
|
||||
|
||||
function resumeCaptureTimers(firstCaptureDelayMs = 0) {
|
||||
if (!canCaptureFrames()) {
|
||||
pauseCaptureTimers();
|
||||
return;
|
||||
}
|
||||
|
||||
clearCaptureInterval();
|
||||
scheduleFirstCapture(firstCaptureDelayMs);
|
||||
}
|
||||
|
||||
function scheduleFirstCapture(delayMs = camera.getImageCaptureDelay(true)) {
|
||||
clearFirstCaptureTimeout();
|
||||
firstCaptureTimeoutId = window.setTimeout(() => {
|
||||
firstCaptureTimeoutId = null;
|
||||
captureFrameIfReady();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
const getRecurringCaptureDelay = (): number => {
|
||||
if (props.captureIntervalMs === null || props.captureIntervalMs === undefined) {
|
||||
return camera.getImageCaptureDelay(false);
|
||||
}
|
||||
|
||||
const customDelay = Number(props.captureIntervalMs);
|
||||
|
||||
if (Number.isFinite(customDelay) && customDelay >= 0) {
|
||||
return Math.floor(customDelay);
|
||||
}
|
||||
|
||||
return camera.getImageCaptureDelay(false);
|
||||
};
|
||||
|
||||
function scheduleFrameReadinessRetry() {
|
||||
if (firstCaptureTimeoutId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleFirstCapture(LPR_VIDEO_NOT_READY_RETRY_MS);
|
||||
}
|
||||
|
||||
function startCaptureInterval() {
|
||||
if (!canCaptureFrames()) {
|
||||
clearCaptureInterval();
|
||||
return;
|
||||
}
|
||||
|
||||
clearCaptureInterval();
|
||||
captureIntervalId = window.setTimeout(() => {
|
||||
captureIntervalId = null;
|
||||
void captureFrameIfReady();
|
||||
}, getRecurringCaptureDelay());
|
||||
}
|
||||
|
||||
const clearRelativeFocusViewportRectCache = () => {
|
||||
cachedRelativeFocusViewportRect = null;
|
||||
cachedVideoViewportRect = null;
|
||||
hasCachedRelativeFocusViewportRect = false;
|
||||
};
|
||||
|
||||
const observeVideoGeometry = () => {
|
||||
videoResizeObserver?.disconnect();
|
||||
videoResizeObserver = null;
|
||||
|
||||
if (typeof ResizeObserver === 'undefined' || !videoRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
videoResizeObserver = new ResizeObserver(clearRelativeFocusViewportRectCache);
|
||||
videoResizeObserver.observe(videoRef.value);
|
||||
};
|
||||
|
||||
function stopCamera() {
|
||||
clearFirstCaptureTimeout();
|
||||
clearCaptureInterval();
|
||||
clearRelativeFocusViewportRectCache();
|
||||
hasRequestedVideoPreviewPlay = false;
|
||||
lastAppliedTrackEnabled = null;
|
||||
if (cameraStream.value) {
|
||||
cameraStream.value.getTracks().forEach(track => track.stop());
|
||||
cameraStream.value = null;
|
||||
@@ -69,75 +417,179 @@ function toggleCamera() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleVideoLoadedData() {
|
||||
clearRelativeFocusViewportRectCache();
|
||||
if (canCaptureFrames()) {
|
||||
scheduleFirstCapture(0);
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
clearRelativeFocusViewportRectCache();
|
||||
if (isDocumentVisible()) {
|
||||
syncVideoPreviewPlayback();
|
||||
resumeCaptureTimers(0);
|
||||
return;
|
||||
}
|
||||
|
||||
pauseCaptureTimers();
|
||||
syncVideoPreviewPlayback();
|
||||
}
|
||||
|
||||
const shouldUseFocusedLPRCrop = () => props.captureMode === 'lpr';
|
||||
|
||||
const hasUsableRectSize = (rect: { height: number; width: number }): boolean =>
|
||||
Number.isFinite(rect.width)
|
||||
&& Number.isFinite(rect.height)
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
|
||||
const getVideoViewportRect = (): DOMRect | null => {
|
||||
if (!videoRef.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cachedVideoViewportRect !== null) {
|
||||
return cachedVideoViewportRect;
|
||||
}
|
||||
|
||||
const videoRect = videoRef.value.getBoundingClientRect();
|
||||
if (!hasUsableRectSize(videoRect)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
cachedVideoViewportRect = videoRect;
|
||||
return cachedVideoViewportRect;
|
||||
};
|
||||
|
||||
const getRelativeFocusViewportRect = (videoViewportRect: DOMRect | null): LPRFrameViewportRect | null => {
|
||||
if (!shouldUseFocusedLPRCrop() || !props.getFocusViewportRect || !videoRef.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasCachedRelativeFocusViewportRect) {
|
||||
return cachedRelativeFocusViewportRect;
|
||||
}
|
||||
|
||||
const focusRect = props.getFocusViewportRect();
|
||||
if (!focusRect || !hasUsableRectSize(focusRect)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!videoViewportRect || !hasUsableRectSize(videoViewportRect)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
cachedRelativeFocusViewportRect = {
|
||||
height: focusRect.height,
|
||||
width: focusRect.width,
|
||||
x: focusRect.x - videoViewportRect.left,
|
||||
y: focusRect.y - videoViewportRect.top,
|
||||
};
|
||||
hasCachedRelativeFocusViewportRect = true;
|
||||
|
||||
return cachedRelativeFocusViewportRect;
|
||||
};
|
||||
|
||||
const getFrameCaptureOptions = () => {
|
||||
const shouldUseFocusedCrop = shouldUseFocusedLPRCrop();
|
||||
const videoViewportRect = shouldUseFocusedCrop ? getVideoViewportRect() : null;
|
||||
|
||||
return {
|
||||
focusCrop: shouldUseFocusedCrop,
|
||||
focusViewportRect: shouldUseFocusedCrop ? getRelativeFocusViewportRect(videoViewportRect) : null,
|
||||
shouldBuildVisualFingerprint: shouldUseFocusedCrop ? props.shouldBuildVisualFingerprint : undefined,
|
||||
shouldEncode: shouldUseFocusedCrop ? props.shouldEncodeFrame : undefined,
|
||||
...(videoViewportRect
|
||||
? {
|
||||
viewportHeight: videoViewportRect.height,
|
||||
viewportWidth: videoViewportRect.width,
|
||||
}
|
||||
: {}),
|
||||
visualFingerprintCanvas: shouldUseFocusedCrop ? visualFingerprintCanvasRef.value : null,
|
||||
};
|
||||
};
|
||||
|
||||
const getFrame = () => {
|
||||
if (!canCaptureFrames()) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (videoRef.value && isCameraActive.value) {
|
||||
const canvas = canvasRef.value;
|
||||
if (canvas) {
|
||||
const context = canvas.getContext('2d', {
|
||||
alpha: false,
|
||||
willReadFrequently: true
|
||||
});
|
||||
isFrameCaptureInProgress = true;
|
||||
|
||||
if (!context) {
|
||||
console.error('Failed to get canvas context');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set canvas size to match video's native resolution
|
||||
const videoWidth = videoRef.value.videoWidth;
|
||||
const videoHeight = videoRef.value.videoHeight;
|
||||
|
||||
canvas.width = videoWidth;
|
||||
canvas.height = videoHeight;
|
||||
|
||||
// Clear previous frame
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw current frame
|
||||
context.drawImage(videoRef.value, 0, 0, videoWidth, videoHeight);
|
||||
|
||||
// Get frame data as base64
|
||||
const frameData = canvas.toDataURL('image/jpeg', 0.95);
|
||||
|
||||
// Emit the frame data
|
||||
return captureVideoFrameBlobForLPR(videoRef.value, canvas, getFrameCaptureOptions())
|
||||
.then((frameData) => {
|
||||
if (frameData && canCaptureFrames()) {
|
||||
emits('update:frame', frameData);
|
||||
}
|
||||
|
||||
return frameData;
|
||||
return nextTick().then(() => frameData);
|
||||
})
|
||||
.finally(() => {
|
||||
isFrameCaptureInProgress = false;
|
||||
syncVideoPreviewPlayback();
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return Promise.resolve(null);
|
||||
};
|
||||
|
||||
// Update canvas dimensions when video size changes
|
||||
watch(() => videoRef.value?.videoWidth, (newWidth) => {
|
||||
if (canvasRef.value && newWidth) {
|
||||
canvasRef.value.width = newWidth;
|
||||
canvasRef.value.height = videoRef.value?.videoHeight || 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for zoom level changes
|
||||
watch(() => camera.getZoom(), (newZoom) => {
|
||||
watch(() => camera.getZoom(), () => {
|
||||
if (isCameraActive.value) {
|
||||
stopCamera();
|
||||
startCamera();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// Emit a picture every 10 seconds
|
||||
if (!isCameraMounted.value) {
|
||||
isCameraMounted.value = true;
|
||||
setInterval(() => {
|
||||
if (isCameraActive.value) {
|
||||
getFrame();
|
||||
}
|
||||
}, camera.getImageCaptureDelay(false)); // Implement a method to get the delay based on camera settings
|
||||
startCamera();
|
||||
watch(() => props.captureEnabled, (isCaptureEnabled) => {
|
||||
if (isCaptureEnabled) {
|
||||
resumeCaptureTimers(0);
|
||||
} else {
|
||||
pauseCaptureTimers();
|
||||
}
|
||||
});
|
||||
|
||||
watch([() => props.captureMode, () => props.getFocusViewportRect], clearRelativeFocusViewportRectCache);
|
||||
|
||||
watch(() => props.captureIntervalMs, () => {
|
||||
if (canCaptureFrames() && !isFrameCaptureInProgress && firstCaptureTimeoutId === null) {
|
||||
startCaptureInterval();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.pausePreview, (isPreviewPaused) => {
|
||||
syncVideoPreviewPlayback();
|
||||
|
||||
if (isPreviewPaused) {
|
||||
pauseCaptureTimers();
|
||||
return;
|
||||
}
|
||||
|
||||
resumeCaptureTimers(LPR_PREVIEW_RESUME_CAPTURE_DELAY_MS);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
document.addEventListener('scroll', clearRelativeFocusViewportRectCache, true);
|
||||
window.addEventListener('orientationchange', clearRelativeFocusViewportRectCache);
|
||||
window.addEventListener('resize', clearRelativeFocusViewportRectCache);
|
||||
observeVideoGeometry();
|
||||
isCameraMounted.value = true;
|
||||
startCamera();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
document.removeEventListener('scroll', clearRelativeFocusViewportRectCache, true);
|
||||
window.removeEventListener('orientationchange', clearRelativeFocusViewportRectCache);
|
||||
window.removeEventListener('resize', clearRelativeFocusViewportRectCache);
|
||||
videoResizeObserver?.disconnect();
|
||||
videoResizeObserver = null;
|
||||
clearRelativeFocusViewportRectCache();
|
||||
stopCamera();
|
||||
});
|
||||
|
||||
@@ -161,6 +613,7 @@ watch(() => isCameraActive.value, (newVal) => {
|
||||
autoplay
|
||||
playsinline
|
||||
:class="{ 'is-active': isCameraActive }"
|
||||
@loadeddata="handleVideoLoadedData"
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
@@ -170,9 +623,14 @@ watch(() => isCameraActive.value, (newVal) => {
|
||||
class="capture-canvas"
|
||||
></canvas>
|
||||
|
||||
<canvas
|
||||
ref="visualFingerprintCanvasRef"
|
||||
class="visual-fingerprint-canvas"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
|
||||
<div v-if="!isCameraActive" class="camera-inactive">
|
||||
<p>Camera is not active.</p>
|
||||
<p>Please enable camera access.</p>
|
||||
<p>{{ t(cameraErrorKey) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -202,6 +660,10 @@ video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.visual-fingerprint-canvas {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.camera-inactive {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
export const LPR_CAMERA_VIDEO_WIDTH = 1024;
|
||||
export const LPR_CAMERA_VIDEO_HEIGHT = 576;
|
||||
export const LPR_CAMERA_FRAME_RATE = 5;
|
||||
export const LPR_FRAME_MAX_WIDTH = 1024;
|
||||
export const LPR_FRAME_MAX_HEIGHT = 576;
|
||||
export const LPR_FRAME_SCANNER_MAX_SIZE = 384;
|
||||
export const LPR_FRAME_JPEG_QUALITY = 0.72;
|
||||
export const LPR_FRAME_SCANNER_JPEG_QUALITY = 0.6;
|
||||
export const LPR_FRAME_MIME_TYPE = "image/jpeg";
|
||||
export const LPR_FRAME_FILE_NAME = "license-plate.jpg";
|
||||
export const LPR_FRAME_CLIENT_CAPTURE_MS_FIELD = "client_capture_ms";
|
||||
export const LPR_FRAME_CLIENT_DRAW_MS_FIELD = "client_draw_ms";
|
||||
export const LPR_FRAME_CLIENT_ENCODE_MS_FIELD = "client_encode_ms";
|
||||
export const LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD = "client_preflight_ms";
|
||||
export const LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD = "client_visual_fingerprint_ms";
|
||||
export const LPR_FRAME_CLIENT_WIDTH_FIELD = "client_frame_width";
|
||||
export const LPR_FRAME_CLIENT_HEIGHT_FIELD = "client_frame_height";
|
||||
export const LPR_FRAME_CLIENT_BYTES_FIELD = "client_frame_bytes";
|
||||
export const LPR_FRAME_FOCUS_ASPECT_RATIO = 16 / 9;
|
||||
export const LPR_FRAME_SCANNER_FOCUS_SCALE = 0.85;
|
||||
export const LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE = 8;
|
||||
const HTML_MEDIA_HAVE_CURRENT_DATA = 2;
|
||||
const LPR_FRAME_FINGERPRINT_SAMPLE_BYTES = 32;
|
||||
const LPR_FRAME_FINGERPRINT_HASH_SEED = 2166136261;
|
||||
const LPR_FRAME_FINGERPRINT_HASH_PRIME = 16777619;
|
||||
const NIBBLE_BIT_COUNT = [0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4];
|
||||
const LPR_ENCODING_CANVAS_CONTEXT_OPTIONS = {
|
||||
alpha: false,
|
||||
desynchronized: true,
|
||||
};
|
||||
|
||||
export type FrameSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type FrameSizeConstraints = {
|
||||
maxHeight: number;
|
||||
maxWidth: number;
|
||||
};
|
||||
|
||||
export type FrameSourceRect = {
|
||||
height: number;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type LPRFrameViewportRect = {
|
||||
height: number;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type LPRFramePayload = {
|
||||
blob: Blob;
|
||||
captureDurationMs: number;
|
||||
captureTimings?: LPRFrameCaptureTimings;
|
||||
filename: string;
|
||||
fingerprint: string;
|
||||
getContentFingerprint?: () => Promise<string>;
|
||||
getVisualFingerprint?: () => string | null;
|
||||
height: number;
|
||||
mimeType: string;
|
||||
visualFingerprint?: string;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export type LPRFrameCaptureTimings = {
|
||||
drawMs: number;
|
||||
encodeMs: number;
|
||||
visualFingerprintMs: number;
|
||||
};
|
||||
|
||||
export type LPRFrameEncodeCandidate = {
|
||||
height: number;
|
||||
visualFingerprint?: string;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export type LPRFrameCaptureOptions = {
|
||||
focusCrop?: boolean;
|
||||
focusScale?: number;
|
||||
focusViewportRect?: LPRFrameViewportRect | null;
|
||||
jpegQuality?: number;
|
||||
onFrameDrawn?: () => void;
|
||||
shouldBuildVisualFingerprint?: () => boolean;
|
||||
shouldEncode?: (candidate: LPRFrameEncodeCandidate) => boolean | Promise<boolean>;
|
||||
viewportHeight?: number;
|
||||
viewportWidth?: number;
|
||||
visualFingerprintCanvas?: HTMLCanvasElement | null;
|
||||
};
|
||||
|
||||
type LPRFrameEncodingCanvas = HTMLCanvasElement | OffscreenCanvas;
|
||||
type LPRFrameEncodingContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
|
||||
type LPRFrameEncodedCanvas = {
|
||||
blob: Blob;
|
||||
canvas: LPRFrameEncodingCanvas;
|
||||
timings: Pick<LPRFrameCaptureTimings, "drawMs" | "encodeMs">;
|
||||
};
|
||||
|
||||
const offscreenEncodingCanvases = new WeakMap<HTMLCanvasElement, OffscreenCanvas>();
|
||||
const disabledOffscreenEncodingCanvases = new WeakSet<HTMLCanvasElement>();
|
||||
const encodingCanvasContexts = new WeakMap<LPRFrameEncodingCanvas, LPRFrameEncodingContext>();
|
||||
const visualFingerprintCanvasContexts = new WeakMap<HTMLCanvasElement, CanvasRenderingContext2D>();
|
||||
|
||||
const clampNumber = (value: number, min: number, max: number): number =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
export const getConstrainedFrameSize = (
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
maxWidth = LPR_FRAME_MAX_WIDTH,
|
||||
maxHeight = LPR_FRAME_MAX_HEIGHT
|
||||
): FrameSize => {
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
const scale = Math.min(1, maxWidth / sourceWidth, maxHeight / sourceHeight);
|
||||
|
||||
return {
|
||||
width: Math.max(1, Math.round(sourceWidth * scale)),
|
||||
height: Math.max(1, Math.round(sourceHeight * scale)),
|
||||
};
|
||||
};
|
||||
|
||||
export const getLPRFrameSizeConstraints = (options: LPRFrameCaptureOptions = {}): FrameSizeConstraints =>
|
||||
options.focusCrop === false
|
||||
? {
|
||||
maxHeight: LPR_FRAME_MAX_HEIGHT,
|
||||
maxWidth: LPR_FRAME_MAX_WIDTH,
|
||||
}
|
||||
: {
|
||||
maxHeight: LPR_FRAME_SCANNER_MAX_SIZE,
|
||||
maxWidth: LPR_FRAME_SCANNER_MAX_SIZE,
|
||||
};
|
||||
|
||||
export const getLPRFrameTargetSize = (
|
||||
sourceRect: FrameSourceRect,
|
||||
options: LPRFrameCaptureOptions = {}
|
||||
): FrameSize => {
|
||||
const constraints = getLPRFrameSizeConstraints(options);
|
||||
|
||||
return getConstrainedFrameSize(
|
||||
sourceRect.width,
|
||||
sourceRect.height,
|
||||
constraints.maxWidth,
|
||||
constraints.maxHeight
|
||||
);
|
||||
};
|
||||
|
||||
export const getVisibleCoverSourceRect = (
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number
|
||||
): FrameSourceRect => {
|
||||
if (!Number.isFinite(sourceWidth) || !Number.isFinite(sourceHeight) || sourceWidth <= 0 || sourceHeight <= 0) {
|
||||
return { height: 0, width: 0, x: 0, y: 0 };
|
||||
}
|
||||
|
||||
if (
|
||||
!Number.isFinite(viewportWidth) ||
|
||||
!Number.isFinite(viewportHeight) ||
|
||||
viewportWidth <= 0 ||
|
||||
viewportHeight <= 0
|
||||
) {
|
||||
return { height: sourceHeight, width: sourceWidth, x: 0, y: 0 };
|
||||
}
|
||||
|
||||
const sourceAspectRatio = sourceWidth / sourceHeight;
|
||||
const viewportAspectRatio = viewportWidth / viewportHeight;
|
||||
|
||||
if (viewportAspectRatio > sourceAspectRatio) {
|
||||
const visibleHeight = Math.max(1, Math.min(sourceHeight, Math.round(sourceWidth / viewportAspectRatio)));
|
||||
|
||||
return {
|
||||
height: visibleHeight,
|
||||
width: sourceWidth,
|
||||
x: 0,
|
||||
y: Math.max(0, Math.round((sourceHeight - visibleHeight) / 2)),
|
||||
};
|
||||
}
|
||||
|
||||
const visibleWidth = Math.max(1, Math.min(sourceWidth, Math.round(sourceHeight * viewportAspectRatio)));
|
||||
|
||||
return {
|
||||
height: sourceHeight,
|
||||
width: visibleWidth,
|
||||
x: Math.max(0, Math.round((sourceWidth - visibleWidth) / 2)),
|
||||
y: 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCenteredFocusSourceRect = (
|
||||
sourceRect: FrameSourceRect,
|
||||
focusAspectRatio = LPR_FRAME_FOCUS_ASPECT_RATIO,
|
||||
focusScale = 1
|
||||
): FrameSourceRect => {
|
||||
if (
|
||||
!Number.isFinite(sourceRect.width) ||
|
||||
!Number.isFinite(sourceRect.height) ||
|
||||
sourceRect.width <= 0 ||
|
||||
sourceRect.height <= 0
|
||||
) {
|
||||
return { height: 0, width: 0, x: 0, y: 0 };
|
||||
}
|
||||
|
||||
if (!Number.isFinite(focusAspectRatio) || focusAspectRatio <= 0) {
|
||||
return sourceRect;
|
||||
}
|
||||
|
||||
let focusedRect: FrameSourceRect;
|
||||
const sourceAspectRatio = sourceRect.width / sourceRect.height;
|
||||
if (sourceAspectRatio > focusAspectRatio) {
|
||||
const focusedWidth = Math.max(1, Math.min(sourceRect.width, Math.round(sourceRect.height * focusAspectRatio)));
|
||||
|
||||
focusedRect = {
|
||||
height: sourceRect.height,
|
||||
width: focusedWidth,
|
||||
x: sourceRect.x + Math.max(0, Math.round((sourceRect.width - focusedWidth) / 2)),
|
||||
y: sourceRect.y,
|
||||
};
|
||||
} else {
|
||||
const focusedHeight = Math.max(1, Math.min(sourceRect.height, Math.round(sourceRect.width / focusAspectRatio)));
|
||||
|
||||
focusedRect = {
|
||||
height: focusedHeight,
|
||||
width: sourceRect.width,
|
||||
x: sourceRect.x,
|
||||
y: sourceRect.y + Math.max(0, Math.round((sourceRect.height - focusedHeight) / 2)),
|
||||
};
|
||||
}
|
||||
|
||||
if (!Number.isFinite(focusScale) || focusScale <= 0 || focusScale >= 1) {
|
||||
return focusedRect;
|
||||
}
|
||||
|
||||
const scaledWidth = Math.max(1, Math.round(focusedRect.width * focusScale));
|
||||
const scaledHeight = Math.max(1, Math.round(focusedRect.height * focusScale));
|
||||
|
||||
return {
|
||||
height: scaledHeight,
|
||||
width: scaledWidth,
|
||||
x: focusedRect.x + Math.max(0, Math.round((focusedRect.width - scaledWidth) / 2)),
|
||||
y: focusedRect.y + Math.max(0, Math.round((focusedRect.height - scaledHeight) / 2)),
|
||||
};
|
||||
};
|
||||
|
||||
export const getViewportAnchoredFocusSourceRect = (
|
||||
centeredFocusRect: FrameSourceRect,
|
||||
visibleSourceRect: FrameSourceRect,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
focusViewportRect: LPRFrameViewportRect | null | undefined
|
||||
): FrameSourceRect => {
|
||||
if (
|
||||
!focusViewportRect ||
|
||||
!Number.isFinite(viewportWidth) ||
|
||||
!Number.isFinite(viewportHeight) ||
|
||||
!Number.isFinite(focusViewportRect.x) ||
|
||||
!Number.isFinite(focusViewportRect.y) ||
|
||||
!Number.isFinite(focusViewportRect.width) ||
|
||||
!Number.isFinite(focusViewportRect.height) ||
|
||||
viewportWidth <= 0 ||
|
||||
viewportHeight <= 0 ||
|
||||
focusViewportRect.width <= 0 ||
|
||||
focusViewportRect.height <= 0 ||
|
||||
centeredFocusRect.width <= 0 ||
|
||||
centeredFocusRect.height <= 0 ||
|
||||
visibleSourceRect.width <= 0 ||
|
||||
visibleSourceRect.height <= 0 ||
|
||||
centeredFocusRect.width > visibleSourceRect.width ||
|
||||
centeredFocusRect.height > visibleSourceRect.height
|
||||
) {
|
||||
return centeredFocusRect;
|
||||
}
|
||||
|
||||
const focusCenterX = focusViewportRect.x + (focusViewportRect.width / 2);
|
||||
const focusCenterY = focusViewportRect.y + (focusViewportRect.height / 2);
|
||||
const sourceCenterX = visibleSourceRect.x + ((focusCenterX / viewportWidth) * visibleSourceRect.width);
|
||||
const sourceCenterY = visibleSourceRect.y + ((focusCenterY / viewportHeight) * visibleSourceRect.height);
|
||||
const minX = visibleSourceRect.x;
|
||||
const minY = visibleSourceRect.y;
|
||||
const maxX = visibleSourceRect.x + visibleSourceRect.width - centeredFocusRect.width;
|
||||
const maxY = visibleSourceRect.y + visibleSourceRect.height - centeredFocusRect.height;
|
||||
|
||||
return {
|
||||
height: centeredFocusRect.height,
|
||||
width: centeredFocusRect.width,
|
||||
x: clampNumber(Math.round(sourceCenterX - (centeredFocusRect.width / 2)), minX, maxX),
|
||||
y: clampNumber(Math.round(sourceCenterY - (centeredFocusRect.height / 2)), minY, maxY),
|
||||
};
|
||||
};
|
||||
|
||||
export const getLPRFrameSourceRect = (
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
options: LPRFrameCaptureOptions = {}
|
||||
): FrameSourceRect => {
|
||||
const visibleSourceRect = getVisibleCoverSourceRect(sourceWidth, sourceHeight, viewportWidth, viewportHeight);
|
||||
|
||||
if (options.focusCrop === false) {
|
||||
return visibleSourceRect;
|
||||
}
|
||||
|
||||
const centeredFocusRect = getCenteredFocusSourceRect(
|
||||
visibleSourceRect,
|
||||
LPR_FRAME_FOCUS_ASPECT_RATIO,
|
||||
options.focusScale ?? LPR_FRAME_SCANNER_FOCUS_SCALE
|
||||
);
|
||||
|
||||
return getViewportAnchoredFocusSourceRect(
|
||||
centeredFocusRect,
|
||||
visibleSourceRect,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
options.focusViewportRect
|
||||
);
|
||||
};
|
||||
|
||||
const getVideoFrameSourceRect = (video: HTMLVideoElement, options: LPRFrameCaptureOptions = {}): FrameSourceRect => {
|
||||
const configuredViewportWidth = Number(options.viewportWidth);
|
||||
const configuredViewportHeight = Number(options.viewportHeight);
|
||||
const hasConfiguredViewportSize =
|
||||
Number.isFinite(configuredViewportWidth) &&
|
||||
configuredViewportWidth > 0 &&
|
||||
Number.isFinite(configuredViewportHeight) &&
|
||||
configuredViewportHeight > 0;
|
||||
const viewportWidth = hasConfiguredViewportSize ? configuredViewportWidth : video.clientWidth;
|
||||
const viewportHeight = hasConfiguredViewportSize ? configuredViewportHeight : video.clientHeight;
|
||||
|
||||
return getLPRFrameSourceRect(
|
||||
video.videoWidth,
|
||||
video.videoHeight,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
export const isVideoFrameReadyForLPR = (video: HTMLVideoElement): boolean =>
|
||||
video.readyState >= HTML_MEDIA_HAVE_CURRENT_DATA
|
||||
&& video.videoWidth > 0
|
||||
&& video.videoHeight > 0;
|
||||
|
||||
const sampleBlobFingerprintBytes = async (blob: Blob): Promise<string> => {
|
||||
let hash = LPR_FRAME_FINGERPRINT_HASH_SEED;
|
||||
const sampleWidth = Math.min(LPR_FRAME_FINGERPRINT_SAMPLE_BYTES, blob.size);
|
||||
const offsets = [
|
||||
0,
|
||||
Math.max(0, Math.floor(blob.size / 2) - Math.floor(sampleWidth / 2)),
|
||||
Math.max(0, blob.size - sampleWidth),
|
||||
];
|
||||
let previousOffset: number | null = null;
|
||||
|
||||
for (const offset of offsets) {
|
||||
if (offset === previousOffset) {
|
||||
continue;
|
||||
}
|
||||
previousOffset = offset;
|
||||
const bytes = new Uint8Array(await blob.slice(offset, offset + sampleWidth).arrayBuffer());
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
const byte = bytes[index];
|
||||
hash ^= byte;
|
||||
hash = Math.imul(hash, LPR_FRAME_FINGERPRINT_HASH_PRIME) >>> 0;
|
||||
}
|
||||
}
|
||||
|
||||
return hash.toString(16).padStart(8, "0");
|
||||
};
|
||||
|
||||
export const buildLPRFrameFingerprint = async (
|
||||
blob: Blob,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<string> => `${width}x${height}:${blob.size}:${await sampleBlobFingerprintBytes(blob)}`;
|
||||
|
||||
export const buildLPRFrameFingerprintKey = (
|
||||
blob: Blob,
|
||||
width: number,
|
||||
height: number
|
||||
): string => `${width}x${height}:${blob.size}`;
|
||||
|
||||
export const getVisualFingerprintDistance = (first: string | null | undefined, second: string | null | undefined): number => {
|
||||
if (!first || !second || first.length !== second.length) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
let distance = 0;
|
||||
for (let index = 0; index < first.length; index += 1) {
|
||||
const firstNibble = Number.parseInt(first[index], 16);
|
||||
const secondNibble = Number.parseInt(second[index], 16);
|
||||
if (!Number.isInteger(firstNibble) || !Number.isInteger(secondNibble)) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
distance += NIBBLE_BIT_COUNT[firstNibble ^ secondNibble];
|
||||
}
|
||||
|
||||
return distance;
|
||||
};
|
||||
|
||||
const buildSourceVisualFingerprint = (
|
||||
source: CanvasImageSource,
|
||||
sourceRect: FrameSourceRect,
|
||||
visualFingerprintCanvas: HTMLCanvasElement | null | undefined
|
||||
): string | null => {
|
||||
if (
|
||||
sourceRect.width <= 0 ||
|
||||
sourceRect.height <= 0 ||
|
||||
!Number.isFinite(sourceRect.x) ||
|
||||
!Number.isFinite(sourceRect.y) ||
|
||||
!Number.isFinite(sourceRect.width) ||
|
||||
!Number.isFinite(sourceRect.height) ||
|
||||
!visualFingerprintCanvas
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (visualFingerprintCanvas.width !== LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE) {
|
||||
visualFingerprintCanvas.width = LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE;
|
||||
}
|
||||
if (visualFingerprintCanvas.height !== LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE) {
|
||||
visualFingerprintCanvas.height = LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE;
|
||||
}
|
||||
|
||||
let context = visualFingerprintCanvasContexts.get(visualFingerprintCanvas) ?? null;
|
||||
if (context === null) {
|
||||
context = visualFingerprintCanvas.getContext("2d", {
|
||||
alpha: false,
|
||||
willReadFrequently: true,
|
||||
});
|
||||
if (context !== null) {
|
||||
visualFingerprintCanvasContexts.set(visualFingerprintCanvas, context);
|
||||
}
|
||||
}
|
||||
if (!context || typeof context.getImageData !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
context.drawImage(
|
||||
source,
|
||||
sourceRect.x,
|
||||
sourceRect.y,
|
||||
sourceRect.width,
|
||||
sourceRect.height,
|
||||
0,
|
||||
0,
|
||||
LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE,
|
||||
LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE
|
||||
);
|
||||
|
||||
const imageData = context.getImageData(
|
||||
0,
|
||||
0,
|
||||
LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE,
|
||||
LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE
|
||||
).data;
|
||||
const sampleCount = LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE * LPR_FRAME_VISUAL_FINGERPRINT_GRID_SIZE;
|
||||
let luminanceTotal = 0;
|
||||
for (let pixelOffset = 0; pixelOffset < imageData.length; pixelOffset += 4) {
|
||||
luminanceTotal +=
|
||||
(imageData[pixelOffset] * 0.299)
|
||||
+ (imageData[pixelOffset + 1] * 0.587)
|
||||
+ (imageData[pixelOffset + 2] * 0.114);
|
||||
}
|
||||
|
||||
const averageLuminance = luminanceTotal / sampleCount;
|
||||
let fingerprint = "";
|
||||
for (let pixelOffset = 0; pixelOffset < imageData.length; pixelOffset += 16) {
|
||||
let nibble = 0;
|
||||
for (let offset = 0; offset < 4; offset += 1) {
|
||||
const offsetPixel = pixelOffset + (offset * 4);
|
||||
const luminance =
|
||||
(imageData[offsetPixel] * 0.299)
|
||||
+ (imageData[offsetPixel + 1] * 0.587)
|
||||
+ (imageData[offsetPixel + 2] * 0.114);
|
||||
if (luminance > averageLuminance) {
|
||||
nibble |= 1 << (3 - offset);
|
||||
}
|
||||
}
|
||||
fingerprint += nibble.toString(16);
|
||||
}
|
||||
|
||||
return fingerprint;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buildMemoizedLPRFrameContentFingerprint = (
|
||||
blob: Blob,
|
||||
width: number,
|
||||
height: number
|
||||
): (() => Promise<string>) => {
|
||||
let fingerprintPromise: Promise<string> | null = null;
|
||||
|
||||
return () => {
|
||||
fingerprintPromise ??= buildLPRFrameFingerprint(blob, width, height).catch((error) => {
|
||||
fingerprintPromise = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return fingerprintPromise;
|
||||
};
|
||||
};
|
||||
|
||||
const nowMs = (): number =>
|
||||
typeof performance !== "undefined" && typeof performance.now === "function"
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
|
||||
const getJpegQualityForFrame = (options: LPRFrameCaptureOptions): number => {
|
||||
if (Number.isFinite(options.jpegQuality) && options.jpegQuality > 0 && options.jpegQuality <= 1) {
|
||||
return options.jpegQuality;
|
||||
}
|
||||
|
||||
return options.focusCrop === false
|
||||
? LPR_FRAME_JPEG_QUALITY
|
||||
: LPR_FRAME_SCANNER_JPEG_QUALITY;
|
||||
};
|
||||
|
||||
const shouldEncodeFrameCandidate = async (
|
||||
candidate: LPRFrameEncodeCandidate,
|
||||
shouldEncode: LPRFrameCaptureOptions["shouldEncode"]
|
||||
): Promise<boolean> => {
|
||||
if (typeof shouldEncode !== "function") {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return await shouldEncode(candidate);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const shouldBuildVisualFingerprintBeforeEncode = (
|
||||
shouldBuildVisualFingerprint: LPRFrameCaptureOptions["shouldBuildVisualFingerprint"]
|
||||
): boolean => {
|
||||
if (typeof shouldBuildVisualFingerprint !== "function") {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return shouldBuildVisualFingerprint();
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const getOffscreenEncodingCanvas = (fallbackCanvas: HTMLCanvasElement): OffscreenCanvas | null => {
|
||||
if (typeof OffscreenCanvas === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (disabledOffscreenEncodingCanvases.has(fallbackCanvas)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let canvas = offscreenEncodingCanvases.get(fallbackCanvas) ?? null;
|
||||
if (canvas === null) {
|
||||
canvas = new OffscreenCanvas(1, 1);
|
||||
offscreenEncodingCanvases.set(fallbackCanvas, canvas);
|
||||
}
|
||||
|
||||
return canvas;
|
||||
};
|
||||
|
||||
const isOffscreenEncodingCanvas = (canvas: LPRFrameEncodingCanvas): canvas is OffscreenCanvas =>
|
||||
typeof OffscreenCanvas !== "undefined" && canvas instanceof OffscreenCanvas;
|
||||
|
||||
const encodeCanvasBlob = (
|
||||
canvas: LPRFrameEncodingCanvas,
|
||||
mimeType: string,
|
||||
quality: number
|
||||
): Promise<Blob | null> => {
|
||||
if (isOffscreenEncodingCanvas(canvas)) {
|
||||
return typeof canvas.convertToBlob === "function"
|
||||
? canvas.convertToBlob({ type: mimeType, quality }).catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (typeof canvas.toBlob !== "function") {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, mimeType, quality);
|
||||
});
|
||||
};
|
||||
|
||||
const getEncodingCanvasContext = (canvas: LPRFrameEncodingCanvas): LPRFrameEncodingContext | null => {
|
||||
let context = encodingCanvasContexts.get(canvas) ?? null;
|
||||
if (context !== null) {
|
||||
return context;
|
||||
}
|
||||
|
||||
context = canvas.getContext("2d", LPR_ENCODING_CANVAS_CONTEXT_OPTIONS) as LPRFrameEncodingContext | null;
|
||||
if (context !== null) {
|
||||
encodingCanvasContexts.set(canvas, context);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
const drawAndEncodeFrame = async (
|
||||
video: HTMLVideoElement,
|
||||
canvas: LPRFrameEncodingCanvas,
|
||||
sourceRect: FrameSourceRect,
|
||||
targetSize: FrameSize,
|
||||
options: LPRFrameCaptureOptions
|
||||
): Promise<LPRFrameEncodedCanvas | null> => {
|
||||
if (canvas.width !== targetSize.width) {
|
||||
canvas.width = targetSize.width;
|
||||
}
|
||||
if (canvas.height !== targetSize.height) {
|
||||
canvas.height = targetSize.height;
|
||||
}
|
||||
|
||||
const context = getEncodingCanvasContext(canvas);
|
||||
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const drawStartedAt = nowMs();
|
||||
context.drawImage(
|
||||
video,
|
||||
sourceRect.x,
|
||||
sourceRect.y,
|
||||
sourceRect.width,
|
||||
sourceRect.height,
|
||||
0,
|
||||
0,
|
||||
targetSize.width,
|
||||
targetSize.height
|
||||
);
|
||||
const drawMs = Math.max(0, nowMs() - drawStartedAt);
|
||||
try {
|
||||
options.onFrameDrawn?.();
|
||||
} catch {
|
||||
// Capture must continue even if preview throttling cannot be applied.
|
||||
}
|
||||
|
||||
const encodeStartedAt = nowMs();
|
||||
const blob = await encodeCanvasBlob(canvas, LPR_FRAME_MIME_TYPE, getJpegQualityForFrame(options));
|
||||
const encodeMs = Math.max(0, nowMs() - encodeStartedAt);
|
||||
if (!blob) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
blob,
|
||||
canvas,
|
||||
timings: {
|
||||
drawMs,
|
||||
encodeMs,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const captureEncodedCanvas = async (
|
||||
video: HTMLVideoElement,
|
||||
fallbackCanvas: HTMLCanvasElement,
|
||||
sourceRect: FrameSourceRect,
|
||||
targetSize: FrameSize,
|
||||
options: LPRFrameCaptureOptions
|
||||
): Promise<LPRFrameEncodedCanvas | null> => {
|
||||
const offscreenCanvas = getOffscreenEncodingCanvas(fallbackCanvas);
|
||||
if (offscreenCanvas !== null) {
|
||||
try {
|
||||
const encoded = await drawAndEncodeFrame(video, offscreenCanvas, sourceRect, targetSize, options);
|
||||
if (encoded !== null) {
|
||||
return encoded;
|
||||
}
|
||||
disabledOffscreenEncodingCanvases.add(fallbackCanvas);
|
||||
} catch {
|
||||
// Some browsers expose OffscreenCanvas but reject drawing live video into it.
|
||||
disabledOffscreenEncodingCanvases.add(fallbackCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
const encoded = await drawAndEncodeFrame(video, fallbackCanvas, sourceRect, targetSize, options);
|
||||
if (encoded === null) {
|
||||
console.error("Failed to capture camera frame");
|
||||
}
|
||||
|
||||
return encoded;
|
||||
};
|
||||
|
||||
export const captureVideoFrameBlobForLPR = async (
|
||||
video: HTMLVideoElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
options: LPRFrameCaptureOptions = {}
|
||||
): Promise<LPRFramePayload | null> => {
|
||||
const captureStartedAt = nowMs();
|
||||
const captureTimings: LPRFrameCaptureTimings = {
|
||||
drawMs: 0,
|
||||
encodeMs: 0,
|
||||
visualFingerprintMs: 0,
|
||||
};
|
||||
const sourceRect = getVideoFrameSourceRect(video, options);
|
||||
const targetSize = getLPRFrameTargetSize(sourceRect, options);
|
||||
|
||||
if (targetSize.width === 0 || targetSize.height === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visualFingerprintCanvas = options.visualFingerprintCanvas ?? null;
|
||||
let visualFingerprint: string | null = null;
|
||||
if (shouldBuildVisualFingerprintBeforeEncode(options.shouldBuildVisualFingerprint)) {
|
||||
const visualFingerprintStartedAt = nowMs();
|
||||
visualFingerprint = buildSourceVisualFingerprint(video, sourceRect, visualFingerprintCanvas);
|
||||
captureTimings.visualFingerprintMs = Math.max(0, nowMs() - visualFingerprintStartedAt);
|
||||
}
|
||||
let hasTriedLazyVisualFingerprint = false;
|
||||
const candidate: LPRFrameEncodeCandidate = {
|
||||
height: targetSize.height,
|
||||
...(visualFingerprint !== null ? { visualFingerprint } : {}),
|
||||
width: targetSize.width,
|
||||
};
|
||||
|
||||
if (!await shouldEncodeFrameCandidate(candidate, options.shouldEncode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encoded = await captureEncodedCanvas(video, canvas, sourceRect, targetSize, options);
|
||||
if (encoded === null) {
|
||||
return null;
|
||||
}
|
||||
const { blob, canvas: encodedCanvas } = encoded;
|
||||
captureTimings.drawMs = encoded.timings.drawMs;
|
||||
captureTimings.encodeMs = encoded.timings.encodeMs;
|
||||
|
||||
const getVisualFingerprint = visualFingerprintCanvas
|
||||
? () => {
|
||||
if (visualFingerprint !== null || hasTriedLazyVisualFingerprint) {
|
||||
return visualFingerprint;
|
||||
}
|
||||
|
||||
hasTriedLazyVisualFingerprint = true;
|
||||
visualFingerprint = buildSourceVisualFingerprint(encodedCanvas, {
|
||||
height: targetSize.height,
|
||||
width: targetSize.width,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}, visualFingerprintCanvas);
|
||||
|
||||
return visualFingerprint;
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
blob,
|
||||
captureDurationMs: Math.max(0, nowMs() - captureStartedAt),
|
||||
captureTimings,
|
||||
filename: LPR_FRAME_FILE_NAME,
|
||||
fingerprint: buildLPRFrameFingerprintKey(blob, targetSize.width, targetSize.height),
|
||||
getContentFingerprint: buildMemoizedLPRFrameContentFingerprint(blob, targetSize.width, targetSize.height),
|
||||
...(getVisualFingerprint ? { getVisualFingerprint } : {}),
|
||||
height: targetSize.height,
|
||||
mimeType: blob.type || LPR_FRAME_MIME_TYPE,
|
||||
...(visualFingerprint !== null ? { visualFingerprint } : {}),
|
||||
width: targetSize.width,
|
||||
};
|
||||
};
|
||||
@@ -64,6 +64,7 @@ export const useOrderMetadataAutosave = ({
|
||||
|
||||
const requestedValue = normalizeValue(draft.value);
|
||||
const sequence = ++nextSequence;
|
||||
let saveCompleted = false;
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
@@ -83,6 +84,8 @@ export const useOrderMetadataAutosave = ({
|
||||
await onSaved(savedValue, requestedValue);
|
||||
}
|
||||
|
||||
saveCompleted = true;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (typeof onError === "function") {
|
||||
@@ -95,14 +98,13 @@ export const useOrderMetadataAutosave = ({
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
|
||||
if (saveAgainAfterCurrentRequest || isDirty.value) {
|
||||
const shouldSaveAgain = saveAgainAfterCurrentRequest;
|
||||
saveAgainAfterCurrentRequest = false;
|
||||
|
||||
if (isDirty.value) {
|
||||
if (saveCompleted && (shouldSaveAgain || isDirty.value) && isDirty.value) {
|
||||
void persist();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleSave = () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, getCurrentInstance, onUnmounted, ref } from "vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
|
||||
|
||||
const normalizeBooleanAnswer = (value) => {
|
||||
if (value === true || value === false) {
|
||||
@@ -270,6 +271,55 @@ const mergeByNumericId = (existingItems, incomingItems) => {
|
||||
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() {
|
||||
const loading = ref(false);
|
||||
const requestError = ref(null);
|
||||
@@ -295,19 +345,94 @@ export function useSelfServeLogic() {
|
||||
const lastResolvedVehicleTypeId = ref(null);
|
||||
const summaryVisibleQuestionIds = ref([]);
|
||||
const summaryQuestionOrder = ref({});
|
||||
const latestFetchRequestId = ref(0);
|
||||
const activeFetchAbortController = ref(null);
|
||||
const latestMutationRequestId = ref(0);
|
||||
const latestSuccessfulFetchKey = ref(null);
|
||||
const inFlightFetchKey = ref(null);
|
||||
|
||||
const beginFetchRequest = () => {
|
||||
latestFetchRequestId.value += 1;
|
||||
return latestFetchRequestId.value;
|
||||
const createFetchKey = (departmentId, vehicleTypeId, laneId, reg) => {
|
||||
const normalizedDepartmentId = parseInt(departmentId);
|
||||
const normalizedLaneId = parseInt(laneId);
|
||||
const normalizedReg = String(reg || "").trim().toUpperCase();
|
||||
const normalizedVehicleTypeId = parseInt(vehicleTypeId);
|
||||
const vehicleTypeKey = !Number.isNaN(normalizedVehicleTypeId) && normalizedVehicleTypeId > 0
|
||||
? normalizedVehicleTypeId
|
||||
: "";
|
||||
|
||||
if (
|
||||
Number.isNaN(normalizedDepartmentId)
|
||||
|| normalizedDepartmentId <= 0
|
||||
|| Number.isNaN(normalizedLaneId)
|
||||
|| normalizedLaneId <= 0
|
||||
|| normalizedReg.length < 2
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [normalizedDepartmentId, normalizedLaneId, normalizedReg, vehicleTypeKey].join("|");
|
||||
};
|
||||
|
||||
const isFetchRequestActive = (requestId) => (
|
||||
requestId === null
|
||||
|| requestId === undefined
|
||||
|| requestId === latestFetchRequestId.value
|
||||
const isCanceledRequest = (error) => (
|
||||
error?.code === "ERR_CANCELED"
|
||||
|| error?.name === "CanceledError"
|
||||
|| error?.name === "AbortError"
|
||||
);
|
||||
|
||||
const abortActiveFetchRequest = () => {
|
||||
// Abort the previous self-serve read before a newer one starts; only the live signal may write state.
|
||||
activeFetchAbortController.value?.abort();
|
||||
activeFetchAbortController.value = null;
|
||||
};
|
||||
|
||||
const beginFetchRequest = (externalSignal = null) => {
|
||||
abortActiveFetchRequest();
|
||||
if (typeof AbortController !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
abortController.abort();
|
||||
} else {
|
||||
externalSignal.addEventListener("abort", () => abortController.abort(), { once: true });
|
||||
}
|
||||
}
|
||||
activeFetchAbortController.value = abortController;
|
||||
return abortController;
|
||||
};
|
||||
|
||||
const isFetchSignalActive = (signal = null) => (
|
||||
!signal || (!signal.aborted && activeFetchAbortController.value?.signal === signal)
|
||||
);
|
||||
|
||||
const requestPreviewAllowed = (laneId, reg, vehicleTypeId = null, signal = null) => {
|
||||
const previewAllowed = SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed;
|
||||
if (!signal || previewAllowed.length < 3) {
|
||||
return vehicleTypeId
|
||||
? previewAllowed(laneId, reg, vehicleTypeId)
|
||||
: previewAllowed(laneId, reg);
|
||||
}
|
||||
|
||||
return previewAllowed(laneId, reg, vehicleTypeId, { signal });
|
||||
};
|
||||
|
||||
const requestWashSummary = (params = {}, signal = null) => {
|
||||
const washSummary = SessionUser.objects.self_serve_vehicle_conditions.get.washSummary;
|
||||
if (!signal || washSummary.length < 2) {
|
||||
return washSummary(params);
|
||||
}
|
||||
|
||||
return washSummary(params, { signal });
|
||||
};
|
||||
|
||||
const beginMutationRequest = () => {
|
||||
latestMutationRequestId.value += 1;
|
||||
return latestMutationRequestId.value;
|
||||
};
|
||||
|
||||
const isMutationRequestActive = (requestId) => requestId === latestMutationRequestId.value;
|
||||
|
||||
const setSummaryVisibleQuestions = (questionList = []) => {
|
||||
const normalizedIds = (Array.isArray(questionList) ? questionList : [])
|
||||
.map((question) => parseInt(question?.id ?? 0))
|
||||
@@ -414,8 +539,8 @@ export function useSelfServeLogic() {
|
||||
};
|
||||
|
||||
const applyPreviewData = async (previewData, options = {}) => {
|
||||
const { mergeQuestions = false, requestId = null } = options;
|
||||
if (!isFetchRequestActive(requestId)) {
|
||||
const { mergeQuestions = false, signal = null } = options;
|
||||
if (!isFetchSignalActive(signal)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -424,6 +549,10 @@ export function useSelfServeLogic() {
|
||||
machineType.value = previewData?.machine_type || machineType.value;
|
||||
vehicle.value = previewData?.vehicle || null;
|
||||
session.value = previewData?.session || session.value;
|
||||
if (!previewData?.session?.id) {
|
||||
summary.value = null;
|
||||
events.value = [];
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(previewData || {}, "config_version_id")) {
|
||||
configVersionId.value = previewData?.config_version_id ?? null;
|
||||
}
|
||||
@@ -442,6 +571,7 @@ export function useSelfServeLogic() {
|
||||
|
||||
questions.value = nextQuestions;
|
||||
answers.value = buildAnswersMap(nextQuestions);
|
||||
setSummaryVisibleQuestions(nextQuestions);
|
||||
|
||||
const normalizedConditions = Array.isArray(previewData?.conditions)
|
||||
? previewData.conditions.map(normalizeCondition)
|
||||
@@ -461,19 +591,21 @@ export function useSelfServeLogic() {
|
||||
? previewData.tasks.map(normalizeTask).sort((a, b) => a.order_priority - b.order_priority)
|
||||
: [];
|
||||
|
||||
tasks.value = normalizedTasks;
|
||||
applyAllowedServices(previewData);
|
||||
|
||||
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
|
||||
if (!isFetchRequestActive(requestId)) {
|
||||
if (!isFetchSignalActive(signal)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
tasks.value = hydratedTasks;
|
||||
applyAllowedServices(previewData);
|
||||
return true;
|
||||
};
|
||||
|
||||
const applySummaryData = async (summaryData, options = {}) => {
|
||||
const { requestId = null } = options;
|
||||
if (!isFetchRequestActive(requestId)) {
|
||||
const { signal = null } = options;
|
||||
if (!isFetchSignalActive(signal)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -512,9 +644,10 @@ export function useSelfServeLogic() {
|
||||
const normalizedTasks = summaryData.tasks
|
||||
.map(normalizeTask)
|
||||
.sort((a, b) => a.order_priority - b.order_priority);
|
||||
const mergedTasks = mergeTaskSets(tasks.value, normalizedTasks);
|
||||
|
||||
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
|
||||
if (!isFetchRequestActive(requestId)) {
|
||||
const hydratedTasks = await hydrateTaskAttachments(mergedTasks);
|
||||
if (!isFetchSignalActive(signal)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -526,7 +659,7 @@ export function useSelfServeLogic() {
|
||||
};
|
||||
|
||||
const downloadAttachment = async (taskId, attachmentId, attachment = null) => {
|
||||
const existingDownloadLink = attachment?.download_link || null;
|
||||
const existingDownloadLink = safeAttachmentDownloadLink(attachment?.download_link);
|
||||
if (existingDownloadLink) {
|
||||
window.open(existingDownloadLink, "_blank", "noopener");
|
||||
return;
|
||||
@@ -534,10 +667,9 @@ export function useSelfServeLogic() {
|
||||
|
||||
try {
|
||||
const response = await SessionUser.objects.self_serve_tasks.attachments.download(taskId, attachmentId);
|
||||
if (response?.data?.download_link) {
|
||||
window.open(response.data.download_link, "_blank", "noopener");
|
||||
} else if (response?.download_link) {
|
||||
window.open(response.download_link, "_blank", "noopener");
|
||||
const downloadLink = safeAttachmentDownloadLink(response?.data?.download_link || response?.download_link);
|
||||
if (downloadLink) {
|
||||
window.open(downloadLink, "_blank", "noopener");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
@@ -545,7 +677,7 @@ export function useSelfServeLogic() {
|
||||
};
|
||||
|
||||
const fetchWashSummary = async (params = {}, manageLoading = true, options = {}) => {
|
||||
const { requestId = null } = options;
|
||||
const { signal = null } = options;
|
||||
if (!params.session_id && !(params.lane_id && params.reg)) {
|
||||
return null;
|
||||
}
|
||||
@@ -555,30 +687,39 @@ export function useSelfServeLogic() {
|
||||
}
|
||||
|
||||
try {
|
||||
const summaryData = await SessionUser.objects.self_serve_vehicle_conditions.get.washSummary(params);
|
||||
const didApply = await applySummaryData(summaryData, { requestId });
|
||||
const summaryData = await requestWashSummary(params, signal);
|
||||
const didApply = await applySummaryData(summaryData, { signal });
|
||||
if (!didApply) {
|
||||
return null;
|
||||
}
|
||||
requestError.value = null;
|
||||
return summaryData;
|
||||
} catch (error) {
|
||||
if (isCanceledRequest(error)) {
|
||||
return null;
|
||||
}
|
||||
console.error("Error fetching self-serve summary:", error);
|
||||
if (isFetchRequestActive(requestId)) {
|
||||
if (isFetchSignalActive(signal)) {
|
||||
requestError.value = extractErrorMessage(error, "Kunne ikke hente vaskestatus. Prøv igen.");
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (manageLoading) {
|
||||
if (manageLoading && isFetchSignalActive(signal)) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null) => {
|
||||
const requestId = beginFetchRequest();
|
||||
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null, options = {}) => {
|
||||
const fetchKey = createFetchKey(_departmentId, _vehicleTypeId, laneId, reg);
|
||||
if (!options.force && fetchKey && (fetchKey === latestSuccessfulFetchKey.value || fetchKey === inFlightFetchKey.value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!laneId || !reg || reg.trim().length < 2) {
|
||||
abortActiveFetchRequest();
|
||||
latestSuccessfulFetchKey.value = null;
|
||||
inFlightFetchKey.value = null;
|
||||
preview.value = null;
|
||||
summary.value = null;
|
||||
session.value = null;
|
||||
@@ -604,8 +745,11 @@ export function useSelfServeLogic() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const abortController = beginFetchRequest(options.signal || null);
|
||||
const signal = abortController?.signal || null;
|
||||
loading.value = true;
|
||||
requestError.value = null;
|
||||
inFlightFetchKey.value = fetchKey;
|
||||
try {
|
||||
const normalizedReg = reg.trim().toUpperCase();
|
||||
const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
|
||||
@@ -620,18 +764,18 @@ export function useSelfServeLogic() {
|
||||
const contextKey = `${parseInt(laneId)}:${normalizedReg}:${contextVehicleTypeKey}`;
|
||||
const shouldMergeQuestions = lastPreviewContextKey.value === contextKey;
|
||||
const previewData = hasVehicleTypeOverride
|
||||
? await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg, normalizedVehicleTypeId)
|
||||
: await SessionUser.objects.self_serve_vehicle_conditions.get.previewAllowed(laneId, normalizedReg);
|
||||
? await requestPreviewAllowed(laneId, normalizedReg, normalizedVehicleTypeId, signal)
|
||||
: await requestPreviewAllowed(laneId, normalizedReg, null, signal);
|
||||
|
||||
if (!isFetchRequestActive(requestId)) {
|
||||
if (!isFetchSignalActive(signal)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const didApplyPreview = await applyPreviewData(previewData, {
|
||||
mergeQuestions: shouldMergeQuestions,
|
||||
requestId,
|
||||
signal,
|
||||
});
|
||||
if (!didApplyPreview || !isFetchRequestActive(requestId)) {
|
||||
if (!didApplyPreview || !isFetchSignalActive(signal)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -646,11 +790,11 @@ export function useSelfServeLogic() {
|
||||
summaryData = await fetchWashSummary(
|
||||
{ session_id: previewData.session.id },
|
||||
false,
|
||||
{ requestId }
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
if (!isFetchRequestActive(requestId)) {
|
||||
if (!isFetchSignalActive(signal)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -672,9 +816,9 @@ export function useSelfServeLogic() {
|
||||
const fallbackSummary = await fetchWashSummary(
|
||||
fallbackSummaryParams,
|
||||
false,
|
||||
{ requestId }
|
||||
{ signal }
|
||||
);
|
||||
if (!isFetchRequestActive(requestId)) {
|
||||
if (!isFetchSignalActive(signal)) {
|
||||
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 (!hasSummaryQuestions && isFetchRequestActive(requestId)) {
|
||||
if (!hasSummaryQuestions && isFetchSignalActive(signal)) {
|
||||
setSummaryVisibleQuestions(questions.value);
|
||||
}
|
||||
|
||||
if (isFetchSignalActive(signal)) {
|
||||
latestSuccessfulFetchKey.value = fetchKey;
|
||||
}
|
||||
|
||||
return previewData;
|
||||
} catch (error) {
|
||||
if (isCanceledRequest(error)) {
|
||||
return null;
|
||||
}
|
||||
console.error("Error fetching self-serve preview:", error);
|
||||
if (isFetchRequestActive(requestId)) {
|
||||
if (isFetchSignalActive(signal)) {
|
||||
requestError.value = extractErrorMessage(error);
|
||||
}
|
||||
return null;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -706,6 +862,7 @@ export function useSelfServeLogic() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mutationRequestId = beginMutationRequest();
|
||||
loading.value = true;
|
||||
try {
|
||||
const normalizedReg = reg.trim().toUpperCase();
|
||||
@@ -733,6 +890,10 @@ export function useSelfServeLogic() {
|
||||
const payload = response?.data?.data || response?.data || response || {};
|
||||
const responseSummary = payload?.selfserve || null;
|
||||
|
||||
if (!isMutationRequestActive(mutationRequestId)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
answers.value = {
|
||||
...answers.value,
|
||||
[parseInt(questionId)]: value,
|
||||
@@ -749,7 +910,10 @@ export function useSelfServeLogic() {
|
||||
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,
|
||||
[parseInt(questionId)]: value,
|
||||
@@ -757,11 +921,15 @@ export function useSelfServeLogic() {
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error("Error synchronizing vehicle answer:", error);
|
||||
if (isMutationRequestActive(mutationRequestId)) {
|
||||
requestError.value = extractErrorMessage(error, "Kunne ikke gemme svaret. Prøv igen.");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (isMutationRequestActive(mutationRequestId)) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearVehicleAnswers = async ({ departmentId = null, laneId, reg, questionIds = [], vehicleTypeId = null }) => {
|
||||
@@ -769,6 +937,7 @@ export function useSelfServeLogic() {
|
||||
return { deletedCount: 0 };
|
||||
}
|
||||
|
||||
const mutationRequestId = beginMutationRequest();
|
||||
loading.value = true;
|
||||
try {
|
||||
const normalizedReg = reg.trim().toUpperCase();
|
||||
@@ -804,6 +973,10 @@ export function useSelfServeLogic() {
|
||||
SessionUser.objects.self_serve_vehicle_conditions.delete(conditionId)
|
||||
)));
|
||||
|
||||
if (!isMutationRequestActive(mutationRequestId)) {
|
||||
return { deletedCount: conditionIdsToDelete.length };
|
||||
}
|
||||
|
||||
// Force a non-merge refresh so cleared answers are not kept from local state.
|
||||
lastPreviewContextKey.value = null;
|
||||
const refreshVehicleTypeCandidate = vehicleTypeId !== null && vehicleTypeId !== undefined
|
||||
@@ -814,16 +987,20 @@ export function useSelfServeLogic() {
|
||||
? normalizedRefreshVehicleTypeId
|
||||
: null;
|
||||
|
||||
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg);
|
||||
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg, { force: true });
|
||||
|
||||
return { deletedCount: conditionIdsToDelete.length };
|
||||
} catch (error) {
|
||||
console.error("Error clearing self-serve answers:", error);
|
||||
if (isMutationRequestActive(mutationRequestId)) {
|
||||
requestError.value = extractErrorMessage(error, "Kunne ikke nulstille svar. Prøv igen.");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (isMutationRequestActive(mutationRequestId)) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
@@ -984,9 +1161,27 @@ export function useSelfServeLogic() {
|
||||
return activeTaskServices.value.includes(serviceName);
|
||||
};
|
||||
|
||||
const updateLaneAllowedServices = async (laneId) => {
|
||||
const updateLaneAllowedServices = async (laneId, options = {}) => {
|
||||
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 {
|
||||
const response = await SessionUser.request('/modules/self-serve/lane/services/allowed', 'post', {
|
||||
@@ -1032,6 +1227,12 @@ export function useSelfServeLogic() {
|
||||
}
|
||||
};
|
||||
|
||||
if (getCurrentInstance()) {
|
||||
onUnmounted(() => {
|
||||
abortActiveFetchRequest();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error: requestError,
|
||||
@@ -1049,6 +1250,7 @@ export function useSelfServeLogic() {
|
||||
answers,
|
||||
completedTasks,
|
||||
allowedServices,
|
||||
resolvedVehicleTypeId: lastResolvedVehicleTypeId,
|
||||
configVersionId,
|
||||
evaluationTrace,
|
||||
visibleQuestions,
|
||||
@@ -1060,6 +1262,7 @@ export function useSelfServeLogic() {
|
||||
allowed,
|
||||
fetchSelfServeData,
|
||||
fetchWashSummary,
|
||||
cancelSelfServeFetch: abortActiveFetchRequest,
|
||||
syncVehicleAnswer,
|
||||
clearVehicleAnswers,
|
||||
evaluateRule,
|
||||
|
||||
@@ -2,12 +2,32 @@ import { computed, nextTick, onUnmounted, ref, watch } from "vue";
|
||||
import { getDepartmentsGuest } from "@/components/pagination/departmentTabs.vue";
|
||||
import { locations } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
|
||||
const isLaneSelfServeEnabled = (lane) => !(
|
||||
lane?.selfserve_enabled === false
|
||||
|| lane?.selfserve_enabled === 0
|
||||
|| lane?.selfserve_enabled === "0"
|
||||
|| ["false", "off", "no"].includes(String(lane?.selfserve_enabled ?? "").trim().toLowerCase())
|
||||
);
|
||||
const isLaneSelfServeEnabled = (lane) =>
|
||||
!(
|
||||
lane?.selfserve_enabled === false ||
|
||||
lane?.selfserve_enabled === 0 ||
|
||||
lane?.selfserve_enabled === "0" ||
|
||||
["false", "off", "no"].includes(
|
||||
String(lane?.selfserve_enabled ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
)
|
||||
);
|
||||
|
||||
const hasSelfServeEnabledLane = (department) =>
|
||||
department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled);
|
||||
|
||||
const normalizeLocationCoordinates = (locationValue = locations.location.value) =>
|
||||
locations.normalizeCoordinatePair(locationValue?.coords);
|
||||
|
||||
const normalizeDepartmentCoordinates = (department) =>
|
||||
locations.normalizeCoordinatePair(
|
||||
{
|
||||
latitude: department?.latitude,
|
||||
longitude: department?.longitude,
|
||||
},
|
||||
{ allowZeroPair: false }
|
||||
);
|
||||
|
||||
const toDepartmentViewModel = (department, distance = null) => ({
|
||||
id: department.id,
|
||||
@@ -18,12 +38,22 @@ const toDepartmentViewModel = (department, distance = null) => ({
|
||||
self_serve_enabled: department.self_serve_enabled,
|
||||
});
|
||||
|
||||
const extractDepartmentFetchErrorMessage = (error) => {
|
||||
const candidates = [
|
||||
error?.response?.data?.data?.message,
|
||||
error?.response?.data?.message,
|
||||
error?.response?.data?.error,
|
||||
error?.message,
|
||||
];
|
||||
|
||||
return (
|
||||
candidates.find((candidate) => typeof candidate === "string" && candidate.trim() !== "") ||
|
||||
"Kunne ikke hente vaskeafdelinger. Prøv igen."
|
||||
);
|
||||
};
|
||||
|
||||
export function useWashDepartments(options = {}) {
|
||||
const {
|
||||
includeLanes = false,
|
||||
refreshIntervalMs = 0,
|
||||
canAccessSuperUser = () => false,
|
||||
} = options;
|
||||
const { includeLanes = false, refreshIntervalMs = 0, canAccessSuperUser = () => false } = options;
|
||||
|
||||
const guestDepartments = ref([]);
|
||||
const nearestDepartment = ref(null);
|
||||
@@ -31,6 +61,8 @@ export function useWashDepartments(options = {}) {
|
||||
const forceNearestDepartmentEvaluationId = ref(0);
|
||||
const isSearchingDepartments = ref(false);
|
||||
const lastDepartmentFetchTime = ref(null);
|
||||
const departmentFetchError = ref(null);
|
||||
const departmentSelectionStrategy = ref(null);
|
||||
|
||||
let refreshInterval = null;
|
||||
|
||||
@@ -57,13 +89,25 @@ export function useWashDepartments(options = {}) {
|
||||
return nearestDepartment.value.self_serve_enabled === true;
|
||||
});
|
||||
|
||||
const buildGuestDepartmentParams = () => (
|
||||
includeLanes ? { include_lanes: true } : {}
|
||||
);
|
||||
const isDepartmentSelectionDistanceBased = computed(() => departmentSelectionStrategy.value === "distance");
|
||||
|
||||
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
|
||||
|
||||
const hasLocationCoordinates = (locationValue = locations.location.value) =>
|
||||
normalizeLocationCoordinates(locationValue) !== null;
|
||||
|
||||
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
|
||||
|
||||
const fetchDepartments = async () => {
|
||||
try {
|
||||
guestDepartments.value = await getDepartmentsGuest(buildGuestDepartmentParams());
|
||||
lastDepartmentFetchTime.value = Date.now();
|
||||
departmentFetchError.value = null;
|
||||
} catch (error) {
|
||||
departmentFetchError.value = extractDepartmentFetchErrorMessage(error);
|
||||
console.warn("Failed to fetch wash departments:", error);
|
||||
}
|
||||
|
||||
evaluateLocationDepartments(locations.location.value);
|
||||
return guestDepartments.value;
|
||||
};
|
||||
@@ -74,83 +118,112 @@ export function useWashDepartments(options = {}) {
|
||||
}
|
||||
|
||||
const currentTime = Date.now();
|
||||
if (
|
||||
!lastDepartmentFetchTime.value
|
||||
|| (currentTime - lastDepartmentFetchTime.value) >= refreshIntervalMs
|
||||
) {
|
||||
if (!lastDepartmentFetchTime.value || currentTime - lastDepartmentFetchTime.value >= refreshIntervalMs) {
|
||||
return fetchDepartments();
|
||||
}
|
||||
|
||||
return guestDepartments.value;
|
||||
};
|
||||
|
||||
const getForcedDepartment = () => guestDepartments.value.find(
|
||||
(department) => department.id === forceNearestDepartmentEvaluationId.value
|
||||
);
|
||||
const getForcedDepartment = () =>
|
||||
guestDepartments.value.find((department) => department.id === forceNearestDepartmentEvaluationId.value);
|
||||
|
||||
const evaluateLocationDepartments = (locationValue = locations.location.value) => {
|
||||
if (isForcingNearestDepartment.value && forceNearestDepartmentEvaluationId.value) {
|
||||
const forcedDepartment = getForcedDepartment();
|
||||
if (forcedDepartment) {
|
||||
nearestDepartment.value = toDepartmentViewModel(forcedDepartment);
|
||||
departmentSelectionStrategy.value = "forced";
|
||||
return nearestDepartment.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (guestDepartments.value.length === 0) {
|
||||
nearestDepartment.value = null;
|
||||
departmentSelectionStrategy.value = null;
|
||||
return nearestDepartment.value;
|
||||
}
|
||||
|
||||
if (!hasLocationCoordinates(locationValue)) {
|
||||
const fallbackDepartment = guestDepartments.value.find(hasSelfServeEnabledLane);
|
||||
nearestDepartment.value = fallbackDepartment ? toDepartmentViewModel(fallbackDepartment) : null;
|
||||
departmentSelectionStrategy.value = fallbackDepartment ? "fallback" : null;
|
||||
return nearestDepartment.value;
|
||||
}
|
||||
|
||||
const from = normalizeLocationCoordinates(locationValue);
|
||||
let currentNearestDepartment = {
|
||||
id: null,
|
||||
distance: Infinity,
|
||||
};
|
||||
|
||||
guestDepartments.value.forEach((department) => {
|
||||
if (!locationValue?.coords) {
|
||||
const to = normalizeDepartmentCoordinates(department);
|
||||
if (!from || !to) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = {
|
||||
latitude: locationValue.coords.latitude,
|
||||
longitude: locationValue.coords.longitude,
|
||||
};
|
||||
const to = {
|
||||
latitude: department.latitude,
|
||||
longitude: department.longitude,
|
||||
};
|
||||
const distance = locations.getDistance(from, to);
|
||||
|
||||
if (distance < currentNearestDepartment.distance) {
|
||||
if (Number.isFinite(distance) && distance < currentNearestDepartment.distance) {
|
||||
currentNearestDepartment = toDepartmentViewModel(department, distance);
|
||||
}
|
||||
});
|
||||
|
||||
if (currentNearestDepartment.id) {
|
||||
nearestDepartment.value = currentNearestDepartment;
|
||||
departmentSelectionStrategy.value = "distance";
|
||||
return nearestDepartment.value;
|
||||
}
|
||||
|
||||
const fallbackDepartment =
|
||||
guestDepartments.value.find((department) => department.self_serve_enabled === true) || guestDepartments.value[0];
|
||||
if (fallbackDepartment) {
|
||||
nearestDepartment.value = toDepartmentViewModel(fallbackDepartment);
|
||||
departmentSelectionStrategy.value = "fallback";
|
||||
} else {
|
||||
nearestDepartment.value = null;
|
||||
departmentSelectionStrategy.value = null;
|
||||
}
|
||||
|
||||
return nearestDepartment.value;
|
||||
};
|
||||
|
||||
const orderDepartmentsByDistance = (departmentsList = guestDepartments.value) => {
|
||||
if (!locations.location.value?.coords) {
|
||||
const currentCoords = normalizeLocationCoordinates(locations.location.value);
|
||||
if (!currentCoords) {
|
||||
return departmentsList;
|
||||
}
|
||||
|
||||
const distanceFromCurrentLocation = (department) => {
|
||||
const departmentCoords = normalizeDepartmentCoordinates(department);
|
||||
if (!departmentCoords) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return locations.getDistance(currentCoords, departmentCoords);
|
||||
};
|
||||
|
||||
return departmentsList.slice().sort((departmentA, departmentB) => {
|
||||
const currentCoords = locations.location.value.coords;
|
||||
const distanceA = locations.getDistance(
|
||||
{ latitude: currentCoords.latitude, longitude: currentCoords.longitude },
|
||||
{ latitude: departmentA.latitude, longitude: departmentA.longitude }
|
||||
);
|
||||
const distanceB = locations.getDistance(
|
||||
{ latitude: currentCoords.latitude, longitude: currentCoords.longitude },
|
||||
{ latitude: departmentB.latitude, longitude: departmentB.longitude }
|
||||
);
|
||||
const distanceA = distanceFromCurrentLocation(departmentA);
|
||||
const distanceB = distanceFromCurrentLocation(departmentB);
|
||||
|
||||
if (!Number.isFinite(distanceA) && !Number.isFinite(distanceB)) {
|
||||
return 0;
|
||||
}
|
||||
if (!Number.isFinite(distanceA)) {
|
||||
return 1;
|
||||
}
|
||||
if (!Number.isFinite(distanceB)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return distanceA - distanceB;
|
||||
});
|
||||
};
|
||||
|
||||
const startDepartmentSearch = () => {
|
||||
if (!canAccessSuperUser()) {
|
||||
if (!canAccessSuperUser() && hasLocationCoordinates()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,19 +264,30 @@ export function useWashDepartments(options = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => guestDepartments.value, () => {
|
||||
watch(
|
||||
() => guestDepartments.value,
|
||||
() => {
|
||||
evaluateLocationDepartments(locations.location.value);
|
||||
}, { deep: true });
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(() => locations.location.value, (newValue) => {
|
||||
watch(
|
||||
() => locations.location.value,
|
||||
(newValue) => {
|
||||
evaluateLocationDepartments(newValue);
|
||||
}, { deep: true });
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(() => isSearchingDepartments.value, (isSearching) => {
|
||||
watch(
|
||||
() => isSearchingDepartments.value,
|
||||
(isSearching) => {
|
||||
if (!isSearching) {
|
||||
evaluateLocationDepartments(locations.location.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh();
|
||||
@@ -216,6 +300,10 @@ export function useWashDepartments(options = {}) {
|
||||
forceNearestDepartmentEvaluationId,
|
||||
isSearchingDepartments,
|
||||
lastDepartmentFetchTime,
|
||||
departmentFetchError,
|
||||
departmentSelectionStrategy,
|
||||
isDepartmentSelectionDistanceBased,
|
||||
isDepartmentSelectionFallbackBased,
|
||||
availableProductIds,
|
||||
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
||||
fetchDepartments,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user