Compare commits
328
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
2ab09076f5 | ||
|
|
97e17fadd4 | ||
|
|
ef780e5542 | ||
|
|
931c31c7be | ||
|
|
25f2be875c | ||
|
|
ff70326665 | ||
|
|
52ed49211a | ||
|
|
0565fc2a7c | ||
|
|
d30273cda8 | ||
|
|
b893e69b54 | ||
|
|
eabb12ef97 | ||
|
|
225741cf7b | ||
|
|
dad42da6d9 | ||
|
|
0fa2284e89 | ||
|
|
cc241e5e19 | ||
|
|
c5a536fbea | ||
|
|
3a7fef11ef | ||
|
|
625757fafc | ||
|
|
e1a8af5a72 | ||
|
|
c437c89cfa |
@@ -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]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
upload-qodana-config:
|
upload-qodana-config:
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -20,9 +24,9 @@ jobs:
|
|||||||
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
|
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v $(pwd):/workspace \
|
-v "$(pwd):/workspace" \
|
||||||
-w /workspace \
|
-w /workspace \
|
||||||
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
|
-e QODANA_CONFIGURATIONS_TOKEN \
|
||||||
jetbrains/qodana-configuration-uploader:latest \
|
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \
|
||||||
--global-configs-file qodana-global-configurations.yaml \
|
--global-configs-file qodana-global-configurations.yaml \
|
||||||
--qodana-host https://qodana.cloud
|
--qodana-host https://qodana.cloud
|
||||||
|
|||||||
@@ -1,105 +1,151 @@
|
|||||||
name: Frontend Release
|
name: Frontend Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- Automated Tests
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: frontend-release-${{ github.ref }}
|
group: frontend-release-${{ github.event.workflow_run.head_branch }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-upload-and-verify:
|
build-upload-and-verify:
|
||||||
|
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
env:
|
env:
|
||||||
RELEASE_BASE_URL: https://dev.truckwash.io
|
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||||
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
|
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
|
||||||
|
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||||
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
|
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
|
||||||
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
|
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
|
||||||
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
|
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||||
RELEASE_WAIT_INITIAL_SECONDS: 30
|
RELEASE_WAIT_INITIAL_SECONDS: 45
|
||||||
RELEASE_WAIT_TIMEOUT_SECONDS: 300
|
RELEASE_WAIT_TIMEOUT_SECONDS: 600
|
||||||
RELEASE_POLL_INTERVAL_SECONDS: 10
|
RELEASE_POLL_INTERVAL_SECONDS: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
|
||||||
|
- name: Check release commit is current
|
||||||
|
id: branch-head
|
||||||
|
run: |
|
||||||
|
latest_sha="$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | awk '{print $1}')"
|
||||||
|
if [[ -z "$latest_sha" ]]; then
|
||||||
|
echo "Could not resolve origin/$RELEASE_BRANCH." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$latest_sha" != "$RELEASE_EXPECTED_COMMIT" ]]; then
|
||||||
|
echo "current=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Skipping stale release for $RELEASE_EXPECTED_COMMIT; origin/$RELEASE_BRANCH is $latest_sha."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "current=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Release commit is current for $RELEASE_BRANCH."
|
||||||
|
env:
|
||||||
|
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm ci --legacy-peer-deps
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
- name: Check AI workflow sync
|
- name: Check AI workflow sync
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: node scripts/sync-ai-workflow.mjs --check
|
run: node scripts/sync-ai-workflow.mjs --check
|
||||||
|
|
||||||
- name: Source and i18n checks
|
- name: Source and i18n checks
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: |
|
run: |
|
||||||
npm run text:check-encoding
|
npm run text:check-encoding
|
||||||
npm run i18n:v2:source-check
|
npm run i18n:v2:source-check
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run test:unit
|
run: npm run test:unit
|
||||||
env:
|
env:
|
||||||
VITEST_BATCH_SIZE: 5
|
VITEST_BATCH_SIZE: 5
|
||||||
|
|
||||||
- name: Build release artifact
|
- name: Build release artifact
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Install Playwright Chromium
|
- name: Install Playwright Chromium
|
||||||
run: npx playwright install --with-deps chromium
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
run: node scripts/install-playwright-browsers.mjs chromium
|
||||||
|
|
||||||
- name: Production Playwright gate
|
- name: Production Playwright gate
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run test:e2e:prod
|
run: npm run test:e2e:prod
|
||||||
|
|
||||||
- name: Upload dist artifact
|
- name: Upload dist artifact
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
|
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
|
||||||
path: dist
|
path: dist
|
||||||
retention-days: 14
|
retention-days: 3
|
||||||
|
|
||||||
- name: Install lftp
|
- name: Request Release Manager auto sync
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: |
|
run: |
|
||||||
if ! command -v lftp >/dev/null 2>&1; then
|
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||||
sudo apt-get update
|
response_file="$(mktemp)"
|
||||||
sudo apt-get install -y lftp
|
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
|
fi
|
||||||
|
|
||||||
- name: Upload hashed assets before release metadata
|
|
||||||
run: |
|
|
||||||
test -n "$RELEASE_DEPLOY_HOST" || (echo "RELEASE_DEPLOY_HOST is required" >&2; exit 1)
|
|
||||||
test -n "$RELEASE_DEPLOY_USER" || (echo "RELEASE_DEPLOY_USER is required" >&2; exit 1)
|
|
||||||
test -n "$RELEASE_DEPLOY_PASSWORD" || (echo "RELEASE_DEPLOY_PASSWORD is required" >&2; exit 1)
|
|
||||||
npm run release:upload:lftp
|
|
||||||
env:
|
env:
|
||||||
RELEASE_DEPLOY_HOST: ${{ secrets.RELEASE_DEPLOY_HOST }}
|
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||||
RELEASE_DEPLOY_USER: ${{ secrets.RELEASE_DEPLOY_USER }}
|
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||||
RELEASE_DEPLOY_PASSWORD: ${{ secrets.RELEASE_DEPLOY_PASSWORD }}
|
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||||
RELEASE_DEPLOY_REMOTE_ROOT: ${{ secrets.RELEASE_DEPLOY_REMOTE_ROOT }}
|
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
|
||||||
- name: Wait for exact uploaded build
|
- name: Wait for Coolify release artifact
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run release:verify-upload
|
run: npm run release:verify-upload
|
||||||
|
|
||||||
- name: Public live Playwright gate
|
- name: Public live Playwright gate
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run test:e2e:live:public
|
run: npm run test:e2e:live:public
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --use-system-ca
|
NODE_OPTIONS: --use-system-ca
|
||||||
|
|
||||||
- name: Credentialed live Playwright gate
|
- name: Credentialed live Playwright gate
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run test:e2e:live:roles
|
run: npm run test:e2e:live:roles
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --use-system-ca
|
NODE_OPTIONS: --use-system-ca
|
||||||
@@ -112,28 +158,35 @@ jobs:
|
|||||||
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
||||||
|
|
||||||
- name: Record Release Manager gate
|
- name: Record Release Manager gate
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: |
|
run: |
|
||||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||||
|
release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}"
|
||||||
curl --fail --show-error --silent \
|
curl --fail --show-error --silent \
|
||||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
|
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
|
||||||
env:
|
env:
|
||||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||||
|
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||||
|
RELEASE_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
|
- name: Update server version after verification
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
run: npm run release:update-server-version
|
run: npm run release:update-server-version
|
||||||
env:
|
env:
|
||||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||||
RELEASE_VERSION: ${{ github.sha }}
|
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: failure()
|
if: failure() && steps.branch-head.outputs.current == 'true'
|
||||||
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
|
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
|
||||||
path: output/playwright
|
path: output/playwright
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 14
|
retention-days: 3
|
||||||
|
|||||||
+141
-25
@@ -3,6 +3,8 @@ name: Automated Tests
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 2 * * *"
|
- cron: "0 2 * * *"
|
||||||
@@ -11,13 +13,14 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: frontend-tests-${{ github.workflow }}-${{ github.ref }}
|
group: frontend-tests-${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
format-tests:
|
format-tests:
|
||||||
# Match the labels exposed by the Coolify-managed GitHub runner.
|
# CI runs on the repository's self-hosted runner pool.
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64, pleno, frontend]
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -26,7 +29,6 @@ jobs:
|
|||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Check AI workflow sync
|
- name: Check AI workflow sync
|
||||||
run: node scripts/sync-ai-workflow.mjs --check
|
run: node scripts/sync-ai-workflow.mjs --check
|
||||||
@@ -39,7 +41,8 @@ jobs:
|
|||||||
|
|
||||||
build-and-unit:
|
build-and-unit:
|
||||||
needs: format-tests
|
needs: format-tests
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64, pleno, frontend]
|
||||||
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -48,11 +51,16 @@ jobs:
|
|||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --legacy-peer-deps
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Check i18n source consistency
|
||||||
|
run: npm run i18n:v2:check
|
||||||
|
|
||||||
- name: Build sanity check
|
- name: Build sanity check
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
@@ -64,47 +72,122 @@ jobs:
|
|||||||
e2e-pr:
|
e2e-pr:
|
||||||
if: github.event_name != 'schedule'
|
if: github.event_name != 'schedule'
|
||||||
needs: build-and-unit
|
needs: build-and-unit
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||||
|
runs-on: [self-hosted, Linux, X64, pleno, frontend]
|
||||||
|
timeout-minutes: 30
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
max-parallel: 4
|
||||||
|
matrix:
|
||||||
|
suite: [core, changed]
|
||||||
|
project: [chromium-desktop, chromium-mobile]
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_PR_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||||
PLAYWRIGHT_PR_HEAD: ${{ github.sha }}
|
PLAYWRIGHT_REPORTER_MODE: line-html
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Resolve Playwright diff refs
|
||||||
|
id: playwright-diff
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||||
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
|
HEAD_SHA: ${{ github.sha }}
|
||||||
|
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
zero_sha="0000000000000000000000000000000000000000"
|
||||||
|
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_BASE_SHA" ]]; then
|
||||||
|
base_ref="$PR_BASE_SHA"
|
||||||
|
elif [[ -z "$PUSH_BEFORE_SHA" || "$PUSH_BEFORE_SHA" == "$zero_sha" ]]; then
|
||||||
|
git fetch --no-tags --prune origin "$DEFAULT_BRANCH"
|
||||||
|
base_ref="origin/$DEFAULT_BRANCH"
|
||||||
|
else
|
||||||
|
base_ref="$PUSH_BEFORE_SHA"
|
||||||
|
fi
|
||||||
|
echo "base=$base_ref" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --legacy-peer-deps
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: npx playwright install --with-deps chromium
|
run: node scripts/install-playwright-browsers.mjs chromium
|
||||||
|
|
||||||
- name: Run Playwright PR tests
|
- name: Set Playwright dev server port
|
||||||
run: npm run test:e2e:pr -- --base="$PLAYWRIGHT_PR_BASE" --head="$PLAYWRIGHT_PR_HEAD"
|
shell: bash
|
||||||
|
env:
|
||||||
|
MATRIX_SUITE: ${{ matrix.suite }}
|
||||||
|
MATRIX_PROJECT: ${{ matrix.project }}
|
||||||
|
RUN_ID: ${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
workflow_offset=$(( (RUN_ID % 90) * 600 ))
|
||||||
|
case "$MATRIX_SUITE" in
|
||||||
|
core) suite_offset=0 ;;
|
||||||
|
changed) suite_offset=10 ;;
|
||||||
|
*) echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$MATRIX_PROJECT" in
|
||||||
|
chromium-desktop) project_offset=1 ;;
|
||||||
|
chromium-mobile) project_offset=2 ;;
|
||||||
|
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
echo "PLAYWRIGHT_DEV_PORT=$((10000 + workflow_offset + suite_offset + project_offset))" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Run Playwright smoke tests
|
||||||
|
if: matrix.suite == 'core'
|
||||||
|
run: |
|
||||||
|
ulimit -n 16384 || true
|
||||||
|
npx playwright test --grep @smoke --project="${{ matrix.project }}"
|
||||||
|
|
||||||
|
- name: Run Playwright PR core tests
|
||||||
|
if: matrix.suite == 'core'
|
||||||
|
run: |
|
||||||
|
ulimit -n 16384 || true
|
||||||
|
npm run test:e2e:pr -- --core-only --project="${{ matrix.project }}"
|
||||||
|
|
||||||
|
- name: Run Playwright changed-area tests
|
||||||
|
if: matrix.suite == 'changed'
|
||||||
|
run: |
|
||||||
|
ulimit -n 16384 || true
|
||||||
|
npm run test:e2e:pr -- --changed-only --project="${{ matrix.project }}" --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: failure()
|
if: failure() || cancelled()
|
||||||
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report-pr
|
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||||
path: output/playwright
|
path: |
|
||||||
|
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
|
||||||
|
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 7
|
retention-days: 3
|
||||||
|
|
||||||
e2e-full:
|
e2e-full:
|
||||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
|
if: >
|
||||||
needs: build-and-unit
|
always() &&
|
||||||
|
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
|
||||||
|
needs.build-and-unit.result == 'success' &&
|
||||||
|
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
|
||||||
|
needs: [build-and-unit, e2e-pr]
|
||||||
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
|
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64, pleno, frontend]
|
||||||
|
timeout-minutes: 60
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
max-parallel: 4
|
||||||
matrix:
|
matrix:
|
||||||
role: [customer, subuser, admin, superuser]
|
role: [customer, subuser, admin, superuser]
|
||||||
browser: [chromium, firefox, webkit]
|
browser: [chromium, firefox, webkit]
|
||||||
@@ -130,19 +213,52 @@ jobs:
|
|||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --legacy-peer-deps
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: npx playwright install --with-deps ${{ matrix.browser_install }}
|
run: node scripts/install-playwright-browsers.mjs ${{ matrix.browser_install }}
|
||||||
|
|
||||||
|
- name: Set Playwright dev server port
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
MATRIX_ROLE: ${{ matrix.role }}
|
||||||
|
MATRIX_BROWSER: ${{ matrix.browser }}
|
||||||
|
MATRIX_DEVICE: ${{ matrix.device }}
|
||||||
|
RUN_ID: ${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
workflow_offset=$(( (RUN_ID % 90) * 600 ))
|
||||||
|
case "$MATRIX_ROLE" in
|
||||||
|
customer) role_offset=0 ;;
|
||||||
|
subuser) role_offset=100 ;;
|
||||||
|
admin) role_offset=200 ;;
|
||||||
|
superuser) role_offset=300 ;;
|
||||||
|
*) echo "Unsupported Playwright role: $MATRIX_ROLE" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$MATRIX_BROWSER" in
|
||||||
|
chromium) browser_offset=0 ;;
|
||||||
|
firefox) browser_offset=30 ;;
|
||||||
|
webkit) browser_offset=60 ;;
|
||||||
|
*) echo "Unsupported Playwright browser: $MATRIX_BROWSER" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$MATRIX_DEVICE" in
|
||||||
|
mobile) device_offset=1 ;;
|
||||||
|
tablet) device_offset=2 ;;
|
||||||
|
desktop) device_offset=3 ;;
|
||||||
|
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
echo "PLAYWRIGHT_DEV_PORT=$((10000 + workflow_offset + role_offset + browser_offset + device_offset))" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Run full Playwright slice
|
- name: Run full Playwright slice
|
||||||
run: npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
|
run: |
|
||||||
|
ulimit -n 16384 || true
|
||||||
|
npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: failure()
|
if: failure() || cancelled()
|
||||||
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
|
name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
|
||||||
@@ -151,4 +267,4 @@ jobs:
|
|||||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
|
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
|
||||||
output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt
|
output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 14
|
retention-days: 3
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"setupCompletedAt": "2026-06-07T11:37:04.444Z"
|
||||||
|
}
|
||||||
@@ -1,16 +1,6 @@
|
|||||||
FROM node:24-alpine AS build
|
FROM node:24-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG RELEASE_COMMIT_SHA=""
|
|
||||||
ARG COMMIT_SHA=""
|
|
||||||
ARG GITHUB_SHA=""
|
|
||||||
ARG SOURCE_COMMIT=""
|
|
||||||
ARG VITE_BASE_PATH=""
|
|
||||||
ENV RELEASE_COMMIT_SHA="${RELEASE_COMMIT_SHA}"
|
|
||||||
ENV COMMIT_SHA="${COMMIT_SHA}"
|
|
||||||
ENV GITHUB_SHA="${GITHUB_SHA}"
|
|
||||||
ENV SOURCE_COMMIT="${SOURCE_COMMIT}"
|
|
||||||
ENV VITE_BASE_PATH="${VITE_BASE_PATH}"
|
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
@@ -18,6 +8,14 @@ COPY package*.json ./
|
|||||||
RUN npm ci --ignore-scripts
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
ARG SOURCE_COMMIT
|
||||||
|
ARG RELEASE_COMMIT_SHA
|
||||||
|
ARG COMMIT_SHA
|
||||||
|
ARG GITHUB_SHA
|
||||||
|
ENV SOURCE_COMMIT=$SOURCE_COMMIT
|
||||||
|
ENV RELEASE_COMMIT_SHA=$RELEASE_COMMIT_SHA
|
||||||
|
ENV COMMIT_SHA=$COMMIT_SHA
|
||||||
|
ENV GITHUB_SHA=$GITHUB_SHA
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|||||||
@@ -22,6 +22,36 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
By default, the Vite dev server proxies `/api/*` to the remote stable API at
|
||||||
|
`https://api-v2.truckwash.io/master/api`. This lets the Vue app run locally
|
||||||
|
without a local PHP API container.
|
||||||
|
|
||||||
|
To develop against a local PHP API instead:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:VITE_API_PROXY_TARGET="http://localhost"; npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
To use another remote API route:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$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
|
||||||
|
$env:VITE_API_PROXY_TARGET="http://localhost"; $env:VITE_API_PROXY_STRIP_PREFIX="false"; npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
### Compile and Minify for Production
|
### Compile and Minify for Production
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+3
-3
@@ -56,9 +56,9 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "io.truckwash.twa"
|
applicationId "io.truckwash.twa"
|
||||||
minSdkVersion 21
|
minSdkVersion 21
|
||||||
targetSdkVersion 35
|
targetSdkVersion 36
|
||||||
versionCode 6
|
versionCode 7
|
||||||
versionName "6"
|
versionName "7"
|
||||||
|
|
||||||
// The name for the application
|
// The name for the application
|
||||||
resValue "string", "appName", twaManifest.name
|
resValue "string", "appName", twaManifest.name
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"pleno-pwa-test"}
|
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"/"}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# User wash start production-readiness QA matrix
|
||||||
|
|
||||||
|
This note documents deterministic coverage for the self-serve user wash start flow. The `@dynamic-image` Playwright case remains separated because it validates rendered image behavior in addition to deterministic state and API transitions.
|
||||||
|
|
||||||
|
## Unit matrix
|
||||||
|
|
||||||
|
| Area | Required scenario | Coverage |
|
||||||
|
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `useWashFlowState` | Step transitions for vehicle, questions, lane, tasks, in-progress, completed | `tests/unit/use-wash-flow-state-production.spec.js` validates step clickability, next-button gating, questions-to-lane updates, lane-to-start target selection, task completion transition, in-progress navigation, and completed-step non-clickability contract. |
|
||||||
|
| `useWashSessionActions` | START success and failure | `tests/unit/use-wash-session-actions-production.spec.js` covers successful manual START state mutation and failed START retryability without active-wash mutation. |
|
||||||
|
| `useWashSessionActions` | Relay enable success and relay enable failure with STOP rollback | `tests/unit/use-wash-session-actions-production.spec.js` covers machine relay success and relay failure rollback via STOP. |
|
||||||
|
| `useWashSessionActions` | STOP failure and already-stopped STOP recovery | `tests/unit/use-wash-session-actions-production.spec.js` covers failed STOP preserving active state and already-not-occupied STOP clearing local state. |
|
||||||
|
| `useSelfServeLogic` | Preview/summary merge | `tests/unit/use-self-serve-logic-production.spec.js` covers preview questions merging with summary questions, answer maps, visible question order, tasks, and allowed services. |
|
||||||
|
| `useSelfServeLogic` | Request race handling | `tests/unit/use-self-serve-logic-production.spec.js` covers stale preview/summary responses being ignored when a newer request wins. |
|
||||||
|
| `useSelfServeLogic` | Allowed services updates | `tests/unit/use-self-serve-logic-production.spec.js` covers lane allowed-service endpoint updates and first-failure fallback behavior. |
|
||||||
|
| `useSelfServeLogic` | Answer sync failures | `tests/unit/use-self-serve-logic-production.spec.js` covers error reporting while preserving the caller's optimistic local answer. |
|
||||||
|
| `MyWashStart.vue` | Local progress restore, server active-wash restore, recent-completion suppression, unmount cleanup, duplicate-fetch prevention | `tests/unit/my-wash-start-production.spec.js` locks the component contracts for restore ordering, authenticated active-wash application, recent-completion suppression across restore/polling, unmount cleanup, and duplicate-fetch/sync guards. |
|
||||||
|
|
||||||
|
## E2E mocked matrix
|
||||||
|
|
||||||
|
| Required scenario | Coverage |
|
||||||
|
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Full manual flow | `tests/e2e/self-serve-wash.spec.js` covers direct manual wash start, guided completion, STOP, exit-gate open, completed state, and close/reset. |
|
||||||
|
| Full machine flow | `tests/e2e/self-serve-wash.spec.js` covers machine task rendering, dynamic image progress, required task completion, machine start path, and reload persistence. |
|
||||||
|
| Reload/resume active wash | `tests/e2e/self-serve-wash.spec.js` covers local progress reload and authenticated server active-wash resume from another device. |
|
||||||
|
| Backend says wash completed during polling | `tests/e2e/self-serve-wash.spec.js` covers server polling of active wash and local transition to completed when the backend no longer reports the matching in-progress wash. |
|
||||||
|
| Network failures for preview, answer sync, START, relay enable, STOP | `tests/e2e/self-serve-wash.spec.js` covers retryable START failure, STOP failure preservation, allowed-services gateway timeout/retry, answer sync background resilience, and unit-level relay rollback. Add mocked network route overrides when expanding browser-level failure assertions. |
|
||||||
|
|
||||||
|
## Optional live smoke
|
||||||
|
|
||||||
|
| Optional scenario | Coverage |
|
||||||
|
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Dynamic image smoke | The `@dynamic-image` Playwright test in `tests/e2e/self-serve-wash.spec.js` is tagged separately from deterministic CI coverage so it can be included or excluded explicitly with Playwright grep controls. |
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import vue from "eslint-plugin-vue";
|
||||||
|
|
||||||
|
const commonGlobals = {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
...globals.es2024,
|
||||||
|
grecaptcha: "readonly",
|
||||||
|
};
|
||||||
|
|
||||||
|
const vitestGlobals = {
|
||||||
|
...globals.vitest,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lintTargets = ["**/*.{js,mjs,cjs,ts,tsx,vue}"];
|
||||||
|
const tsTargets = ["**/*.{ts,tsx,mts,cts}"];
|
||||||
|
|
||||||
|
function warningRules(rules = {}) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(rules).map(([name, value]) => {
|
||||||
|
if (value === "off" || value === 0) {
|
||||||
|
return [name, value];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return [name, ["warn", ...value.slice(1)]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [name, "warn"];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function warningConfig(config, files) {
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
files: config.files ?? files,
|
||||||
|
rules: warningRules(config.rules),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsRecommended = warningConfig(js.configs.recommended, lintTargets);
|
||||||
|
const tsRecommended = tseslint.configs.recommended.map((config) =>
|
||||||
|
warningConfig(config, tsTargets),
|
||||||
|
);
|
||||||
|
const vueEssential = vue.configs["flat/essential"].map((config) =>
|
||||||
|
warningConfig(config, ["**/*.vue"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ignoredPaths = [
|
||||||
|
"app/build/**",
|
||||||
|
"coverage/**",
|
||||||
|
"dist/**",
|
||||||
|
"node_modules/**",
|
||||||
|
"node_modules.codex-backup/**",
|
||||||
|
"output/**",
|
||||||
|
"public/build/**",
|
||||||
|
"src/assets/test/**",
|
||||||
|
"test/**",
|
||||||
|
"vendor/**",
|
||||||
|
"*.timestamp-*.mjs",
|
||||||
|
"**/*.timestamp-*.mjs",
|
||||||
|
];
|
||||||
|
|
||||||
|
const commonUnusedOptions = {
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ignoredPaths,
|
||||||
|
},
|
||||||
|
jsRecommended,
|
||||||
|
...tsRecommended,
|
||||||
|
...vueEssential,
|
||||||
|
{
|
||||||
|
files: ["**/*.vue"],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
parser: tseslint.parser,
|
||||||
|
extraFileExtensions: [".vue"],
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: lintTargets,
|
||||||
|
plugins: {
|
||||||
|
vue,
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
globals: commonGlobals,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-console": "off",
|
||||||
|
"no-debugger": "warn",
|
||||||
|
"no-empty": "warn",
|
||||||
|
"no-undef": "warn",
|
||||||
|
"no-unused-vars": ["warn", commonUnusedOptions],
|
||||||
|
"no-useless-assignment": "warn",
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
"vue/no-mutating-props": "warn",
|
||||||
|
"vue/no-unused-components": "warn",
|
||||||
|
"vue/no-unused-vars": "warn",
|
||||||
|
"vue/no-v-html": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
plugins: {
|
||||||
|
"@typescript-eslint": tseslint.plugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", commonUnusedOptions],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["tests/**/*.{js,ts}", "playwright*.ts", "scripts/**/*.{js,mjs}"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...commonGlobals,
|
||||||
|
...globals.node,
|
||||||
|
...vitestGlobals,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-empty": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
+13
-4
@@ -2,10 +2,19 @@
|
|||||||
<html lang="" class="theme-light" data-theme="light">
|
<html lang="" class="theme-light" data-theme="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
|
<script>
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
|
(function () {
|
||||||
<link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
|
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/);
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
|
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/';
|
||||||
|
var base = document.createElement('base');
|
||||||
|
base.href = href;
|
||||||
|
document.currentScript.after(base);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="icon" type="image/png" href="%BASE_URL%assets/favicons/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="%BASE_URL%assets/favicons/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="%BASE_URL%assets/favicons/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="%BASE_URL%assets/favicons/apple-touch-icon.png" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
|
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
|||||||
@@ -5,22 +5,39 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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$ {
|
location ~ ^/(release-entry|release-manifest)\.json$ {
|
||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
|
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
|
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
|
||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
|
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
try_files /$2.json =404;
|
try_files /$2.json =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
|
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
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;
|
try_files /$static_asset_path =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(?:.+/)?(?<static_file_path>manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
|
location ~ ^/(?:.+/)?(?<static_file_path>index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
|
||||||
try_files /$static_file_path =404;
|
try_files /$static_file_path =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,11 +47,15 @@ server {
|
|||||||
|
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
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;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
|
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
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;
|
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-19
@@ -3056,6 +3056,8 @@ paths:
|
|||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- Orders
|
- Orders
|
||||||
|
x-api-coverage:
|
||||||
|
happy: true
|
||||||
summary: List orders
|
summary: List orders
|
||||||
description: Retrieve a paginated list of orders
|
description: Retrieve a paginated list of orders
|
||||||
operationId: listOrders
|
operationId: listOrders
|
||||||
@@ -4622,7 +4624,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Self-Serve
|
- Self-Serve
|
||||||
summary: Add vehicle condition
|
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
|
operationId: addSelfserveVehicleCondition
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -4657,14 +4659,6 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
nullable: true
|
nullable: true
|
||||||
description: Alias for vehicle_type.
|
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:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Successfully added vehicle condition
|
description: Successfully added vehicle condition
|
||||||
@@ -4681,7 +4675,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Self-Serve
|
- Self-Serve
|
||||||
summary: Update vehicle condition
|
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
|
operationId: updateSelfserveVehicleCondition
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
@@ -4717,14 +4711,6 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
nullable: true
|
nullable: true
|
||||||
description: Alias for vehicle_type.
|
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:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Successfully updated vehicle condition
|
description: Successfully updated vehicle condition
|
||||||
@@ -5400,7 +5386,7 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [department, gateway_id, action]
|
required: [department, gateway_id, action, confirm]
|
||||||
properties:
|
properties:
|
||||||
department: { type: integer }
|
department: { type: integer }
|
||||||
gateway_id: { type: integer }
|
gateway_id: { type: integer }
|
||||||
@@ -8702,6 +8688,116 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/SelfServeLaneStatus'
|
$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:
|
/modules/self-serve/lane/wash/in-progress:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "io.truckwash.twa.staging"
|
applicationId "io.truckwash.twa.staging"
|
||||||
minSdkVersion 21
|
minSdkVersion 21
|
||||||
targetSdkVersion 35
|
targetSdkVersion 36
|
||||||
versionCode 5
|
versionCode 6
|
||||||
versionName "5"
|
versionName "6"
|
||||||
|
|
||||||
// The name for the application
|
// The name for the application
|
||||||
resValue "string", "appName", twaManifest.name
|
resValue "string", "appName", twaManifest.name
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"pleno-pwa-test"}
|
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"/"}
|
||||||
|
|||||||
Generated
+1120
-42
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -7,11 +7,13 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:dev": "vite build --mode development",
|
"build:dev": "vite build --mode development",
|
||||||
|
"lint": "eslint eslint.config.js src tests scripts vite.config.js vitest.config.js playwright*.ts --quiet",
|
||||||
|
"lint:strict": "npm run lint:report -- --max-warnings=0",
|
||||||
|
"lint:report": "eslint eslint.config.js src tests scripts vite.config.js vitest.config.js playwright*.ts",
|
||||||
"format:tests": "prettier --write \"tests/**/*.{js,ts}\"",
|
"format:tests": "prettier --write \"tests/**/*.{js,ts}\"",
|
||||||
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
|
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
|
||||||
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
|
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
|
||||||
"prepare": "node scripts/prepare-husky.mjs",
|
"prepare": "node scripts/prepare-husky.mjs",
|
||||||
"postinstall": "node scripts/postinstall-sync-playwright-root-links.mjs",
|
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
|
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
|
||||||
"text:fix-encoding": "node scripts/text-encoding.mjs fix",
|
"text:fix-encoding": "node scripts/text-encoding.mjs fix",
|
||||||
@@ -109,17 +111,23 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@creativebulma/bulma-divider": "^1.1.0",
|
"@creativebulma/bulma-divider": "^1.1.0",
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@event-calendar/core": "^4.1.0",
|
"@event-calendar/core": "^4.1.0",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/event-calendar__core": "^3.7.0",
|
"@types/event-calendar__core": "^3.7.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"eslint": "^10.4.1",
|
||||||
|
"eslint-plugin-vue": "^10.9.2",
|
||||||
|
"globals": "^17.6.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"prettier": "2.8.8",
|
"prettier": "2.8.8",
|
||||||
"sass-embedded": "^1.81.0",
|
"sass-embedded": "^1.81.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.60.1",
|
||||||
"vite": "7.1.11",
|
"vite": "7.1.11",
|
||||||
"vite-plugin-pwa": "^1.0.2",
|
"vite-plugin-pwa": "^1.0.2",
|
||||||
"vite-plugin-vue-devtools": "^7.5.4",
|
"vite-plugin-vue-devtools": "^7.5.4",
|
||||||
|
|||||||
@@ -38,10 +38,14 @@ function buildProject(name: string, browserName: "chromium" | "firefox" | "webki
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
testIgnore: ["**/release/**"],
|
testIgnore: ["**/release/**"],
|
||||||
|
snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}-win32{ext}",
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: isCI,
|
forbidOnly: isCI,
|
||||||
retries: isCI ? 2 : 0,
|
retries: isCI ? 2 : 0,
|
||||||
|
expect: {
|
||||||
|
timeout: 15_000,
|
||||||
|
},
|
||||||
workers,
|
workers,
|
||||||
...(process.env.PLAYWRIGHT_BASE_URL
|
...(process.env.PLAYWRIGHT_BASE_URL
|
||||||
? {}
|
? {}
|
||||||
|
|||||||
+40
-29
@@ -1,4 +1,5 @@
|
|||||||
import { execFile, spawn } from "node:child_process";
|
import { execFile, spawn } from "node:child_process";
|
||||||
|
import { createWriteStream } from "node:fs";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
@@ -11,15 +12,26 @@ const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://${devHost}:${devPort}
|
|||||||
const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`)
|
const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`)
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||||
const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`);
|
const serverLogDir = path.resolve(process.cwd(), "output/playwright");
|
||||||
|
const pidFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.json`);
|
||||||
|
const stdoutLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.stdout.log`);
|
||||||
|
const stderrLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.stderr.log`);
|
||||||
|
const viteCliPath = path.resolve(process.cwd(), "node_modules/vite/bin/vite.js");
|
||||||
const serverOutputLimit = 80;
|
const serverOutputLimit = 80;
|
||||||
const activeOutputReaders = [];
|
const activeOutputReaders = [];
|
||||||
|
const viteServerEnv = {
|
||||||
|
...process.env,
|
||||||
|
PLAYWRIGHT: "1",
|
||||||
|
...(process.env.CI
|
||||||
|
? {
|
||||||
|
CHOKIDAR_INTERVAL: process.env.CHOKIDAR_INTERVAL || "300",
|
||||||
|
CHOKIDAR_USEPOLLING: process.env.CHOKIDAR_USEPOLLING || "true",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
// Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot.
|
// Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot.
|
||||||
const viteDevArgs = [
|
const viteDevArgs = [
|
||||||
"run",
|
...(process.env.PLAYWRIGHT_VITE_FORCE === "1" ? ["--force"] : []),
|
||||||
"dev",
|
|
||||||
"--",
|
|
||||||
"--force",
|
|
||||||
...(process.platform === "win32" ? ["--configLoader", "runner"] : []),
|
...(process.platform === "win32" ? ["--configLoader", "runner"] : []),
|
||||||
"--host",
|
"--host",
|
||||||
devHost,
|
devHost,
|
||||||
@@ -176,14 +188,18 @@ async function killProcessTree(pid) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureProcessOutput(stream, lines) {
|
function captureProcessOutput(stream, lines, logStream) {
|
||||||
const reader = readline.createInterface({ input: stream });
|
const reader = readline.createInterface({ input: stream });
|
||||||
reader.on("line", (line) => {
|
reader.on("line", (line) => {
|
||||||
lines.push(line);
|
lines.push(line);
|
||||||
|
logStream.write(`${line}\n`);
|
||||||
if (lines.length > serverOutputLimit) {
|
if (lines.length > serverOutputLimit) {
|
||||||
lines.splice(0, lines.length - serverOutputLimit);
|
lines.splice(0, lines.length - serverOutputLimit);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
reader.on("close", () => {
|
||||||
|
logStream.end();
|
||||||
|
});
|
||||||
return reader;
|
return reader;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +384,10 @@ async function warmModuleGraph(entryUrl, { depth = 2, timeoutMs = 120_000 } = {}
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const specifier of extractModuleImports(source)) {
|
for (const specifier of extractModuleImports(source)) {
|
||||||
|
if (specifier.startsWith("/node_modules/.vite/deps/")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const expectedContentType = resolveExpectedContentType(specifier);
|
const expectedContentType = resolveExpectedContentType(specifier);
|
||||||
const importUrl = new URL(specifier, current.url).toString();
|
const importUrl = new URL(specifier, current.url).toString();
|
||||||
|
|
||||||
@@ -436,31 +456,22 @@ export default async function globalSetup() {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverProcess =
|
const serverProcess = spawn(process.execPath, [viteCliPath, ...viteDevArgs], {
|
||||||
process.platform === "win32"
|
cwd: process.cwd(),
|
||||||
? spawn("cmd.exe", ["/d", "/s", "/c", `npm.cmd ${viteDevArgs.join(" ")}`], {
|
detached: true,
|
||||||
cwd: process.cwd(),
|
env: viteServerEnv,
|
||||||
detached: true,
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
env: {
|
windowsHide: true,
|
||||||
...process.env,
|
});
|
||||||
PLAYWRIGHT: "1",
|
|
||||||
},
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
windowsHide: true,
|
|
||||||
})
|
|
||||||
: spawn("npm", viteDevArgs, {
|
|
||||||
cwd: process.cwd(),
|
|
||||||
detached: true,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
PLAYWRIGHT: "1",
|
|
||||||
},
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
const stdoutLines = [];
|
const stdoutLines = [];
|
||||||
const stderrLines = [];
|
const stderrLines = [];
|
||||||
const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines);
|
const stdoutLogStream = createWriteStream(stdoutLogFile, { flags: "w" });
|
||||||
const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines);
|
const stderrLogStream = createWriteStream(stderrLogFile, { flags: "w" });
|
||||||
|
serverProcess.on("exit", (code, signal) => {
|
||||||
|
stderrLogStream.write(`[playwright-global-setup] vite exited with code ${code ?? "null"} signal ${signal ?? "null"}\n`);
|
||||||
|
});
|
||||||
|
const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines, stdoutLogStream);
|
||||||
|
const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines, stderrLogStream);
|
||||||
activeOutputReaders.push(stdoutReader, stderrReader);
|
activeOutputReaders.push(stdoutReader, stderrReader);
|
||||||
serverProcess.unref();
|
serverProcess.unref();
|
||||||
|
|
||||||
|
|||||||
+52
-21
@@ -1,3 +1,4 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const baseURL = "http://127.0.0.1:4173";
|
const baseURL = "http://127.0.0.1:4173";
|
||||||
@@ -5,6 +6,55 @@ const isCI = !!process.env.CI;
|
|||||||
|
|
||||||
process.env.PLAYWRIGHT_BASE_URL = baseURL;
|
process.env.PLAYWRIGHT_BASE_URL = baseURL;
|
||||||
|
|
||||||
|
const osReleaseValue = (key: string) => {
|
||||||
|
try {
|
||||||
|
const body = fs.readFileSync("/etc/os-release", "utf8");
|
||||||
|
const match = body.match(new RegExp(`^${key}=(.*)$`, "m"));
|
||||||
|
return String(match?.[1] || "").replace(/^"|"$/g, "");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUnsupportedWebKitHost = () => {
|
||||||
|
if (process.platform !== "linux") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const id = osReleaseValue("ID").toLowerCase();
|
||||||
|
const version = Number.parseFloat(osReleaseValue("VERSION_ID"));
|
||||||
|
return id === "ubuntu" && Number.isFinite(version) && version >= 26.04;
|
||||||
|
};
|
||||||
|
|
||||||
|
const webKitOverride = String(process.env.PLAYWRIGHT_PROD_WEBKIT || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const includeWebKit =
|
||||||
|
webKitOverride === "1" || (webKitOverride !== "0" && webKitOverride !== "false" && !isUnsupportedWebKitHost());
|
||||||
|
|
||||||
|
const projects = [
|
||||||
|
{
|
||||||
|
name: "chromium-desktop",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chromium-mobile",
|
||||||
|
use: {
|
||||||
|
...devices["Pixel 5"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (includeWebKit) {
|
||||||
|
projects.push({
|
||||||
|
name: "webkit-desktop",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Safari"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e/release",
|
testDir: "./tests/e2e/release",
|
||||||
testMatch: /.*\.local-prod\.spec\.ts/,
|
testMatch: /.*\.local-prod\.spec\.ts/,
|
||||||
@@ -12,7 +62,7 @@ export default defineConfig({
|
|||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: isCI,
|
forbidOnly: isCI,
|
||||||
retries: isCI ? 2 : 0,
|
retries: isCI ? 2 : 0,
|
||||||
workers: isCI ? 2 : 1,
|
workers: isCI ? 2 : 3,
|
||||||
reporter: [["list"], ["html", { open: "never", outputFolder: "output/playwright/prod/report" }]],
|
reporter: [["list"], ["html", { open: "never", outputFolder: "output/playwright/prod/report" }]],
|
||||||
outputDir: "output/playwright/prod/test-results",
|
outputDir: "output/playwright/prod/test-results",
|
||||||
use: {
|
use: {
|
||||||
@@ -28,24 +78,5 @@ export default defineConfig({
|
|||||||
timeout: 240_000,
|
timeout: 240_000,
|
||||||
reuseExistingServer: !isCI,
|
reuseExistingServer: !isCI,
|
||||||
},
|
},
|
||||||
projects: [
|
projects,
|
||||||
{
|
|
||||||
name: "chromium-desktop",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Chrome"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "chromium-mobile",
|
|
||||||
use: {
|
|
||||||
...devices["Pixel 5"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "webkit-desktop",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Safari"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-4
@@ -2,6 +2,10 @@
|
|||||||
Options -MultiViews
|
Options -MultiViews
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_mime.c>
|
||||||
|
AddType application/manifest+json .webmanifest
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
<IfModule mod_rewrite.c>
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
|
|
||||||
@@ -23,17 +27,17 @@
|
|||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteRule ^.+/((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
|
RewriteRule ^.+/((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
|
||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
|
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
|
||||||
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
|
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
|
||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
|
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
|
||||||
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
|
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
|
||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
@@ -41,7 +45,7 @@
|
|||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteRule ^(?:.*?/)?(?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
|
RewriteRule ^(?:.*?/)?(?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
|
||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "Truck Wash Kundeportal",
|
"name": "Truck Wash Kundeportal",
|
||||||
"short_name": "Truck Wash",
|
"short_name": "Truck Wash",
|
||||||
"description": "Access your Truck Wash accounts and transactions from anywhere.",
|
"description": "Access your Truck Wash accounts and transactions from anywhere.",
|
||||||
|
"id": "/",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "assets/favicons/web-app-manifest-192x192.png",
|
"src": "assets/favicons/web-app-manifest-192x192.png",
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
|
const browsers = process.argv.slice(2);
|
||||||
|
const requestedBrowsers = browsers.length > 0 ? browsers : ["chromium"];
|
||||||
|
|
||||||
|
const fallbackHostPlatformByUnsupportedPlatform = (platform) => {
|
||||||
|
const ubuntuMatch = platform.match(/^ubuntu(\d+\.\d+)-(x64|arm64)$/);
|
||||||
|
if (ubuntuMatch && Number.parseInt(ubuntuMatch[1], 10) >= 26) {
|
||||||
|
return `ubuntu24.04-${ubuntuMatch[2]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const outputText = (result) => `${result.stdout || ""}\n${result.stderr || ""}`;
|
||||||
|
|
||||||
|
const unsupportedHostPlatform = (result) => {
|
||||||
|
const output = outputText(result);
|
||||||
|
return (
|
||||||
|
output.match(/Cannot install dependencies for (?<platform>\S+) with Playwright/i)?.groups?.platform ||
|
||||||
|
output.match(/Playwright does not support \S+ on (?<platform>\S+)/i)?.groups?.platform ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runPlaywrightInstall = (args, env = {}) =>
|
||||||
|
spawnSync("npx", ["playwright", "install", ...args], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...env,
|
||||||
|
},
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeOutput = (result) => {
|
||||||
|
if (result.stdout) {
|
||||||
|
process.stdout.write(result.stdout);
|
||||||
|
}
|
||||||
|
if (result.stderr) {
|
||||||
|
process.stderr.write(result.stderr);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUnsupportedHostPlatformFailure = (result) => {
|
||||||
|
const output = outputText(result);
|
||||||
|
return (
|
||||||
|
result.status !== 0 &&
|
||||||
|
/Playwright does not support .* on /i.test(output)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
|
||||||
|
writeOutput(withDepsResult);
|
||||||
|
|
||||||
|
if (withDepsResult.status === 0) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasUnsupportedHostPlatformFailure(withDepsResult)) {
|
||||||
|
process.exit(withDepsResult.status ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsupportedPlatform = unsupportedHostPlatform(withDepsResult);
|
||||||
|
const fallbackHostPlatform = unsupportedPlatform
|
||||||
|
? fallbackHostPlatformByUnsupportedPlatform(unsupportedPlatform)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!fallbackHostPlatform) {
|
||||||
|
console.error(
|
||||||
|
`Playwright dependency install is unsupported for ${unsupportedPlatform || "this platform"}, ` +
|
||||||
|
"and this script does not have a safe browser archive fallback for it."
|
||||||
|
);
|
||||||
|
process.exit(withDepsResult.status ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
[
|
||||||
|
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
|
||||||
|
`Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
|
||||||
|
"The self-hosted runner image must provide the required browser system libraries.",
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
|
||||||
|
const fallbackResult = runPlaywrightInstall(requestedBrowsers, {
|
||||||
|
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE: fallbackHostPlatform,
|
||||||
|
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: "1",
|
||||||
|
});
|
||||||
|
writeOutput(fallbackResult);
|
||||||
|
process.exit(fallbackResult.status ?? 1);
|
||||||
@@ -34,6 +34,12 @@ export const sourceMappings = [
|
|||||||
],
|
],
|
||||||
projects: chromiumProjects,
|
projects: chromiumProjects,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "user-vehicles",
|
||||||
|
patterns: [/^src\/components\/displays\/user\/vehicles\//u, /^src\/views\/dashboards\/userDashboard\/vehicles\//u],
|
||||||
|
specs: ["tests/e2e/userVehicles.spec.ts"],
|
||||||
|
projects: chromiumProjects,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "pos",
|
name: "pos",
|
||||||
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u],
|
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u],
|
||||||
|
|||||||
@@ -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
|
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
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() {
|
upload_file() {
|
||||||
local source_file="$1"
|
local source_file="$1"
|
||||||
local remote_file="$2"
|
local remote_file="$2"
|
||||||
if [[ -f "$source_file" ]]; then
|
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
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
for directory in assets resources favicons icons img sounds .well-known; do
|
for directory in assets resources favicons icons img sounds .well-known; do
|
||||||
if [[ -d "$DIST_DIR/$directory" ]]; then
|
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
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
@@ -7,6 +8,24 @@ function numberEnv(name, fallback) {
|
|||||||
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function booleanEnv(name) {
|
||||||
|
return /^(1|true|yes)$/i.test(process.env[name] || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendGithubEnv(values) {
|
||||||
|
const envFile = process.env.GITHUB_ENV;
|
||||||
|
if (!envFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = Object.entries(values)
|
||||||
|
.filter(([, value]) => value)
|
||||||
|
.map(([key, value]) => `${key}=${String(value).replace(/\r?\n/g, "")}`);
|
||||||
|
if (lines.length > 0) {
|
||||||
|
fs.appendFileSync(envFile, `${lines.join("\n")}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function requiredUrl() {
|
function requiredUrl() {
|
||||||
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
|
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -121,6 +140,7 @@ async function verifyShell(baseUrl, shellPath) {
|
|||||||
async function verifyRelease(baseUrl) {
|
async function verifyRelease(baseUrl) {
|
||||||
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
|
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
|
||||||
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
|
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
|
||||||
|
const strictBuildId = booleanEnv("RELEASE_STRICT_BUILD_ID");
|
||||||
const manifest = await fetchJson(baseUrl, "release-manifest.json");
|
const manifest = await fetchJson(baseUrl, "release-manifest.json");
|
||||||
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
|
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
|
||||||
|
|
||||||
@@ -130,7 +150,7 @@ async function verifyRelease(baseUrl) {
|
|||||||
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
|
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
|
||||||
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
|
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
|
||||||
}
|
}
|
||||||
if (expectedBuildId && manifest.build_id !== expectedBuildId) {
|
if (strictBuildId && expectedBuildId && manifest.build_id !== expectedBuildId) {
|
||||||
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
|
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
|
||||||
}
|
}
|
||||||
if (releaseEntry.entry !== manifest.entry) {
|
if (releaseEntry.entry !== manifest.entry) {
|
||||||
@@ -191,6 +211,10 @@ async function main() {
|
|||||||
attempt += 1;
|
attempt += 1;
|
||||||
try {
|
try {
|
||||||
const result = await verifyRelease(baseUrl);
|
const result = await verifyRelease(baseUrl);
|
||||||
|
appendGithubEnv({
|
||||||
|
RELEASE_VERIFIED_BUILD_ID: result.build_id,
|
||||||
|
RELEASE_VERIFIED_COMMIT: result.commit_sha,
|
||||||
|
});
|
||||||
console.log(
|
console.log(
|
||||||
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
|
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,31 +1,41 @@
|
|||||||
import { execFile, spawn } from "node:child_process";
|
import { execFile, spawn } from "node:child_process";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
const workingDirectory = process.cwd();
|
const workingDirectory = process.cwd();
|
||||||
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
|
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
|
||||||
const execFileAsync = promisify(execFile);
|
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 listEntryPattern = /^\s+\[[^\]]+\]\s+›\s+(.+?):(\d+):(\d+)\s+›\s+(.+)\s*$/u;
|
||||||
|
|
||||||
const ownedFilesByRole = {
|
export const ownedFilesByRole = {
|
||||||
customer: [
|
customer: [
|
||||||
"auth.smoke.spec.js",
|
"auth.smoke.spec.js",
|
||||||
"booking-selfserve.smoke.spec.js",
|
"booking-selfserve.smoke.spec.js",
|
||||||
"connectivityIssue.spec.ts",
|
"connectivityIssue.spec.ts",
|
||||||
"example.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.smoke.spec.ts",
|
||||||
"i18n.views.spec.ts",
|
"i18n.views.spec.ts",
|
||||||
"navigation.smoke.spec.js",
|
"navigation.smoke.spec.js",
|
||||||
"qr-new-customer-layout.spec.ts",
|
"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",
|
"self-serve-wash.spec.js",
|
||||||
|
"session-release-runtime.spec.ts",
|
||||||
"user-orders.spec.ts",
|
"user-orders.spec.ts",
|
||||||
"userBookings.spec.ts",
|
"userBookings.spec.ts",
|
||||||
"userBookWash.spec.ts",
|
"userBookWash.spec.ts",
|
||||||
"userHome.spec.ts",
|
"userHome.spec.ts",
|
||||||
"userInvoices.spec.ts",
|
"userInvoices.spec.ts",
|
||||||
"userMyWashStart.spec.ts",
|
"userMyWashStart.spec.ts",
|
||||||
|
"userMyWashStartFlow.spec.ts",
|
||||||
"userProfileInvoicing.spec.ts",
|
"userProfileInvoicing.spec.ts",
|
||||||
"userProfileNotifications.spec.ts",
|
"userProfileNotifications.spec.ts",
|
||||||
"userProfileSecurity.spec.ts",
|
"userProfileSecurity.spec.ts",
|
||||||
@@ -53,6 +63,7 @@ const ownedFilesByRole = {
|
|||||||
"assign-draft-order-modal-layout.spec.ts",
|
"assign-draft-order-modal-layout.spec.ts",
|
||||||
"change-invoice-collection.spec.ts",
|
"change-invoice-collection.spec.ts",
|
||||||
"change-customer.spec.ts",
|
"change-customer.spec.ts",
|
||||||
|
"default-mobile-redirect.spec.ts",
|
||||||
"economic-queue-workflow.spec.js",
|
"economic-queue-workflow.spec.js",
|
||||||
"pos-customer-rules.spec.js",
|
"pos-customer-rules.spec.js",
|
||||||
"pos-desktop-card-payments.spec.js",
|
"pos-desktop-card-payments.spec.js",
|
||||||
@@ -62,18 +73,24 @@ const ownedFilesByRole = {
|
|||||||
"pos.visual.spec.js",
|
"pos.visual.spec.js",
|
||||||
],
|
],
|
||||||
superuser: [
|
superuser: [
|
||||||
|
"coolify-infrastructure.spec.js",
|
||||||
"edge-gateways.fleet-outline.spec.js",
|
"edge-gateways.fleet-outline.spec.js",
|
||||||
"edge-gateways.routes.spec.js",
|
"edge-gateways.routes.spec.js",
|
||||||
"edge-gateways.smoke.spec.js",
|
"edge-gateways.smoke.spec.js",
|
||||||
"edge-gateways.visual.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-distribution.smoke.spec.js",
|
||||||
"invoice-transfer-monitor.spec.ts",
|
"invoice-transfer-monitor.spec.ts",
|
||||||
"invoice-transfer-queue-history.spec.js",
|
"invoice-transfer-queue-history.spec.js",
|
||||||
"invoicing-period.smoke.spec.js",
|
"invoicing-period.smoke.spec.js",
|
||||||
"issue-repro-duplicates-date.spec.js",
|
"issue-repro-duplicates-date.spec.js",
|
||||||
|
"release-manager.spec.js",
|
||||||
"self-serve-sessions.spec.js",
|
"self-serve-sessions.spec.js",
|
||||||
"self-serve-studio-audit-navigation.spec.js",
|
"self-serve-studio-audit-navigation.spec.js",
|
||||||
"self-serve-studio-flow.spec.js",
|
"self-serve-studio-flow.spec.js",
|
||||||
|
"session-bootstrap.spec.ts",
|
||||||
"superuser-bookings.spec.ts",
|
"superuser-bookings.spec.ts",
|
||||||
"superuser-customer-complaints.spec.ts",
|
"superuser-customer-complaints.spec.ts",
|
||||||
"superuser-customers-mass-import.spec.ts",
|
"superuser-customers-mass-import.spec.ts",
|
||||||
@@ -84,12 +101,13 @@ const ownedFilesByRole = {
|
|||||||
"superuser-drafts.spec.ts",
|
"superuser-drafts.spec.ts",
|
||||||
"superuser-products-layout.spec.ts",
|
"superuser-products-layout.spec.ts",
|
||||||
"superuser-system-status.smoke.spec.js",
|
"superuser-system-status.smoke.spec.js",
|
||||||
|
"superuser-users.spec.ts",
|
||||||
"superuser-vehicles.smoke.spec.js",
|
"superuser-vehicles.smoke.spec.js",
|
||||||
"workfeed-config.smoke.spec.js",
|
"workfeed-config.smoke.spec.js",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const titleRules = [
|
export const titleRules = [
|
||||||
{ role: "customer", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[User\]/u] },
|
{ role: "customer", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[User\]/u] },
|
||||||
{ role: "subuser", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Subuser\]/u] },
|
{ role: "subuser", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Subuser\]/u] },
|
||||||
{ role: "admin", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Operator\]/u] },
|
{ role: "admin", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Operator\]/u] },
|
||||||
@@ -116,6 +134,11 @@ const titleRules = [
|
|||||||
file: "subuser-management.spec.ts",
|
file: "subuser-management.spec.ts",
|
||||||
patterns: [/^authorized subuser managers/i, /^subuser self-service/i, /^subusers without /i],
|
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] },
|
{ role: "subuser", file: "userProfileVisibility.spec.ts", patterns: [/\[PROFILE\]\[Subuser\]\[Visibility\]/u] },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -200,7 +223,7 @@ function toBaseName(filePath) {
|
|||||||
return filePath.split(/[\\/]/u).pop() || filePath;
|
return filePath.split(/[\\/]/u).pop() || filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseListedTests(listOutput) {
|
export function parseListedTests(listOutput) {
|
||||||
return listOutput
|
return listOutput
|
||||||
.split(/\r?\n/u)
|
.split(/\r?\n/u)
|
||||||
.map((line) => {
|
.map((line) => {
|
||||||
@@ -222,7 +245,7 @@ function parseListedTests(listOutput) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyTest(testEntry) {
|
export function classifyTest(testEntry) {
|
||||||
const matches = new Set();
|
const matches = new Set();
|
||||||
const directOwner = ownedFileToRole.get(testEntry.fileName);
|
const directOwner = ownedFileToRole.get(testEntry.fileName);
|
||||||
|
|
||||||
@@ -309,8 +332,8 @@ async function runPlaywright(project, testListPath, forwardedArgs) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
export async function main(argv = process.argv.slice(2)) {
|
||||||
const { options, forwardedArgs } = parseCliArgs(process.argv.slice(2));
|
const { options, forwardedArgs } = parseCliArgs(argv);
|
||||||
validateOptions(options, forwardedArgs);
|
validateOptions(options, forwardedArgs);
|
||||||
|
|
||||||
const listOutput = await listProjectTests(options.project, forwardedArgs);
|
const listOutput = await listProjectTests(options.project, forwardedArgs);
|
||||||
@@ -342,4 +365,17 @@ async function main() {
|
|||||||
await runPlaywright(options.project, testListPath, forwardedArgs);
|
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) {
|
function parseArgs(rawArgs) {
|
||||||
const parsed = {
|
const parsed = {
|
||||||
help: false,
|
help: false,
|
||||||
|
coreOnly: false,
|
||||||
changedOnly: false,
|
changedOnly: false,
|
||||||
listOnly: false,
|
listOnly: false,
|
||||||
base: "",
|
base: "",
|
||||||
@@ -43,6 +44,11 @@ function parseArgs(rawArgs) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value === "--core-only") {
|
||||||
|
parsed.coreOnly = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (value === "--changed-only") {
|
if (value === "--changed-only") {
|
||||||
parsed.changedOnly = true;
|
parsed.changedOnly = true;
|
||||||
continue;
|
continue;
|
||||||
@@ -103,12 +109,14 @@ Options:
|
|||||||
--base <ref> Base ref for changed-area detection
|
--base <ref> Base ref for changed-area detection
|
||||||
--head <ref> Head ref for changed-area detection. Default: HEAD
|
--head <ref> Head ref for changed-area detection. Default: HEAD
|
||||||
--project <name> Restrict to one Chromium Playwright project. Can be repeated.
|
--project <name> Restrict to one Chromium Playwright project. Can be repeated.
|
||||||
|
--core-only Run only the core @pr gate
|
||||||
--changed-only Run changed-area selection without the core @pr gate
|
--changed-only Run changed-area selection without the core @pr gate
|
||||||
--list-only List selected tests instead of running them
|
--list-only List selected tests instead of running them
|
||||||
-h, --help Show help
|
-h, --help Show help
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
npm run test:e2e:pr
|
npm run test:e2e:pr
|
||||||
|
npm run test:e2e:pr -- --core-only --project=chromium-desktop
|
||||||
npm run test:e2e:changed -- --base=HEAD~1 --head=HEAD
|
npm run test:e2e:changed -- --base=HEAD~1 --head=HEAD
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -159,6 +167,7 @@ async function runPlaywright({ label, commandArgs, artifactSuffix }) {
|
|||||||
PLAYWRIGHT: "1",
|
PLAYWRIGHT: "1",
|
||||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: getArtifactNamespace(artifactSuffix),
|
PLAYWRIGHT_ARTIFACT_NAMESPACE: getArtifactNamespace(artifactSuffix),
|
||||||
PLAYWRIGHT_REPORTER_MODE: "line-html",
|
PLAYWRIGHT_REPORTER_MODE: "line-html",
|
||||||
|
PLAYWRIGHT_WORKERS: process.env.PLAYWRIGHT_WORKERS || "1",
|
||||||
},
|
},
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
@@ -318,11 +327,19 @@ function groupSpecsByProjects(specProjects) {
|
|||||||
|
|
||||||
async function runCorePrGate() {
|
async function runCorePrGate() {
|
||||||
const projects = getSelectedProjects();
|
const projects = getSelectedProjects();
|
||||||
return runPlaywright({
|
for (const project of projects) {
|
||||||
label: `core ${prGrep} gate`,
|
const code = await runPlaywright({
|
||||||
artifactSuffix: "core",
|
label: `core ${prGrep} gate (${project})`,
|
||||||
commandArgs: ["--grep", prGrep, ...buildProjectArgs(projects)],
|
artifactSuffix: `core-${project}`,
|
||||||
});
|
commandArgs: ["--grep", prGrep, "--project", project],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runChangedSelection(selection) {
|
async function runChangedSelection(selection) {
|
||||||
@@ -332,11 +349,19 @@ async function runChangedSelection(selection) {
|
|||||||
console.log(
|
console.log(
|
||||||
`[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(", ")}`
|
`[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(", ")}`
|
||||||
);
|
);
|
||||||
return runPlaywright({
|
for (const project of projects) {
|
||||||
label: `fallback ${smokeGrep} gate`,
|
const code = await runPlaywright({
|
||||||
artifactSuffix: "smoke-fallback",
|
label: `fallback ${smokeGrep} gate (${project})`,
|
||||||
commandArgs: ["--grep", smokeGrep, "--grep-invert", prGrep, ...buildProjectArgs(projects)],
|
artifactSuffix: `smoke-fallback-${project}`,
|
||||||
});
|
commandArgs: ["--grep", smokeGrep, "--grep-invert", prGrep, "--project", project],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = groupSpecsByProjects(selection.specProjects);
|
const groups = groupSpecsByProjects(selection.specProjects);
|
||||||
@@ -346,14 +371,16 @@ async function runChangedSelection(selection) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [index, group] of groups.entries()) {
|
for (const [index, group] of groups.entries()) {
|
||||||
const code = await runPlaywright({
|
for (const project of group.projects) {
|
||||||
label: `changed-area specs ${index + 1}/${groups.length}`,
|
const code = await runPlaywright({
|
||||||
artifactSuffix: `changed-${index + 1}`,
|
label: `changed-area specs ${index + 1}/${groups.length} (${project})`,
|
||||||
commandArgs: [...group.specs, ...buildProjectArgs(group.projects)],
|
artifactSuffix: `changed-${index + 1}-${project}`,
|
||||||
});
|
commandArgs: [...group.specs, "--project", project],
|
||||||
|
});
|
||||||
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
return code;
|
return code;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +426,10 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.coreOnly && args.changedOnly) {
|
||||||
|
throw new Error("--core-only and --changed-only cannot be used together.");
|
||||||
|
}
|
||||||
|
|
||||||
await fs.access(playwrightCliPath);
|
await fs.access(playwrightCliPath);
|
||||||
|
|
||||||
if (!args.changedOnly) {
|
if (!args.changedOnly) {
|
||||||
@@ -409,6 +440,10 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.coreOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const changed = await getChangedFiles();
|
const changed = await getChangedFiles();
|
||||||
if (changed.unavailable) {
|
if (changed.unavailable) {
|
||||||
console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`);
|
console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`);
|
||||||
|
|||||||
+2
-2
@@ -18,9 +18,9 @@ const { t, te, locale } = useI18n({ useScope: "global" });
|
|||||||
const APP_TITLE = "Truck Wash";
|
const APP_TITLE = "Truck Wash";
|
||||||
const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/LayoutV2.vue"));
|
const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/LayoutV2.vue"));
|
||||||
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
|
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
|
||||||
const VersionCheck = defineAsyncComponent(() => import("@/components/global/VersionCheck.vue"));
|
|
||||||
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
|
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
|
||||||
const ErrorReportLauncher = defineAsyncComponent(() => import("@/components/global/ErrorReportLauncher.vue"));
|
const ErrorReportLauncher = defineAsyncComponent(() => import("@/components/global/ErrorReportLauncher.vue"));
|
||||||
|
const FrontendMaintenanceMenu = defineAsyncComponent(() => import("@/components/global/FrontendMaintenanceMenu.vue"));
|
||||||
const ReleaseChannelUnavailable = defineAsyncComponent(() =>
|
const ReleaseChannelUnavailable = defineAsyncComponent(() =>
|
||||||
import("@/components/release/ReleaseChannelUnavailable.vue")
|
import("@/components/release/ReleaseChannelUnavailable.vue")
|
||||||
);
|
);
|
||||||
@@ -123,13 +123,13 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
|
|||||||
<header></header>
|
<header></header>
|
||||||
<main>
|
<main>
|
||||||
<DefaultPageWrapper>
|
<DefaultPageWrapper>
|
||||||
<VersionCheck />
|
|
||||||
<router-view />
|
<router-view />
|
||||||
</DefaultPageWrapper>
|
</DefaultPageWrapper>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
||||||
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
||||||
|
<FrontendMaintenanceMenu />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import CustomerComplaintsPagination from "@/components/displays/pagination/models/SuperUserDashboard/CustomerComplaintsPagination.vue";
|
import CustomerComplaintsPagination from "@/components/displays/pagination/models/SuperUserDashboard/CustomerComplaintsPagination.vue";
|
||||||
import PageTitle from "@/components/global/PageTitle.vue";
|
|
||||||
import { useI18n } from "vue-i18n";
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div data-testid="superuser-complaints-page">
|
<div data-testid="superuser-complaints-page">
|
||||||
<PageTitle
|
<CustomerComplaintsPagination :auto-load="true" />
|
||||||
:title="t('superuser.pages.complaints.title')"
|
|
||||||
:subtitle="t('superuser.pages.complaints.subtitle')"
|
|
||||||
/>
|
|
||||||
<CustomerComplaintsPagination auto-load="true" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
|
||||||
import { getDepartmentListData } from "@/components/session/Session.vue";
|
|
||||||
import { showCreateDepartmentForm } from "@/components/forms/superUser/createDepartmentForm.vue";
|
import { showCreateDepartmentForm } from "@/components/forms/superUser/createDepartmentForm.vue";
|
||||||
import PageTitle from "@/components/global/PageTitle.vue";
|
import PageTitle from "@/components/global/PageTitle.vue";
|
||||||
import DepartmentsPagination from "@/components/displays/pagination/models/SuperUserDashboard/DepartmentsPagination.vue";
|
import DepartmentsPagination from "@/components/displays/pagination/models/SuperUserDashboard/DepartmentsPagination.vue";
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const tableData = ref([]);
|
|
||||||
|
|
||||||
getDepartmentListData().then((response) => {
|
|
||||||
tableData.value = response.data.data;
|
|
||||||
});
|
|
||||||
|
|
||||||
const redirect = (path) => {
|
|
||||||
window.location = path;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -36,4 +25,4 @@ const redirect = (path) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,15 +6,35 @@ import SubusersPagination from "@/components/displays/pagination/models/SuperUse
|
|||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue";
|
import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
endpoint: {
|
||||||
|
type: String,
|
||||||
|
default: "/subusers",
|
||||||
|
},
|
||||||
|
showCustomer: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
superuserPage: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const canInvite = computed(() => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_ADD"));
|
const canInvite = computed(() =>
|
||||||
|
props.superuserPage
|
||||||
|
? SessionUser.canAccessSuperUser()
|
||||||
|
: SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_ADD")
|
||||||
|
);
|
||||||
const requiresGrantSelection = computed(
|
const requiresGrantSelection = computed(
|
||||||
() => SessionUser.isSubuser.value && !SessionUser.subuser.selectedGrantCustomerNumber.value
|
() => !props.superuserPage && SessionUser.isSubuser.value && !SessionUser.subuser.selectedGrantCustomerNumber.value
|
||||||
);
|
);
|
||||||
const paginationVersion = ref(0);
|
const paginationVersion = ref(0);
|
||||||
const paginationKey = computed(() =>
|
const paginationKey = computed(() =>
|
||||||
SessionUser.isSubuser.value
|
props.superuserPage
|
||||||
|
? `subusers-superuser-${paginationVersion.value}`
|
||||||
|
: SessionUser.isSubuser.value
|
||||||
? `subusers-${SessionUser.subuser.selectedGrantCustomerNumber.value || "none"}-${paginationVersion.value}`
|
? `subusers-${SessionUser.subuser.selectedGrantCustomerNumber.value || "none"}-${paginationVersion.value}`
|
||||||
: `subusers-user-${paginationVersion.value}`
|
: `subusers-user-${paginationVersion.value}`
|
||||||
);
|
);
|
||||||
@@ -22,13 +42,13 @@ const paginationKey = computed(() =>
|
|||||||
const onInviteClick = async () => {
|
const onInviteClick = async () => {
|
||||||
await SessionUser.objects.subusers.functions.showInviteForm(() => {
|
await SessionUser.objects.subusers.functions.showInviteForm(() => {
|
||||||
paginationVersion.value += 1;
|
paginationVersion.value += 1;
|
||||||
});
|
}, { superuser: props.superuserPage });
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<PageTitle :title="t('superuser.pages.subusers.title')" :subtitle="t('superuser.pages.subusers.subtitle')">
|
<PageTitle :title="SessionUser.objects.subusers.meta.title" :subtitle="t('superuser.pages.subusers.subtitle')">
|
||||||
<template #buttons>
|
<template #buttons>
|
||||||
<button v-if="canInvite" class="button is-dark" type="button" @click="onInviteClick">
|
<button v-if="canInvite" class="button is-dark" type="button" @click="onInviteClick">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
@@ -39,7 +59,7 @@ const onInviteClick = async () => {
|
|||||||
</template>
|
</template>
|
||||||
</PageTitle>
|
</PageTitle>
|
||||||
|
|
||||||
<div v-if="SessionUser.isSubuser.value" class="mb-5">
|
<div v-if="SessionUser.isSubuser.value && !superuserPage" class="mb-5">
|
||||||
<SubuserGrantSelector />
|
<SubuserGrantSelector />
|
||||||
<p class="help">
|
<p class="help">
|
||||||
Vælg den kunde, du vil administrere chauffører for. Listen og rettighederne følger det valgte kundenummer.
|
Vælg den kunde, du vil administrere chauffører for. Listen og rettighederne følger det valgte kundenummer.
|
||||||
@@ -50,7 +70,13 @@ const onInviteClick = async () => {
|
|||||||
Vælg først en kunde for at se og administrere chauffører.
|
Vælg først en kunde for at se og administrere chauffører.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubusersPagination v-else :key="paginationKey" auto-load="true" />
|
<SubusersPagination
|
||||||
|
v-else
|
||||||
|
:key="paginationKey"
|
||||||
|
:endpoint="endpoint"
|
||||||
|
:show-customer="showCustomer"
|
||||||
|
auto-load="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
reg_3: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
order_booking_id: {
|
order_booking_id: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: null,
|
default: null,
|
||||||
@@ -93,6 +97,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
allowBookingDeletion: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
department_lane_id: {
|
department_lane_id: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: null,
|
default: null,
|
||||||
@@ -839,6 +847,38 @@ watch(isDropdownOpen, async (isOpen) => {
|
|||||||
|
|
||||||
const attachmentsFromOrder = ref([]);
|
const attachmentsFromOrder = ref([]);
|
||||||
const attachmentsFromOrderError = ref(null);
|
const attachmentsFromOrderError = ref(null);
|
||||||
|
const SELF_SERVE_WASH_ATTACHMENT_TYPE = "SELF_SERVE_WASH";
|
||||||
|
|
||||||
|
const normalizePositiveInteger = (value) => {
|
||||||
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAttachmentOtherPayload = (attachment) => attachment?.content?.other ?? null;
|
||||||
|
|
||||||
|
const isSelfServeWashAttachment = (attachment) => {
|
||||||
|
const other = getAttachmentOtherPayload(attachment);
|
||||||
|
return Boolean(other && typeof other === "object" && other.type === SELF_SERVE_WASH_ATTACHMENT_TYPE);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelfServeWashAttachment = computed(() =>
|
||||||
|
attachmentsFromOrder.value.find((attachment) => isSelfServeWashAttachment(attachment)) || null
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSelfServeWashPayload = computed(() => getAttachmentOtherPayload(getSelfServeWashAttachment.value));
|
||||||
|
|
||||||
|
const getSelfServeWashCustomerNumber = computed(() =>
|
||||||
|
normalizePositiveInteger(getSelfServeWashPayload.value?.customer_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
const canAcceptSelfServeWashDraft = computed(() =>
|
||||||
|
Boolean(
|
||||||
|
props.order_id
|
||||||
|
&& getSelfServeWashCustomerNumber.value
|
||||||
|
&& normalizePositiveInteger(props.customer_number) !== getSelfServeWashCustomerNumber.value
|
||||||
|
&& (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const isObjectUrl = (value) => typeof value === "string" && value.startsWith("blob:");
|
const isObjectUrl = (value) => typeof value === "string" && value.startsWith("blob:");
|
||||||
|
|
||||||
@@ -863,16 +903,39 @@ const clearAttachmentPreviewState = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getAttachmentLabel = (attachment) => {
|
const getAttachmentLabel = (attachment) => {
|
||||||
|
if (isSelfServeWashAttachment(attachment)) {
|
||||||
|
const customerNumber = normalizePositiveInteger(attachment?.content?.other?.customer_number);
|
||||||
|
return customerNumber
|
||||||
|
? t("admin.pos.settings_wheel.self_serve_wash_attachment_for_customer", { customerNumber })
|
||||||
|
: t("admin.pos.settings_wheel.self_serve_wash_attachment");
|
||||||
|
}
|
||||||
|
|
||||||
|
const other = getAttachmentOtherPayload(attachment);
|
||||||
|
const otherLabel = typeof other === "string"
|
||||||
|
? other
|
||||||
|
: other && typeof other === "object"
|
||||||
|
? (other.label || other.type || JSON.stringify(other))
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
attachment?.content?.document ||
|
attachment?.content?.document ||
|
||||||
attachment?.content?.image ||
|
attachment?.content?.image ||
|
||||||
attachment?.content?.other ||
|
otherLabel ||
|
||||||
`Attachment ${attachment?.id ?? ""}`.trim()
|
`Attachment ${attachment?.id ?? ""}`.trim()
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 getAttachmentExtension = (attachment) => {
|
||||||
const match = getAttachmentLabel(attachment)
|
const match = String(getAttachmentLabel(attachment))
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.match(/(\.[a-z0-9]+)$/);
|
.match(/(\.[a-z0-9]+)$/);
|
||||||
|
|
||||||
@@ -894,17 +957,51 @@ const getAttachmentPreviewKind = (attachment) => {
|
|||||||
return "office";
|
return "office";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (String(attachment?.content?.other || "").startsWith("http")) {
|
const other = getAttachmentOtherPayload(attachment);
|
||||||
|
if (typeof other === "string" && other.startsWith("http")) {
|
||||||
return "link";
|
return "link";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachment?.content?.other) {
|
if (other) {
|
||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "none";
|
return "none";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatAttachmentText = (attachment) => {
|
||||||
|
const other = getAttachmentOtherPayload(attachment);
|
||||||
|
if (isSelfServeWashAttachment(attachment)) {
|
||||||
|
const parts = [
|
||||||
|
t("admin.pos.settings_wheel.self_serve_wash_attachment"),
|
||||||
|
other?.customer_number
|
||||||
|
? `${t("admin.pos.settings_wheel.self_serve_customer")}: #${other.customer_number}`
|
||||||
|
: null,
|
||||||
|
other?.subuser?.name || other?.subuser?.username || other?.subuser_id
|
||||||
|
? `${t("admin.pos.settings_wheel.self_serve_driver")}: ${other?.subuser?.name || other?.subuser?.username || `#${other.subuser_id}`}`
|
||||||
|
: null,
|
||||||
|
other?.license_plate
|
||||||
|
? `${t("pos.license_plate")}: ${other.license_plate}`
|
||||||
|
: null,
|
||||||
|
other?.elapsed_wash_time_seconds
|
||||||
|
? `${t("admin.pos.settings_wheel.self_serve_elapsed")}: ${Math.ceil(Number(other.elapsed_wash_time_seconds) / 60)} min`
|
||||||
|
: null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof other === "string") {
|
||||||
|
return other;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (other && typeof other === "object") {
|
||||||
|
return JSON.stringify(other, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
const getAttachmentPreviewPlaceholderIcon = (attachment) => {
|
const getAttachmentPreviewPlaceholderIcon = (attachment) => {
|
||||||
const previewKind = getAttachmentPreviewKind(attachment);
|
const previewKind = getAttachmentPreviewKind(attachment);
|
||||||
|
|
||||||
@@ -965,6 +1062,10 @@ const activeAttachmentPreviewSource = computed(() => {
|
|||||||
return previewSourcesById.value[activeAttachment.value.id] ?? null;
|
return previewSourcesById.value[activeAttachment.value.id] ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasWashCertificateAttachment = computed(() =>
|
||||||
|
attachmentsFromOrder.value.some((attachment) => isWashCertificateAttachment(attachment))
|
||||||
|
);
|
||||||
|
|
||||||
const hasCachedPreviewSource = (attachmentId) => {
|
const hasCachedPreviewSource = (attachmentId) => {
|
||||||
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
|
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
|
||||||
};
|
};
|
||||||
@@ -1529,6 +1630,137 @@ 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) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await SessionUser.request("/collected-invoices", "GET", {
|
||||||
|
page: 1,
|
||||||
|
limit: 100,
|
||||||
|
order: "closed_at:asc",
|
||||||
|
filters: `customer_number:${normalizedCustomerNumber},booked_invoice_id:is_null`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.isArray(response?.data?.data) ? response.data.data : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureOpenInvoiceCollectionForCustomer = async (customerNumber) => {
|
||||||
|
const collections = await getAvailableInvoiceCollectionsForCustomer(customerNumber);
|
||||||
|
const openCollection = collections.find((collection) => collection?.closed_at === null);
|
||||||
|
const openCollectionId = normalizePositiveInteger(openCollection?.id);
|
||||||
|
if (openCollectionId) {
|
||||||
|
return openCollectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await SessionUser.objects.collectedOrderInvoices.add(
|
||||||
|
customerNumber,
|
||||||
|
t("admin.pos.drafts_assignment.new_collection_name"),
|
||||||
|
t("admin.pos.drafts_assignment.new_collection_description"),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
return normalizePositiveInteger(response?.data?.data?.id ?? response?.data?.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const acceptSelfServeWashDraft = async () => {
|
||||||
|
const customerNumber = getSelfServeWashCustomerNumber.value;
|
||||||
|
const orderId = normalizePositiveInteger(props.order_id);
|
||||||
|
if (!customerNumber || !orderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await Swal.fire({
|
||||||
|
title: t("admin.pos.settings_wheel.accept_self_serve_wash"),
|
||||||
|
text: t("admin.pos.settings_wheel.accept_self_serve_wash_confirm", { customerNumber }),
|
||||||
|
icon: "question",
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: t("common.confirm"),
|
||||||
|
cancelButtonText: t("common.cancel"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.isConfirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invoiceCollectionId = await ensureOpenInvoiceCollectionForCustomer(customerNumber);
|
||||||
|
if (!invoiceCollectionId) {
|
||||||
|
throw new Error(t("admin.pos.drafts_assignment.invoice_collection_empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await SessionUser.objects.orders.functions.assignDraftCustomer({
|
||||||
|
order_id: orderId,
|
||||||
|
customer_id: customerNumber,
|
||||||
|
invoice_collection_id: invoiceCollectionId,
|
||||||
|
department_id: normalizePositiveInteger(props.department_id),
|
||||||
|
recalculate_prices: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await props.refreshFunction();
|
||||||
|
await Swal.fire({
|
||||||
|
icon: "success",
|
||||||
|
title: t("admin.pos.settings_wheel.accept_self_serve_wash_success"),
|
||||||
|
timer: 1800,
|
||||||
|
showConfirmButton: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await Swal.fire({
|
||||||
|
icon: "error",
|
||||||
|
title: t("admin.pos.drafts_assignment.error"),
|
||||||
|
text: SessionUser.functions.parseErrorMessage(error) || t("admin.pos.drafts_assignment.error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canDeleteOrderBooking = computed(() =>
|
||||||
|
!props.order_id &&
|
||||||
|
(props.allowBookingDeletion || SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser())
|
||||||
|
);
|
||||||
|
|
||||||
const flatBuiltInMenuSections = computed(() => {
|
const flatBuiltInMenuSections = computed(() => {
|
||||||
const sections = [];
|
const sections = [];
|
||||||
|
|
||||||
@@ -1583,7 +1815,7 @@ const flatBuiltInMenuSections = computed(() => {
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
!props.order_id
|
canDeleteOrderBooking.value
|
||||||
? buildMenuAction("booking-delete", {
|
? buildMenuAction("booking-delete", {
|
||||||
icon: "fas fa-trash-alt",
|
icon: "fas fa-trash-alt",
|
||||||
label: t("admin.pos.settings_wheel.delete_booking"),
|
label: t("admin.pos.settings_wheel.delete_booking"),
|
||||||
@@ -1614,6 +1846,16 @@ const flatBuiltInMenuSections = computed(() => {
|
|||||||
? redirectDepartmentOrderPage(props.order_id, true)
|
? redirectDepartmentOrderPage(props.order_id, true)
|
||||||
: SessionUser.functions.redirectTo.user("/orders/" + props.order_id, true),
|
: SessionUser.functions.redirectTo.user("/orders/" + props.order_id, true),
|
||||||
}),
|
}),
|
||||||
|
SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()
|
||||||
|
? canAcceptSelfServeWashDraft.value
|
||||||
|
? buildMenuAction("order-accept-self-serve-wash", {
|
||||||
|
icon: "fas fa-check-circle",
|
||||||
|
label: t("admin.pos.settings_wheel.accept_self_serve_wash"),
|
||||||
|
template: "success",
|
||||||
|
clickAction: acceptSelfServeWashDraft,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
: null,
|
||||||
SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()
|
SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()
|
||||||
? buildMenuAction("order-attach-wash-certificate", {
|
? buildMenuAction("order-attach-wash-certificate", {
|
||||||
icon: "fas fa-paperclip",
|
icon: "fas fa-paperclip",
|
||||||
@@ -1661,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()) {
|
if (props.invoice_collection_id && SessionUser.canAccessSuperUser()) {
|
||||||
const invoiceCollectionLinkSection = buildMenuSection(
|
const invoiceCollectionLinkSection = buildMenuSection(
|
||||||
"invoice-collection-link",
|
"invoice-collection-link",
|
||||||
@@ -1962,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(
|
const vehicleSection = buildMenuSection(
|
||||||
"vehicle",
|
"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.multiple
|
||||||
: SessionUser.objects.vehicles.meta.labels.single,
|
: SessionUser.objects.vehicles.meta.labels.single,
|
||||||
[
|
[
|
||||||
@@ -1983,6 +2259,13 @@ const flatBuiltInMenuSections = computed(() => {
|
|||||||
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_2, true),
|
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_2, true),
|
||||||
})
|
})
|
||||||
: null,
|
: 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,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2252,11 +2535,13 @@ const syncDesktopFlyoutPosition = () => {
|
|||||||
dropdownContentEl.style.top = "";
|
dropdownContentEl.style.top = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const padding = 12;
|
const viewportInsets = getViewportInsets();
|
||||||
|
const viewportTop = viewportInsets.top;
|
||||||
|
const viewportBottom = viewportHeight - viewportInsets.bottom;
|
||||||
let top = triggerRect.bottom;
|
let top = triggerRect.bottom;
|
||||||
|
|
||||||
if (top + contentHeight > viewportHeight - padding) {
|
if (top + contentHeight > viewportBottom) {
|
||||||
top = Math.max(padding, triggerRect.top - contentHeight);
|
top = Math.max(viewportTop, triggerRect.top - contentHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextFixedStyles = {
|
const nextFixedStyles = {
|
||||||
@@ -2477,7 +2762,7 @@ const syncDesktopFlyoutPosition = () => {
|
|||||||
{{ t("admin.pos.attachments_office_preview_unavailable") }}
|
{{ t("admin.pos.attachments_office_preview_unavailable") }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="activeAttachmentPreviewKind === 'text'" class="action-settings-wheel-attachment-panel__text">
|
<span v-else-if="activeAttachmentPreviewKind === 'text'" class="action-settings-wheel-attachment-panel__text">
|
||||||
{{ activeAttachment.content?.other }}
|
{{ formatAttachmentText(activeAttachment) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="action-settings-wheel-attachment-panel__text">
|
<span v-else class="action-settings-wheel-attachment-panel__text">
|
||||||
{{ t("admin.pos.attachments_no_preview") }}
|
{{ t("admin.pos.attachments_no_preview") }}
|
||||||
@@ -2867,6 +3152,7 @@ const syncDesktopFlyoutPosition = () => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: #4a5568;
|
color: #4a5568;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-settings-wheel-attachment-panel__link {
|
.action-settings-wheel-attachment-panel__link {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ const props = defineProps({
|
|||||||
icon: String,
|
icon: String,
|
||||||
label: String,
|
label: String,
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
|
testId: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
template: String // The style of the button (default, danger, success, warning, info, light)
|
template: String // The style of the button (default, danger, success, warning, info, light)
|
||||||
});
|
});
|
||||||
const emit = defineEmits(['selected']);
|
const emit = defineEmits(['selected']);
|
||||||
@@ -131,6 +135,7 @@ const getLabelColor = () => {
|
|||||||
@click.stop.prevent="click"
|
@click.stop.prevent="click"
|
||||||
:class="{'is-disabled': isDisabled()}"
|
:class="{'is-disabled': isDisabled()}"
|
||||||
:disabled="isDisabled()"
|
:disabled="isDisabled()"
|
||||||
|
:data-testid="props.testId || undefined"
|
||||||
>
|
>
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i :class="getIcon() + ' ' + getIconColor()"></i>
|
<i :class="getIcon() + ' ' + getIconColor()"></i>
|
||||||
|
|||||||
@@ -415,7 +415,7 @@ const handleShortcutSelection = (event) => {
|
|||||||
:disabled="props.isDisabled || props.isReadonly"
|
:disabled="props.isDisabled || props.isReadonly"
|
||||||
@change="handleShortcutSelection"
|
@change="handleShortcutSelection"
|
||||||
>
|
>
|
||||||
<option value="" disabled>Vaelg periode</option>
|
<option value="" disabled>Vælg periode</option>
|
||||||
<option v-for="shortcut in shortcuts" :key="shortcut.label" :value="shortcut.label">
|
<option v-for="shortcut in shortcuts" :key="shortcut.label" :value="shortcut.label">
|
||||||
{{ shortcut.label }}
|
{{ shortcut.label }}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {computed, onMounted, ref, watch} from 'vue';
|
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||||
import {BSwitch} from "buefy";
|
import {BSwitch} from "buefy";
|
||||||
@@ -18,6 +18,39 @@ const selfServeEnabled = ref(false);
|
|||||||
const isLoadingSelfServeEnabled = ref(false);
|
const isLoadingSelfServeEnabled = ref(false);
|
||||||
const isSavingSelfServeEnabled = ref(false);
|
const isSavingSelfServeEnabled = ref(false);
|
||||||
const bookingsCount = ref(0);
|
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 = () => {
|
const getDepartmentId = () => {
|
||||||
return parseInt(router.currentRoute.value.params.departmentId);
|
return parseInt(router.currentRoute.value.params.departmentId);
|
||||||
@@ -190,13 +223,13 @@ const getSelfServeStatus = async () => {
|
|||||||
const departmentId = getDepartmentId();
|
const departmentId = getDepartmentId();
|
||||||
if (!departmentId) return;
|
if (!departmentId) return;
|
||||||
|
|
||||||
isLoadingSelfServeEnabled.value = true;
|
setSelfServeLoading(true);
|
||||||
try {
|
try {
|
||||||
selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId);
|
selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch self-serve status", error);
|
console.error("Failed to fetch self-serve status", error);
|
||||||
} finally {
|
} finally {
|
||||||
isLoadingSelfServeEnabled.value = false;
|
setSelfServeLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,6 +237,10 @@ onMounted(() => {
|
|||||||
getSelfServeStatus();
|
getSelfServeStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearSelfServeLoadingTimer();
|
||||||
|
});
|
||||||
|
|
||||||
// Watch the departmentId
|
// Watch the departmentId
|
||||||
watch(() => router.currentRoute.value.params.departmentId, () => {
|
watch(() => router.currentRoute.value.params.departmentId, () => {
|
||||||
getTodaysBookings();
|
getTodaysBookings();
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { watch } from 'vue';
|
import { watch } from 'vue';
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { getCurrentStep, setDepartment, getOrderId, setStep, setOrderId, searchAndSelectCustomer, loadOrderItems } from "@/components/shop/POSDepartmentProcess.vue";
|
import {
|
||||||
|
clearActivePosOrderContext,
|
||||||
|
getCurrentStep,
|
||||||
|
setDepartment,
|
||||||
|
getOrderId,
|
||||||
|
setStep,
|
||||||
|
setOrderId,
|
||||||
|
searchAndSelectCustomer,
|
||||||
|
loadOrderItems,
|
||||||
|
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||||
import PosDepartmentStep1 from "@/components/displays/department/pos/steps/PosDepartmentStep1.vue";
|
import PosDepartmentStep1 from "@/components/displays/department/pos/steps/PosDepartmentStep1.vue";
|
||||||
import PosDepartmentStep2 from "@/components/displays/department/pos/steps/PosDepartmentStep2.vue";
|
import PosDepartmentStep2 from "@/components/displays/department/pos/steps/PosDepartmentStep2.vue";
|
||||||
import PosDepartmentStep3 from "@/components/displays/department/pos/steps/PosDepartmentStep3.vue";
|
import PosDepartmentStep3 from "@/components/displays/department/pos/steps/PosDepartmentStep3.vue";
|
||||||
@@ -41,20 +50,33 @@ const debugVehicles = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
setDepartment();
|
|
||||||
|
|
||||||
watch(() => router.currentRoute.value.params.departmentId, (nextDepartmentId) => {
|
const routeStateHandlers = {
|
||||||
if (nextDepartmentId) {
|
|
||||||
setDepartment(nextDepartmentId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
applyPosRouteSearch(window.location.search, {
|
|
||||||
setOrderId,
|
setOrderId,
|
||||||
loadOrderItems,
|
loadOrderItems,
|
||||||
setStep,
|
setStep,
|
||||||
searchAndSelectCustomer,
|
searchAndSelectCustomer,
|
||||||
});
|
clearActivePosOrderContext,
|
||||||
|
resetMobilePos: () => pos.reset.pos(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRouteSearch = (route) => {
|
||||||
|
const fullPath = route?.fullPath || "";
|
||||||
|
const queryIndex = fullPath.indexOf("?");
|
||||||
|
return queryIndex >= 0 ? fullPath.slice(queryIndex) : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyCurrentPosRoute = () => {
|
||||||
|
const currentRoute = router.currentRoute.value;
|
||||||
|
if (currentRoute.params.departmentId) {
|
||||||
|
setDepartment(currentRoute.params.departmentId);
|
||||||
|
} else {
|
||||||
|
setDepartment();
|
||||||
|
}
|
||||||
|
applyPosRouteSearch(getRouteSearch(currentRoute), routeStateHandlers);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(() => router.currentRoute.value.fullPath, applyCurrentPosRoute, { immediate: true });
|
||||||
|
|
||||||
/** Define the createOrder function */
|
/** Define the createOrder function */
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
selectPreferredStripeTerminalReaderId,
|
selectPreferredStripeTerminalReaderId,
|
||||||
STRIPE_TERMINAL_STATUS,
|
STRIPE_TERMINAL_STATUS,
|
||||||
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
|
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
|
||||||
|
import { normalizeStripeInvoice } from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
|
||||||
|
|
||||||
const POLLING_INTERVAL_MS = 5000;
|
const POLLING_INTERVAL_MS = 5000;
|
||||||
const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required';
|
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 paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
|
||||||
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(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 hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null);
|
||||||
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
|
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
|
||||||
const isStripeEmailInvoiceTerminalState = computed(() => {
|
const isStripeEmailInvoiceTerminalState = computed(() => {
|
||||||
@@ -202,7 +180,7 @@ const loadStripeInvoiceState = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await getOrder(props.order_id, true);
|
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;
|
stripeInvoice.value = nextInvoice;
|
||||||
if (nextInvoice) {
|
if (nextInvoice) {
|
||||||
emailPanelState.value = 'tracking';
|
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 acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
|
||||||
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
|
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
|
||||||
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
|
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({
|
const props = defineProps({
|
||||||
order: {
|
order: {
|
||||||
@@ -324,7 +334,21 @@ const hasCachedPreviewSource = (attachmentId) => {
|
|||||||
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, 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) {
|
if (!downloadLink) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -340,7 +364,12 @@ const createEmbeddablePreviewUrl = async (downloadLink) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const objectUrl = URL.createObjectURL(fileBlob);
|
const safePreviewBlob = createSafePreviewBlob(fileBlob, previewKind);
|
||||||
|
if (!safePreviewBlob) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(safePreviewBlob);
|
||||||
generatedObjectUrls.add(objectUrl);
|
generatedObjectUrls.add(objectUrl);
|
||||||
return objectUrl;
|
return objectUrl;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -372,7 +401,7 @@ const ensurePreviewSource = async (attachment) => {
|
|||||||
attachment.id,
|
attachment.id,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
const previewSource = await createEmbeddablePreviewUrl(downloadLink);
|
const previewSource = await createEmbeddablePreviewUrl(downloadLink, previewKind);
|
||||||
previewSourcesById.value = {
|
previewSourcesById.value = {
|
||||||
...previewSourcesById.value,
|
...previewSourcesById.value,
|
||||||
[attachment.id]: previewSource,
|
[attachment.id]: previewSource,
|
||||||
@@ -517,6 +546,7 @@ const toggleDropdown = async () => {
|
|||||||
:src="activePreviewSource"
|
:src="activePreviewSource"
|
||||||
class="order-attachments-preview-panel__document"
|
class="order-attachments-preview-panel__document"
|
||||||
title="Attachment preview"
|
title="Attachment preview"
|
||||||
|
sandbox
|
||||||
></iframe>
|
></iframe>
|
||||||
<a
|
<a
|
||||||
v-else-if="activePreviewKind === 'link'"
|
v-else-if="activePreviewKind === 'link'"
|
||||||
|
|||||||
@@ -275,6 +275,10 @@ const getInvoiceCollectionResponseOrders = (response) => {
|
|||||||
return response.data.orders;
|
return response.data.orders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(response?.data?.data?.orders)) {
|
||||||
|
return response.data.data.orders;
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(response?.includes?.orders)) {
|
if (Array.isArray(response?.includes?.orders)) {
|
||||||
return response.includes.orders;
|
return response.includes.orders;
|
||||||
}
|
}
|
||||||
@@ -649,6 +653,41 @@ const getInvoiceCollectionDetails = (invoiceCollectionId) => {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
const getInvoiceCollectionDetailOrders = (invoiceCollectionId) => {
|
||||||
|
const details = getInvoiceCollectionDetails(invoiceCollectionId);
|
||||||
|
return details ? getInvoiceCollectionResponseOrders(details) : [];
|
||||||
|
};
|
||||||
|
const getHiddenOrdersByInvoiceCollection = (invoiceCollectionId) => {
|
||||||
|
const visibleOrderIds = new Set(getOrdersByInvoiceCollection(invoiceCollectionId).map((order) => Number(order?.id)));
|
||||||
|
return getInvoiceCollectionDetailOrders(invoiceCollectionId).filter((order) => !visibleOrderIds.has(Number(order?.id)));
|
||||||
|
};
|
||||||
|
const getHiddenOrderIdsByInvoiceCollection = (invoiceCollectionId) => (
|
||||||
|
getHiddenOrdersByInvoiceCollection(invoiceCollectionId).map((order) => Number(order?.id))
|
||||||
|
);
|
||||||
|
const getHiddenInvoiceCollectionFlags = (invoiceCollectionId) => {
|
||||||
|
const hiddenOrderIds = new Set(getHiddenOrderIdsByInvoiceCollection(invoiceCollectionId));
|
||||||
|
if (hiddenOrderIds.size === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
|
||||||
|
if (!isActiveInvoicePeriodFlag(flag)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetType = String(flag?.target_type || "");
|
||||||
|
if (!["order", "order_field", "order_item", "order_item_field"].includes(targetType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagOrderId = Number(
|
||||||
|
flag?.order_id
|
||||||
|
|| flag?.context?.order_id
|
||||||
|
|| (["order", "order_field"].includes(targetType) ? flag?.target_id : 0)
|
||||||
|
);
|
||||||
|
return flagOrderId > 0 && hiddenOrderIds.has(flagOrderId);
|
||||||
|
}));
|
||||||
|
};
|
||||||
watch(
|
watch(
|
||||||
[() => props.orders, () => props.excludedOrderIds, () => props.groupInvoiceCollection],
|
[() => props.orders, () => props.excludedOrderIds, () => props.groupInvoiceCollection],
|
||||||
() => {
|
() => {
|
||||||
@@ -999,6 +1038,13 @@ const formatCashierName = (order) => {
|
|||||||
}}
|
}}
|
||||||
er ikke vist, men vil muligvis blive faktureret alligevel.</span
|
er ikke vist, men vil muligvis blive faktureret alligevel.</span
|
||||||
>
|
>
|
||||||
|
<InvoicingPeriodFlagList
|
||||||
|
v-if="getHiddenInvoiceCollectionFlags(order.invoice_collection_id).length > 0"
|
||||||
|
class="mt-3"
|
||||||
|
compact
|
||||||
|
:flags="getHiddenInvoiceCollectionFlags(order.invoice_collection_id)"
|
||||||
|
@statusChanged="emitFlagStatusChanged"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -1260,7 +1306,12 @@ const formatCashierName = (order) => {
|
|||||||
v-bind:user_id="order.user_id"
|
v-bind:user_id="order.user_id"
|
||||||
v-bind:order_id="order.id"
|
v-bind:order_id="order.id"
|
||||||
v-bind:invoice_collection_id="order.invoice_collection_id"
|
v-bind:invoice_collection_id="order.invoice_collection_id"
|
||||||
|
v-bind:customer_number="order.customer_id"
|
||||||
|
v-bind:department_id="order.department_id"
|
||||||
v-bind:reg_1="order.reg_1"
|
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"
|
:refreshFunction="loadList"
|
||||||
@deleted="loadList()"
|
@deleted="loadList()"
|
||||||
@flag-created="emitFlagCreated"
|
@flag-created="emitFlagCreated"
|
||||||
@@ -1501,7 +1552,12 @@ const formatCashierName = (order) => {
|
|||||||
v-bind:user_id="order.user_id"
|
v-bind:user_id="order.user_id"
|
||||||
v-bind:order_id="order.id"
|
v-bind:order_id="order.id"
|
||||||
v-bind:invoice_collection_id="order.invoice_collection_id"
|
v-bind:invoice_collection_id="order.invoice_collection_id"
|
||||||
|
v-bind:customer_number="order.customer_id"
|
||||||
|
v-bind:department_id="order.department_id"
|
||||||
v-bind:reg_1="order.reg_1"
|
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"
|
:refreshFunction="loadList"
|
||||||
@deleted="loadList()"
|
@deleted="loadList()"
|
||||||
@flag-created="emitFlagCreated"
|
@flag-created="emitFlagCreated"
|
||||||
@@ -1992,6 +2048,7 @@ const formatCashierName = (order) => {
|
|||||||
v-bind:user_id="selectedOrderForActionsMenu.user_id"
|
v-bind:user_id="selectedOrderForActionsMenu.user_id"
|
||||||
v-bind:order_id="selectedOrderForActionsMenu.id"
|
v-bind:order_id="selectedOrderForActionsMenu.id"
|
||||||
v-bind:invoice_collection_id="selectedOrderForActionsMenu.invoice_collection_id"
|
v-bind:invoice_collection_id="selectedOrderForActionsMenu.invoice_collection_id"
|
||||||
|
v-bind:customer_number="selectedOrderForActionsMenu.customer_id"
|
||||||
v-bind:reg_1="selectedOrderForActionsMenu.reg_1"
|
v-bind:reg_1="selectedOrderForActionsMenu.reg_1"
|
||||||
v-bind:reg_2="selectedOrderForActionsMenu.reg_2"
|
v-bind:reg_2="selectedOrderForActionsMenu.reg_2"
|
||||||
v-bind:reg_3="selectedOrderForActionsMenu.reg_3"
|
v-bind:reg_3="selectedOrderForActionsMenu.reg_3"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
step,
|
step,
|
||||||
setDesktopStep1PreflightHandler,
|
setDesktopStep1PreflightHandler,
|
||||||
clearDesktopStep1PreflightHandler,
|
clearDesktopStep1PreflightHandler,
|
||||||
|
pushPosRouteState,
|
||||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||||
import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue";
|
import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue";
|
||||||
import { POS_STEP_1_VERSION } from "@/config.js";
|
import { POS_STEP_1_VERSION } from "@/config.js";
|
||||||
@@ -92,6 +93,7 @@ const duplicateDetailsExpanded = ref(false);
|
|||||||
const pendingNextResolution = ref(false);
|
const pendingNextResolution = ref(false);
|
||||||
const isDesktopLastWashCopying = ref(false);
|
const isDesktopLastWashCopying = ref(false);
|
||||||
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
|
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
|
||||||
|
let focusOnReg1TimeoutId = null;
|
||||||
const isDesktopStep1Active = computed(() => getCurrentStep() === 1);
|
const isDesktopStep1Active = computed(() => getCurrentStep() === 1);
|
||||||
|
|
||||||
const setTab = (tab) => {
|
const setTab = (tab) => {
|
||||||
@@ -117,7 +119,16 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const focusOnReg1 = () => {
|
const focusOnReg1 = () => {
|
||||||
setTimeout(() => {
|
if (focusOnReg1TimeoutId !== null) {
|
||||||
|
clearTimeout(focusOnReg1TimeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
focusOnReg1TimeoutId = setTimeout(() => {
|
||||||
|
focusOnReg1TimeoutId = null;
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const reg1Input = document.getElementById("reg_1");
|
const reg1Input = document.getElementById("reg_1");
|
||||||
if (reg1Input) {
|
if (reg1Input) {
|
||||||
reg1Input.focus();
|
reg1Input.focus();
|
||||||
@@ -267,12 +278,8 @@ const bookingSelectionObjects = computed(() => {
|
|||||||
const contentSegments = [
|
const contentSegments = [
|
||||||
`${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`,
|
`${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("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`,
|
||||||
`${t("common.reference")}: ${
|
`${t("common.reference")}: ${getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")}`,
|
||||||
getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")
|
`${t("common.services")}: ${getOrderBookingServiceText(booking) || t("admin.pos.not_found")}`,
|
||||||
}`,
|
|
||||||
`${t("common.services")}: ${
|
|
||||||
getOrderBookingServiceText(booking) || t("admin.pos.not_found")
|
|
||||||
}`,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -297,7 +304,7 @@ const bookingSelectionObjects = computed(() => {
|
|||||||
const duplicateDetailsObjects = computed(() => {
|
const duplicateDetailsObjects = computed(() => {
|
||||||
return duplicateOrders.value.map((order) => ({
|
return duplicateOrders.value.map((order) => ({
|
||||||
id: Number(order.id),
|
id: Number(order.id),
|
||||||
label: `${t("common.order")} #${order.id} - ${formatDuplicateOrderDate(order.created_at)}`,
|
label: `${t("admin.pos.order")} #${order.id} - ${formatDuplicateOrderDate(order.created_at)}`,
|
||||||
content: getDuplicateOrderContent(order),
|
content: getDuplicateOrderContent(order),
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
@@ -639,7 +646,7 @@ const pushDesktopStepTwoRoute = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
|
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDesktopLastWashCopy = async (payload = {}) => {
|
const handleDesktopLastWashCopy = async (payload = {}) => {
|
||||||
@@ -681,6 +688,10 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
if (focusOnReg1TimeoutId !== null) {
|
||||||
|
clearTimeout(focusOnReg1TimeoutId);
|
||||||
|
focusOnReg1TimeoutId = null;
|
||||||
|
}
|
||||||
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
|
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -750,13 +761,9 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
<div class="pos-shell-actions">
|
<div class="pos-shell-actions">
|
||||||
<ButtonsBox class="pos-actions pos-actions--stacked">
|
<ButtonsBox class="pos-actions pos-actions--stacked">
|
||||||
<Cancel tabindex="2" class="is-fullwidth" />
|
<Cancel tabindex="5" class="is-fullwidth" />
|
||||||
</ButtonsBox>
|
</ButtonsBox>
|
||||||
<div
|
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail">
|
||||||
v-if="shouldShowActionRailControls"
|
|
||||||
class="pos-shell-actions__rail"
|
|
||||||
data-testid="pos-step-1-action-rail"
|
|
||||||
>
|
|
||||||
<PosDesktopDuplicateWarning
|
<PosDesktopDuplicateWarning
|
||||||
v-if="shouldShowDuplicateWarningInActionRail"
|
v-if="shouldShowDuplicateWarningInActionRail"
|
||||||
:title="t('admin.pos.warning')"
|
:title="t('admin.pos.warning')"
|
||||||
|
|||||||
@@ -24,70 +24,79 @@ import {
|
|||||||
} from "./objects/PosDepartmentStepMobileFlow.vue";
|
} from "./objects/PosDepartmentStepMobileFlow.vue";
|
||||||
import { PosSearchResult } from "./objects/PosSearchResult.vue";
|
import { PosSearchResult } from "./objects/PosSearchResult.vue";
|
||||||
import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.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 PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||||
import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.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 PosDepartmentStepMobile1Debug from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue";
|
||||||
import PosDepartmentStepMobileAttachments from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.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 PosDepartmentStep1MobileTransactionHistory from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue";
|
||||||
import { attachments } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.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
|
// Debug mode flag
|
||||||
const debug_mode = ref(false);
|
const debug_mode = ref(false);
|
||||||
// Debug array to store request results
|
// Debug array to store request results
|
||||||
const debug_request_results = ref([]);
|
const debug_request_results = ref<unknown[]>([]);
|
||||||
|
|
||||||
const lastParsedImage = ref(null);
|
type ParsedFrameFingerprint = {
|
||||||
const setLastCapturedImage = (image: string) => {
|
content: string | null;
|
||||||
camera.latestImage.value = image;
|
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 = {
|
type LPRResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
license_plate_number: string;
|
license_plate_number: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const latestLPRResponse = ref<LPRResponse | null>(null);
|
type LPRScanContext = {
|
||||||
const LPR_IMAGE_MAX_WIDTH = 1280;
|
activeVehicleIndex: number;
|
||||||
const LPR_IMAGE_MAX_HEIGHT = 720;
|
attachmentView: boolean;
|
||||||
const LPR_IMAGE_JPEG_QUALITY = 0.72;
|
manualInput: boolean;
|
||||||
|
registrationNumbers: string[];
|
||||||
const compressImageForLPR = (image: string): Promise<string> => {
|
transactionHistoryView: boolean;
|
||||||
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;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = () => {
|
const activeVehicleIndexNext = () => {
|
||||||
// Increment the active vehicle index, wrapping around if necessary
|
// Increment the active vehicle index, wrapping around if necessary
|
||||||
if (vehicles.activeVehicleIndex.value < 3) {
|
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
|
// Check if the time since the last successful parse is enough
|
||||||
if (!camera.hasDelayAfterSuccessPassed()) {
|
if (!camera.hasDelayAfterSuccessPassed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (lastParsedImage.value === image) {
|
|
||||||
// If the image is the same as the last parsed one, skip parsing
|
isLPRFrameProcessing.value = true;
|
||||||
return;
|
try {
|
||||||
}
|
const clientPreflightStartedAt = nowMs();
|
||||||
lastParsedImage.value = image; // Update the last parsed image
|
const isDuplicateFrame = await shouldSkipDuplicateFrame(image);
|
||||||
camera.setLatestImage(image); // Update the latest image in the camera object
|
const clientPreflightDurationMs = Math.max(0, nowMs() - clientPreflightStartedAt);
|
||||||
// Function to parse the image data
|
|
||||||
const compressedImage = await compressImageForLPR(image);
|
if (shouldSkipLPRForCurrentState()) {
|
||||||
SessionUser.request("/modules/scanner/lpr", "POST", {
|
return;
|
||||||
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) {
|
if (debug_mode.value) {
|
||||||
debug_request_results.value.push(response);
|
debug_request_results.value.push(response);
|
||||||
}
|
}
|
||||||
// If the response is not successful, stop here.
|
|
||||||
if (!response.data.success) {
|
if (!isSameLPRScanContext(requestScanContext, getLPRScanContext())) {
|
||||||
return;
|
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;
|
latestLPRResponse.value = response.data.data as LPRResponse;
|
||||||
// Set the last successful capture time
|
// Set the last successful capture time
|
||||||
camera.setLastSuccess();
|
camera.setLastSuccess();
|
||||||
|
scheduleSuccessCooldown();
|
||||||
// Handle parsed result.
|
// Handle parsed result.
|
||||||
handleLPRResult();
|
handleLPRResult();
|
||||||
})
|
} catch (error) {
|
||||||
.catch((error) => {
|
if (isAbortError(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (debug_mode.value) {
|
if (debug_mode.value) {
|
||||||
debug_request_results.value.push(error);
|
debug_request_results.value.push(error);
|
||||||
}
|
}
|
||||||
|
markLastParsedImageOutcome("miss");
|
||||||
|
scheduleNoPlateBackoff();
|
||||||
//console.error("Error parsing image:", error);
|
//console.error("Error parsing image:", error);
|
||||||
});
|
} finally {
|
||||||
|
if (lprRequestAbortController === abortController) {
|
||||||
|
lprRequestAbortController = null;
|
||||||
|
}
|
||||||
|
isLPRRequestInFlight.value = false;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLPRFrameProcessing.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(manualInput, (newValue) => {
|
watch(manualInput, (newValue) => {
|
||||||
// Update the header transparency when manualInput changes
|
// Update the header transparency when manualInput changes
|
||||||
setTransparency(!newValue);
|
setTransparency(!newValue);
|
||||||
});
|
});
|
||||||
type statusIcon =
|
|
||||||
| typeof VerifiedCustomer
|
|
||||||
| typeof KnownCustomer
|
|
||||||
| typeof UnknownCustomer
|
|
||||||
| typeof CardPaymentCustomer
|
|
||||||
| typeof BookedCustomer;
|
|
||||||
|
|
||||||
const registrationNumbers = computed(() => {
|
const registrationNumbers = computed(() => {
|
||||||
// Return the registration numbers of all vehicles
|
// Return the registration numbers of all vehicles
|
||||||
return [
|
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) {
|
function getSearchResultIndex(object: PosSearchResult) {
|
||||||
let targetIndex = vehicles.activeVehicleIndex.value; // Default to the current active vehicle index
|
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)
|
// 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 : "";
|
return activeVehicle ? activeVehicle.reg : "";
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeSelectionHasRegistrationNumber = computed(() => {
|
|
||||||
return vehicles.getActiveVehicle()?.reg && vehicles.getActiveVehicle()?.reg.length > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const shouldCustomerBeModified = computed(() => {
|
const shouldCustomerBeModified = computed(() => {
|
||||||
// Check if the customer should be modified based on the active vehicle index
|
// Check if the customer should be modified based on the active vehicle index
|
||||||
return vehicles.activeVehicleIndex.value === 1;
|
return vehicles.activeVehicleIndex.value === 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
document.addEventListener("visibilitychange", handleDocumentVisibilityChange);
|
||||||
// Set the header to be transparent
|
// Set the header to be transparent
|
||||||
setTransparency(!views.isAnyActive.value);
|
setTransparency(!views.isAnyActive.value);
|
||||||
setBackgroundColor(backgroundColors.default); // Set the default background color
|
setBackgroundColor(backgroundColors.default); // Set the default background color
|
||||||
setOverflow(false); // Prevent scrolling
|
setOverflow(false); // Prevent scrolling
|
||||||
});
|
});
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener("visibilitychange", handleDocumentVisibilityChange);
|
||||||
|
abortLPRRequest();
|
||||||
|
clearNoPlateBackoff();
|
||||||
|
clearDuplicateFrameBackoff();
|
||||||
|
clearSuccessCooldown();
|
||||||
// Reset the header settings when the component is unmounted
|
// Reset the header settings when the component is unmounted
|
||||||
setTransparency(false); // Reset transparency
|
setTransparency(false); // Reset transparency
|
||||||
setBackgroundColor(backgroundColors.default); // Reset to default background color
|
setBackgroundColor(backgroundColors.default); // Reset to default background color
|
||||||
setOverflow(true); // Allow scrolling again
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -263,7 +765,16 @@ onUnmounted(() => {
|
|||||||
<!-- Default: Scanner view -->
|
<!-- Default: Scanner view -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="background-fixed">
|
<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>
|
||||||
<div class="custom-content" data-testid="pos-mobile-step-1">
|
<div class="custom-content" data-testid="pos-mobile-step-1">
|
||||||
<!-- Meta objects, registration number auto-lookup -->
|
<!-- Meta objects, registration number auto-lookup -->
|
||||||
@@ -289,7 +800,13 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
<!-- Scanner outline object -->
|
<!-- Scanner outline object -->
|
||||||
<div class="is-align-content-center is-flex is-justify-content-center">
|
<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>
|
</div>
|
||||||
<!-- Reg. 1, Reg. 2, Reg. 3 -->
|
<!-- Reg. 1, Reg. 2, Reg. 3 -->
|
||||||
<div
|
<div
|
||||||
@@ -303,7 +820,10 @@ onUnmounted(() => {
|
|||||||
<!-- Location -->
|
<!-- Location -->
|
||||||
<PosDepartmentStepMobile1Location />
|
<PosDepartmentStepMobile1Location />
|
||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
|
<PosDepartmentStepMobileFixedBottomControl
|
||||||
|
variant="pos-step"
|
||||||
|
:use-backdrop-blur="views.attachmentView.value"
|
||||||
|
>
|
||||||
<!-- Attachments -->
|
<!-- Attachments -->
|
||||||
<div class="is-flex is-justify-content-center">
|
<div class="is-flex is-justify-content-center">
|
||||||
<PosDepartmentStepMobileAttachments :showDefaultControls="false" v-show="views.attachmentView.value" />
|
<PosDepartmentStepMobileAttachments :showDefaultControls="false" v-show="views.attachmentView.value" />
|
||||||
@@ -429,4 +949,10 @@ onUnmounted(() => {
|
|||||||
.custom-content > * {
|
.custom-content > * {
|
||||||
width: min(100%, 48rem);
|
width: min(100%, 48rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scanner-focus-target {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ import PosDepartmentStepMobile2AdditionalItems from "@/components/displays/depar
|
|||||||
import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
|
import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
|
||||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||||
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
|
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Set the header to be transparent
|
// Set the header to be transparent
|
||||||
@@ -477,6 +480,8 @@ const updateLastOrderItemPrices = (items: PosOrderItem[]) => {
|
|||||||
const layout = {
|
const layout = {
|
||||||
classes: <string[]>[],
|
classes: <string[]>[],
|
||||||
};
|
};
|
||||||
|
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||||
|
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
|
||||||
|
|
||||||
const onCopyLastOrder = (vehicleIndex: number) => {
|
const onCopyLastOrder = (vehicleIndex: number) => {
|
||||||
lastOrders.select(vehicleIndex);
|
lastOrders.select(vehicleIndex);
|
||||||
@@ -777,15 +782,18 @@ const buildDesiredOrderItemShapes = () => {
|
|||||||
|
|
||||||
const addonShapes = (transactionItems.primaryItem.value.addons || [])
|
const addonShapes = (transactionItems.primaryItem.value.addons || [])
|
||||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||||
.map((addon: any) => ({
|
.map((addon: any) => {
|
||||||
kind: "addon",
|
const addonProduct = addon?.product ?? addon;
|
||||||
relatedKey: "primary",
|
return {
|
||||||
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
|
kind: "addon",
|
||||||
quantity: Number(addon?.quantity ?? 0),
|
relatedKey: "primary",
|
||||||
related_item_id: "__PRIMARY__",
|
product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
|
||||||
price: Number(addon?.product?.price ?? addon?.price ?? 0),
|
quantity: Number(addon?.quantity ?? 0),
|
||||||
notes: String(addon?.product?.notes ?? ""),
|
related_item_id: "__PRIMARY__",
|
||||||
}));
|
price: Number(addonProduct?.price ?? addon?.price ?? 0),
|
||||||
|
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const additionalShapes = (transactionItems.additionalItems.value || [])
|
const additionalShapes = (transactionItems.additionalItems.value || [])
|
||||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||||
@@ -952,16 +960,17 @@ const syncCurrentTransactionToOrder = async () => {
|
|||||||
|
|
||||||
const addonPromises = (transactionItems.primaryItem.value.addons || [])
|
const addonPromises = (transactionItems.primaryItem.value.addons || [])
|
||||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||||
.map((addon: any) =>
|
.map((addon: any) => {
|
||||||
createOrderItem(
|
const addonProduct = addon?.product ?? addon;
|
||||||
|
return createOrderItem(
|
||||||
normalizedOrderId,
|
normalizedOrderId,
|
||||||
addon.product.id,
|
addonProduct.id,
|
||||||
Number(addon.quantity),
|
Number(addon.quantity),
|
||||||
createdPrimaryItemId,
|
createdPrimaryItemId,
|
||||||
addon.product?.notes || "",
|
addonProduct?.notes || addon?.notes || "",
|
||||||
addon.product.price
|
addonProduct.price ?? addon.price
|
||||||
)
|
);
|
||||||
);
|
});
|
||||||
|
|
||||||
const additionalPromises = (transactionItems.additionalItems.value || [])
|
const additionalPromises = (transactionItems.additionalItems.value || [])
|
||||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||||
@@ -973,11 +982,146 @@ const syncCurrentTransactionToOrder = async () => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeText = (value: unknown) => String(value ?? "").trim();
|
||||||
|
|
||||||
|
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
|
||||||
|
|
||||||
|
const getProductId = (product: any) =>
|
||||||
|
Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
|
||||||
|
|
||||||
|
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
|
||||||
|
|
||||||
|
const productRequiresOrderItemNote = (product: any) => {
|
||||||
|
if (!product) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
|
||||||
|
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
|
||||||
|
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const productHasOrderItemNote = (product: any) =>
|
||||||
|
normalizeText(product?.notes ?? product?.product?.notes).length > 0;
|
||||||
|
|
||||||
|
const getSelectedProductsMissingRequiredNotes = () => {
|
||||||
|
const missingProducts: any[] = [];
|
||||||
|
const primaryProduct = transactionItems.primaryItem.value;
|
||||||
|
|
||||||
|
if (productRequiresOrderItemNote(primaryProduct) && !productHasOrderItemNote(primaryProduct)) {
|
||||||
|
missingProducts.push(primaryProduct);
|
||||||
|
}
|
||||||
|
|
||||||
|
(primaryProduct?.addons || [])
|
||||||
|
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||||
|
.forEach((addon: any) => {
|
||||||
|
const addonProduct = addon?.product ?? addon;
|
||||||
|
if (
|
||||||
|
(productRequiresOrderItemNote(addonProduct) || productRequiresOrderItemNote(addon)) &&
|
||||||
|
!productHasOrderItemNote(addonProduct) &&
|
||||||
|
!productHasOrderItemNote(addon)
|
||||||
|
) {
|
||||||
|
missingProducts.push(addonProduct);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(transactionItems.additionalItems.value || [])
|
||||||
|
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||||
|
.forEach((item: any) => {
|
||||||
|
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
|
||||||
|
missingProducts.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return missingProducts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const promptForRequiredProductNote = (product: any) =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
const hadOriginalNote = Object.prototype.hasOwnProperty.call(product, "notes");
|
||||||
|
const originalNote = product?.notes;
|
||||||
|
|
||||||
|
const resolveAndClose = (didConfirm: boolean) => {
|
||||||
|
if (!didConfirm) {
|
||||||
|
if (hadOriginalNote) {
|
||||||
|
product.notes = originalNote;
|
||||||
|
} else {
|
||||||
|
delete product.notes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (popups.get()?.id === "add_product_note") {
|
||||||
|
popups.clear();
|
||||||
|
}
|
||||||
|
resolve(didConfirm);
|
||||||
|
};
|
||||||
|
|
||||||
|
popups.select("add_product_note", {
|
||||||
|
title: `${t("common.note")}: ${getProductName(product) || `#${getProductId(product)}`}`,
|
||||||
|
message: t("objects.products.columns.requires_note"),
|
||||||
|
component: "add_product_note",
|
||||||
|
hideHeader: true,
|
||||||
|
style: { maxHeight: "40vh" },
|
||||||
|
props: {
|
||||||
|
product,
|
||||||
|
validationMessage: "",
|
||||||
|
},
|
||||||
|
actionButtons: [
|
||||||
|
{
|
||||||
|
label: t("common.confirm"),
|
||||||
|
description: t("common.confirm"),
|
||||||
|
color: "primary",
|
||||||
|
testId: "pos-mobile-product-note-confirm",
|
||||||
|
onClick: () => {
|
||||||
|
const activePopup = popups.get();
|
||||||
|
const normalizedNote = normalizeText(activePopup?.props?.product?.notes);
|
||||||
|
if (!normalizedNote) {
|
||||||
|
if (activePopup?.props) {
|
||||||
|
activePopup.props.validationMessage = t("objects.products.columns.requires_note");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
product.notes = normalizedNote;
|
||||||
|
resolveAndClose(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("common.cancel"),
|
||||||
|
description: t("common.cancel"),
|
||||||
|
color: "light",
|
||||||
|
testId: "pos-mobile-product-note-cancel",
|
||||||
|
onClick: () => resolveAndClose(false),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const ensureRequiredOrderItemNotes = async () => {
|
||||||
|
const missingProducts = getSelectedProductsMissingRequiredNotes();
|
||||||
|
for (const product of missingProducts) {
|
||||||
|
if (productHasOrderItemNote(product)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const didConfirm = await promptForRequiredProductNote(product);
|
||||||
|
if (!didConfirm) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const onBeforeComplete = async () => {
|
const onBeforeComplete = async () => {
|
||||||
if (!transactionItems.primaryItem.value) {
|
if (!transactionItems.primaryItem.value) {
|
||||||
throw new Error("No primary item selected");
|
throw new Error("No primary item selected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await ensureRequiredOrderItemNotes())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
await syncCurrentTransactionToOrder();
|
await syncCurrentTransactionToOrder();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
reg_1,
|
reg_1,
|
||||||
reset_all_values,
|
reset_all_values,
|
||||||
setStep,
|
setStep,
|
||||||
|
pushPosRouteState,
|
||||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { StripeModule } from "@/components/stripe/StripeModule.vue";
|
import { StripeModule } from "@/components/stripe/StripeModule.vue";
|
||||||
@@ -70,10 +71,10 @@ const formatCurrency = (amount) => {
|
|||||||
const navigateToItems = () => {
|
const navigateToItems = () => {
|
||||||
setStep(2);
|
setStep(2);
|
||||||
if (normalizedOrderId.value) {
|
if (normalizedOrderId.value) {
|
||||||
window.history.pushState({}, '', `?id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
|
pushPosRouteState(`id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.history.pushState({}, '', `?step=2`);
|
pushPosRouteState("step=2");
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSuccessfulMobileCardFlow = () => {
|
const clearSuccessfulMobileCardFlow = () => {
|
||||||
|
|||||||
+29
-11
@@ -2,25 +2,43 @@
|
|||||||
import { useGeolocation } from "@vueuse/core";
|
import { useGeolocation } from "@vueuse/core";
|
||||||
import { watch } from "vue";
|
import { watch } from "vue";
|
||||||
import { locations } from "../objects/PosDepartmentStepMobileFlow.vue";
|
import { locations } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||||
const emits = defineEmits<{
|
|
||||||
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
|
const props = withDefaults(defineProps<{
|
||||||
}>();
|
enableHighAccuracy?: boolean;
|
||||||
const { coords, locatedAt, error, resume, pause } = useGeolocation({
|
maximumAge?: number;
|
||||||
|
timeout?: number;
|
||||||
|
}>(), {
|
||||||
enableHighAccuracy: true,
|
enableHighAccuracy: true,
|
||||||
maximumAge: 30000,
|
maximumAge: 30000,
|
||||||
timeout: 27000,
|
timeout: 27000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { coords, locatedAt, error, resume, pause } = useGeolocation({
|
||||||
|
enableHighAccuracy: props.enableHighAccuracy,
|
||||||
|
maximumAge: props.maximumAge,
|
||||||
|
timeout: props.timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getLocationTimestamp = () => {
|
||||||
|
const timestamp = Number(locatedAt.value);
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => {
|
const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => {
|
||||||
if (newCoords.latitude && newCoords.longitude) {
|
const normalizedCoords = locations.normalizeCoordinatePair(newCoords);
|
||||||
|
if (normalizedCoords) {
|
||||||
|
const timestamp = getLocationTimestamp();
|
||||||
locations.set({
|
locations.set({
|
||||||
coords: {
|
coords: normalizedCoords,
|
||||||
latitude: newCoords.latitude,
|
timestamp: new Date(timestamp),
|
||||||
longitude: newCoords.longitude,
|
locatedAt: timestamp,
|
||||||
},
|
|
||||||
timestamp: new Date(),
|
|
||||||
errorMessage: error.value ? error.value.message : null,
|
errorMessage: error.value ? error.value.message : null,
|
||||||
})
|
})
|
||||||
emits('location-updated', newCoords);
|
emits('location-updated', normalizedCoords);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
watch(coords, (newCoords) => {
|
watch(coords, (newCoords) => {
|
||||||
|
|||||||
+60
-5
@@ -14,7 +14,7 @@ type attachment = {
|
|||||||
image: string | null;
|
image: string | null;
|
||||||
document: string | null;
|
document: string | null;
|
||||||
relation: string | null;
|
relation: string | null;
|
||||||
other: string | null;
|
other: unknown;
|
||||||
src: string | null; // For document preview (e.g., PDF URL) // THIS IS NEVER STORED, JUST FOR PREVIEW PURPOSES
|
src: string | null; // For document preview (e.g., PDF URL) // THIS IS NEVER STORED, JUST FOR PREVIEW PURPOSES
|
||||||
};
|
};
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -22,6 +22,8 @@ type attachment = {
|
|||||||
deleted_at: string | null;
|
deleted_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SELF_SERVE_WASH_ATTACHMENT_TYPE = 'SELF_SERVE_WASH';
|
||||||
|
|
||||||
const getAttachmentContent = (attachmentEntry: attachment) => {
|
const getAttachmentContent = (attachmentEntry: attachment) => {
|
||||||
if (!attachmentEntry.content) {
|
if (!attachmentEntry.content) {
|
||||||
return {
|
return {
|
||||||
@@ -43,7 +45,19 @@ const getAttachmentContent = (attachmentEntry: attachment) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getAttachmentOtherText = (attachmentEntry: attachment) => {
|
const getAttachmentOtherText = (attachmentEntry: attachment) => {
|
||||||
return getAttachmentContent(attachmentEntry).other || '';
|
const other = getAttachmentContent(attachmentEntry).other;
|
||||||
|
if (typeof other === 'string') {
|
||||||
|
return other;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelfServeWashAttachment(attachmentEntry)) {
|
||||||
|
const customerNumber = getSelfServeWashPayload(attachmentEntry)?.customer_number;
|
||||||
|
return customerNumber
|
||||||
|
? `${t('admin.pos.settings_wheel.self_serve_wash_attachment')} #${customerNumber}`
|
||||||
|
: t('admin.pos.settings_wheel.self_serve_wash_attachment');
|
||||||
|
}
|
||||||
|
|
||||||
|
return other && typeof other === 'object' ? JSON.stringify(other) : '';
|
||||||
};
|
};
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
attachments: {
|
attachments: {
|
||||||
@@ -79,6 +93,26 @@ const determineAttachmentType = (attachment: attachment): 'image' | 'document' |
|
|||||||
return 'unknown';
|
return 'unknown';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSelfServeWashPayload = (attachmentEntry: attachment): Record<string, any> | null => {
|
||||||
|
const other = getAttachmentContent(attachmentEntry).other;
|
||||||
|
return other && typeof other === 'object' && (other as Record<string, any>).type === SELF_SERVE_WASH_ATTACHMENT_TYPE
|
||||||
|
? other as Record<string, any>
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelfServeWashAttachment = (attachmentEntry: attachment): boolean => {
|
||||||
|
return getSelfServeWashPayload(attachmentEntry) !== null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSelfServeDriver = (payload: Record<string, any>): string => {
|
||||||
|
return payload.subuser?.name || payload.subuser?.username || (payload.subuser_id ? `#${payload.subuser_id}` : '-');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatElapsedMinutes = (seconds: unknown): string => {
|
||||||
|
const parsed = Number(seconds);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? `${Math.ceil(parsed / 60)} min` : '-';
|
||||||
|
};
|
||||||
|
|
||||||
const getAttachmentTypeIcon = (attachment: attachment): string => {
|
const getAttachmentTypeIcon = (attachment: attachment): string => {
|
||||||
const type = determineAttachmentType(attachment);
|
const type = determineAttachmentType(attachment);
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -309,14 +343,35 @@ const onClickAttachWashCertificate = () => {
|
|||||||
</template>
|
</template>
|
||||||
<!-- OTHER PREVIEW -->
|
<!-- OTHER PREVIEW -->
|
||||||
<template v-else-if="determineAttachmentType(attachment) === 'other' && getAttachmentContent(attachment).other">
|
<template v-else-if="determineAttachmentType(attachment) === 'other' && getAttachmentContent(attachment).other">
|
||||||
|
<template v-if="isSelfServeWashAttachment(attachment)">
|
||||||
|
<div class="content is-size-7">
|
||||||
|
<p class="has-text-weight-semibold">{{ t('admin.pos.settings_wheel.self_serve_wash_attachment') }}</p>
|
||||||
|
<p>
|
||||||
|
<strong>{{ t('admin.pos.settings_wheel.self_serve_customer') }}:</strong>
|
||||||
|
#{{ getSelfServeWashPayload(attachment)?.customer_number || '-' }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>{{ t('admin.pos.settings_wheel.self_serve_driver') }}:</strong>
|
||||||
|
{{ formatSelfServeDriver(getSelfServeWashPayload(attachment) || {}) }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>{{ t('pos.license_plate') }}:</strong>
|
||||||
|
{{ getSelfServeWashPayload(attachment)?.license_plate || '-' }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>{{ t('admin.pos.settings_wheel.self_serve_elapsed') }}:</strong>
|
||||||
|
{{ formatElapsedMinutes(getSelfServeWashPayload(attachment)?.elapsed_wash_time_seconds) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<!-- If the other type is a URL, you can create a link -->
|
<!-- If the other type is a URL, you can create a link -->
|
||||||
<template v-if="getAttachmentContent(attachment).other.startsWith('http')">
|
<template v-else-if="typeof getAttachmentContent(attachment).other === 'string' && getAttachmentContent(attachment).other.startsWith('http')">
|
||||||
<a :href="getAttachmentContent(attachment).other" target="_blank" rel="noopener noreferrer">
|
<a :href="String(getAttachmentContent(attachment).other)" target="_blank" rel="noopener noreferrer">
|
||||||
{{ getAttachmentContent(attachment).other }}
|
{{ getAttachmentContent(attachment).other }}
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span>{{ getAttachmentContent(attachment).other }}</span>
|
<span>{{ getAttachmentOtherText(attachment) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<!-- NO PREVIEW -->
|
<!-- NO PREVIEW -->
|
||||||
|
|||||||
+14
-21
@@ -1,34 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
reset_all_values,
|
reset_all_values,
|
||||||
customer_name,
|
|
||||||
nextStep,
|
|
||||||
searchAndSelectCustomer,
|
|
||||||
isCustomerSelected,
|
|
||||||
order_id,
|
order_id,
|
||||||
customer_id,
|
getStoredPosOrderId,
|
||||||
step,
|
|
||||||
reg_1,
|
|
||||||
reg_2,
|
|
||||||
reg_3,
|
|
||||||
reference,
|
|
||||||
order_notes,
|
|
||||||
setDepartment,
|
|
||||||
getDepartment,
|
|
||||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||||
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
|
|
||||||
import { resetPos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
import { resetPos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||||
|
|
||||||
|
const toPositiveInteger = (value: unknown) => {
|
||||||
|
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||||
|
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||||
|
};
|
||||||
|
|
||||||
const onClickClearAll = async () => {
|
const onClickClearAll = async () => {
|
||||||
// 1. Delete the order (If any)
|
const storedOrderId = getStoredPosOrderId();
|
||||||
// Check localstorage for pos_order_id
|
const currentOrderId = toPositiveInteger(order_id.value);
|
||||||
if (localStorage.getItem("pos_order_id")) {
|
const activeDraftOrderId = storedOrderId && currentOrderId === storedOrderId ? storedOrderId : null;
|
||||||
order_id.value = parseInt(localStorage.getItem("pos_order_id") || "0");
|
|
||||||
}
|
// 1. Delete only the active mobile draft order, never a historical route-loaded order.
|
||||||
if (order_id.value) {
|
if (activeDraftOrderId) {
|
||||||
try {
|
try {
|
||||||
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(order_id.value);
|
order_id.value = activeDraftOrderId;
|
||||||
|
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(activeDraftOrderId);
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-10
@@ -299,14 +299,12 @@ const getSelectedBookingId = () => {
|
|||||||
|
|
||||||
const completeStep2Order = async ({
|
const completeStep2Order = async ({
|
||||||
bookingSafetySeal = null,
|
bookingSafetySeal = null,
|
||||||
markOrderCompleted = false,
|
|
||||||
}: {
|
}: {
|
||||||
bookingSafetySeal?: string | null;
|
bookingSafetySeal?: string | null;
|
||||||
markOrderCompleted?: boolean;
|
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
await finalizeCurrentMobileOrder({
|
await finalizeCurrentMobileOrder({
|
||||||
bookingSafetySeal,
|
bookingSafetySeal,
|
||||||
markOrderCompleted,
|
markOrderCompleted: true,
|
||||||
});
|
});
|
||||||
popups.select("completed_transaction", {
|
popups.select("completed_transaction", {
|
||||||
message: `Order #${order_id.value} successfully created.`,
|
message: `Order #${order_id.value} successfully created.`,
|
||||||
@@ -327,14 +325,12 @@ const step2 = async () => {
|
|||||||
const resolvedSafetySeal = getResolvedMobileSafetySeal();
|
const resolvedSafetySeal = getResolvedMobileSafetySeal();
|
||||||
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
|
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
|
||||||
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
|
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
|
||||||
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
|
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
|
||||||
syncMobileSafetySealState(resolvedSafetySeal);
|
syncMobileSafetySealState(resolvedSafetySeal);
|
||||||
await completeStep2Order({
|
await completeStep2Order({
|
||||||
bookingSafetySeal: resolvedSafetySeal,
|
bookingSafetySeal: resolvedSafetySeal,
|
||||||
markOrderCompleted: false,
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -344,7 +340,6 @@ const step2 = async () => {
|
|||||||
syncMobileSafetySealState(safetySeal);
|
syncMobileSafetySealState(safetySeal);
|
||||||
await completeStep2Order({
|
await completeStep2Order({
|
||||||
bookingSafetySeal: String(safetySeal ?? ""),
|
bookingSafetySeal: String(safetySeal ?? ""),
|
||||||
markOrderCompleted: false,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -358,9 +353,7 @@ const step2 = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await completeStep2Order({
|
await completeStep2Order();
|
||||||
markOrderCompleted: shouldMarkOrderAsCompleted,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
errors.value.push(error);
|
errors.value.push(error);
|
||||||
console.warn("An error occurred while completing the mobile order:", error);
|
console.warn("An error occurred while completing the mobile order:", error);
|
||||||
@@ -412,7 +405,10 @@ const onClick = async () => {
|
|||||||
|
|
||||||
isProcessingClick.value = true;
|
isProcessingClick.value = true;
|
||||||
try {
|
try {
|
||||||
await props.onBeforeStep();
|
const beforeStepResult = await props.onBeforeStep();
|
||||||
|
if (beforeStepResult === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Proceed to the next step
|
// Proceed to the next step
|
||||||
switch (step.value) {
|
switch (step.value) {
|
||||||
case 1:
|
case 1:
|
||||||
|
|||||||
+13
-1
@@ -11,6 +11,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
useBackdropBlur: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
variant: {
|
variant: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'default',
|
default: 'default',
|
||||||
@@ -25,6 +29,13 @@ const props = defineProps({
|
|||||||
const FIXED_BOTTOM_HEIGHT_CSS_VAR = '--pos-mobile-fixed-bottom-height';
|
const FIXED_BOTTOM_HEIGHT_CSS_VAR = '--pos-mobile-fixed-bottom-height';
|
||||||
|
|
||||||
const style = computed(() => {
|
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
|
// Smooth blur transition
|
||||||
if (props.smoothBlur) {
|
if (props.smoothBlur) {
|
||||||
return {
|
return {
|
||||||
@@ -32,12 +43,13 @@ const style = computed(() => {
|
|||||||
WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`,
|
WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`,
|
||||||
transition: 'backdrop-filter 0.3s ease, -webkit-backdrop-filter 0.3s ease',
|
transition: 'backdrop-filter 0.3s ease, -webkit-backdrop-filter 0.3s ease',
|
||||||
// Add gradient from bottom
|
// 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
|
// Add gradient from top
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
background,
|
||||||
backdropFilter: `blur(${props.blurAmount * 10}px)`,
|
backdropFilter: `blur(${props.blurAmount * 10}px)`,
|
||||||
WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`
|
WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -9,6 +9,9 @@ const product = props.product;
|
|||||||
const note = ref(product.notes || "");
|
const note = ref(product.notes || "");
|
||||||
watch(note, (newNote) => {
|
watch(note, (newNote) => {
|
||||||
product.notes = newNote;
|
product.notes = newNote;
|
||||||
|
if (props) {
|
||||||
|
props.validationMessage = "";
|
||||||
|
}
|
||||||
// If the note is empty, remove it from the product
|
// If the note is empty, remove it from the product
|
||||||
if (newNote === "") {
|
if (newNote === "") {
|
||||||
delete product.notes;
|
delete product.notes;
|
||||||
@@ -25,9 +28,13 @@ watch(note, (newNote) => {
|
|||||||
class="input is-searched"
|
class="input is-searched"
|
||||||
v-model="note"
|
v-model="note"
|
||||||
type="text"
|
type="text"
|
||||||
|
data-testid="pos-mobile-product-note-input"
|
||||||
placeholder="Indtast note"
|
placeholder="Indtast note"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="props?.validationMessage" class="help is-danger mt-2">
|
||||||
|
{{ props.validationMessage }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -125,4 +132,4 @@ input.is-searched {
|
|||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+83
-9
@@ -100,18 +100,57 @@ const getLocation = () => {
|
|||||||
const clearLocation = () => {
|
const clearLocation = () => {
|
||||||
location.value = null;
|
location.value = null;
|
||||||
};
|
};
|
||||||
|
type CoordinatePair = {
|
||||||
|
latitude?: number | string | null;
|
||||||
|
longitude?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeCoordinatePair = (
|
||||||
|
coordinates: CoordinatePair | null | undefined,
|
||||||
|
{ allowZeroPair = true }: { allowZeroPair?: boolean } = {}
|
||||||
|
): { latitude: number; longitude: number } | null => {
|
||||||
|
const latitude = Number(coordinates?.latitude);
|
||||||
|
const longitude = Number(coordinates?.longitude);
|
||||||
|
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowZeroPair && latitude === 0 && longitude === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { latitude, longitude };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasValidCoordinatePair = (
|
||||||
|
coordinates: CoordinatePair | null | undefined,
|
||||||
|
options: { allowZeroPair?: boolean } = {}
|
||||||
|
): boolean => normalizeCoordinatePair(coordinates, options) !== null;
|
||||||
|
|
||||||
// Get distance in kilometers between two locations
|
// Get distance in kilometers between two locations
|
||||||
const getDistance = (
|
const getDistance = (
|
||||||
from: { latitude: number; longitude: number },
|
from: CoordinatePair,
|
||||||
to: { latitude: number; longitude: number }
|
to: CoordinatePair
|
||||||
): number => {
|
): number => {
|
||||||
|
const normalizedFrom = normalizeCoordinatePair(from);
|
||||||
|
const normalizedTo = normalizeCoordinatePair(to);
|
||||||
|
|
||||||
|
if (!normalizedFrom || !normalizedTo) {
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
const toRad = (value: number) => (value * Math.PI) / 180;
|
const toRad = (value: number) => (value * Math.PI) / 180;
|
||||||
|
|
||||||
const R = 6371; // Radius of the Earth in kilometers
|
const R = 6371; // Radius of the Earth in kilometers
|
||||||
const dLat = toRad(to.latitude - from.latitude);
|
const dLat = toRad(normalizedTo.latitude - normalizedFrom.latitude);
|
||||||
const dLon = toRad(to.longitude - from.longitude);
|
const dLon = toRad(normalizedTo.longitude - normalizedFrom.longitude);
|
||||||
const lat1 = toRad(from.latitude);
|
const lat1 = toRad(normalizedFrom.latitude);
|
||||||
const lat2 = toRad(to.latitude);
|
const lat2 = toRad(normalizedTo.latitude);
|
||||||
|
|
||||||
const a =
|
const a =
|
||||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
|
||||||
@@ -124,6 +163,8 @@ const locations = {
|
|||||||
set: setLocation,
|
set: setLocation,
|
||||||
get: getLocation,
|
get: getLocation,
|
||||||
clear: clearLocation,
|
clear: clearLocation,
|
||||||
|
normalizeCoordinatePair,
|
||||||
|
hasValidCoordinatePair,
|
||||||
getDistance,
|
getDistance,
|
||||||
defaultTimeout: locationTimeout,
|
defaultTimeout: locationTimeout,
|
||||||
};
|
};
|
||||||
@@ -788,7 +829,15 @@ const promptForNotesIfRequired = (product: PosProduct, callback: (notes: string)
|
|||||||
label: "Bekræft",
|
label: "Bekræft",
|
||||||
description: "Bekræft noten og fortsæt",
|
description: "Bekræft noten og fortsæt",
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
callback(popups.get()?.props?.product?.notes || "");
|
const activePopup = popups.get();
|
||||||
|
const note = String(activePopup?.props?.product?.notes || "").trim();
|
||||||
|
if (!note) {
|
||||||
|
if (activePopup?.props) {
|
||||||
|
activePopup.props.validationMessage = "Note er påkrævet for dette produkt";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(note);
|
||||||
clearPopup();
|
clearPopup();
|
||||||
},
|
},
|
||||||
color: "primary",
|
color: "primary",
|
||||||
@@ -1016,9 +1065,9 @@ const getTotalAttachmentsCount = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
// Function to take a picture as a base64 attachment
|
// Function to take a picture as a base64 attachment
|
||||||
const takePicture = () => {
|
const takePicture = async () => {
|
||||||
// Save the last picture to the base64 attachments
|
// Save the last picture to the base64 attachments
|
||||||
const lastPicture = latestImage.value;
|
const lastPicture = await getLatestImage();
|
||||||
if (lastPicture) {
|
if (lastPicture) {
|
||||||
addAttachmentBase64({
|
addAttachmentBase64({
|
||||||
filename: "last_picture.jpg",
|
filename: "last_picture.jpg",
|
||||||
@@ -1069,6 +1118,7 @@ watch(
|
|||||||
/** Camera */
|
/** Camera */
|
||||||
// Define the reactive properties
|
// Define the reactive properties
|
||||||
const latestImage = ref<string | null>(null);
|
const latestImage = ref<string | null>(null);
|
||||||
|
const latestImageBlob = ref<Blob | null>(null);
|
||||||
const isCameraMounted = ref<boolean>(false);
|
const isCameraMounted = ref<boolean>(false);
|
||||||
const cameraImageCaptureDelayInitial = ref<number>(500); // Initial delay for camera image capture in milliseconds (First capture)
|
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)
|
const cameraImageCaptureDelaySubsequent = ref<number>(1005); // Further captures delay in milliseconds (Every capture after the first one)
|
||||||
@@ -1110,13 +1160,34 @@ const hasCameraImageCaptureDelayAfterSuccessPassed = (): boolean => {
|
|||||||
const currentTime = Date.now();
|
const currentTime = Date.now();
|
||||||
return currentTime - cameraImageCaptureLastSuccess.value > cameraImageCaptureDelayAfterSuccess.value;
|
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.
|
// Function to retrieve the latest image frame.
|
||||||
const getLatestImage = async () => {
|
const getLatestImage = async () => {
|
||||||
|
if (!latestImage.value && latestImageBlob.value) {
|
||||||
|
latestImage.value = await blobToDataUrl(latestImageBlob.value);
|
||||||
|
}
|
||||||
|
|
||||||
return latestImage.value;
|
return latestImage.value;
|
||||||
};
|
};
|
||||||
// Function to set the latest image frame.
|
// Function to set the latest image frame.
|
||||||
const setLatestImage = (image: string | null) => {
|
const setLatestImage = (image: string | null) => {
|
||||||
latestImage.value = image;
|
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.
|
// Function to set the camera mounted state.
|
||||||
const setCameraMounted = (mounted: boolean) => {
|
const setCameraMounted = (mounted: boolean) => {
|
||||||
@@ -1125,6 +1196,7 @@ const setCameraMounted = (mounted: boolean) => {
|
|||||||
// Function to clear the latest image.
|
// Function to clear the latest image.
|
||||||
const clearLatestImage = () => {
|
const clearLatestImage = () => {
|
||||||
latestImage.value = null;
|
latestImage.value = null;
|
||||||
|
latestImageBlob.value = null;
|
||||||
};
|
};
|
||||||
// Function to clear the camera mounted state.
|
// Function to clear the camera mounted state.
|
||||||
const clearCameraMounted = () => {
|
const clearCameraMounted = () => {
|
||||||
@@ -1145,10 +1217,12 @@ const setCameraImageCaptureDelay = (isFirstCapture: boolean, delay: number) => {
|
|||||||
|
|
||||||
const camera = {
|
const camera = {
|
||||||
latestImage,
|
latestImage,
|
||||||
|
latestImageBlob,
|
||||||
get: getLatestImage,
|
get: getLatestImage,
|
||||||
mounted: isCameraMounted,
|
mounted: isCameraMounted,
|
||||||
setMounted: setCameraMounted,
|
setMounted: setCameraMounted,
|
||||||
setLatestImage,
|
setLatestImage,
|
||||||
|
setLatestImageBlob,
|
||||||
clearLatestImage,
|
clearLatestImage,
|
||||||
clearMounted: clearCameraMounted,
|
clearMounted: clearCameraMounted,
|
||||||
getImageCaptureDelay: getCameraImageCaptureDelay,
|
getImageCaptureDelay: getCameraImageCaptureDelay,
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export type PosLocation = {
|
|||||||
coords?: PosLocationCoords | null;
|
coords?: PosLocationCoords | null;
|
||||||
/** Timestamp */
|
/** Timestamp */
|
||||||
timestamp?: Date | null;
|
timestamp?: Date | null;
|
||||||
|
/** Browser geolocation timestamp in milliseconds */
|
||||||
|
locatedAt?: number | null;
|
||||||
/** Error message */
|
/** Error message */
|
||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,6 +3,45 @@ export const XLVASK_USAGE_AMOUNT_CACHE_TTL_MS = 10 * 60 * 1000;
|
|||||||
const CACHE_PREFIX = "xlvask-usage-amount:";
|
const CACHE_PREFIX = "xlvask-usage-amount:";
|
||||||
const memoryCache = new Map();
|
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 = () => {
|
const safeSessionStorage = () => {
|
||||||
try {
|
try {
|
||||||
if (typeof window === "undefined" || !window.sessionStorage) {
|
if (typeof window === "undefined" || !window.sessionStorage) {
|
||||||
@@ -15,9 +54,7 @@ const safeSessionStorage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const normalizeUsageLogId = (objectOrId) => {
|
const normalizeUsageLogId = (objectOrId) => {
|
||||||
const value = typeof objectOrId === "object"
|
const value = typeof objectOrId === "object" ? objectOrId?.usage_log_id ?? objectOrId?.id : objectOrId;
|
||||||
? objectOrId?.usage_log_id ?? objectOrId?.id
|
|
||||||
: objectOrId;
|
|
||||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||||
};
|
};
|
||||||
@@ -34,17 +71,18 @@ export const buildXlvaskUsageAmountCacheKey = (objectOrId) => {
|
|||||||
|
|
||||||
export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
||||||
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||||
if (!cacheKey) {
|
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||||
|
if (!scopedCacheKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const memoryEntry = memoryCache.get(cacheKey);
|
const memoryEntry = memoryCache.get(scopedCacheKey);
|
||||||
if (isFreshEntry(memoryEntry)) {
|
if (isFreshEntry(memoryEntry)) {
|
||||||
return memoryEntry.data;
|
return memoryEntry.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (memoryEntry) {
|
if (memoryEntry) {
|
||||||
memoryCache.delete(cacheKey);
|
memoryCache.delete(scopedCacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = safeSessionStorage();
|
const storage = safeSessionStorage();
|
||||||
@@ -52,14 +90,14 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storageKey = `${CACHE_PREFIX}${cacheKey}`;
|
const storageKey = `${CACHE_PREFIX}${scopedCacheKey}`;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
|
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
|
||||||
if (!isFreshEntry(parsed)) {
|
if (!isFreshEntry(parsed)) {
|
||||||
storage.removeItem(storageKey);
|
storage.removeItem(storageKey);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
memoryCache.set(cacheKey, parsed);
|
memoryCache.set(scopedCacheKey, parsed);
|
||||||
return parsed.data;
|
return parsed.data;
|
||||||
} catch {
|
} catch {
|
||||||
storage.removeItem(storageKey);
|
storage.removeItem(storageKey);
|
||||||
@@ -69,7 +107,8 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
|||||||
|
|
||||||
export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||||
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||||
if (!cacheKey) {
|
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||||
|
if (!scopedCacheKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +116,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
|||||||
storedAt: Date.now(),
|
storedAt: Date.now(),
|
||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
memoryCache.set(cacheKey, entry);
|
memoryCache.set(scopedCacheKey, entry);
|
||||||
|
|
||||||
const storage = safeSessionStorage();
|
const storage = safeSessionStorage();
|
||||||
if (!storage) {
|
if (!storage) {
|
||||||
@@ -85,7 +124,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
|
storage.setItem(`${CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort cache. Quota errors should not block XL Vask usage rows.
|
// 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) => {
|
export const clearCachedXlvaskUsageAmount = (objectOrId = null) => {
|
||||||
const storage = safeSessionStorage();
|
const storage = safeSessionStorage();
|
||||||
const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId);
|
const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||||
|
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||||
|
|
||||||
if (cacheKey) {
|
if (scopedCacheKey) {
|
||||||
memoryCache.delete(cacheKey);
|
memoryCache.delete(scopedCacheKey);
|
||||||
try {
|
try {
|
||||||
storage?.removeItem(`${CACHE_PREFIX}${cacheKey}`);
|
storage?.removeItem(`${CACHE_PREFIX}${scopedCacheKey}`);
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort cache cleanup.
|
// Best-effort cache cleanup.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ const clearAnswers = async () => {
|
|||||||
|
|
||||||
const confirmation = await Swal.fire({
|
const confirmation = await Swal.fire({
|
||||||
title: "Ryd besvarelser?",
|
title: "Ryd besvarelser?",
|
||||||
text: `Registreringsnummer ${normalizedReg.value} pa bane ${selectedLaneId.value} bliver ryddet.`,
|
text: `Registreringsnummer ${normalizedReg.value} på bane ${selectedLaneId.value} bliver ryddet.`,
|
||||||
icon: "warning",
|
icon: "warning",
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
confirmButtonText: "Ja, ryd besvarelser",
|
confirmButtonText: "Ja, ryd besvarelser",
|
||||||
@@ -284,7 +284,7 @@ watch(dynamicImageUrl, () => {
|
|||||||
<div class="modal-background" @click="closeModal"></div>
|
<div class="modal-background" @click="closeModal"></div>
|
||||||
<div class="modal-card" style="width: 95%; max-width: 1200px;">
|
<div class="modal-card" style="width: 95%; max-width: 1200px;">
|
||||||
<header class="modal-card-head">
|
<header class="modal-card-head">
|
||||||
<p class="modal-card-title">Self-serve preview</p>
|
<p class="modal-card-title">Forhåndsvisning af selvvask</p>
|
||||||
<button class="delete" aria-label="close" @click="closeModal"></button>
|
<button class="delete" aria-label="close" @click="closeModal"></button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -301,7 +301,7 @@ watch(dynamicImageUrl, () => {
|
|||||||
<label class="label">Bane</label>
|
<label class="label">Bane</label>
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select v-model="selectedLaneId" data-testid="self-serve-try-lane">
|
<select v-model="selectedLaneId" data-testid="self-serve-try-lane">
|
||||||
<option :value="null">Vaelg bane</option>
|
<option :value="null">Vælg bane</option>
|
||||||
<option v-for="entry in availableLanes" :key="entry.id" :value="entry.id">
|
<option v-for="entry in availableLanes" :key="entry.id" :value="entry.id">
|
||||||
{{ entry.name }} (ID: {{ entry.id }})
|
{{ entry.name }} (ID: {{ entry.id }})
|
||||||
</option>
|
</option>
|
||||||
@@ -313,7 +313,7 @@ watch(dynamicImageUrl, () => {
|
|||||||
<input class="input" :value="selectedLaneId" type="text" disabled>
|
<input class="input" :value="selectedLaneId" type="text" disabled>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-3">
|
<div class="column is-3">
|
||||||
<label class="label">Koretojstype</label>
|
<label class="label">Køretøjstype</label>
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select v-model="selectedVehicleTypeId" data-testid="self-serve-try-vehicle-type">
|
<select v-model="selectedVehicleTypeId" data-testid="self-serve-try-vehicle-type">
|
||||||
<option :value="null">Auto (fra registreringsnummer)</option>
|
<option :value="null">Auto (fra registreringsnummer)</option>
|
||||||
@@ -325,7 +325,7 @@ watch(dynamicImageUrl, () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="column is-3 is-flex is-align-items-flex-end">
|
<div class="column is-3 is-flex is-align-items-flex-end">
|
||||||
<button class="button is-link is-fullwidth" data-testid="self-serve-try-refresh" :class="{ 'is-loading': loading }" @click="refresh">
|
<button class="button is-link is-fullwidth" data-testid="self-serve-try-refresh" :class="{ 'is-loading': loading }" @click="refresh">
|
||||||
Hent preview
|
Hent forhåndsvisning
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,10 +335,10 @@ watch(dynamicImageUrl, () => {
|
|||||||
Tilladt: {{ allowed ? "Ja" : "Nej" }}
|
Tilladt: {{ allowed ? "Ja" : "Nej" }}
|
||||||
</span>
|
</span>
|
||||||
<span class="tag" :class="machineAvailable ? 'is-success' : 'is-light'">
|
<span class="tag" :class="machineAvailable ? 'is-success' : 'is-light'">
|
||||||
Maskine tilgaengelig: {{ machineAvailable ? "Ja" : "Nej" }}
|
Maskine tilgængelig: {{ machineAvailable ? "Ja" : "Nej" }}
|
||||||
</span>
|
</span>
|
||||||
<span class="tag" :class="allDisplayQuestionsAnswered ? 'is-success' : 'is-warning'">
|
<span class="tag" :class="allDisplayQuestionsAnswered ? 'is-success' : 'is-warning'">
|
||||||
Alle synlige sporgsmal besvaret: {{ allDisplayQuestionsAnswered ? "Ja" : "Nej" }}
|
Alle synlige spørgsmål besvaret: {{ allDisplayQuestionsAnswered ? "Ja" : "Nej" }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="session" class="tag is-info">
|
<span v-if="session" class="tag is-info">
|
||||||
Session: {{ session.status }} (#{{ session.id }})
|
Session: {{ session.status }} (#{{ session.id }})
|
||||||
@@ -347,10 +347,10 @@ watch(dynamicImageUrl, () => {
|
|||||||
Maskintype: {{ machineType.name }}
|
Maskintype: {{ machineType.name }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="lane" class="tag is-light">
|
<span v-if="lane" class="tag is-light">
|
||||||
Lane: {{ lane.name || lane.id }}
|
Bane: {{ lane.name || lane.id }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="configVersionId" class="tag is-dark">
|
<span v-if="configVersionId" class="tag is-dark">
|
||||||
Config version: #{{ configVersionId }}
|
Konfigurationsversion: #{{ configVersionId }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -364,10 +364,10 @@ watch(dynamicImageUrl, () => {
|
|||||||
|
|
||||||
<div class="column is-6">
|
<div class="column is-6">
|
||||||
<div class="box" style="height: 100%">
|
<div class="box" style="height: 100%">
|
||||||
<h4 class="title is-5">Sporgsmal</h4>
|
<h4 class="title is-5">Spørgsmål</h4>
|
||||||
|
|
||||||
<div v-if="displayQuestions.length === 0" class="notification is-success is-light">
|
<div v-if="displayQuestions.length === 0" class="notification is-success is-light">
|
||||||
<p>Ingen synlige sporgsmal for denne preview.</p>
|
<p>Ingen synlige spørgsmål for denne forhåndsvisning.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelfServeQuestionCards
|
<SelfServeQuestionCards
|
||||||
@@ -375,18 +375,18 @@ watch(dynamicImageUrl, () => {
|
|||||||
:answers="answers"
|
:answers="answers"
|
||||||
@answer-question="submitAnswer"
|
@answer-question="submitAnswer"
|
||||||
/>
|
/>
|
||||||
<p v-if="questions.length === 0" class="has-text-centered is-italic">Ingen sporgsmal fundet.</p>
|
<p v-if="questions.length === 0" class="has-text-centered is-italic">Ingen spørgsmål fundet.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-6">
|
<div class="column is-6">
|
||||||
<div class="box" style="height: 100%">
|
<div class="box" style="height: 100%">
|
||||||
<h4 class="title is-5">Tasks og session</h4>
|
<h4 class="title is-5">Opgaver og session</h4>
|
||||||
|
|
||||||
<div v-if="displayedDynamicImageUrl" class="mb-4">
|
<div v-if="displayedDynamicImageUrl" class="mb-4">
|
||||||
<img
|
<img
|
||||||
:src="displayedDynamicImageUrl"
|
:src="displayedDynamicImageUrl"
|
||||||
alt="Machine status preview"
|
alt="Forhåndsvisning af maskinstatus"
|
||||||
data-testid="self-serve-try-dynamic-image"
|
data-testid="self-serve-try-dynamic-image"
|
||||||
style="max-width: 100%; height: auto; border-radius: 4px; display: block; margin-left: auto; margin-right: auto;"
|
style="max-width: 100%; height: auto; border-radius: 4px; display: block; margin-left: auto; margin-right: auto;"
|
||||||
@error="onDynamicImageError"
|
@error="onDynamicImageError"
|
||||||
@@ -401,11 +401,11 @@ watch(dynamicImageUrl, () => {
|
|||||||
@download-attachment="downloadAttachment"
|
@download-attachment="downloadAttachment"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p v-if="activeTasks.length === 0" class="is-italic">Ingen aktive tasks.</p>
|
<p v-if="activeTasks.length === 0" class="is-italic">Ingen aktive opgaver.</p>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<h5 class="subtitle is-6">Seneste haendelser</h5>
|
<h5 class="subtitle is-6">Seneste hændelser</h5>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="event in events" :key="event.id" class="mb-2">
|
<li v-for="event in events" :key="event.id" class="mb-2">
|
||||||
<strong>{{ event.type }}</strong>
|
<strong>{{ event.type }}</strong>
|
||||||
@@ -416,16 +416,16 @@ watch(dynamicImageUrl, () => {
|
|||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<h5 class="subtitle is-6">Evaluation trace</h5>
|
<h5 class="subtitle is-6">Evalueringsspor</h5>
|
||||||
<div v-if="evaluationTrace" class="content is-small">
|
<div v-if="evaluationTrace" class="content is-small">
|
||||||
<pre>{{ JSON.stringify(evaluationTrace, null, 2) }}</pre>
|
<pre>{{ JSON.stringify(evaluationTrace, null, 2) }}</pre>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="is-italic">Ingen trace-data returneret.</p>
|
<p v-else class="is-italic">Ingen sporingsdata returneret.</p>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<div class="is-flex is-align-items-center is-justify-content-space-between mb-2">
|
<div class="is-flex is-align-items-center is-justify-content-space-between mb-2">
|
||||||
<h5 class="subtitle is-6 mb-0">Besvarede sporgsmal</h5>
|
<h5 class="subtitle is-6 mb-0">Besvarede spørgsmål</h5>
|
||||||
<button
|
<button
|
||||||
class="button is-small is-light"
|
class="button is-small is-light"
|
||||||
data-testid="self-serve-try-clear-answers"
|
data-testid="self-serve-try-clear-answers"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, onBeforeUnmount, onMounted, ref } from 'vue';
|
import { computed, inject, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from "vue-i18n";
|
||||||
import * as paginatedListModule from "@/components/pagination/paginatedList.vue";
|
import * as paginatedListModule from "@/components/pagination/paginatedList.vue";
|
||||||
const paginatedList = inject(paginatedListModule.PaginatedListKey, paginatedListModule);
|
const paginatedList = inject(paginatedListModule.PaginatedListKey, paginatedListModule);
|
||||||
const { search, metaSearch, isLoading, loadList } = paginatedList;
|
const { search, metaSearch, isLoading, loadList } = paginatedList;
|
||||||
@@ -13,10 +13,15 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
searchPlaceholder: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const canExport = computed(() => typeof paginatedList?.exportToExcel === 'function');
|
const canExport = computed(() => typeof paginatedList?.exportToExcel === "function");
|
||||||
const isExporting = computed(() => Boolean(paginatedList?.isExporting?.value));
|
const isExporting = computed(() => Boolean(paginatedList?.isExporting?.value));
|
||||||
|
const resolvedSearchPlaceholder = computed(() => props.searchPlaceholder || t("global.search_placeholder"));
|
||||||
const isActionDropdownOpen = ref(false);
|
const isActionDropdownOpen = ref(false);
|
||||||
const actionDropdownRef = ref<HTMLElement | null>(null);
|
const actionDropdownRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
@@ -61,11 +66,11 @@ const handleDocumentClick = (event: MouseEvent) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', handleDocumentClick);
|
document.addEventListener("click", handleDocumentClick);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('click', handleDocumentClick);
|
document.removeEventListener("click", handleDocumentClick);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -74,12 +79,12 @@ onBeforeUnmount(() => {
|
|||||||
<div class="columns is-vcentered is-multiline pagination-general-search-reload">
|
<div class="columns is-vcentered is-multiline pagination-general-search-reload">
|
||||||
<div v-if="!props.hideSearch" class="column pagination-general-search-reload__search-column">
|
<div v-if="!props.hideSearch" class="column pagination-general-search-reload__search-column">
|
||||||
<input
|
<input
|
||||||
data-testid="pagination-search-input"
|
data-testid="pagination-search-input"
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="$t('global.search_placeholder')"
|
:placeholder="resolvedSearchPlaceholder"
|
||||||
v-model="metaSearch"
|
v-model="metaSearch"
|
||||||
@input="search($event.target.value)"
|
@input="search($event.target.value)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-narrow pagination-general-search-reload__buttons-column" v-if="$slots.buttons">
|
<div class="column is-narrow pagination-general-search-reload__buttons-column" v-if="$slots.buttons">
|
||||||
@@ -134,7 +139,7 @@ onBeforeUnmount(() => {
|
|||||||
@click.prevent="handleExportExcel"
|
@click.prevent="handleExportExcel"
|
||||||
>
|
>
|
||||||
<i class="fas fa-file-excel" aria-hidden="true"></i>
|
<i class="fas fa-file-excel" aria-hidden="true"></i>
|
||||||
<span>{{ t('pagination.download_excel') }}</span>
|
<span>{{ t("pagination.download_excel") }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, inject } from 'vue';
|
import { ref, inject } from "vue";
|
||||||
import * as paginatedListModule from "@/components/pagination/paginatedList.vue";
|
import * as paginatedListModule from "@/components/pagination/paginatedList.vue";
|
||||||
|
|
||||||
const paginatedList = inject(paginatedListModule.PaginatedListKey, paginatedListModule);
|
const paginatedList = inject(paginatedListModule.PaginatedListKey, paginatedListModule);
|
||||||
const {
|
const { isLoading, loadList, metaCurrentPage, metaItemsPerPage, metaTotalItems, setMetaItemsPerPage, setPage } =
|
||||||
isLoading,
|
paginatedList;
|
||||||
loadList,
|
|
||||||
metaCurrentPage,
|
|
||||||
metaItemsPerPage,
|
|
||||||
metaTotalItems,
|
|
||||||
setMetaItemsPerPage,
|
|
||||||
setPage,
|
|
||||||
} = paginatedList;
|
|
||||||
|
|
||||||
import PaginationDisplayGeneralSearchReload
|
import PaginationDisplayGeneralSearchReload from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
|
||||||
from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
|
|
||||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
||||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
||||||
|
|
||||||
@@ -31,6 +23,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
searchPlaceholder: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
hidePagination: {
|
hidePagination: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -48,6 +44,7 @@ const isSmall = ref(window.innerWidth < 1024);
|
|||||||
<PaginationDisplayGeneralSearchReload
|
<PaginationDisplayGeneralSearchReload
|
||||||
v-if="!props.hideSearch || $slots.buttons"
|
v-if="!props.hideSearch || $slots.buttons"
|
||||||
:hide-search="props.hideSearch"
|
:hide-search="props.hideSearch"
|
||||||
|
:search-placeholder="props.searchPlaceholder"
|
||||||
>
|
>
|
||||||
<template #buttons="{ loadList }" v-if="$slots.buttons">
|
<template #buttons="{ loadList }" v-if="$slots.buttons">
|
||||||
<slot name="buttons" :loadList="loadList"></slot>
|
<slot name="buttons" :loadList="loadList"></slot>
|
||||||
@@ -66,7 +63,7 @@ const isSmall = ref(window.innerWidth < 1024);
|
|||||||
<template #paginationColumns>
|
<template #paginationColumns>
|
||||||
<slot name="paginationDisplayFiltersElement"></slot>
|
<slot name="paginationDisplayFiltersElement"></slot>
|
||||||
<slot name="leftPaginationColumns"></slot>
|
<slot name="leftPaginationColumns"></slot>
|
||||||
<div class="column is-auto-fill my-3" v-if="!isSmall"/>
|
<div class="column is-auto-fill my-3" v-if="!isSmall" />
|
||||||
<div class="columns is-multiline">
|
<div class="columns is-multiline">
|
||||||
<div class="column is-12 p-1 m-0"></div>
|
<div class="column is-12 p-1 m-0"></div>
|
||||||
<slot name="rightPaginationColumns"></slot>
|
<slot name="rightPaginationColumns"></slot>
|
||||||
@@ -89,5 +86,4 @@ const isSmall = ref(window.innerWidth < 1024);
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
</style>
|
|
||||||
|
|||||||
+29
-46
@@ -1,30 +1,30 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { provide, ref } from "vue";
|
import { provide, ref } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
|
||||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
|
||||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
|
||||||
import CustomerComplaintsTable from "@/components/displays/superuser/tables/customerComplaintsTable.vue";
|
import CustomerComplaintsTable from "@/components/displays/superuser/tables/customerComplaintsTable.vue";
|
||||||
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
|
|
||||||
const props = defineProps(["hideSearch", "autoLoad"]);
|
const props = defineProps({
|
||||||
|
hideSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
autoLoad: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const paginatedList = usePaginatedList();
|
const paginatedList = usePaginatedList();
|
||||||
provide(PaginatedListKey, paginatedList);
|
provide(PaginatedListKey, paginatedList);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isLoading,
|
|
||||||
list,
|
list,
|
||||||
loadList,
|
loadList,
|
||||||
metaCurrentPage,
|
|
||||||
metaItemsPerPage,
|
|
||||||
metaTotalItems,
|
|
||||||
setEndpoint,
|
setEndpoint,
|
||||||
setMetaItemsPerPage,
|
|
||||||
setPage,
|
|
||||||
search,
|
|
||||||
setFilter,
|
setFilter,
|
||||||
setOrder,
|
setOrder,
|
||||||
hideSearchField,
|
hideSearchField,
|
||||||
@@ -54,6 +54,11 @@ const onDepartmentFilterChange = (value) => {
|
|||||||
setFilter("department_id", parsedDepartmentId, true);
|
setFilter("department_id", parsedDepartmentId, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onOrderDirectionChange = (value) => {
|
||||||
|
setOrder("created_at", value);
|
||||||
|
loadList();
|
||||||
|
};
|
||||||
|
|
||||||
setEndpoint("/departments/daily-reports/complaints", false);
|
setEndpoint("/departments/daily-reports/complaints", false);
|
||||||
setOrder("created_at", "desc");
|
setOrder("created_at", "desc");
|
||||||
|
|
||||||
@@ -69,22 +74,15 @@ if (props.autoLoad) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input
|
<TableLabeledPagination
|
||||||
v-if="!hideSearchField"
|
:label="t('superuser.pages.complaints.title')"
|
||||||
class="input"
|
:hide-search="hideSearchField"
|
||||||
data-testid="superuser-complaints-search"
|
|
||||||
type="text"
|
|
||||||
:placeholder="t('superuser.pages.complaints.search_placeholder')"
|
|
||||||
@input="search($event.target.value)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PaginationDisplay
|
|
||||||
:metaItemsPerPage="metaItemsPerPage"
|
|
||||||
:loadFunction="loadList"
|
|
||||||
:isLoading="isLoading"
|
|
||||||
:setMetaItemsPerPage="setMetaItemsPerPage"
|
|
||||||
>
|
>
|
||||||
<template #paginationColumns>
|
<template #description>
|
||||||
|
<p class="is-size-6 mb-4">{{ t('superuser.pages.complaints.subtitle') }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #paginationDisplayFiltersElement>
|
||||||
<div class="column is-narrow my-3">
|
<div class="column is-narrow my-3">
|
||||||
<label class="label is-small">{{ t('superuser.pages.complaints.department_filter') }}</label>
|
<label class="label is-small">{{ t('superuser.pages.complaints.department_filter') }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
@@ -112,7 +110,7 @@ if (props.autoLoad) {
|
|||||||
<div class="select">
|
<div class="select">
|
||||||
<select
|
<select
|
||||||
data-testid="superuser-complaints-order-direction"
|
data-testid="superuser-complaints-order-direction"
|
||||||
@change="setOrder('created_at', $event.target.value); loadList();"
|
@change="onOrderDirectionChange($event.target.value)"
|
||||||
>
|
>
|
||||||
<option value="desc" selected>{{ t('pagination.descending') }}</option>
|
<option value="desc" selected>{{ t('pagination.descending') }}</option>
|
||||||
<option value="asc">{{ t('pagination.ascending') }}</option>
|
<option value="asc">{{ t('pagination.ascending') }}</option>
|
||||||
@@ -121,26 +119,11 @@ if (props.autoLoad) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</PaginationDisplay>
|
|
||||||
|
|
||||||
<CustomerComplaintsTable :objects="list" />
|
<template #default>
|
||||||
|
<CustomerComplaintsTable :objects="list" />
|
||||||
<PaginationNavigation
|
</template>
|
||||||
:currentPage="metaCurrentPage"
|
</TableLabeledPagination>
|
||||||
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
|
|
||||||
:loadFunction="loadList"
|
|
||||||
:setPage="setPage"
|
|
||||||
:isLoading="isLoading"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LoadButtonWhileAwait
|
|
||||||
class="is-dark"
|
|
||||||
:isLoading="isLoading"
|
|
||||||
:loadFunction="loadList"
|
|
||||||
icon="fas fa-sync-alt"
|
|
||||||
>
|
|
||||||
{{ t('pagination.reload') }}
|
|
||||||
</LoadButtonWhileAwait>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
+20
-46
@@ -1,45 +1,27 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
let props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
|
|
||||||
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import {
|
|
||||||
usePaginatedList,
|
|
||||||
PaginatedListKey
|
|
||||||
} from "@/components/pagination/paginatedList.vue";
|
|
||||||
import { provide } from "vue";
|
import { provide } from "vue";
|
||||||
|
import CustomersTable from "@/components/displays/superuser/tables/customersTable.vue";
|
||||||
|
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
|
||||||
|
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
hideSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
autoLoad: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const paginatedList = usePaginatedList();
|
const paginatedList = usePaginatedList();
|
||||||
provide(PaginatedListKey, paginatedList);
|
provide(PaginatedListKey, paginatedList);
|
||||||
const {
|
|
||||||
isLoaded,
|
|
||||||
isLoading,
|
|
||||||
list,
|
|
||||||
loadList,
|
|
||||||
metaCurrentPage,
|
|
||||||
metaItemsPerPage,
|
|
||||||
metaTotalItems,
|
|
||||||
setEndpoint,
|
|
||||||
setMetaItemsPerPage,
|
|
||||||
setPage,
|
|
||||||
metaSearch,
|
|
||||||
search,
|
|
||||||
setFilter,
|
|
||||||
setOrder,
|
|
||||||
hideSearchField,
|
|
||||||
setHideSearchField,
|
|
||||||
} = paginatedList;
|
|
||||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
|
||||||
|
|
||||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
const { list, loadList, setEndpoint, hideSearchField, setHideSearchField } = paginatedList;
|
||||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
|
||||||
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
|
|
||||||
import CustomersTable from "@/components/displays/superuser/tables/customersTable.vue";
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const router = useRouter();
|
|
||||||
setEndpoint("/customers", false);
|
setEndpoint("/customers", false);
|
||||||
|
|
||||||
// Hide the search field, if the hideSearch prop is set
|
|
||||||
if (props.hideSearch) {
|
if (props.hideSearch) {
|
||||||
setHideSearchField(true);
|
setHideSearchField(true);
|
||||||
}
|
}
|
||||||
@@ -47,20 +29,12 @@ if (props.hideSearch) {
|
|||||||
if (props.autoLoad) {
|
if (props.autoLoad) {
|
||||||
loadList();
|
loadList();
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_customers')" v-if="!hideSearchField"/>
|
<TableLabeledPagination :label="$t('customers.title')" :hide-search="hideSearchField">
|
||||||
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
|
<CustomersTable :objects="list" />
|
||||||
<template #paginationColumns>
|
</TableLabeledPagination>
|
||||||
</template>
|
|
||||||
</PaginationDisplay>
|
|
||||||
<CustomersTable :objects="list" />
|
|
||||||
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
|
|
||||||
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{ t('pagination.reload') }}</LoadButtonWhileAwait>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|||||||
+7
-33
@@ -3,9 +3,7 @@ import { computed, provide, ref } from "vue";
|
|||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
import DepartmentsTable from "@/components/displays/superuser/tables/departmentsTable.vue";
|
import DepartmentsTable from "@/components/displays/superuser/tables/departmentsTable.vue";
|
||||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
|
||||||
import PaginationDisplayGeneralSearchReload from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
|
|
||||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
|
||||||
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||||
|
|
||||||
const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
|
const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
|
||||||
@@ -14,15 +12,9 @@ const paginatedList = usePaginatedList();
|
|||||||
provide(PaginatedListKey, paginatedList);
|
provide(PaginatedListKey, paginatedList);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isLoading,
|
|
||||||
list,
|
list,
|
||||||
loadList,
|
loadList,
|
||||||
metaCurrentPage,
|
|
||||||
metaItemsPerPage,
|
|
||||||
metaTotalItems,
|
|
||||||
setEndpoint,
|
setEndpoint,
|
||||||
setMetaItemsPerPage,
|
|
||||||
setPage,
|
|
||||||
setFilter,
|
setFilter,
|
||||||
setOrder,
|
setOrder,
|
||||||
hideSearchField,
|
hideSearchField,
|
||||||
@@ -54,14 +46,11 @@ if (props.autoLoad) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PaginationDisplayGeneralSearchReload v-if="!hideSearchField" />
|
<TableLabeledPagination
|
||||||
<PaginationDisplay
|
:label="t('common.departments')"
|
||||||
:metaItemsPerPage="metaItemsPerPage"
|
:hide-search="hideSearchField"
|
||||||
:loadFunction="loadList"
|
|
||||||
:isLoading="isLoading"
|
|
||||||
:setMetaItemsPerPage="setMetaItemsPerPage"
|
|
||||||
>
|
>
|
||||||
<template #paginationColumns>
|
<template #paginationDisplayFiltersElement>
|
||||||
<div class="column is-narrow my-3 department-status-filter">
|
<div class="column is-narrow my-3 department-status-filter">
|
||||||
<label class="label is-small" for="department-archive-filter">{{
|
<label class="label is-small" for="department-archive-filter">{{
|
||||||
t("common.status")
|
t("common.status")
|
||||||
@@ -81,23 +70,8 @@ if (props.autoLoad) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</PaginationDisplay>
|
<DepartmentsTable :objects="sortedList" />
|
||||||
<div v-if="hideSearchField" class="mb-3">
|
</TableLabeledPagination>
|
||||||
<button class="button is-dark" :class="{ 'is-loading': isLoading }" :disabled="isLoading" @click="loadList">
|
|
||||||
<span class="icon is-small">
|
|
||||||
<i class="fas fa-sync-alt" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
<span>{{ t("pagination.reload") }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<DepartmentsTable :objects="sortedList" />
|
|
||||||
<PaginationNavigation
|
|
||||||
:currentPage="metaCurrentPage"
|
|
||||||
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
|
|
||||||
:loadFunction="loadList"
|
|
||||||
:setPage="setPage"
|
|
||||||
:isLoading="isLoading"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
+20
-3
@@ -7,7 +7,24 @@ import PaginationDisplay from "@/components/displays/pagination/PaginationDispla
|
|||||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||||
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||||
|
|
||||||
const props = defineProps(["hideSearch", "autoLoad"]);
|
const props = defineProps({
|
||||||
|
hideSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
autoLoad: {
|
||||||
|
type: [Boolean, String],
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
endpoint: {
|
||||||
|
type: String,
|
||||||
|
default: "/subusers",
|
||||||
|
},
|
||||||
|
showCustomer: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const paginatedList = usePaginatedList();
|
const paginatedList = usePaginatedList();
|
||||||
@@ -30,7 +47,7 @@ const {
|
|||||||
setAdditionalQueryParameters,
|
setAdditionalQueryParameters,
|
||||||
} = paginatedList;
|
} = paginatedList;
|
||||||
|
|
||||||
setEndpoint("/subusers", false);
|
setEndpoint(props.endpoint, false);
|
||||||
setAdditionalQueryParameters({ include_non_enabled: true });
|
setAdditionalQueryParameters({ include_non_enabled: true });
|
||||||
setOrder("created_at", "desc");
|
setOrder("created_at", "desc");
|
||||||
|
|
||||||
@@ -82,7 +99,7 @@ if (props.autoLoad) {
|
|||||||
</template>
|
</template>
|
||||||
</PaginationDisplay>
|
</PaginationDisplay>
|
||||||
|
|
||||||
<SubusersTable :objects="list" />
|
<SubusersTable :objects="list" :show-customer="showCustomer" />
|
||||||
|
|
||||||
<PaginationNavigation
|
<PaginationNavigation
|
||||||
:currentPage="metaCurrentPage"
|
:currentPage="metaCurrentPage"
|
||||||
|
|||||||
@@ -1,45 +1,21 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import UsersTable from "@/components/displays/superuser/tables/usersTable.vue";
|
|
||||||
|
|
||||||
let props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
|
|
||||||
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import {
|
|
||||||
usePaginatedList,
|
|
||||||
PaginatedListKey
|
|
||||||
} from "@/components/pagination/paginatedList.vue";
|
|
||||||
import { provide } from "vue";
|
import { provide } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
|
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
|
||||||
|
import UsersTable from "@/components/displays/superuser/tables/usersTable.vue";
|
||||||
|
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||||
|
|
||||||
|
const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
|
||||||
|
|
||||||
const paginatedList = usePaginatedList();
|
const paginatedList = usePaginatedList();
|
||||||
provide(PaginatedListKey, paginatedList);
|
provide(PaginatedListKey, paginatedList);
|
||||||
const {
|
const { list, loadList, setEndpoint, setFilter, setOrder, hideSearchField, setHideSearchField } = paginatedList;
|
||||||
isLoaded,
|
|
||||||
isLoading,
|
|
||||||
list,
|
|
||||||
loadList,
|
|
||||||
metaCurrentPage,
|
|
||||||
metaItemsPerPage,
|
|
||||||
metaTotalItems,
|
|
||||||
setEndpoint,
|
|
||||||
setMetaItemsPerPage,
|
|
||||||
setPage,
|
|
||||||
metaSearch,
|
|
||||||
search,
|
|
||||||
setFilter,
|
|
||||||
setOrder,
|
|
||||||
hideSearchField,
|
|
||||||
setHideSearchField,
|
|
||||||
} = paginatedList;
|
|
||||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
|
||||||
|
|
||||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
const { t } = useI18n();
|
||||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
|
||||||
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const router = useRouter();
|
|
||||||
setEndpoint("/users", false);
|
setEndpoint("/users", false);
|
||||||
setFilter("customer_number", 0, false);
|
setFilter("customer_number", 0, false);
|
||||||
|
setOrder("created_at", "desc");
|
||||||
|
|
||||||
// Hide the search field, if the hideSearch prop is set
|
// Hide the search field, if the hideSearch prop is set
|
||||||
if (props.hideSearch) {
|
if (props.hideSearch) {
|
||||||
@@ -49,32 +25,35 @@ if (props.hideSearch) {
|
|||||||
if (props.autoLoad) {
|
if (props.autoLoad) {
|
||||||
loadList();
|
loadList();
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_user')" v-if="!hideSearchField"/>
|
<TableLabeledPagination
|
||||||
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
|
:label="t('superuser.pages.employees.title')"
|
||||||
<template #paginationColumns>
|
:hide-search="hideSearchField"
|
||||||
|
:search-placeholder="t('global.search_user')"
|
||||||
|
>
|
||||||
|
<template #paginationDisplayFiltersElement>
|
||||||
<!-- Sort by created_at -->
|
<!-- Sort by created_at -->
|
||||||
<div class="column is-narrow my-3">
|
<div class="column is-narrow my-3">
|
||||||
<label class="label is-small">{{ t('pagination.order_direction') }}</label>
|
<label class="label is-small">{{ t("pagination.order_direction") }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<div class="select">
|
<div class="select">
|
||||||
<select @change="setOrder('created_at', $event.target.value); loadList();">
|
<select
|
||||||
<option value="asc">{{ t('pagination.ascending') }}</option>
|
@change="
|
||||||
<option value="desc" selected>{{ t('pagination.descending') }}</option>
|
setOrder('created_at', $event.target.value);
|
||||||
|
loadList();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option value="asc">{{ t("pagination.ascending") }}</option>
|
||||||
|
<option value="desc" selected>{{ t("pagination.descending") }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</PaginationDisplay>
|
<UsersTable :objects="list" />
|
||||||
<UsersTable :objects="list" />
|
</TableLabeledPagination>
|
||||||
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
|
|
||||||
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{ t('pagination.reload') }}</LoadButtonWhileAwait>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|||||||
+226
-117
@@ -1,25 +1,21 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { list, loadList, setEndpoint, setFilter, setOrder } from "@/components/pagination/paginatedList.vue";
|
||||||
list,
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
loadList,
|
|
||||||
setEndpoint,
|
|
||||||
setFilter,
|
|
||||||
setOrder
|
|
||||||
} from "@/components/pagination/paginatedList.vue";
|
|
||||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
|
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
|
||||||
import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displays/tables/OrderBookingsTable.vue";
|
import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displays/tables/OrderBookingsTable.vue";
|
||||||
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
|
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
|
||||||
import { Colors } from "@/ThemeConfig.vue";
|
import { Colors } from "@/ThemeConfig.vue";
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n();
|
||||||
/**
|
/**
|
||||||
* Router
|
* Router
|
||||||
*/
|
*/
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
|
||||||
|
const isAdminRoute = computed(() => router.currentRoute.value.path.startsWith("/admin"));
|
||||||
/**
|
/**
|
||||||
* Props
|
* Props
|
||||||
*/
|
*/
|
||||||
@@ -27,41 +23,40 @@ const props = defineProps({
|
|||||||
filters: {
|
filters: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
required: false
|
required: false,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
const orderIdFilter = ref('*');
|
const orderIdFilter = ref("*");
|
||||||
const onOrderIdFilterChange = (event) => {
|
const onOrderIdFilterChange = (event) => {
|
||||||
const val = event.target.value;
|
const val = event.target.value;
|
||||||
orderIdFilter.value = val;
|
orderIdFilter.value = val;
|
||||||
// Apply filter: '*' clears, 'is null' shows without order, 'not null' shows with order
|
// Apply filter: '*' clears, 'is null' shows without order, 'not null' shows with order
|
||||||
setFilter('order_id', val);
|
setFilter("order_id", val);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Department filter
|
// Department filter
|
||||||
const departmentFilter = ref('*');
|
const departmentFilter = ref("*");
|
||||||
const onDepartmentFilterChange = (event) => {
|
const onDepartmentFilterChange = (event) => {
|
||||||
const val = event.target.value;
|
const val = event.target.value;
|
||||||
departmentFilter.value = val;
|
departmentFilter.value = val;
|
||||||
setFilter('department', val);
|
setFilter("department", val);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only today filter
|
// Only today filter
|
||||||
const onlyTodayFilter = ref('*');
|
const onlyTodayFilter = ref("*");
|
||||||
const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
|
const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
|
||||||
const val = event.target.value;
|
const val = event.target.value;
|
||||||
onlyTodayFilter.value = val;
|
onlyTodayFilter.value = val;
|
||||||
if (val === '*') {
|
if (val === "*") {
|
||||||
setFilter('datetime', val, false); // Clear filter
|
setFilter("datetime", val, false); // Clear filter
|
||||||
setFilter('datetime-date_from', null, false);
|
setFilter("datetime-date_from", null, false);
|
||||||
setFilter('datetime-date_to', null, false);
|
setFilter("datetime-date_to", null, false);
|
||||||
} else {
|
} else {
|
||||||
const startOfDay = new Date().setHours(0, 0, 0, 0);
|
const startOfDay = new Date().setHours(0, 0, 0, 0);
|
||||||
const endOfDay = new Date().setHours(23, 59, 59, 999);
|
const endOfDay = new Date().setHours(23, 59, 59, 999);
|
||||||
//setFilter('datetime', null, false);
|
//setFilter('datetime', null, false);
|
||||||
setFilter('datetime-date_from', new Date(startOfDay).toISOString(), false);
|
setFilter("datetime-date_from", new Date(startOfDay).toISOString(), false);
|
||||||
setFilter('datetime-date_to', new Date(endOfDay).toISOString(), false);
|
setFilter("datetime-date_to", new Date(endOfDay).toISOString(), false);
|
||||||
}
|
}
|
||||||
if (autoLoadList) {
|
if (autoLoadList) {
|
||||||
loadList();
|
loadList();
|
||||||
@@ -69,34 +64,38 @@ const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Version selector + helpers
|
// Version selector + helpers
|
||||||
const versionSelector = ref('new');
|
const versionSelector = ref("new");
|
||||||
const resetVersionToNew = () => {
|
const resetVersionToNew = () => {
|
||||||
versionSelector.value = 'new';
|
versionSelector.value = "new";
|
||||||
};
|
};
|
||||||
const showLegacyOrderBookingsPortal = () => {
|
const showLegacyOrderBookingsPortal = () => {
|
||||||
const departmentId = SessionUser.functions.getDepartmentIdFromUrl();
|
const departmentId = SessionUser.functions.getDepartmentIdFromUrl();
|
||||||
if (!departmentId) {
|
if (!departmentId) {
|
||||||
SessionUser.functions.redirectTo.user('/bookings-legacy', true);
|
SessionUser.functions.redirectTo.user("/bookings-legacy", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
SessionUser.functions.redirectTo.department(SessionUser.functions.getDepartmentIdFromUrl(), 'modules/bookings-legacy', true);
|
SessionUser.functions.redirectTo.department(
|
||||||
|
SessionUser.functions.getDepartmentIdFromUrl(),
|
||||||
|
"modules/bookings-legacy",
|
||||||
|
true
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setEndpoint(SessionUser.objects.order_bookings.meta.endpoint, false);
|
setEndpoint(SessionUser.objects.order_bookings.meta.endpoint, false);
|
||||||
setOrder('datetime', 'desc', false);
|
setOrder("datetime", "desc", false);
|
||||||
// Apply initial filters from props
|
// Apply initial filters from props
|
||||||
for (const [key, value] of Object.entries(props.filters)) {
|
for (const [key, value] of Object.entries(props.filters)) {
|
||||||
let setFilterKey = true;
|
let setFilterKey = true;
|
||||||
// Also set the filter controls if applicable
|
// Also set the filter controls if applicable
|
||||||
if (key === 'order_id') {
|
if (key === "order_id") {
|
||||||
orderIdFilter.value = value;
|
orderIdFilter.value = value;
|
||||||
} else if (key === 'department') {
|
} else if (key === "department") {
|
||||||
departmentFilter.value = value;
|
departmentFilter.value = value;
|
||||||
} else if (key === 'only_today' && value === true) {
|
} else if (key === "only_today" && value === true) {
|
||||||
setFilterKey = false; // Since the only_today filter is handled separately
|
setFilterKey = false; // Since the only_today filter is handled separately
|
||||||
onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split('T')[0] } }, false);
|
onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split("T")[0] } }, false);
|
||||||
}
|
}
|
||||||
if (setFilterKey) {
|
if (setFilterKey) {
|
||||||
setFilter(key, value, false);
|
setFilter(key, value, false);
|
||||||
@@ -106,108 +105,218 @@ onMounted(() => {
|
|||||||
// Load departments for filter options
|
// Load departments for filter options
|
||||||
getDepartments().catch(() => {});
|
getDepartments().catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="order-bookings-pagination">
|
||||||
<TableLabeledPagination :label="t('pagination.bookings_overview')">
|
<TableLabeledPagination :label="t('pagination.bookings_overview')" class="order-bookings-pagination__table">
|
||||||
<template #paginationDisplayFiltersElement>
|
<template #paginationDisplayFiltersElement>
|
||||||
<!-- Status filter -->
|
<!-- Status filter -->
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow order-bookings-pagination__filter">
|
||||||
<div class="field mb-0">
|
<div class="field mb-0">
|
||||||
<label class="label is-small">{{ t('common.status') }}</label>
|
<label class="label is-small">{{ t("common.status") }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<div class="select">
|
<div class="select">
|
||||||
<select :value="orderIdFilter" @change="onOrderIdFilterChange">
|
<select :value="orderIdFilter" @change="onOrderIdFilterChange">
|
||||||
<option value="*">{{ t('common.all') }}</option>
|
<option value="*">{{ t("common.all") }}</option>
|
||||||
<option value="is null">{{ t('pagination.not_completed') }}</option>
|
<option value="is null">{{ t("pagination.not_completed") }}</option>
|
||||||
<option value="not null">{{ t('pagination.completed') }}</option>
|
<option value="not null">{{ t("pagination.completed") }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- Department filter -->
|
||||||
<!-- Department filter -->
|
<div class="column is-narrow order-bookings-pagination__filter">
|
||||||
<div class="column is-narrow">
|
<div class="field mb-0">
|
||||||
<div class="field mb-0">
|
<label class="label is-small">{{ t("pagination.department") }}</label>
|
||||||
<label class="label is-small">{{ t('pagination.department') }}</label>
|
<div class="control">
|
||||||
<div class="control">
|
<div class="select">
|
||||||
<div class="select">
|
<select :value="departmentFilter" @change="onDepartmentFilterChange">
|
||||||
<select :value="departmentFilter" @change="onDepartmentFilterChange">
|
<option value="*">{{ t("common.all") }}</option>
|
||||||
<option value="*">{{ t('common.all') }}</option>
|
<option v-for="department in departments" :key="department.id" :value="department.id">
|
||||||
<option v-for="department in departments" :key="department.id" :value="department.id">{{ department.name }}</option>
|
{{ department.name }}
|
||||||
</select>
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- Version selector -->
|
||||||
<!-- Version selector -->
|
<div class="column is-narrow order-bookings-pagination__filter">
|
||||||
<div class="column is-narrow">
|
<div class="field mb-0">
|
||||||
<div class="field mb-0">
|
<label class="label is-small">{{ t("pagination.version") }}</label>
|
||||||
<label class="label is-small">{{ t('pagination.version') }}</label>
|
<div class="control">
|
||||||
<div class="control">
|
<div class="select">
|
||||||
<div class="select">
|
<select
|
||||||
<select @change="showLegacyOrderBookingsPortal(); resetVersionToNew()" v-model="versionSelector">
|
@change="
|
||||||
<option value="new">{{ t('common.new') }}</option>
|
showLegacyOrderBookingsPortal();
|
||||||
<option value="legacy">{{ t('pagination.old') }}</option>
|
resetVersionToNew();
|
||||||
</select>
|
"
|
||||||
|
v-model="versionSelector"
|
||||||
|
>
|
||||||
|
<option value="new">{{ t("common.new") }}</option>
|
||||||
|
<option value="legacy">{{ t("pagination.old") }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- Only today filter -->
|
||||||
<!-- Only today filter -->
|
<div class="column is-narrow order-bookings-pagination__filter" v-if="!isUserRoute">
|
||||||
<div class="column is-narrow">
|
<div class="field mb-0">
|
||||||
<div class="field mb-0">
|
<label class="label is-small">{{ t("pagination.only_today") }}</label>
|
||||||
<label class="label is-small">{{ t('pagination.only_today') }}</label>
|
<div class="control">
|
||||||
<div class="control">
|
<div class="select">
|
||||||
<div class="select">
|
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
|
||||||
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
|
<option value="*">{{ t("common.all") }}</option>
|
||||||
<option value="*">{{ t('common.all') }}</option>
|
<option :value="new Date().toISOString().split('T')[0]">{{ t("common.yes") }}</option>
|
||||||
<option :value="new Date().toISOString().split('T')[0]">{{ t('common.yes') }}</option>
|
</select>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
<template #leftPaginationColumns> </template>
|
||||||
<template #leftPaginationColumns>
|
<template #rightPaginationColumns>
|
||||||
</template>
|
<!-- Toggles and actions depending on route -->
|
||||||
<template #rightPaginationColumns>
|
<!-- User: Only today switch + New booking button -->
|
||||||
<!-- Toggles and actions depending on route -->
|
<div class="column is-narrow order-bookings-pagination__actions" v-if="isUserRoute">
|
||||||
<!-- User: Only today switch + New booking button -->
|
<div class="order-bookings-pagination__actions-grid">
|
||||||
<div class="column is-narrow" v-if="router.currentRoute.value.path.startsWith('/user')">
|
<div class="order-bookings-pagination__today-action">
|
||||||
<label class="label is-small">{{ t('pagination.show_only_today') }}</label>
|
<label class="label is-small">{{ t("pagination.show_only_today") }}</label>
|
||||||
<div class="field">
|
<div class="field mb-0">
|
||||||
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { onOnlyTodayFilterChange({ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } }) }" :class="{ 'is-link': onlyTodayFilter !== '*' }" :checked="onlyTodayFilter !== '*'" />
|
<input
|
||||||
<label for="today"></label>
|
id="today"
|
||||||
|
type="checkbox"
|
||||||
|
class="switch is-rounded"
|
||||||
|
@change="
|
||||||
|
(event) => {
|
||||||
|
onOnlyTodayFilterChange({
|
||||||
|
target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"
|
||||||
|
:class="{ 'is-link': onlyTodayFilter !== '*' }"
|
||||||
|
:checked="onlyTodayFilter !== '*'"
|
||||||
|
/>
|
||||||
|
<label for="today"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="order-bookings-pagination__new-booking-action">
|
||||||
|
<label class="label is-small order-bookings-pagination__desktop-spacer"> </label>
|
||||||
|
<button
|
||||||
|
class="button is-link button-same-width"
|
||||||
|
data-testid="user-bookings-new-booking"
|
||||||
|
@click="SessionUser.functions.redirectTo.external('/user/bookings/new', true)"
|
||||||
|
>
|
||||||
|
{{ t("pagination.new_booking") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- Admin: pending + only today combined switch -->
|
||||||
<div class="column is-narrow is-float-right" v-if="router.currentRoute.value.path.startsWith('/user')">
|
<div class="column is-narrow" v-if="isAdminRoute">
|
||||||
<label class="label is-small"> </label>
|
<label class="label is-small">{{ t("pagination.show_only_todays_pending") }}</label>
|
||||||
<button
|
<div class="field">
|
||||||
class="button is-link button-same-width"
|
<input
|
||||||
@click="SessionUser.functions.redirectTo.external('/user/bookings/new', true)"
|
id="today-pending"
|
||||||
>
|
type="checkbox"
|
||||||
{{ t('pagination.new_booking') }}
|
class="switch is-rounded"
|
||||||
</button>
|
@change="
|
||||||
</div>
|
(event) => {
|
||||||
<!-- Admin: pending + only today combined switch -->
|
onOnlyTodayFilterChange(
|
||||||
<div class="column is-narrow" v-if="router.currentRoute.value.path.startsWith('/admin')">
|
{ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } },
|
||||||
<label class="label is-small">{{ t('pagination.show_only_todays_pending') }}</label>
|
false
|
||||||
<div class="field">
|
);
|
||||||
<input id="today-pending" type="checkbox" class="switch is-rounded" @change="(event) => { onOnlyTodayFilterChange({ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } }, false); onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } }); }" :class="{ 'is-link': onlyTodayFilter !== '*' && orderIdFilter === 'is null' }" :checked="onlyTodayFilter !== '*' && orderIdFilter === 'is null'" />
|
onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } });
|
||||||
<label for="today-pending"></label>
|
}
|
||||||
|
"
|
||||||
|
:class="{ 'is-link': onlyTodayFilter !== '*' && orderIdFilter === 'is null' }"
|
||||||
|
:checked="onlyTodayFilter !== '*' && orderIdFilter === 'is null'"
|
||||||
|
/>
|
||||||
|
<label for="today-pending"></label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
<template #default>
|
||||||
<template #default>
|
<OrderBookingsTable :objects="list" />
|
||||||
<OrderBookingsTable :objects="list" />
|
</template>
|
||||||
</template>
|
</TableLabeledPagination>
|
||||||
</TableLabeledPagination>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.order-bookings-pagination__filter .select,
|
||||||
|
.order-bookings-pagination__filter select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
.order-bookings-pagination__actions-grid {
|
||||||
|
align-items: flex-end;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-bookings-pagination__today-action .field {
|
||||||
|
min-height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-bookings-pagination__new-booking-action .button {
|
||||||
|
min-width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.order-bookings-pagination__table :deep([data-testid="table-labeled-pagination-filters"] > .columns) {
|
||||||
|
align-items: flex-start;
|
||||||
|
column-gap: 0.75rem;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
row-gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-bookings-pagination__table :deep([data-testid="table-labeled-pagination-filters"] > .columns > .column),
|
||||||
|
.order-bookings-pagination__filter,
|
||||||
|
.order-bookings-pagination__actions {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-bookings-pagination__filter {
|
||||||
|
flex: 1 1 calc(50% - 0.375rem);
|
||||||
|
max-width: calc(50% - 0.375rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-bookings-pagination__table
|
||||||
|
:deep([data-testid="table-labeled-pagination-filters"] > .columns > .columns.is-multiline) {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-bookings-pagination__actions {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-bookings-pagination__actions-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-bookings-pagination__new-booking-action .button {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-bookings-pagination__desktop-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -2,13 +2,16 @@
|
|||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { BButton, BField } from "buefy";
|
import { BButton, BField } from "buefy";
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(
|
||||||
steps: Array<any>;
|
defineProps<{
|
||||||
currentStep: number;
|
steps: Array<any>;
|
||||||
showActions?: boolean;
|
currentStep: number;
|
||||||
}>(), {
|
showActions?: boolean;
|
||||||
showActions: true,
|
}>(),
|
||||||
});
|
{
|
||||||
|
showActions: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "update:currentStep", value: number): void;
|
(e: "update:currentStep", value: number): void;
|
||||||
@@ -45,9 +48,6 @@ const goNext = () => {
|
|||||||
<div class="guided-instructions" data-testid="self-serve-guided-instructions">
|
<div class="guided-instructions" data-testid="self-serve-guided-instructions">
|
||||||
<div class="guided-instructions__header">
|
<div class="guided-instructions__header">
|
||||||
<h3 class="title is-6">{{ $t("self_wash.follow_steps") }}</h3>
|
<h3 class="title is-6">{{ $t("self_wash.follow_steps") }}</h3>
|
||||||
<span class="guided-instructions__counter">
|
|
||||||
{{ normalizedStepIndex + 1 }} / {{ steps.length }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -55,8 +55,18 @@ const goNext = () => {
|
|||||||
class="guided-instructions__step"
|
class="guided-instructions__step"
|
||||||
:data-testid="`self-serve-guided-step-${normalizedStepIndex}`"
|
:data-testid="`self-serve-guided-step-${normalizedStepIndex}`"
|
||||||
>
|
>
|
||||||
<b-field :label="`${normalizedStepIndex + 1}. ${activeStep.title}`">
|
<b-field>
|
||||||
<div class="guided-instructions__content">
|
<template #label>
|
||||||
|
<span class="guided-instructions__step-label" data-testid="self-serve-guided-step-label">
|
||||||
|
<span class="guided-instructions__step-title" data-testid="self-serve-guided-step-title">
|
||||||
|
{{ normalizedStepIndex + 1 }}. {{ activeStep.title }}
|
||||||
|
</span>
|
||||||
|
<span class="guided-instructions__counter" data-testid="self-serve-guided-counter">
|
||||||
|
{{ normalizedStepIndex + 1 }}/{{ steps.length }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<div class="guided-instructions__content" data-testid="self-serve-guided-content">
|
||||||
<p v-if="activeStep.content" class="guided-instructions__paragraph">{{ activeStep.content }}</p>
|
<p v-if="activeStep.content" class="guided-instructions__paragraph">{{ activeStep.content }}</p>
|
||||||
<template v-for="(brush, brushIndex) in activeStep.brushes || []" :key="brushIndex">
|
<template v-for="(brush, brushIndex) in activeStep.brushes || []" :key="brushIndex">
|
||||||
<p class="guided-instructions__paragraph">
|
<p class="guided-instructions__paragraph">
|
||||||
@@ -105,14 +115,28 @@ const goNext = () => {
|
|||||||
.guided-instructions__header {
|
.guided-instructions__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.guided-instructions__step-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guided-instructions__step-title {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.guided-instructions__counter {
|
.guided-instructions__counter {
|
||||||
|
flex: 0 0 auto;
|
||||||
color: #566074;
|
color: #566074;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { BField, BIcon, BMessage, BRadioButton } from "buefy";
|
import { BIcon, BMessage, BRadioButton } from "buefy";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
lanes: Array<any>;
|
lanes: Array<any>;
|
||||||
@@ -19,14 +19,23 @@ const emit = defineEmits<{
|
|||||||
<template>
|
<template>
|
||||||
<div data-testid="self-serve-lane-step">
|
<div data-testid="self-serve-lane-step">
|
||||||
<h1 class="title has-text-centered">{{ $t("self_wash.start_wash") }}</h1>
|
<h1 class="title has-text-centered">{{ $t("self_wash.start_wash") }}</h1>
|
||||||
<b-field :label="$t('self_wash.wash_lane')">
|
<section class="self-serve-choice-group" data-testid="self-serve-lane-choice-group">
|
||||||
<div class="columns is-multiline is-mobile" :class="{ 'is-centered': lanes.length >= 2 }">
|
<h2 class="self-serve-choice-label">{{ $t("self_wash.wash_lane") }}</h2>
|
||||||
|
<div
|
||||||
|
class="self-serve-choice-grid"
|
||||||
|
:class="{ 'self-serve-choice-grid--centered': lanes.length >= 2 }"
|
||||||
|
data-testid="self-serve-lane-options"
|
||||||
|
>
|
||||||
<template v-for="lane in lanes" :key="lane.id">
|
<template v-for="lane in lanes" :key="lane.id">
|
||||||
<div class="column is-half-mobile is-one-third-tablet is-one-quarter-desktop">
|
<div class="self-serve-choice-grid__item">
|
||||||
<b-radio-button
|
<b-radio-button
|
||||||
|
class="self-serve-choice-card"
|
||||||
:model-value="selectedLaneId"
|
:model-value="selectedLaneId"
|
||||||
:native-value="lane.id"
|
:native-value="lane.id"
|
||||||
type="is-link"
|
type="is-link"
|
||||||
|
:disabled="!isLaneAvailable(lane)"
|
||||||
|
:title="!isLaneAvailable(lane) ? $t('self_wash.lane_unavailable') : null"
|
||||||
|
:aria-label="!isLaneAvailable(lane) ? $t('self_wash.lane_unavailable') : null"
|
||||||
:data-testid="`self-serve-lane-option-${lane.id}`"
|
:data-testid="`self-serve-lane-option-${lane.id}`"
|
||||||
@update:model-value="emit('update:selectedLaneId', lane.id)"
|
@update:model-value="emit('update:selectedLaneId', lane.id)"
|
||||||
@input="emit('update:selectedLaneId', lane.id)"
|
@input="emit('update:selectedLaneId', lane.id)"
|
||||||
@@ -36,7 +45,7 @@ const emit = defineEmits<{
|
|||||||
<span v-if="!isLaneAvailable(lane)">
|
<span v-if="!isLaneAvailable(lane)">
|
||||||
<small>
|
<small>
|
||||||
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
|
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
|
||||||
{{ $t("self_wash.occupied") }}
|
{{ $t("self_wash.lane_unavailable") }}
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
@@ -49,18 +58,18 @@ const emit = defineEmits<{
|
|||||||
</b-radio-button>
|
</b-radio-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="lanes.length === 0" class="column is-12">
|
|
||||||
<b-message type="is-warning" aria-close-label="Luk besked">
|
|
||||||
Ingen vaskebaner tilgaengelige for selvvask i <b>{{ departmentName || "denne" }}</b> afdeling.
|
|
||||||
</b-message>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</b-field>
|
<b-message v-if="lanes.length === 0" type="is-warning" aria-close-label="Luk besked">
|
||||||
|
Ingen vaskebaner tilgængelige for selvvask i <b>{{ departmentName || "denne" }}</b> afdeling.
|
||||||
|
</b-message>
|
||||||
|
</section>
|
||||||
|
|
||||||
<b-field label="Maskine eller manuel vask">
|
<section class="self-serve-choice-group" data-testid="self-serve-wash-type-group">
|
||||||
<div class="columns is-mobile is-centered is-multiline">
|
<h2 class="self-serve-choice-label">Maskine eller manuel vask</h2>
|
||||||
<div class="column is-half-mobile is-one-third-tablet is-one-quarter-desktop">
|
<div class="self-serve-choice-grid self-serve-choice-grid--centered" data-testid="self-serve-wash-type-options">
|
||||||
|
<div class="self-serve-choice-grid__item">
|
||||||
<b-radio-button
|
<b-radio-button
|
||||||
|
class="self-serve-choice-card"
|
||||||
:model-value="washType"
|
:model-value="washType"
|
||||||
native-value="Manual"
|
native-value="Manual"
|
||||||
type="is-link"
|
type="is-link"
|
||||||
@@ -72,13 +81,14 @@ const emit = defineEmits<{
|
|||||||
<span>Manuel<br /></span>
|
<span>Manuel<br /></span>
|
||||||
<small>
|
<small>
|
||||||
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
|
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
|
||||||
Tilgaengelig
|
Tilgængelig
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
</b-radio-button>
|
</b-radio-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile is-one-third-tablet is-one-quarter-desktop">
|
<div class="self-serve-choice-grid__item">
|
||||||
<b-radio-button
|
<b-radio-button
|
||||||
|
class="self-serve-choice-card"
|
||||||
:model-value="washType"
|
:model-value="washType"
|
||||||
native-value="Machine"
|
native-value="Machine"
|
||||||
type="is-link"
|
type="is-link"
|
||||||
@@ -105,6 +115,77 @@ const emit = defineEmits<{
|
|||||||
</b-radio-button>
|
</b-radio-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</b-field>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const emit = defineEmits<{
|
|||||||
<div class="card-footer-item">
|
<div class="card-footer-item">
|
||||||
<button
|
<button
|
||||||
class="button is-fullwidth"
|
class="button is-fullwidth"
|
||||||
:class="[answers[question.id] === false ? 'is-danger is-light' : 'is-light']"
|
:class="[answers[question.id] === false ? 'is-danger' : 'is-light']"
|
||||||
:data-testid="`self-serve-question-${question.id}-no`"
|
:data-testid="`self-serve-question-${question.id}-no`"
|
||||||
@click="emit('answer-question', question.id, false)"
|
@click="emit('answer-question', question.id, false)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { BButton, BIcon, BTooltip } from "buefy";
|
import { BButton, BIcon } from "buefy";
|
||||||
import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue";
|
import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -7,18 +7,9 @@ defineProps<{
|
|||||||
visibleQuestions: Array<any>;
|
visibleQuestions: Array<any>;
|
||||||
answers: Record<number, boolean | undefined>;
|
answers: Record<number, boolean | undefined>;
|
||||||
editAnswers: boolean;
|
editAnswers: boolean;
|
||||||
showDebug: boolean;
|
|
||||||
conditions: Array<any>;
|
|
||||||
rules: Array<any>;
|
|
||||||
evaluateCondition: (conditionId: number) => boolean;
|
|
||||||
evaluateRule: (rule: any, visited?: Set<number>) => boolean;
|
|
||||||
isQuestionVisible: (question: any) => boolean;
|
|
||||||
getConditionById: (conditionId: number) => any;
|
|
||||||
getRuleTypeLabel: (type: string) => string;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "toggle-debug"): void;
|
|
||||||
(e: "toggle-edit"): void;
|
(e: "toggle-edit"): void;
|
||||||
(e: "answer-question", questionId: number, value: boolean): void;
|
(e: "answer-question", questionId: number, value: boolean): void;
|
||||||
}>();
|
}>();
|
||||||
@@ -35,27 +26,8 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
|
|||||||
<p>{{ $t("self_wash.loading_data") }}</p>
|
<p>{{ $t("self_wash.loading_data") }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div
|
|
||||||
v-if="isLoading"
|
|
||||||
class="notification is-info is-light py-2 px-3 mb-4"
|
|
||||||
data-testid="self-serve-questions-inline-loading"
|
|
||||||
>
|
|
||||||
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-small" />
|
|
||||||
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="is-flex is-justify-content-center is-align-items-center mb-4">
|
<div class="is-flex is-justify-content-center is-align-items-center mb-4">
|
||||||
<h1 class="title mb-0">{{ $t("self_wash.answer_questions") }}</h1>
|
<h1 class="title mb-0">{{ $t("self_wash.answer_questions") }}</h1>
|
||||||
<b-button
|
|
||||||
size="is-small"
|
|
||||||
icon-left="bug"
|
|
||||||
type="is-ghost"
|
|
||||||
class="ml-2"
|
|
||||||
data-testid="self-serve-toggle-debug"
|
|
||||||
@click="emit('toggle-debug')"
|
|
||||||
>
|
|
||||||
Debug
|
|
||||||
</b-button>
|
|
||||||
<b-button
|
<b-button
|
||||||
v-if="editAnswers"
|
v-if="editAnswers"
|
||||||
size="is-small"
|
size="is-small"
|
||||||
@@ -69,75 +41,16 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
|
|||||||
</b-button>
|
</b-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showDebug" class="box mb-4 has-background-light" data-testid="self-serve-debug-panel">
|
<div class="self-serve-questions-status-slot" aria-live="polite">
|
||||||
<h5 class="subtitle is-5">Debug: Betingelser</h5>
|
<div
|
||||||
<div class="tags">
|
class="notification is-info is-light py-2 px-3 mb-0"
|
||||||
<b-tooltip
|
:class="{ 'is-invisible': !isLoading }"
|
||||||
v-for="condition in conditions"
|
data-testid="self-serve-questions-inline-loading"
|
||||||
:key="condition.id"
|
:aria-hidden="!isLoading"
|
||||||
position="is-top"
|
>
|
||||||
multilined
|
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-small" />
|
||||||
type="is-dark"
|
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
||||||
>
|
|
||||||
<template #content>
|
|
||||||
<div class="has-text-left">
|
|
||||||
<p v-if="condition.description" class="mb-2"><i>{{ condition.description }}</i></p>
|
|
||||||
<p class="is-size-7 has-text-weight-bold mb-1">Regler:</p>
|
|
||||||
<div
|
|
||||||
v-for="rule in rules.filter(entry => parseInt(entry.condition_id as any) === parseInt(condition.id))"
|
|
||||||
:key="rule.id"
|
|
||||||
class="is-size-7"
|
|
||||||
>
|
|
||||||
<span class="icon is-small">
|
|
||||||
<i :class="evaluateRule(rule, new Set([parseInt(condition.id)])) ? 'fas fa-check has-text-success' : 'fas fa-times has-text-danger'" />
|
|
||||||
</span>
|
|
||||||
<span class="ml-1">[{{ getRuleTypeLabel(rule.type) }}] {{ rule.name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<span class="tag" :class="evaluateCondition(condition.id) ? 'is-success' : 'is-light'">
|
|
||||||
<span class="icon is-small mr-1">
|
|
||||||
<i :class="evaluateCondition(condition.id) ? 'fas fa-check-circle' : 'fas fa-times-circle'" />
|
|
||||||
</span>
|
|
||||||
{{ condition.name }}
|
|
||||||
</span>
|
|
||||||
</b-tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<h5 class="subtitle is-5">Debug: Alle mulige sporgsmal (synlighed)</h5>
|
|
||||||
<ul>
|
|
||||||
<li v-for="question in visibleQuestions" :key="question.id" class="is-size-7">
|
|
||||||
<span class="icon is-small">
|
|
||||||
<i :class="isQuestionVisible(question) ? 'fas fa-eye has-text-success' : 'fas fa-eye-slash has-text-grey-light'" />
|
|
||||||
</span>
|
|
||||||
{{ question.question }}
|
|
||||||
<b-tooltip v-if="question.condition_id" position="is-top" multilined type="is-dark">
|
|
||||||
<template #content>
|
|
||||||
<div v-if="getConditionById(question.condition_id)" class="has-text-left">
|
|
||||||
<p v-if="getConditionById(question.condition_id).description" class="mb-2">
|
|
||||||
<i>{{ getConditionById(question.condition_id).description }}</i>
|
|
||||||
</p>
|
|
||||||
<p class="is-size-7 has-text-weight-bold mb-1">Regler:</p>
|
|
||||||
<div
|
|
||||||
v-for="rule in rules.filter(entry => parseInt(entry.condition_id as any) === parseInt(question.condition_id))"
|
|
||||||
:key="rule.id"
|
|
||||||
class="is-size-7"
|
|
||||||
>
|
|
||||||
<span class="icon is-small">
|
|
||||||
<i :class="evaluateRule(rule, new Set([parseInt(question.condition_id)])) ? 'fas fa-check has-text-success' : 'fas fa-times has-text-danger'" />
|
|
||||||
</span>
|
|
||||||
<span class="ml-1">[{{ getRuleTypeLabel(rule.type) }}] {{ rule.name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<span class="has-text-grey is-clickable">
|
|
||||||
(Hvis: {{ getConditionById(question.condition_id)?.name || question.condition_id }})
|
|
||||||
</span>
|
|
||||||
</b-tooltip>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelfServeQuestionCards
|
<SelfServeQuestionCards
|
||||||
@@ -148,3 +61,20 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.self-serve-questions-status-slot {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-questions-status-slot .notification {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-questions-status-slot .notification.is-invisible {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { BCheckbox, BField, BIcon } from "buefy";
|
import { BCheckbox, BField, BIcon } from "buefy";
|
||||||
|
import { getSelfServeTaskDynamicImagePresentation } from "@/services/selfServeDynamicImage.js";
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
tasks: Array<any>;
|
tasks: Array<any>;
|
||||||
@@ -20,16 +21,6 @@ const isImageAttachment = (attachment: any) => (
|
|||||||
&& attachment.content.other.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)
|
&& attachment.content.other.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasTaskDescription = (task: any) => {
|
|
||||||
const description = task?.description;
|
|
||||||
if (typeof description !== "string") {
|
|
||||||
return !!description;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedDescription = description.trim();
|
|
||||||
return trimmedDescription.length > 0 && trimmedDescription !== "-";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getImageAttachments = (task: any) => {
|
const getImageAttachments = (task: any) => {
|
||||||
if (!Array.isArray(task?.attachments)) {
|
if (!Array.isArray(task?.attachments)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -74,20 +65,24 @@ const getProgramWheelSelection = (task: any) => {
|
|||||||
if (!taskUsesProgramPicker(task)) {
|
if (!taskUsesProgramPicker(task)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const rawValue = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
|
|
||||||
if (rawValue === null || rawValue === undefined || rawValue === "") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = Number.parseInt(String(rawValue), 10);
|
return getSelfServeTaskDynamicImagePresentation(task).thumbPosition;
|
||||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTaskTitle = (task: any) => {
|
const formatTaskTitle = (task: any) => {
|
||||||
const title = String(task?.task || "");
|
const title = String(task?.task || "");
|
||||||
const programWheelSelection = getProgramWheelSelection(task);
|
const programWheelSelection = getProgramWheelSelection(task);
|
||||||
|
|
||||||
return programWheelSelection === null ? title : `#${programWheelSelection} ${title}`;
|
if (programWheelSelection === null || /\bprogram\s*#\d+\b/i.test(title)) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
const programTitle = title.match(/^(.*?\bprogram)(\b.*)$/i);
|
||||||
|
if (programTitle) {
|
||||||
|
return `${programTitle[1]} #${programWheelSelection}${programTitle[2]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${title} #${programWheelSelection}`;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -122,13 +117,11 @@ const formatTaskTitle = (task: any) => {
|
|||||||
type="is-success"
|
type="is-success"
|
||||||
:data-testid="`self-serve-task-${task.id}-toggle`"
|
:data-testid="`self-serve-task-${task.id}-toggle`"
|
||||||
@update:model-value="emit('toggle-task', task.id, $event)"
|
@update:model-value="emit('toggle-task', task.id, $event)"
|
||||||
@input="emit('toggle-task', task.id, $event)"
|
|
||||||
/>
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
</div>
|
</div>
|
||||||
<div class="self-serve-task-image-overlay-text">
|
<div class="self-serve-task-image-overlay-text">
|
||||||
<p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p>
|
<p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p>
|
||||||
<p v-if="hasTaskDescription(task)" class="self-serve-task-description">{{ task.description }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,22 +144,21 @@ const formatTaskTitle = (task: any) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="columns is-mobile is-vcentered">
|
<div v-else class="self-serve-task-row">
|
||||||
<div v-if="props.showCheckboxes" class="column is-narrow">
|
<div v-if="props.showCheckboxes" class="self-serve-task-checkbox">
|
||||||
<b-field>
|
<b-field>
|
||||||
<b-checkbox
|
<b-checkbox
|
||||||
|
class="mr-0 pr-0"
|
||||||
size="is-large"
|
size="is-large"
|
||||||
:model-value="completedTasks[task.id] === true"
|
:model-value="completedTasks[task.id] === true"
|
||||||
type="is-success"
|
type="is-success"
|
||||||
:data-testid="`self-serve-task-${task.id}-toggle`"
|
:data-testid="`self-serve-task-${task.id}-toggle`"
|
||||||
@update:model-value="emit('toggle-task', task.id, $event)"
|
@update:model-value="emit('toggle-task', task.id, $event)"
|
||||||
@input="emit('toggle-task', task.id, $event)"
|
|
||||||
/>
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="self-serve-task-content">
|
||||||
<p><strong>{{ formatTaskTitle(task) }}</strong></p>
|
<p><strong>{{ formatTaskTitle(task) }}</strong></p>
|
||||||
<p v-if="hasTaskDescription(task)" class="is-size-7">{{ task.description }}</p>
|
|
||||||
<div v-if="getVisibleServices(task).length > 0" class="tags mt-2">
|
<div v-if="getVisibleServices(task).length > 0" class="tags mt-2">
|
||||||
<span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span>
|
<span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,7 +226,7 @@ const formatTaskTitle = (task: any) => {
|
|||||||
padding: 0.65rem 0.75rem;
|
padding: 0.65rem 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.6rem;
|
gap: 0.35rem;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,12 +234,30 @@ const formatTaskTitle = (task: any) => {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.self-serve-task-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-task-checkbox {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-task-checkbox :deep(.field) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-task-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-task-content p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.self-serve-task-title {
|
.self-serve-task-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.self-serve-task-description {
|
|
||||||
margin: 0.25rem 0 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { BIcon, BSkeleton } from "buefy";
|
import { BIcon } from "buefy";
|
||||||
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
|
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -19,10 +19,21 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isDynamicImageLoading = ref(false);
|
const isDynamicImageLoading = ref(false);
|
||||||
|
const failedDynamicImageUrl = ref<string | null>(null);
|
||||||
|
const hasRenderableDynamicImageUrl = computed(
|
||||||
|
() => !!props.dynamicImageUrl && failedDynamicImageUrl.value !== props.dynamicImageUrl
|
||||||
|
);
|
||||||
|
const showDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value && !isDynamicImageLoading.value);
|
||||||
|
const shouldRenderDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value);
|
||||||
|
|
||||||
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
|
watch(
|
||||||
isDynamicImageLoading.value = !!dynamicImageUrl;
|
() => props.dynamicImageUrl,
|
||||||
}, { immediate: true });
|
(dynamicImageUrl) => {
|
||||||
|
isDynamicImageLoading.value = !!dynamicImageUrl;
|
||||||
|
failedDynamicImageUrl.value = null;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
const emitToggleTask = (taskId: number, value: boolean) => {
|
const emitToggleTask = (taskId: number, value: boolean) => {
|
||||||
emit("toggle-task", taskId, value);
|
emit("toggle-task", taskId, value);
|
||||||
@@ -38,6 +49,7 @@ const onDynamicImageLoad = () => {
|
|||||||
|
|
||||||
const onDynamicImageError = () => {
|
const onDynamicImageError = () => {
|
||||||
isDynamicImageLoading.value = false;
|
isDynamicImageLoading.value = false;
|
||||||
|
failedDynamicImageUrl.value = props.dynamicImageUrl;
|
||||||
emit("clear-dynamic-image");
|
emit("clear-dynamic-image");
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -58,32 +70,43 @@ const onDynamicImageError = () => {
|
|||||||
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
v-if="dynamicImageUrl && isDynamicImageLoading"
|
||||||
|
:key="`${dynamicImageUrl}:preload`"
|
||||||
|
:src="dynamicImageUrl"
|
||||||
|
alt=""
|
||||||
|
data-testid="self-serve-dynamic-image-preload"
|
||||||
|
class="self-serve-dynamic-image-preload"
|
||||||
|
@load="onDynamicImageLoad"
|
||||||
|
@error="onDynamicImageError"
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="dynamicImageUrl"
|
v-if="shouldRenderDynamicImageFrame"
|
||||||
class="self-serve-dynamic-image-frame mb-4"
|
class="self-serve-dynamic-image-frame mb-4"
|
||||||
:class="{ 'is-loading': isDynamicImageLoading }"
|
data-testid="self-serve-dynamic-image-frame"
|
||||||
>
|
>
|
||||||
<b-skeleton
|
<div
|
||||||
v-if="isDynamicImageLoading"
|
v-if="isDynamicImageLoading"
|
||||||
class="self-serve-dynamic-image-skeleton"
|
class="self-serve-dynamic-image-skeleton"
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
data-testid="self-serve-dynamic-image-skeleton"
|
data-testid="self-serve-dynamic-image-skeleton"
|
||||||
/>
|
>
|
||||||
|
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-large" />
|
||||||
|
<span class="is-sr-only">{{ $t("self_wash.loading_data") }}</span>
|
||||||
|
</div>
|
||||||
<img
|
<img
|
||||||
|
v-if="showDynamicImageFrame"
|
||||||
:key="dynamicImageUrl"
|
:key="dynamicImageUrl"
|
||||||
:src="dynamicImageUrl"
|
:src="dynamicImageUrl"
|
||||||
alt="Machine status"
|
alt="Machine status"
|
||||||
data-testid="self-serve-dynamic-image"
|
data-testid="self-serve-dynamic-image"
|
||||||
class="self-serve-dynamic-image"
|
class="self-serve-dynamic-image"
|
||||||
:class="{ 'is-loading': isDynamicImageLoading }"
|
|
||||||
@load="onDynamicImageLoad"
|
@load="onDynamicImageLoad"
|
||||||
@error="onDynamicImageError"
|
@error="onDynamicImageError"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
|
<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">{{ $t("self_wash.start_machine") }}</h1>
|
||||||
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
|
|
||||||
<SelfServeTaskList
|
<SelfServeTaskList
|
||||||
:tasks="activeTasks"
|
:tasks="activeTasks"
|
||||||
:completedTasks="completedTasks"
|
:completedTasks="completedTasks"
|
||||||
@@ -98,36 +121,45 @@ const onDynamicImageError = () => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.self-serve-dynamic-image-frame {
|
.self-serve-dynamic-image-frame {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
overflow: hidden;
|
||||||
|
|
||||||
.self-serve-dynamic-image-frame.is-loading {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 640px;
|
|
||||||
min-height: 180px;
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.self-serve-dynamic-image-skeleton {
|
.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;
|
position: absolute;
|
||||||
inset: 0;
|
width: 0;
|
||||||
overflow: hidden;
|
height: 0;
|
||||||
border-radius: 4px;
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 769px) {
|
||||||
|
.self-serve-dynamic-image-frame {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.self-serve-dynamic-image {
|
.self-serve-dynamic-image {
|
||||||
max-width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: 100%;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
display: block;
|
display: block;
|
||||||
margin-left: auto;
|
object-fit: contain;
|
||||||
margin-right: auto;
|
|
||||||
transition: opacity 120ms ease;
|
transition: opacity 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.self-serve-dynamic-image.is-loading {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
|
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
|
||||||
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
|
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
|
||||||
|
|
||||||
@@ -16,12 +16,16 @@ const props = defineProps<{
|
|||||||
availableProductIds: number[];
|
availableProductIds: number[];
|
||||||
vehicleTypes: Array<any>;
|
vehicleTypes: Array<any>;
|
||||||
vehicleStepError?: string | null;
|
vehicleStepError?: string | null;
|
||||||
|
vehicleStepGuidance?: string | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "update:customerNumber", value: string): void;
|
(e: "update:customerNumber", value: string): void;
|
||||||
|
(e: "update:customer-number", value: string): void;
|
||||||
(e: "update:registrationNumber", value: string): void;
|
(e: "update:registrationNumber", value: string): void;
|
||||||
|
(e: "update:registration-number", value: string): void;
|
||||||
(e: "select-vehicle-type", selection: any): void;
|
(e: "select-vehicle-type", selection: any): void;
|
||||||
|
(e: "selected", selection: any): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const extractRegistrationValue = (value: unknown): string => {
|
const extractRegistrationValue = (value: unknown): string => {
|
||||||
@@ -48,6 +52,19 @@ const normalizeRegistration = (value: unknown) => extractRegistrationValue(value
|
|||||||
|
|
||||||
const typedRegistration = ref(normalizeRegistration(props.registrationNumber));
|
const typedRegistration = ref(normalizeRegistration(props.registrationNumber));
|
||||||
|
|
||||||
|
const filteredRegistrationOptions = computed(() => {
|
||||||
|
const query = normalizeRegistration(typedRegistration.value);
|
||||||
|
const options = props.registrationOptions || [];
|
||||||
|
if (!query) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.filter((option) => {
|
||||||
|
const normalizedOption = normalizeRegistration(option);
|
||||||
|
return normalizedOption !== query && normalizedOption.includes(query);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
watch(() => props.registrationNumber, (newValue) => {
|
watch(() => props.registrationNumber, (newValue) => {
|
||||||
typedRegistration.value = normalizeRegistration(newValue);
|
typedRegistration.value = normalizeRegistration(newValue);
|
||||||
});
|
});
|
||||||
@@ -56,6 +73,18 @@ const emitRegistration = (value: unknown) => {
|
|||||||
const normalized = normalizeRegistration(value);
|
const normalized = normalizeRegistration(value);
|
||||||
typedRegistration.value = normalized;
|
typedRegistration.value = normalized;
|
||||||
emit("update:registrationNumber", normalized);
|
emit("update:registrationNumber", normalized);
|
||||||
|
emit("update:registration-number", normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitCustomerNumber = (value: unknown) => {
|
||||||
|
const normalized = String(value || "");
|
||||||
|
emit("update:customerNumber", normalized);
|
||||||
|
emit("update:customer-number", normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitVehicleTypeSelection = (selection: any) => {
|
||||||
|
emit("select-vehicle-type", selection);
|
||||||
|
emit("selected", selection);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -67,38 +96,41 @@ const emitRegistration = (value: unknown) => {
|
|||||||
:model-value="customerNumber || ''"
|
:model-value="customerNumber || ''"
|
||||||
data-testid="self-serve-customer-number"
|
data-testid="self-serve-customer-number"
|
||||||
:placeholder="$t('self_wash.enter_customer_number')"
|
:placeholder="$t('self_wash.enter_customer_number')"
|
||||||
@input="emit('update:customerNumber', String($event || ''))"
|
@input="emitCustomerNumber($event)"
|
||||||
|
@update:modelValue="emitCustomerNumber($event)"
|
||||||
/>
|
/>
|
||||||
</b-field>
|
</b-field>
|
||||||
<b-field :label="$t('self_wash.registration_number')">
|
<b-field :label="$t('self_wash.registration_number')" class="self-serve-registration-field">
|
||||||
<b-autocomplete
|
<div class="self-serve-registration-control">
|
||||||
:model-value="typedRegistration"
|
<b-autocomplete
|
||||||
data-testid="self-serve-registration"
|
:model-value="typedRegistration"
|
||||||
:data="registrationOptions"
|
data-testid="self-serve-registration"
|
||||||
:placeholder="$t('self_wash.enter_registration_number')"
|
:data="filteredRegistrationOptions"
|
||||||
:debounce="300"
|
:placeholder="$t('self_wash.enter_registration_number')"
|
||||||
:min-length="1"
|
:debounce="300"
|
||||||
:filter="(option, query) => option.toLowerCase().includes(query.toLowerCase())"
|
:min-length="1"
|
||||||
icon-pack="fas"
|
icon-pack="fas"
|
||||||
icon="car-side"
|
icon="car-side"
|
||||||
clearable
|
clearable
|
||||||
:loading="registrationOptions.length === 0 && isCustomerVehiclesLoading"
|
:loading="filteredRegistrationOptions.length === 0 && isCustomerVehiclesLoading"
|
||||||
:selectable-header="true"
|
@select="emitRegistration($event)"
|
||||||
@select="emitRegistration($event)"
|
@typing="emitRegistration($event)"
|
||||||
@typing="emitRegistration($event)"
|
@update:modelValue="emitRegistration($event)"
|
||||||
@update:modelValue="emitRegistration($event)"
|
>
|
||||||
@select-header="emitRegistration(typedRegistration)"
|
</b-autocomplete>
|
||||||
>
|
<p
|
||||||
<template #header>
|
v-if="typedRegistration"
|
||||||
|
class="help self-serve-registration-guidance"
|
||||||
|
data-testid="self-serve-registration-guidance"
|
||||||
|
>
|
||||||
<template v-if="hasMatchingVehicle">
|
<template v-if="hasMatchingVehicle">
|
||||||
{{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }}
|
{{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
{{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }}
|
{{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }}
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</p>
|
||||||
<template #empty>{{ $t("self_wash.no_vehicles_found") }}</template>
|
</div>
|
||||||
</b-autocomplete>
|
|
||||||
</b-field>
|
</b-field>
|
||||||
<b-message
|
<b-message
|
||||||
v-if="props.vehicleStepError"
|
v-if="props.vehicleStepError"
|
||||||
@@ -109,12 +141,21 @@ const emitRegistration = (value: unknown) => {
|
|||||||
>
|
>
|
||||||
{{ props.vehicleStepError }}
|
{{ props.vehicleStepError }}
|
||||||
</b-message>
|
</b-message>
|
||||||
|
<b-message
|
||||||
|
v-else-if="props.vehicleStepGuidance"
|
||||||
|
type="is-info"
|
||||||
|
has-icon
|
||||||
|
:closable="false"
|
||||||
|
data-testid="self-serve-vehicle-step-guidance"
|
||||||
|
>
|
||||||
|
{{ props.vehicleStepGuidance }}
|
||||||
|
</b-message>
|
||||||
<b-field :label="$t('self_wash.select_your_vehicle')">
|
<b-field :label="$t('self_wash.select_your_vehicle')">
|
||||||
<template v-if="availableProductIds.length > 0">
|
<template v-if="availableProductIds.length > 0">
|
||||||
<SelfServeVehicleTypeSelector
|
<SelfServeVehicleTypeSelector
|
||||||
:selectedVehicleTypeId="selectedVehicleTypeId"
|
:selectedVehicleTypeId="selectedVehicleTypeId"
|
||||||
:restrictToProductIds="availableProductIds"
|
:restrictToProductIds="availableProductIds"
|
||||||
@selected="emit('select-vehicle-type', $event)"
|
@selected="emitVehicleTypeSelection"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="vehicleTypes.length === 0">
|
<template v-else-if="vehicleTypes.length === 0">
|
||||||
@@ -130,3 +171,34 @@ const emitRegistration = (value: unknown) => {
|
|||||||
</b-field>
|
</b-field>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.self-serve-registration-control {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-registration-guidance {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-registration-field :deep(.dropdown),
|
||||||
|
.self-serve-registration-field :deep(.autocomplete) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-registration-field :deep(.dropdown-menu) {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-registration-field :deep(.dropdown-item) {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const getLoadingVehicleTypes = (count: number): VehicleTypeTemplate[] => {
|
|||||||
for (let index = 0; index < count; index += 1) {
|
for (let index = 0; index < count; index += 1) {
|
||||||
loadingTypes.push({
|
loadingTypes.push({
|
||||||
id: index,
|
id: index,
|
||||||
name: "Indlaeser...",
|
name: "Indlæser...",
|
||||||
price: 0,
|
price: 0,
|
||||||
loading: true,
|
loading: true,
|
||||||
});
|
});
|
||||||
@@ -146,7 +146,7 @@ watch(() => props.selectedVehicleTypeId, (newId) => {
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
Vaelg venligst din koretojstype ved at klikke pa ikonet ovenfor.
|
Vælg en køretøjstype ved at trykke på et af ikonerne ovenfor.
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<!--
|
||||||
|
VehicleInputSection.vue – customer number, license plate, and vehicle type inputs.
|
||||||
|
|
||||||
|
Props:
|
||||||
|
showCustomerNumberInput – whether to render the customer number field
|
||||||
|
customerNumber – current customer number string
|
||||||
|
registrationNumber – current license plate string
|
||||||
|
registrationOptions – autocomplete options for existing vehicles
|
||||||
|
selectedVehicleTypeId – currently selected vehicle type id
|
||||||
|
selectedVehicleName – human-readable name of selected vehicle type
|
||||||
|
availableProductIds – allowed product ids for the department
|
||||||
|
vehicleTypes – list of vehicle type options
|
||||||
|
|
||||||
|
Emit:
|
||||||
|
update:customerNumber – new customer number string
|
||||||
|
update:registrationNumber – new license plate string
|
||||||
|
selectVehicleType – selected VehicleTypeTemplate object
|
||||||
|
-->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import SelfServeVehicleStep from "@/components/displays/selfServe/SelfServeVehicleStep.vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showCustomerNumberInput: boolean;
|
||||||
|
customerNumber: string | null;
|
||||||
|
registrationNumber: string | null;
|
||||||
|
registrationOptions: string[];
|
||||||
|
selectedVehicleTypeId: number | null;
|
||||||
|
selectedVehicleName: string | null;
|
||||||
|
availableProductIds: (number | string)[];
|
||||||
|
vehicleTypes: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:customerNumber": [value: string];
|
||||||
|
"update:registrationNumber": [value: string];
|
||||||
|
selectVehicleType: [type: { id: number }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleCustomerNumberUpdate(value: string) {
|
||||||
|
emit("update:customerNumber", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRegistrationNumberUpdate(value: string) {
|
||||||
|
emit("update:registrationNumber", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectVehicleType(selection: any) {
|
||||||
|
emit("selectVehicleType", selection);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelfServeVehicleStep
|
||||||
|
:customer-number="customerNumber"
|
||||||
|
:show-customer-number-input="showCustomerNumberInput"
|
||||||
|
:registration-number="registrationNumber"
|
||||||
|
:registration-options="registrationOptions"
|
||||||
|
:is-customer-vehicles-loading="false"
|
||||||
|
:has-matching-vehicle="false"
|
||||||
|
:selected-vehicle-type-id="selectedVehicleTypeId"
|
||||||
|
:selected-vehicle-name="selectedVehicleName"
|
||||||
|
:selected-vehicle-description="null"
|
||||||
|
:available-product-ids="availableProductIds"
|
||||||
|
:vehicle-types="vehicleTypes"
|
||||||
|
:vehicle-step-error="null"
|
||||||
|
:vehicle-step-guidance="null"
|
||||||
|
@update:customer-number="handleCustomerNumberUpdate"
|
||||||
|
@update:customerNumber="handleCustomerNumberUpdate"
|
||||||
|
@update:registration-number="handleRegistrationNumberUpdate"
|
||||||
|
@update:registrationNumber="handleRegistrationNumberUpdate"
|
||||||
|
@select-vehicle-type="handleSelectVehicleType"
|
||||||
|
@selected="handleSelectVehicleType"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Vehicle input section styling is inherited from SelfServeVehicleStep */
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<!--
|
||||||
|
WashProgressCard.vue – timer, guided instructions, and bottom actions during active wash.
|
||||||
|
|
||||||
|
Renders when a wash session is in progress (WASH_IN_PROGRESS step).
|
||||||
|
|
||||||
|
Props:
|
||||||
|
currentGuidedWashStep – index into guidedWashFlowSteps
|
||||||
|
guidedWashFlowSteps – array of instruction step objects
|
||||||
|
formattedElapsed – human-readable elapsed time string
|
||||||
|
isCompletingWash – whether the wash is finishing right now
|
||||||
|
openingPropertyAccessGate – loading state for access gate button
|
||||||
|
openingPropertyExitGate – loading state for exit gate button
|
||||||
|
Emit:
|
||||||
|
update:currentGuidedWashStep – new step index
|
||||||
|
goPreviousGuidedWashStep – navigate to previous instruction
|
||||||
|
goNextGuidedWashStep – navigate to next instruction
|
||||||
|
completeWash – finish the wash session
|
||||||
|
openPropertyAccessGate – open the property access gate (laneId)
|
||||||
|
openPropertyExitGate – open the property exit gate (laneId)
|
||||||
|
requestAssistance – call for assistance
|
||||||
|
-->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { BButton } from "buefy";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentGuidedWashStep: number;
|
||||||
|
guidedWashFlowSteps: any[];
|
||||||
|
formattedElapsed: string;
|
||||||
|
isCompletingWash: boolean;
|
||||||
|
openingPropertyAccessGate: boolean;
|
||||||
|
openingPropertyExitGate: boolean;
|
||||||
|
showProgressActions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showProgressActions: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:currentGuidedWashStep": [step: number];
|
||||||
|
goPreviousGuidedWashStep: [];
|
||||||
|
goNextGuidedWashStep: [];
|
||||||
|
completeWash: [];
|
||||||
|
openPropertyAccessGate: [];
|
||||||
|
openPropertyExitGate: [];
|
||||||
|
requestAssistance: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isLastGuidedWashStep = computed(
|
||||||
|
() =>
|
||||||
|
Array.isArray(props.guidedWashFlowSteps) &&
|
||||||
|
props.guidedWashFlowSteps.length > 0 &&
|
||||||
|
props.currentGuidedWashStep >= props.guidedWashFlowSteps.length - 1
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleGoPrevious() {
|
||||||
|
emit("goPreviousGuidedWashStep");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGoNext() {
|
||||||
|
emit("goNextGuidedWashStep");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleComplete() {
|
||||||
|
emit("completeWash");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenAccessGate() {
|
||||||
|
emit("openPropertyAccessGate");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenExitGate() {
|
||||||
|
emit("openPropertyExitGate");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRequestAssistance() {
|
||||||
|
emit("requestAssistance");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div data-testid="self-serve-wash-progress">
|
||||||
|
<!-- Finishing screen -->
|
||||||
|
<div
|
||||||
|
v-if="isCompletingWash && showProgressActions"
|
||||||
|
class="notification is-info is-light self-serve-finishing-screen"
|
||||||
|
data-testid="self-serve-finishing-wash"
|
||||||
|
>
|
||||||
|
<p class="title is-5 mb-0">{{ $t("self_wash.finishing_wash_exit_opening") }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Guided wash instructions (stepped walkthrough) -->
|
||||||
|
<template v-if="!isCompletingWash">
|
||||||
|
<!--
|
||||||
|
Accept a callback slot for the guided-instructions component so the
|
||||||
|
parent can plug in SelfServeGuidedInstructions or its own implementation.
|
||||||
|
-->
|
||||||
|
<slot
|
||||||
|
v-if="showProgressActions"
|
||||||
|
name="guided-instructions"
|
||||||
|
:current-step="currentGuidedWashStep"
|
||||||
|
:steps="guidedWashFlowSteps"
|
||||||
|
:is-last-step="isLastGuidedWashStep"
|
||||||
|
@update:current-step="emit('update:currentGuidedWashStep', $event)"
|
||||||
|
>
|
||||||
|
<!-- Default: render nothing (parent provides via slot) -->
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<!-- Bottom action bar while wash is in progress -->
|
||||||
|
<div
|
||||||
|
v-show="showProgressActions"
|
||||||
|
class="self-serve-bottom-actions"
|
||||||
|
data-testid="self-serve-bottom-actions"
|
||||||
|
>
|
||||||
|
<!-- Help / assistance -->
|
||||||
|
<div class="self-serve-bottom-actions__row" data-testid="self-serve-session-bottom-actions">
|
||||||
|
<b-button
|
||||||
|
class="self-serve-bottom-actions__button"
|
||||||
|
type="is-link is-light"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-right="info-circle"
|
||||||
|
data-testid="self-serve-nav-help"
|
||||||
|
@click.prevent="handleRequestAssistance"
|
||||||
|
>
|
||||||
|
{{ $t("self_wash.assistance") }}
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Property gate controls -->
|
||||||
|
<div class="self-serve-bottom-actions__row" data-testid="self-serve-property-gate-actions">
|
||||||
|
<b-button
|
||||||
|
class="self-serve-bottom-actions__button"
|
||||||
|
type="is-link is-light"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-left="sign-in-alt"
|
||||||
|
data-testid="self-serve-nav-open-property-access-gate"
|
||||||
|
:loading="openingPropertyAccessGate"
|
||||||
|
:disabled="openingPropertyAccessGate"
|
||||||
|
@click.prevent="handleOpenAccessGate"
|
||||||
|
>
|
||||||
|
{{ $t("self_wash.open_property_access_gate") }}
|
||||||
|
</b-button>
|
||||||
|
|
||||||
|
<b-button
|
||||||
|
class="self-serve-bottom-actions__button"
|
||||||
|
type="is-link is-light"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-left="sign-out-alt"
|
||||||
|
data-testid="self-serve-nav-open-property-exit-gate"
|
||||||
|
:loading="openingPropertyExitGate"
|
||||||
|
:disabled="openingPropertyExitGate"
|
||||||
|
@click.prevent="handleOpenExitGate"
|
||||||
|
>
|
||||||
|
{{ $t("self_wash.open_property_exit_gate") }}
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Guided step navigation -->
|
||||||
|
<div class="self-serve-bottom-actions__row" data-testid="self-serve-guided-bottom-actions">
|
||||||
|
<b-button
|
||||||
|
class="self-serve-bottom-actions__button"
|
||||||
|
type="is-link is-light"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-left="arrow-left"
|
||||||
|
data-testid="self-serve-guided-prev"
|
||||||
|
:disabled="currentGuidedWashStep === 0"
|
||||||
|
@click.prevent="handleGoPrevious"
|
||||||
|
>
|
||||||
|
{{ $t("common.previous") }}
|
||||||
|
</b-button>
|
||||||
|
|
||||||
|
<b-button
|
||||||
|
v-if="!isLastGuidedWashStep"
|
||||||
|
class="self-serve-bottom-actions__button"
|
||||||
|
type="is-link"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-right="arrow-right"
|
||||||
|
data-testid="self-serve-guided-next"
|
||||||
|
@click.prevent="handleGoNext"
|
||||||
|
>
|
||||||
|
{{ $t("common.next") }}
|
||||||
|
</b-button>
|
||||||
|
|
||||||
|
<b-button
|
||||||
|
v-else
|
||||||
|
class="self-serve-bottom-actions__button"
|
||||||
|
type="is-link"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-right="check"
|
||||||
|
data-testid="self-serve-nav-complete"
|
||||||
|
:loading="isCompletingWash"
|
||||||
|
:disabled="isCompletingWash"
|
||||||
|
@click.prevent="handleComplete"
|
||||||
|
>
|
||||||
|
{{ $t("common.done") }}
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.self-serve-finishing-screen {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 32rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-bottom-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0.75rem auto 0;
|
||||||
|
max-width: 30rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-bottom-actions__row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-bottom-actions__button {
|
||||||
|
flex: 1 1 0;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
max-width: 14rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.self-serve-bottom-actions {
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #dfe5f0;
|
||||||
|
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
box-shadow: 0 -0.25rem 0.75rem rgba(17, 47, 95, 0.08);
|
||||||
|
left: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-width: none;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
z-index: 41;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-bottom-actions__button {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
max-width: none;
|
||||||
|
min-height: 2.65rem;
|
||||||
|
padding-left: 0.55rem;
|
||||||
|
padding-right: 0.55rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<!--
|
||||||
|
WashTypeSelector.vue – manual vs machine wash toggle.
|
||||||
|
|
||||||
|
Renders two radio-style cards: "Manual" (always available) and
|
||||||
|
"Machine" (available when the lane supports it).
|
||||||
|
|
||||||
|
Props:
|
||||||
|
modelValue – current wash type ("Manual" | "Machine")
|
||||||
|
isMachineAvailable – whether machine wash is available for this lane
|
||||||
|
|
||||||
|
Emit:
|
||||||
|
update:modelValue – new wash type string
|
||||||
|
-->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { BRadioButton } from "buefy";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: string;
|
||||||
|
isMachineAvailable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [type: string];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="self-serve-choice-group" data-testid="self-serve-wash-type-group">
|
||||||
|
<h2 class="self-serve-choice-label">{{ $t("self_wash.wash_type") }}</h2>
|
||||||
|
<div
|
||||||
|
class="self-serve-choice-grid self-serve-choice-grid--centered"
|
||||||
|
data-testid="self-serve-wash-type-options"
|
||||||
|
>
|
||||||
|
<!-- Manual option (always available) -->
|
||||||
|
<div class="self-serve-choice-grid__item">
|
||||||
|
<b-radio-button
|
||||||
|
class="self-serve-choice-card"
|
||||||
|
:model-value="modelValue"
|
||||||
|
native-value="Manual"
|
||||||
|
type="is-link"
|
||||||
|
data-testid="self-serve-wash-type-manual"
|
||||||
|
@update:model-value="emit('update:modelValue', 'Manual')"
|
||||||
|
@input="emit('update:modelValue', 'Manual')"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span>{{ $t("self_wash.manual") }}<br /></span>
|
||||||
|
<small>
|
||||||
|
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
|
||||||
|
{{ $t("self_wash.available") }}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</b-radio-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Machine option (conditioned) -->
|
||||||
|
<div class="self-serve-choice-grid__item">
|
||||||
|
<b-radio-button
|
||||||
|
class="self-serve-choice-card"
|
||||||
|
:model-value="modelValue"
|
||||||
|
native-value="Machine"
|
||||||
|
type="is-link"
|
||||||
|
:disabled="!isMachineAvailable"
|
||||||
|
data-testid="self-serve-wash-type-machine"
|
||||||
|
@update:model-value="emit('update:modelValue', 'Machine')"
|
||||||
|
@input="emit('update:modelValue', 'Machine')"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span>{{ $t("self_wash.machine") }}<br /></span>
|
||||||
|
<span v-if="!isMachineAvailable">
|
||||||
|
<small>
|
||||||
|
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
|
||||||
|
{{ $t("self_wash.unavailable") }}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<small>
|
||||||
|
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
|
||||||
|
{{ $t("self_wash.available") }}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</b-radio-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.self-serve-choice-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-label {
|
||||||
|
color: #303440;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-grid--centered {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-grid__item {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-card {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 5rem;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
white-space: normal;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-card :deep(.button) {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 5rem;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
white-space: normal;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-card :deep(.button),
|
||||||
|
.self-serve-choice-card span,
|
||||||
|
.self-serve-choice-card :deep(.button span) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-serve-choice-card small,
|
||||||
|
.self-serve-choice-card :deep(.button small) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 769px) {
|
||||||
|
.self-serve-choice-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(11rem, 15rem));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -129,6 +129,19 @@ const getOrderItemFlags = (orderItem) => sortInvoicePeriodFlags((props.invoicePe
|
|||||||
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
|
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const getOrderItemFlagIconClass = (orderItem) => (
|
||||||
|
getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const getOrderItemFlagColorClass = (orderItem) => {
|
||||||
|
const flags = getOrderItemFlags(orderItem);
|
||||||
|
if (flags.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning";
|
||||||
|
};
|
||||||
|
|
||||||
const displayColumnCount = computed(() => (
|
const displayColumnCount = computed(() => (
|
||||||
2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
|
2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
|
||||||
));
|
));
|
||||||
@@ -162,7 +175,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
|||||||
<template v-if="!isOrderItemAddon(item)">
|
<template v-if="!isOrderItemAddon(item)">
|
||||||
<!-- If the item is included in the invoice, show it normally -->
|
<!-- If the item is included in the invoice, show it normally -->
|
||||||
<tr v-if="item.include_in_invoice">
|
<tr v-if="item.include_in_invoice">
|
||||||
<td>{{ item.product.name }}</td>
|
<td>
|
||||||
|
<span
|
||||||
|
v-if="getOrderItemFlags(item).length > 0"
|
||||||
|
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||||
|
:class="getOrderItemFlagColorClass(item)"
|
||||||
|
:data-testid="`order-content-item-flag-indicator-${item.id}`"
|
||||||
|
>
|
||||||
|
<i :class="getOrderItemFlagIconClass(item)"></i>
|
||||||
|
</span>
|
||||||
|
{{ item.product.name }}
|
||||||
|
</td>
|
||||||
<td v-if="props.displayReference">{{ item.reference }}</td>
|
<td v-if="props.displayReference">{{ item.reference }}</td>
|
||||||
<td v-if="props.displayNotes">{{ item.notes }}</td>
|
<td v-if="props.displayNotes">{{ item.notes }}</td>
|
||||||
<td>{{ item.quantity }}</td>
|
<td>{{ item.quantity }}</td>
|
||||||
@@ -170,7 +193,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
|||||||
</tr>
|
</tr>
|
||||||
<!-- If the item is not included in the invoice, show it with a strikethrough -->
|
<!-- If the item is not included in the invoice, show it with a strikethrough -->
|
||||||
<tr v-else class="has-background-warning-light">
|
<tr v-else class="has-background-warning-light">
|
||||||
<td><span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
|
<td>
|
||||||
|
<span
|
||||||
|
v-if="getOrderItemFlags(item).length > 0"
|
||||||
|
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||||
|
:class="getOrderItemFlagColorClass(item)"
|
||||||
|
:data-testid="`order-content-item-flag-indicator-${item.id}`"
|
||||||
|
>
|
||||||
|
<i :class="getOrderItemFlagIconClass(item)"></i>
|
||||||
|
</span>
|
||||||
|
<span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
|
||||||
|
</td>
|
||||||
<td v-if="props.displayReference">{{ item.reference }}</td>
|
<td v-if="props.displayReference">{{ item.reference }}</td>
|
||||||
<td v-if="props.displayNotes">{{ item.notes }}</td>
|
<td v-if="props.displayNotes">{{ item.notes }}</td>
|
||||||
<td>{{ item.quantity }}</td>
|
<td>{{ item.quantity }}</td>
|
||||||
@@ -193,7 +226,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
|||||||
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
|
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
|
||||||
<!-- If the add-on item is included in the invoice, show it normally -->
|
<!-- If the add-on item is included in the invoice, show it normally -->
|
||||||
<tr v-if="addon_item.include_in_invoice">
|
<tr v-if="addon_item.include_in_invoice">
|
||||||
<td>+ {{ addon_item.product.name }}</td>
|
<td>
|
||||||
|
<span
|
||||||
|
v-if="getOrderItemFlags(addon_item).length > 0"
|
||||||
|
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||||
|
:class="getOrderItemFlagColorClass(addon_item)"
|
||||||
|
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
|
||||||
|
>
|
||||||
|
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
|
||||||
|
</span>
|
||||||
|
+ {{ addon_item.product.name }}
|
||||||
|
</td>
|
||||||
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
|
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
|
||||||
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
|
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
|
||||||
<td>{{ addon_item.quantity }}</td>
|
<td>{{ addon_item.quantity }}</td>
|
||||||
@@ -201,7 +244,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
|||||||
</tr>
|
</tr>
|
||||||
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
|
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
|
||||||
<tr v-else class="has-background-warning">
|
<tr v-else class="has-background-warning">
|
||||||
<td>+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
|
<td>
|
||||||
|
<span
|
||||||
|
v-if="getOrderItemFlags(addon_item).length > 0"
|
||||||
|
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||||
|
:class="getOrderItemFlagColorClass(addon_item)"
|
||||||
|
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
|
||||||
|
>
|
||||||
|
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
|
||||||
|
</span>
|
||||||
|
+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
|
||||||
|
</td>
|
||||||
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
|
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
|
||||||
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
|
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
|
||||||
<td>{{ addon_item.quantity }}</td>
|
<td>{{ addon_item.quantity }}</td>
|
||||||
@@ -237,6 +290,14 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
|||||||
<!-- If the item is a primary item, show it -->
|
<!-- If the item is a primary item, show it -->
|
||||||
<template v-if="!isOrderItemAddon(item)">
|
<template v-if="!isOrderItemAddon(item)">
|
||||||
<div>
|
<div>
|
||||||
|
<span
|
||||||
|
v-if="getOrderItemFlags(item).length > 0"
|
||||||
|
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||||
|
:class="getOrderItemFlagColorClass(item)"
|
||||||
|
:data-testid="`order-content-item-flag-indicator-${item.id}`"
|
||||||
|
>
|
||||||
|
<i :class="getOrderItemFlagIconClass(item)"></i>
|
||||||
|
</span>
|
||||||
<strong>{{ item.product.name }}</strong> x {{ item.quantity }}
|
<strong>{{ item.product.name }}</strong> x {{ item.quantity }}
|
||||||
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span>
|
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,6 +309,14 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
|||||||
<!-- Show the add-on items -->
|
<!-- Show the add-on items -->
|
||||||
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
|
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
|
||||||
<small>
|
<small>
|
||||||
|
<span
|
||||||
|
v-if="getOrderItemFlags(addon_item).length > 0"
|
||||||
|
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||||
|
:class="getOrderItemFlagColorClass(addon_item)"
|
||||||
|
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
|
||||||
|
>
|
||||||
|
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
|
||||||
|
</span>
|
||||||
+ {{ addon_item.product.name }} x {{ addon_item.quantity }}
|
+ {{ addon_item.product.name }} x {{ addon_item.quantity }}
|
||||||
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
|
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
|
||||||
</small>
|
</small>
|
||||||
@@ -265,5 +334,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.invoice-period-item-flag-indicator {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,21 +2,31 @@
|
|||||||
import Swal from "sweetalert2";
|
import Swal from "sweetalert2";
|
||||||
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.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({
|
const props = defineProps({
|
||||||
objects: {
|
objects: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
showCustomer: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { loadList } = usePaginatedListInstance();
|
const { loadList } = usePaginatedListInstance();
|
||||||
|
|
||||||
const canEditPermissions = () => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT");
|
const canEditPermissions = () =>
|
||||||
const canDisableAccess = () => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_DELETE");
|
props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT");
|
||||||
|
const canDisableAccess = () =>
|
||||||
|
props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_DELETE");
|
||||||
const canResendInvite = (subuser) =>
|
const canResendInvite = (subuser) =>
|
||||||
(SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
|
(props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
|
||||||
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
|
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
|
||||||
|
const hasRowActions = (subuser) =>
|
||||||
|
(canEditPermissions() && subuser?.grant_id) || canResendInvite(subuser) || (canDisableAccess() && subuser?.grant_id);
|
||||||
|
|
||||||
const formatDateTime = (dateString) => {
|
const formatDateTime = (dateString) => {
|
||||||
if (!dateString) {
|
if (!dateString) {
|
||||||
@@ -50,6 +60,16 @@ const formatEmail = (subuser) => {
|
|||||||
return subuser?.setup_required ? "E-mail oplyses ved accept" : "-";
|
return subuser?.setup_required ? "E-mail oplyses ved accept" : "-";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatCustomer = (subuser) => {
|
||||||
|
if (!props.showCustomer) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = subuser?.customer_number ?? "-";
|
||||||
|
const name = subuser?.customer_name || "Ukendt kunde";
|
||||||
|
return `${number} - ${name}`;
|
||||||
|
};
|
||||||
|
|
||||||
const permissionSummary = (subuser) =>
|
const permissionSummary = (subuser) =>
|
||||||
SessionUser.objects.subusers.functions.permissionSummary(subuser?.grant_permissions || []);
|
SessionUser.objects.subusers.functions.permissionSummary(subuser?.grant_permissions || []);
|
||||||
|
|
||||||
@@ -161,7 +181,7 @@ const onToggleEnabled = async (subuser, enabled) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onResendInvite = async (subuser) => {
|
const onResendInvite = async (subuser) => {
|
||||||
await SessionUser.objects.subusers.functions.resendInvite(subuser, refreshList);
|
await SessionUser.objects.subusers.functions.resendInvite(subuser, refreshList, { superuser: props.showCustomer });
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -171,6 +191,7 @@ const onResendInvite = async (subuser) => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
|
<th v-if="showCustomer">Kunde</th>
|
||||||
<th>Chauffør</th>
|
<th>Chauffør</th>
|
||||||
<th>Kontakt</th>
|
<th>Kontakt</th>
|
||||||
<th>Adgang</th>
|
<th>Adgang</th>
|
||||||
@@ -183,12 +204,17 @@ const onResendInvite = async (subuser) => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="props.objects.length === 0">
|
<tr v-if="props.objects.length === 0">
|
||||||
<td colspan="9" class="has-text-centered has-text-grey py-6">Ingen chauffører fundet.</td>
|
<td :colspan="showCustomer ? 10 : 9" class="has-text-centered has-text-grey py-6">Ingen chauffører fundet.</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr v-for="subuser in props.objects" :key="subuser.id">
|
<tr v-for="subuser in props.objects" :key="`${subuser.id}-${subuser.grant_id || 'none'}`">
|
||||||
<td>{{ subuser.id }}</td>
|
<td>{{ subuser.id }}</td>
|
||||||
|
|
||||||
|
<td v-if="showCustomer">
|
||||||
|
<div class="has-text-weight-semibold">{{ formatCustomer(subuser) }}</div>
|
||||||
|
<div class="is-size-7 has-text-grey">Grant #{{ subuser.grant_id }}</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div class="has-text-weight-semibold">{{ subuser.name || "-" }}</div>
|
<div class="has-text-weight-semibold">{{ subuser.name || "-" }}</div>
|
||||||
<div class="is-size-7 has-text-grey" :data-testid="`subuser-username-${subuser.id}`">
|
<div class="is-size-7 has-text-grey" :data-testid="`subuser-username-${subuser.id}`">
|
||||||
@@ -219,51 +245,49 @@ const onResendInvite = async (subuser) => {
|
|||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div>{{ subuser.grant_note || "-" }}</div>
|
<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>
|
||||||
|
|
||||||
<td>{{ formatDateTime(subuser.created_at) }}</td>
|
<td>{{ formatDateTime(subuser.created_at) }}</td>
|
||||||
<td>{{ formatDateTime(subuser.updated_at) }}</td>
|
<td>{{ formatDateTime(subuser.updated_at) }}</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div class="buttons is-justify-content-flex-end action-buttons">
|
<div class="buttons is-justify-content-flex-end action-buttons" :data-testid="`subuser-actions-${subuser.id}`">
|
||||||
<button
|
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
|
||||||
v-if="canEditPermissions() && subuser.grant_id"
|
<template #actions>
|
||||||
class="button is-small"
|
<ActionSettingsWheelItem
|
||||||
type="button"
|
v-if="canEditPermissions() && subuser.grant_id"
|
||||||
:data-testid="`subuser-permissions-${subuser.id}`"
|
icon="fas fa-pen"
|
||||||
@click="onEditPermissions(subuser)"
|
label="Redigér note"
|
||||||
>
|
:click-action="() => onEditNote(subuser)"
|
||||||
Tilladelser
|
:test-id="`subuser-note-${subuser.id}`"
|
||||||
</button>
|
/>
|
||||||
|
|
||||||
<button
|
<ActionSettingsWheelItem
|
||||||
v-if="canResendInvite(subuser)"
|
v-if="canEditPermissions() && subuser.grant_id"
|
||||||
class="button is-small"
|
icon="fas fa-user-shield"
|
||||||
type="button"
|
label="Tilladelser"
|
||||||
:data-testid="`subuser-resend-${subuser.id}`"
|
:click-action="() => onEditPermissions(subuser)"
|
||||||
@click="onResendInvite(subuser)"
|
:test-id="`subuser-permissions-${subuser.id}`"
|
||||||
>
|
/>
|
||||||
Gensend
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<ActionSettingsWheelItem
|
||||||
v-if="canDisableAccess() && subuser.grant_id"
|
v-if="canResendInvite(subuser)"
|
||||||
class="button is-small"
|
icon="fas fa-paper-plane"
|
||||||
:class="subuser.grant_enabled ? 'is-danger is-light' : 'is-success is-light'"
|
label="Gensend"
|
||||||
type="button"
|
:click-action="() => onResendInvite(subuser)"
|
||||||
:data-testid="`subuser-toggle-${subuser.id}`"
|
:test-id="`subuser-resend-${subuser.id}`"
|
||||||
@click="onToggleEnabled(subuser, !subuser.grant_enabled)"
|
/>
|
||||||
>
|
|
||||||
{{ subuser.grant_enabled ? "Deaktivér" : "Aktivér" }}
|
<ActionSettingsWheelItem
|
||||||
</button>
|
v-if="canDisableAccess() && subuser.grant_id"
|
||||||
|
: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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||||
|
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||||
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { getDepartmentDailyReportComplaintCategoryLabel } from "@/services/departmentDailyReportComplaintCategories.js";
|
import { getDepartmentDailyReportComplaintCategoryLabel } from "@/services/departmentDailyReportComplaintCategories.js";
|
||||||
@@ -92,29 +94,24 @@ const formatCategory = (value) => (
|
|||||||
<td class="complaint-description">{{ complaint.description }}</td>
|
<td class="complaint-description">{{ complaint.description }}</td>
|
||||||
<td>{{ formatCreatedBy(complaint) }}</td>
|
<td>{{ formatCreatedBy(complaint) }}</td>
|
||||||
<td class="has-text-right">
|
<td class="has-text-right">
|
||||||
<div class="buttons is-right is-justify-content-flex-end">
|
<div class="buttons is-right is-justify-content-flex-end" :data-testid="`superuser-complaint-actions-${complaint.id}`">
|
||||||
<button
|
<ActionSettingsWheelButton>
|
||||||
class="button is-small is-dark"
|
<template #actions>
|
||||||
type="button"
|
<ActionSettingsWheelItem
|
||||||
:data-testid="`superuser-complaint-edit-${complaint.id}`"
|
icon="fas fa-pen"
|
||||||
@click="SessionUser.objects.department_daily_report_complaints.functions.showEditForm(complaint, loadList)"
|
:label="$t('global.edit')"
|
||||||
>
|
:test-id="`superuser-complaint-edit-${complaint.id}`"
|
||||||
<span class="icon is-small">
|
:click-action="() => SessionUser.objects.department_daily_report_complaints.functions.showEditForm(complaint, loadList)"
|
||||||
<i class="fas fa-pen"></i>
|
/>
|
||||||
</span>
|
<ActionSettingsWheelItem
|
||||||
<span>{{ $t('global.edit') }}</span>
|
icon="fas fa-trash"
|
||||||
</button>
|
:label="$t('global.delete')"
|
||||||
<button
|
template="danger"
|
||||||
class="button is-small is-danger"
|
:test-id="`superuser-complaint-delete-${complaint.id}`"
|
||||||
type="button"
|
:click-action="() => SessionUser.objects.department_daily_report_complaints.functions.showDeleteConfirmationModal(complaint.id, loadList)"
|
||||||
:data-testid="`superuser-complaint-delete-${complaint.id}`"
|
/>
|
||||||
@click="SessionUser.objects.department_daily_report_complaints.functions.showDeleteConfirmationModal(complaint.id, loadList)"
|
</template>
|
||||||
>
|
</ActionSettingsWheelButton>
|
||||||
<span class="icon is-small">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</span>
|
|
||||||
<span>{{ $t('global.delete') }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -102,26 +102,16 @@ const parseBalance = (user) => {
|
|||||||
{{ parseBalance(user) }}</td>
|
{{ parseBalance(user) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="buttons is-float-right">
|
<div class="buttons is-float-right">
|
||||||
<!-- Disabled customer actions -->
|
<ActionSettingsWheelButton icon="fas fa-exclamation-triangle">
|
||||||
<div class="dropdown is-right is-hoverable">
|
<template #actions>
|
||||||
<div class="dropdown-trigger">
|
<ActionSettingsWheelItem
|
||||||
<button class="button is-small is-danger is-inverted" aria-haspopup="true" aria-controls="dropdown-menu" @click="showCustomerBarred">
|
label="Kundekonto er lukket i E-conomic"
|
||||||
<span class="icon">
|
icon="fas fa-exclamation-triangle"
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
template="warning"
|
||||||
</span>
|
:click-action="showCustomerBarred"
|
||||||
</button>
|
/>
|
||||||
</div>
|
</template>
|
||||||
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
</ActionSettingsWheelButton>
|
||||||
<div class="dropdown-content">
|
|
||||||
<a class="dropdown-item has-text-warning" @click="showCustomerBarred">
|
|
||||||
<span class="icon">
|
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
|
||||||
</span>
|
|
||||||
<span class="ml-1">Kundekonto er lukket i E-conomic</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -143,4 +133,4 @@ const parseBalance = (user) => {
|
|||||||
width: 1%;
|
width: 1%;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps(["objects"]);
|
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { departments, getDepartments, isLoading, getDepartmentName } from "@/components/pagination/departmentTabs.vue";
|
import { useI18n } from "vue-i18n";
|
||||||
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
|
||||||
const { loadList, loadSwitch, metaCurrentPage, metaItemsPerPage, setList } = usePaginatedListInstance();
|
|
||||||
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
|
||||||
|
|
||||||
// Get the departments (If the departments are not already loaded)
|
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||||
if (departments.value.length === 0) {
|
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||||
getDepartments();
|
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
||||||
}
|
import { showEditDepartmentForm } from "@/components/forms/superUser/editDepartmentForm.vue";
|
||||||
|
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||||
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
|
|
||||||
|
const props = defineProps(["objects"]);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { loadList, loadSwitch, metaCurrentPage, metaItemsPerPage, setList } = usePaginatedListInstance();
|
||||||
|
|
||||||
const draggingIndex = ref(null);
|
const draggingIndex = ref(null);
|
||||||
const dragOverIndex = ref(null);
|
const dragOverIndex = ref(null);
|
||||||
@@ -68,18 +70,6 @@ const onDrop = async (event, newIndex) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseCustomerName = (user) => {
|
|
||||||
if (user.customer_name) {
|
|
||||||
return user.customer_name;
|
|
||||||
} else {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
import { showEditDepartmentForm } from "@/components/forms/superUser/editDepartmentForm.vue";
|
|
||||||
|
|
||||||
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
|
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
|
||||||
|
|
||||||
const redirect = (path) => {
|
const redirect = (path) => {
|
||||||
window.location = path;
|
window.location = path;
|
||||||
};
|
};
|
||||||
@@ -136,7 +126,7 @@ const toggleArchived = async (department) => {
|
|||||||
<th>{{ SessionUser.objects.departments.columns.archived.label }}</th>
|
<th>{{ SessionUser.objects.departments.columns.archived.label }}</th>
|
||||||
<th>{{ SessionUser.objects.departments.columns.latitude.label }}</th>
|
<th>{{ SessionUser.objects.departments.columns.latitude.label }}</th>
|
||||||
<th>{{ SessionUser.objects.departments.columns.longitude.label }}</th>
|
<th>{{ SessionUser.objects.departments.columns.longitude.label }}</th>
|
||||||
<th>{{ $t("tables.actions") }}</th>
|
<th class="has-text-right">{{ $t("tables.actions") }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -212,34 +202,41 @@ const toggleArchived = async (department) => {
|
|||||||
column="longitude"
|
column="longitude"
|
||||||
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
|
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
|
||||||
/>
|
/>
|
||||||
<td>
|
<td class="has-text-right">
|
||||||
<div class="buttons">
|
<div
|
||||||
<button
|
class="buttons is-right is-justify-content-flex-end"
|
||||||
class="button is-small"
|
:data-testid="`superuser-department-actions-${department.id}`"
|
||||||
@click="
|
>
|
||||||
showEditDepartmentForm(
|
<ActionSettingsWheelButton>
|
||||||
department.id,
|
<template #actions>
|
||||||
department.name,
|
<ActionSettingsWheelItem
|
||||||
department.description,
|
icon="fas fa-edit"
|
||||||
department.economic_department_id
|
:label="t('global.edit')"
|
||||||
)
|
:test-id="`superuser-department-edit-${department.id}`"
|
||||||
"
|
:click-action="
|
||||||
>
|
() =>
|
||||||
<span class="icon">
|
showEditDepartmentForm(
|
||||||
<i class="fas fa-edit"></i>
|
department.id,
|
||||||
</span>
|
department.name,
|
||||||
</button>
|
department.description,
|
||||||
<button class="button is-small" @click="redirect('/admin/' + department.id)">
|
department.economic_department_id
|
||||||
<!-- External link icon -->
|
)
|
||||||
<span class="icon">
|
"
|
||||||
<i class="fas fa-external-link-alt"></i>
|
/>
|
||||||
</span>
|
<ActionSettingsWheelItem
|
||||||
</button>
|
icon="fas fa-external-link-alt"
|
||||||
<button class="button is-small is-dark" @click="redirect('/superuser/departments/' + department.id)">
|
:label="t('global.open')"
|
||||||
<span class="icon">
|
:test-id="`superuser-department-open-${department.id}`"
|
||||||
<i class="fas fa-cog"></i>
|
:click-action="() => redirect('/admin/' + department.id)"
|
||||||
</span>
|
/>
|
||||||
</button>
|
<ActionSettingsWheelItem
|
||||||
|
icon="fas fa-cog"
|
||||||
|
:label="t('global.settings')"
|
||||||
|
:test-id="`superuser-department-settings-${department.id}`"
|
||||||
|
:click-action="() => redirect('/superuser/departments/' + department.id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ActionSettingsWheelButton>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,44 +1,32 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
defineProps(['objects']);
|
|
||||||
import { departments, getDepartments, isLoading, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
|
|
||||||
import Swal from "sweetalert2";
|
|
||||||
|
|
||||||
// Get the departments (If the departments are not already loaded)
|
|
||||||
if (departments.value.length === 0) {
|
|
||||||
getDepartments();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
|
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
|
||||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||||
|
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
|
||||||
|
|
||||||
const redirect = (path) => {
|
defineProps({
|
||||||
window.location = path;
|
objects: {
|
||||||
}
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const editUser = (user) => {
|
||||||
|
showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<table class="table is-fullwidth">
|
<table class="table is-fullwidth" data-testid="superuser-users-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ $t('objects.columns.id') }}</th>
|
<th>{{ $t("objects.columns.id") }}</th>
|
||||||
<th>{{ $t('tables.users.name') }}</th>
|
<th>{{ $t("tables.users.name") }}</th>
|
||||||
<th>{{ $t('tables.users.role') }}</th>
|
<th>{{ $t("tables.users.role") }}</th>
|
||||||
<th class="has-text-right">{{ $t('tables.actions') }}</th>
|
<th class="has-text-right">{{ $t("tables.actions") }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="user in objects" :key="user.id">
|
<tr v-for="user in objects" :key="user.id" :data-testid="`superuser-users-row-${user.id}`">
|
||||||
<td>{{ user.id }}</td>
|
<td>{{ user.id }}</td>
|
||||||
<td>{{ user.display_name }}</td>
|
<td>{{ user.display_name }}</td>
|
||||||
<td>{{ user.group_id }}</td>
|
<td>{{ user.group_id }}</td>
|
||||||
@@ -46,25 +34,27 @@ const redirect = (path) => {
|
|||||||
<div class="buttons is-float-right">
|
<div class="buttons is-float-right">
|
||||||
<!-- Settings wheel -->
|
<!-- Settings wheel -->
|
||||||
<ActionSettingsWheelButton
|
<ActionSettingsWheelButton
|
||||||
:customer_number="user.customer_number"
|
:customer_number="user.customer_number"
|
||||||
:user_id="user.id"
|
:user_id="user.id"
|
||||||
|
:data-testid="`superuser-user-actions-${user.id}`"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<ActionSettingsWheelItem
|
<ActionSettingsWheelItem
|
||||||
@click="showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id)"
|
:click-action="() => editUser(user)"
|
||||||
icon="fas fa-user-edit"
|
icon="fas fa-user-edit"
|
||||||
:label="$t('global.edit')"
|
:label="$t('global.edit')"
|
||||||
|
:test-id="`superuser-user-edit-${user.id}`"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ActionSettingsWheelButton>
|
</ActionSettingsWheelButton>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
<tr v-if="objects.length === 0">
|
||||||
</table>
|
<td colspan="4">{{ $t("global.no_data") }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
import { departments, getDepartments} from "@/components/pagination/departmentTabs.vue";
|
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { loadList } from "@/components/pagination/paginatedList.vue";
|
import { loadList } from "@/components/pagination/paginatedList.vue";
|
||||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||||
@@ -27,16 +27,15 @@ const props = defineProps({
|
|||||||
reloadList: {
|
reloadList: {
|
||||||
type: Function,
|
type: Function,
|
||||||
required: false,
|
required: false,
|
||||||
default: null
|
default: null,
|
||||||
},
|
},
|
||||||
add_other_customer_id: {
|
add_other_customer_id: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: false,
|
required: false,
|
||||||
default: null
|
default: null,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
// Load the list of vehicles
|
// Load the list of vehicles
|
||||||
if (props.reloadList) {
|
if (props.reloadList) {
|
||||||
@@ -59,13 +58,9 @@ if (departments.value.length === 0) {
|
|||||||
const onClickListAddons = (vehicleId) => {
|
const onClickListAddons = (vehicleId) => {
|
||||||
// Redirect to the product addons page
|
// Redirect to the product addons page
|
||||||
console.log("Fetching product addons for vehicle: " + vehicleId);
|
console.log("Fetching product addons for vehicle: " + vehicleId);
|
||||||
SessionUser.request(
|
SessionUser.request("/vehicles/addons/available", "GET", {
|
||||||
'/vehicles/addons/available',
|
id: vehicleId,
|
||||||
'GET',
|
});
|
||||||
{
|
|
||||||
id: vehicleId
|
|
||||||
},
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const vehicleAddons = ref(null);
|
const vehicleAddons = ref(null);
|
||||||
@@ -76,13 +71,9 @@ const getVehicleAvailableAddons = (vehicleId, forceReload = false) => {
|
|||||||
// Get the vehicle addons from the server
|
// Get the vehicle addons from the server
|
||||||
if (vehicleAddons.value === null) {
|
if (vehicleAddons.value === null) {
|
||||||
vehicleAddons.value = [];
|
vehicleAddons.value = [];
|
||||||
SessionUser.request(
|
SessionUser.request("/vehicles/addons/available", "GET", {
|
||||||
'/vehicles/addons/available',
|
id: vehicleId,
|
||||||
'GET',
|
}).then((response) => {
|
||||||
{
|
|
||||||
id: vehicleId
|
|
||||||
},
|
|
||||||
).then((response) => {
|
|
||||||
console.log("Vehicle addons: ", response.data.data);
|
console.log("Vehicle addons: ", response.data.data);
|
||||||
vehicleAddons.value = response.data.data;
|
vehicleAddons.value = response.data.data;
|
||||||
});
|
});
|
||||||
@@ -93,14 +84,10 @@ const getVehicleAvailableAddons = (vehicleId, forceReload = false) => {
|
|||||||
|
|
||||||
const toggleVehicleAddon = (vehicleId, addonId) => {
|
const toggleVehicleAddon = (vehicleId, addonId) => {
|
||||||
// Toggle the vehicle addon
|
// Toggle the vehicle addon
|
||||||
SessionUser.request(
|
SessionUser.request("/vehicles/addons/toggle", "POST", {
|
||||||
'/vehicles/addons/toggle',
|
vehicle_id: vehicleId,
|
||||||
'POST',
|
addon_id: addonId,
|
||||||
{
|
}).then(() => {
|
||||||
vehicle_id: vehicleId,
|
|
||||||
addon_id: addonId
|
|
||||||
},
|
|
||||||
).then(() => {
|
|
||||||
reload();
|
reload();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -108,9 +95,9 @@ const toggleVehicleAddon = (vehicleId, addonId) => {
|
|||||||
const getVehicleAddonToggleIcon = (vehicleAddon) => {
|
const getVehicleAddonToggleIcon = (vehicleAddon) => {
|
||||||
// Check if the vehicle addon is applied
|
// Check if the vehicle addon is applied
|
||||||
if (isVehicleAddonApplied(vehicleAddon)) {
|
if (isVehicleAddonApplied(vehicleAddon)) {
|
||||||
return 'fas fa-minus';
|
return "fas fa-minus";
|
||||||
} else {
|
} else {
|
||||||
return 'fas fa-plus';
|
return "fas fa-plus";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,140 +124,142 @@ const getProductOptionsLabel = (vehicle) => {
|
|||||||
<div class="table-container" data-testid="user-vehicles-table-container">
|
<div class="table-container" data-testid="user-vehicles-table-container">
|
||||||
<table class="table is-fullwidth" data-testid="user-vehicles-table">
|
<table class="table is-fullwidth" data-testid="user-vehicles-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th v-if="!props.compact">{{ $t('objects.columns.id') }}</th>
|
<th v-if="!props.compact">{{ $t("objects.columns.id") }}</th>
|
||||||
<th v-if="!props.compact">{{ $t('objects.columns.customer_id') }}</th>
|
<th v-if="!props.compact">{{ $t("objects.columns.customer_id") }}</th>
|
||||||
<th>{{ $t('objects.bookings.columns.reg_1') }}</th>
|
<th>{{ $t("objects.bookings.columns.reg_1") }}</th>
|
||||||
<th>{{ $t('vehicles.type') }}</th>
|
<th>{{ $t("vehicles.type") }}</th>
|
||||||
<th>{{ $t('objects.vehicles.columns.wash_subscription') }}</th>
|
<th>{{ $t("objects.vehicles.columns.wash_subscription") }}</th>
|
||||||
<th v-if="!props.compact">{{ SessionUser.objects.product_options.meta.title }}</th>
|
<th v-if="!props.compact">{{ SessionUser.objects.product_options.meta.title }}</th>
|
||||||
<th v-if="!props.compact">{{ $t('common.reference') }}</th>
|
<th v-if="!props.compact">{{ $t("common.reference") }}</th>
|
||||||
<th v-if="!props.compact"></th>
|
<th v-if="!props.compact"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="object in props.vehicles" :key="object.id">
|
<tr v-for="object in props.vehicles" :key="object.id">
|
||||||
<!-- ID -->
|
<!-- ID -->
|
||||||
<EditableTableColumn
|
<EditableTableColumn v-if="!props.compact" :object="object" :loadList="reload" column="id" />
|
||||||
v-if="!props.compact"
|
<!-- Customer ID -->
|
||||||
:object="object"
|
<EditableTableColumn v-if="!props.compact" :object="object" :loadList="reload" column="customer_id" />
|
||||||
:loadList="reload"
|
<!-- Reg -->
|
||||||
column="id"
|
<EditableTableColumn
|
||||||
/>
|
:object="object"
|
||||||
<!-- Customer ID -->
|
:loadList="reload"
|
||||||
<EditableTableColumn
|
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||||
v-if="!props.compact"
|
column="reg"
|
||||||
:object="object"
|
/>
|
||||||
:loadList="reload"
|
<!-- Type -->
|
||||||
column="customer_id"
|
<EditableTableColumn
|
||||||
/>
|
:object="object"
|
||||||
<!-- Reg -->
|
:loadList="reload"
|
||||||
<EditableTableColumn
|
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||||
:object="object"
|
column="type"
|
||||||
:loadList="reload"
|
:parse-function="
|
||||||
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
(value) => {
|
||||||
column="reg"
|
return SessionUser.objects.products.functions.getProductName(value, 'Ukendt');
|
||||||
/>
|
}
|
||||||
<!-- Type -->
|
"
|
||||||
<EditableTableColumn
|
/>
|
||||||
:object="object"
|
<!-- Wash Subscription -->
|
||||||
:loadList="reload"
|
<EditableTableColumn
|
||||||
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
:object="object"
|
||||||
column="type"
|
:loadList="reload"
|
||||||
:parse-function="(value) => {
|
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||||
return SessionUser.objects.products.functions.getProductName(value, 'Ukendt');
|
column="wash_subscription"
|
||||||
}"
|
:parse-function="
|
||||||
/>
|
(value) => {
|
||||||
<!-- Wash Subscription -->
|
return value ? t('common.yes') : t('common.no');
|
||||||
<EditableTableColumn
|
}
|
||||||
:object="object"
|
"
|
||||||
:loadList="reload"
|
/>
|
||||||
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
<!-- Product Options, if the wash subscription is set to true -->
|
||||||
column="wash_subscription"
|
<td v-if="!props.compact">
|
||||||
:parse-function="(value) => {
|
<template v-if="object.wash_subscription">
|
||||||
return value ? t('common.yes') : t('common.no');
|
<!-- Enabled subscription -->
|
||||||
}"
|
<ActionSettingsWheelButton
|
||||||
/>
|
:label="getProductOptionsLabel(object)"
|
||||||
<!-- Product Options, if the wash subscription is set to true -->
|
:icon="SessionUser.objects.product_options.meta.icon"
|
||||||
<td v-if="!props.compact">
|
@mouseenter="getVehicleAvailableAddons(object.id, true)"
|
||||||
<template v-if="object.wash_subscription">
|
>
|
||||||
<!-- Enabled subscription -->
|
<template #actions>
|
||||||
<ActionSettingsWheelButton
|
<!-- List addons -->
|
||||||
:label="getProductOptionsLabel(object)"
|
<template v-for="vehicleAddon in vehicleAddons" :key="vehicleAddon.id">
|
||||||
:icon="SessionUser.objects.product_options.meta.icon"
|
<ActionSettingsWheelItem
|
||||||
@mouseenter="getVehicleAvailableAddons(object.id, true)"
|
:label="
|
||||||
>
|
(isVehicleAddonApplied(vehicleAddon)
|
||||||
<template #actions>
|
? SessionUser.objects.global.language.remove
|
||||||
<!-- List addons -->
|
: SessionUser.objects.global.language.add) +
|
||||||
<template v-for="vehicleAddon in vehicleAddons" :key="vehicleAddon.id">
|
' ' +
|
||||||
<ActionSettingsWheelItem
|
vehicleAddon.name
|
||||||
:label="(isVehicleAddonApplied(vehicleAddon) ? SessionUser.objects.global.language.remove : SessionUser.objects.global.language.add) + ' ' + vehicleAddon.name"
|
"
|
||||||
:icon="getVehicleAddonToggleIcon(vehicleAddon)"
|
:icon="getVehicleAddonToggleIcon(vehicleAddon)"
|
||||||
:click-action="() => toggleVehicleAddon(object.id, vehicleAddon.id)"
|
:click-action="() => toggleVehicleAddon(object.id, vehicleAddon.id)"
|
||||||
:template="isVehicleAddonApplied(vehicleAddon) ? 'danger' : 'default'"
|
:template="isVehicleAddonApplied(vehicleAddon) ? 'danger' : 'default'"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
<!-- If there are no addons, show a message -->
|
||||||
|
<ActionSettingsWheelItem
|
||||||
|
v-if="vehicleAddons ? vehicleAddons.length === 0 : true"
|
||||||
|
:label="$t('global.no_data')"
|
||||||
|
icon="fas fa-list"
|
||||||
|
/>
|
||||||
|
<!-- Addons -->
|
||||||
|
</template>
|
||||||
|
</ActionSettingsWheelButton>
|
||||||
</template>
|
</template>
|
||||||
<!-- If there are no addons, show a message -->
|
<template v-else>
|
||||||
<ActionSettingsWheelItem
|
<!-- Disabled subscription -->
|
||||||
v-if="vehicleAddons ? vehicleAddons.length === 0 : true"
|
{{ $t("global.no_data") }}
|
||||||
:label="$t('global.no_data')"
|
</template>
|
||||||
icon="fas fa-list"
|
</td>
|
||||||
/>
|
<!-- Reference to the vehicle -->
|
||||||
<!-- Addons -->
|
<EditableTableColumn
|
||||||
</template>
|
v-if="!props.compact"
|
||||||
</ActionSettingsWheelButton>
|
:object="object"
|
||||||
</template>
|
:loadList="reload"
|
||||||
<template v-else>
|
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||||
<!-- Disabled subscription -->
|
column="reference"
|
||||||
{{ $t('global.no_data') }}
|
/>
|
||||||
</template>
|
<!-- Actions -->
|
||||||
</td>
|
<td>
|
||||||
<!-- Reference to the vehicle -->
|
<!-- Actions stay grouped behind the wheel menu. -->
|
||||||
<EditableTableColumn
|
<ActionSettingsWheelButton
|
||||||
v-if="!props.compact"
|
v-if="!props.compact"
|
||||||
:object="object"
|
:user_id="object.user_id"
|
||||||
:loadList="reload"
|
:reg_1="object.reg"
|
||||||
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
:displayActionsDirectly="false"
|
||||||
column="reference"
|
>
|
||||||
/>
|
<template #actions>
|
||||||
<!-- Actions -->
|
<!-- View (Redirect to the vehicle page) -->
|
||||||
<td>
|
<ActionSettingsWheelItem
|
||||||
<!-- Actions -->
|
:label="$t('global.manage') + ' ' + $t('objects.vehicles.single')"
|
||||||
<ActionSettingsWheelButton
|
icon="fas fa-eye"
|
||||||
v-if="!props.compact"
|
:click-action="() => redirectUserVehiclePage(object.id)"
|
||||||
:user_id="object.user_id"
|
/>
|
||||||
:reg_1="object.reg"
|
<!-- Delete -->
|
||||||
:displayActionsDirectly="true"
|
<ActionSettingsWheelItem
|
||||||
>
|
:label="$t('global.delete') + ' ' + $t('objects.vehicles.single')"
|
||||||
<template #actions>
|
icon="fas fa-trash"
|
||||||
<!-- View (Redirect to the vehicle page) -->
|
:template="'danger'"
|
||||||
<ActionSettingsWheelItem
|
:click-action="
|
||||||
:label="$t('global.manage') + ' ' + $t('objects.vehicles.single')"
|
() => SessionUser.objects.vehicles.functions.showDeleteObjectForm(object.id, () => reload())
|
||||||
icon="fas fa-eye"
|
"
|
||||||
:click-action="() => redirectUserVehiclePage(object.id)"
|
/>
|
||||||
/>
|
</template>
|
||||||
<!-- Delete -->
|
</ActionSettingsWheelButton>
|
||||||
<ActionSettingsWheelItem
|
</td>
|
||||||
:label="$t('global.delete') + ' ' + $t('objects.vehicles.single')"
|
</tr>
|
||||||
icon="fas fa-trash"
|
|
||||||
:template="'danger'"
|
|
||||||
:click-action="() => SessionUser.objects.vehicles.functions.showDeleteObjectForm(object.id, () => reload())"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</ActionSettingsWheelButton>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="10">{{ $t('tables.showing') }} {{ props.vehicles.length }} {{ $t('objects.vehicles.multiple') }}</td>
|
<td colspan="10">
|
||||||
</tr>
|
{{ $t("tables.showing") }} {{ props.vehicles.length }} {{ $t("objects.vehicles.multiple") }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
|
|||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
||||||
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
||||||
|
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||||
|
|
||||||
const employees = ref([]);
|
const employees = ref([]);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -52,6 +53,7 @@ const login = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save the token in the local storage
|
// Save the token in the local storage
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', data.token);
|
localStorage.setItem('token', data.token);
|
||||||
// Redirect to the dashboard
|
// Redirect to the dashboard
|
||||||
window.location.href = '/admin';
|
window.location.href = '/admin';
|
||||||
@@ -72,6 +74,7 @@ const loginWithPasskey = async () => {
|
|||||||
const result = await authenticateWithPasskey('employee', null, recaptchaToken);
|
const result = await authenticateWithPasskey('employee', null, recaptchaToken);
|
||||||
|
|
||||||
if (result.token) {
|
if (result.token) {
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', result.token);
|
localStorage.setItem('token', result.token);
|
||||||
window.location.href = '/admin';
|
window.location.href = '/admin';
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { parseError, clearErrors, addError, getError } from "@/components/reques
|
|||||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||||
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
||||||
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
||||||
|
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ const login = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save the token in the local storage
|
// Save the token in the local storage
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', data.token);
|
localStorage.setItem('token', data.token);
|
||||||
// Set the success message
|
// Set the success message
|
||||||
successMessage.value = "Du er nu logget ind!"
|
successMessage.value = "Du er nu logget ind!"
|
||||||
@@ -103,6 +105,7 @@ const loginWithPasskey = async () => {
|
|||||||
const result = await authenticateWithPasskey('user', customerNum, recaptchaToken);
|
const result = await authenticateWithPasskey('user', customerNum, recaptchaToken);
|
||||||
console.log('Passkey authentication result:', result);
|
console.log('Passkey authentication result:', result);
|
||||||
if (result.token) {
|
if (result.token) {
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', result.token);
|
localStorage.setItem('token', result.token);
|
||||||
successMessage.value = "Du er nu logget ind!";
|
successMessage.value = "Du er nu logget ind!";
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { parseError, getError, addError, clearErrors } from "@/components/reques
|
|||||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
|
||||||
|
import { getSubuserPasswordPolicyError } from "@/services/subuserPasswordPolicy.js";
|
||||||
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
|
||||||
|
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -39,6 +41,12 @@ const twoFactorToken = ref('');
|
|||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
clearErrors();
|
clearErrors();
|
||||||
|
const passwordPolicyError = getSubuserPasswordPolicyError(password.value);
|
||||||
|
if (passwordPolicyError) {
|
||||||
|
addError(passwordPolicyError, 'auth');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let requestBody = { password: password.value };
|
let requestBody = { password: password.value };
|
||||||
|
|
||||||
@@ -62,6 +70,7 @@ const login = async () => {
|
|||||||
|
|
||||||
// Save the session token
|
// Save the session token
|
||||||
if (data.session) {
|
if (data.session) {
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', data.session);
|
localStorage.setItem('token', data.session);
|
||||||
localStorage.setItem('is_subuser', 'true');
|
localStorage.setItem('is_subuser', 'true');
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -90,6 +99,7 @@ const loginWithPasskey = async () => {
|
|||||||
// Subuser login returns 'session' token, user login returns 'token'
|
// Subuser login returns 'session' token, user login returns 'token'
|
||||||
const sessionToken = result.session || result.token;
|
const sessionToken = result.session || result.token;
|
||||||
if (sessionToken) {
|
if (sessionToken) {
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', sessionToken);
|
localStorage.setItem('token', sessionToken);
|
||||||
localStorage.setItem('is_subuser', 'true');
|
localStorage.setItem('is_subuser', 'true');
|
||||||
// Reload the page to update the UI
|
// Reload the page to update the UI
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { verify2FA } from "@/services/TwoFactorAuthService.js";
|
import { verify2FA } from "@/services/TwoFactorAuthService.js";
|
||||||
import { parseError, getError, clearErrors } from "@/components/request/HandleGlobalError.vue";
|
import { parseError, getError, clearErrors } from "@/components/request/HandleGlobalError.vue";
|
||||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||||
|
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -43,9 +44,11 @@ const verify = async () => {
|
|||||||
|
|
||||||
// Handle the response based on user type
|
// Handle the response based on user type
|
||||||
if (props.userType === 'subuser' && result.session) {
|
if (props.userType === 'subuser' && result.session) {
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', result.session);
|
localStorage.setItem('token', result.session);
|
||||||
localStorage.setItem('is_subuser', 'true');
|
localStorage.setItem('is_subuser', 'true');
|
||||||
} else if (result.token) {
|
} else if (result.token) {
|
||||||
|
clearEdgeGatewayWorkspaceCache();
|
||||||
localStorage.setItem('token', result.token);
|
localStorage.setItem('token', result.token);
|
||||||
localStorage.removeItem('is_subuser');
|
localStorage.removeItem('is_subuser');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,16 +441,23 @@ const addProductWithAddonsToOrder = async (product_id) => {
|
|||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
confirmButtonText: 'Tilføj',
|
confirmButtonText: 'Tilføj',
|
||||||
showLoaderOnConfirm: true,
|
showLoaderOnConfirm: true,
|
||||||
|
inputValidator: (note) => {
|
||||||
|
if (!String(note || '').trim()) {
|
||||||
|
return 'Note er påkrævet for dette produkt';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
preConfirm: (note) => {
|
preConfirm: (note) => {
|
||||||
|
const normalizedNote = String(note || '').trim();
|
||||||
const confirmedOrderId = getValidOrderId();
|
const confirmedOrderId = getValidOrderId();
|
||||||
if (!confirmedOrderId) {
|
if (!confirmedOrderId) {
|
||||||
Swal.showValidationMessage('Order ID is required');
|
Swal.showValidationMessage('Order ID is required');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Show the fake create order item
|
// Show the fake create order item
|
||||||
showFakeCreateOrderItem(product_id, 1, 0, 0, note);
|
showFakeCreateOrderItem(product_id, 1, 0, 0, normalizedNote);
|
||||||
// Create the order item
|
// Create the order item
|
||||||
return createOrderItem(confirmedOrderId, product_id, 1, 0, note).then(async (result) => {
|
return createOrderItem(confirmedOrderId, product_id, 1, 0, normalizedNote).then(async (result) => {
|
||||||
let order_item_id = result.data.data.id;
|
let order_item_id = result.data.data.id;
|
||||||
// Add the addons to the order
|
// Add the addons to the order
|
||||||
await addAddonsToOrderMiddleware(product_id, 1, order_item_id, confirmedOrderId).then(() => {
|
await addAddonsToOrderMiddleware(product_id, 1, order_item_id, confirmedOrderId).then(() => {
|
||||||
|
|||||||
@@ -217,6 +217,54 @@ const getVehicleReferenceValue = () => {
|
|||||||
return String(vehicleObject.value?.reference ?? "").trim();
|
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 setVehicleObject = (emittedVehicleObject, options = {}) => {
|
||||||
const normalizedOptions = {
|
const normalizedOptions = {
|
||||||
preserveManualReference: true,
|
preserveManualReference: true,
|
||||||
@@ -224,17 +272,26 @@ const setVehicleObject = (emittedVehicleObject, options = {}) => {
|
|||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
vehicleObject.value = emittedVehicleObject;
|
const emittedVehiclePlate = normalizePlateValue(emittedVehicleObject?.reg || reg_1.value);
|
||||||
|
|
||||||
const emittedVehiclePlate = normalizePlateValue(vehicleObject.value?.reg || reg_1.value);
|
|
||||||
if (
|
if (
|
||||||
emittedVehiclePlate &&
|
emittedVehiclePlate &&
|
||||||
skippedDesktopBookingVehiclePlate.value === emittedVehiclePlate &&
|
skippedDesktopBookingVehiclePlate.value === emittedVehiclePlate &&
|
||||||
!isBookingMarkedVehicle(vehicleObject.value)
|
!isBookingMarkedVehicle(emittedVehicleObject)
|
||||||
) {
|
) {
|
||||||
skippedDesktopBookingVehiclePlate.value = "";
|
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 matchedVehicleCustomerNumber = normalizeCustomerNumber(vehicleObject.value?.customer_id);
|
||||||
const selectedCustomerNumber = normalizeCustomerNumber(customer_id.value);
|
const selectedCustomerNumber = normalizeCustomerNumber(customer_id.value);
|
||||||
|
|
||||||
@@ -274,6 +331,10 @@ const setBookingObject = (emittedBookingObject, options = {}) => {
|
|||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (getBookingStateKey(bookingObject.value) === getBookingStateKey(emittedBookingObject)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
bookingObject.value = emittedBookingObject;
|
bookingObject.value = emittedBookingObject;
|
||||||
|
|
||||||
if (emittedBookingObject && emittedBookingObject.reference_number) {
|
if (emittedBookingObject && emittedBookingObject.reference_number) {
|
||||||
@@ -284,7 +345,12 @@ const setBookingObject = (emittedBookingObject, options = {}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setBookingMatches = (emittedBookingMatches) => {
|
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) => {
|
const mergeBookingMatchDetails = (booking) => {
|
||||||
@@ -294,17 +360,23 @@ const mergeBookingMatchDetails = (booking) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let didMergeBooking = false;
|
let didMergeBooking = false;
|
||||||
bookingMatches.value = bookingMatches.value.map((entry) => {
|
let didChangeBooking = false;
|
||||||
|
const nextBookingMatches = bookingMatches.value.map((entry) => {
|
||||||
if (normalizeBookingId(entry?.id) !== normalizedBookingId) {
|
if (normalizeBookingId(entry?.id) !== normalizedBookingId) {
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
didMergeBooking = true;
|
didMergeBooking = true;
|
||||||
return { ...entry, ...booking };
|
const mergedBooking = { ...entry, ...booking };
|
||||||
|
const hasChangedEntry = hasBookingDetailsChanged(entry, mergedBooking);
|
||||||
|
didChangeBooking = didChangeBooking || hasChangedEntry;
|
||||||
|
return hasChangedEntry ? mergedBooking : entry;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!didMergeBooking) {
|
if (!didMergeBooking) {
|
||||||
bookingMatches.value = [...bookingMatches.value, booking];
|
bookingMatches.value = [...bookingMatches.value, booking];
|
||||||
|
} else if (didChangeBooking) {
|
||||||
|
bookingMatches.value = nextBookingMatches;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -416,6 +416,21 @@ const shouldPreserveCustomerSelection = (plateValue) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let pendingVehicleCustomerLookup = null;
|
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 syncMatchedVehicleSelection = (vehicle, options = {}) => {
|
||||||
const normalizedOptions = {
|
const normalizedOptions = {
|
||||||
plateOverride: null,
|
plateOverride: null,
|
||||||
@@ -590,6 +605,7 @@ watch(reg_1, (newValue) => {
|
|||||||
// Define the search ID for this input change
|
// Define the search ID for this input change
|
||||||
const search_id = register_new_search();
|
const search_id = register_new_search();
|
||||||
console.log("reg_1 changed:", newValue);
|
console.log("reg_1 changed:", newValue);
|
||||||
|
lastAutoSyncedVehicleKey.value = "";
|
||||||
const shouldKeepCustomerSelection = shouldPreserveCustomerSelection(newValue);
|
const shouldKeepCustomerSelection = shouldPreserveCustomerSelection(newValue);
|
||||||
selectedDropdownItem.value = -1;
|
selectedDropdownItem.value = -1;
|
||||||
// Check if the new value is empty, if so, clear the vehicles_matching array
|
// 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 currentValue = reg_1.value;
|
||||||
const vehicle = newValue.find((vehicle) => vehicle.reg === currentValue);
|
const vehicle = newValue.find((vehicle) => vehicle.reg === currentValue);
|
||||||
if (vehicle) {
|
if (vehicle) {
|
||||||
syncMatchedVehicleSelection(vehicle);
|
const nextAutoSyncKey = getVehicleAutoSyncKey(vehicle, currentValue);
|
||||||
|
if (lastAutoSyncedVehicleKey.value !== nextAutoSyncKey) {
|
||||||
|
lastAutoSyncedVehicleKey.value = nextAutoSyncKey;
|
||||||
|
syncMatchedVehicleSelection(vehicle);
|
||||||
|
}
|
||||||
} else if (getBookingMatchesForSelection(null, currentValue).length > 0) {
|
} else if (getBookingMatchesForSelection(null, currentValue).length > 0) {
|
||||||
|
lastAutoSyncedVehicleKey.value = "";
|
||||||
clearCustomerConflict();
|
clearCustomerConflict();
|
||||||
emitVehicleObject(null, currentValue);
|
emitVehicleObject(null, currentValue);
|
||||||
} else {
|
} else {
|
||||||
|
lastAutoSyncedVehicleKey.value = "";
|
||||||
clearCustomerConflict();
|
clearCustomerConflict();
|
||||||
}
|
}
|
||||||
// Check if the selectedDropdownItem index is valid
|
// Check if the selectedDropdownItem index is valid
|
||||||
@@ -1015,6 +1037,7 @@ const getCurrentIconColor = () => {
|
|||||||
@blur="lostfocus"
|
@blur="lostfocus"
|
||||||
@keydown="arrowKeyHandler"
|
@keydown="arrowKeyHandler"
|
||||||
id="reg_1"
|
id="reg_1"
|
||||||
|
tabindex="1"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -1037,6 +1060,7 @@ const getCurrentIconColor = () => {
|
|||||||
@blur="lostfocus"
|
@blur="lostfocus"
|
||||||
@keydown="arrowKeyHandler"
|
@keydown="arrowKeyHandler"
|
||||||
id="reg_1"
|
id="reg_1"
|
||||||
|
tabindex="1"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -394,12 +394,16 @@ onBeforeUnmount(() => {
|
|||||||
v-model="typedReference"
|
v-model="typedReference"
|
||||||
:data="groupedSuggestions"
|
:data="groupedSuggestions"
|
||||||
field="reference"
|
field="reference"
|
||||||
|
input-id="reference"
|
||||||
|
data-testid="pos-desktop-step-1-reference-input"
|
||||||
group-field="group"
|
group-field="group"
|
||||||
group-options="items"
|
group-options="items"
|
||||||
:keep-first="true"
|
:keep-first="true"
|
||||||
open-on-focus
|
open-on-focus
|
||||||
expanded
|
expanded
|
||||||
:loading="isFetching"
|
:loading="isFetching"
|
||||||
|
:tabindex="props.tabindex"
|
||||||
|
:aria-invalid="props.requiredWarning ? 'true' : undefined"
|
||||||
custom-class="has-sharp-edges"
|
custom-class="has-sharp-edges"
|
||||||
icon-pack="fas"
|
icon-pack="fas"
|
||||||
max-height="320"
|
max-height="320"
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const customer_suggestions = ref([
|
|||||||
const isSettingCustomer = ref(false);
|
const isSettingCustomer = ref(false);
|
||||||
const isSettingCustomerToInteger = ref(0);
|
const isSettingCustomerToInteger = ref(0);
|
||||||
const isSettingCustomerStartTime = ref(null);
|
const isSettingCustomerStartTime = ref(null);
|
||||||
|
let customerSuggestionsRequestId = 0;
|
||||||
|
|
||||||
const isSettingCustomerTo = (customer_number) => {
|
const isSettingCustomerTo = (customer_number) => {
|
||||||
return isSettingCustomerToInteger.value === parseInt(customer_number);
|
return isSettingCustomerToInteger.value === parseInt(customer_number);
|
||||||
@@ -88,10 +89,17 @@ const getCustomerSuggestions = () => {
|
|||||||
if (!props.reg_1) {
|
if (!props.reg_1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const requestedReg1 = props.reg_1;
|
||||||
|
const requestId = ++customerSuggestionsRequestId;
|
||||||
|
|
||||||
SessionUser.request("/department/vehicle/customer-suggestions", "GET", {
|
SessionUser.request("/department/vehicle/customer-suggestions", "GET", {
|
||||||
reg_1: props.reg_1,
|
reg_1: requestedReg1,
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
if (requestId !== customerSuggestionsRequestId || requestedReg1 !== props.reg_1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Assuming the response contains an array of customer suggestions
|
// Assuming the response contains an array of customer suggestions
|
||||||
console.log("Customer suggestions:", response.data.data);
|
console.log("Customer suggestions:", response.data.data);
|
||||||
let suggestions = [];
|
let suggestions = [];
|
||||||
@@ -126,6 +134,7 @@ watch(
|
|||||||
if (newValue) {
|
if (newValue) {
|
||||||
getCustomerSuggestions();
|
getCustomerSuggestions();
|
||||||
} else {
|
} else {
|
||||||
|
customerSuggestionsRequestId += 1;
|
||||||
customer_suggestions.value = [];
|
customer_suggestions.value = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, reactive, ref } from "vue";
|
import { computed, nextTick, reactive, ref, watch } from "vue";
|
||||||
|
import { useMediaQuery } from "@vueuse/core";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
releaseRuntimeState,
|
releaseRuntimeState,
|
||||||
} from "@/services/releaseTimeline.js";
|
} from "@/services/releaseTimeline.js";
|
||||||
import { submitErrorReport } from "@/services/errorReports.js";
|
import { submitErrorReport } from "@/services/errorReports.js";
|
||||||
|
import { errorReportLaunchRequestId } from "@/services/errorReportLauncher.js";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n({ useScope: "global" });
|
const { t } = useI18n({ useScope: "global" });
|
||||||
@@ -21,6 +23,9 @@ const FALLBACK_LABELS = {
|
|||||||
"error_report.before_error": "What were you doing before the error occurred?",
|
"error_report.before_error": "What were you doing before the error occurred?",
|
||||||
"error_report.expected": "What did you expect would happen?",
|
"error_report.expected": "What did you expect would happen?",
|
||||||
"error_report.actual": "What actually happened?",
|
"error_report.actual": "What actually happened?",
|
||||||
|
"error_report.before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
|
||||||
|
"error_report.expected_placeholder": "Describe the result you expected to see.",
|
||||||
|
"error_report.actual_placeholder": "Describe what you saw instead, including any error text.",
|
||||||
"error_report.consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
|
"error_report.consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
|
||||||
"error_report.submit": "Submit report",
|
"error_report.submit": "Submit report",
|
||||||
"error_report.submitted": "Error report submitted.",
|
"error_report.submitted": "Error report submitted.",
|
||||||
@@ -46,7 +51,12 @@ const form = reactive({
|
|||||||
data_collection_accepted: false,
|
data_collection_accepted: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isMobileReportPlacement = useMediaQuery("(max-width: 768px)");
|
||||||
|
const isDesktopNavigationReportPlacement = useMediaQuery("(min-width: 1024px)");
|
||||||
const isAuthenticated = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
|
const isAuthenticated = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
|
||||||
|
const shouldShowFloatingButton = computed(
|
||||||
|
() => !isMobileReportPlacement.value && !isDesktopNavigationReportPlacement.value
|
||||||
|
);
|
||||||
const isValid = computed(() => (
|
const isValid = computed(() => (
|
||||||
form.before_error.trim().length > 0
|
form.before_error.trim().length > 0
|
||||||
&& form.expected.trim().length > 0
|
&& form.expected.trim().length > 0
|
||||||
@@ -96,6 +106,12 @@ const open = () => {
|
|||||||
void loadHtml2Canvas().catch(() => {});
|
void loadHtml2Canvas().catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
watch(errorReportLaunchRequestId, () => {
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
if (isSubmitting.value) {
|
if (isSubmitting.value) {
|
||||||
return;
|
return;
|
||||||
@@ -300,6 +316,7 @@ const submit = async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="isAuthenticated" data-error-report-exclude>
|
<div v-if="isAuthenticated" data-error-report-exclude>
|
||||||
<button
|
<button
|
||||||
|
v-if="shouldShowFloatingButton"
|
||||||
class="button is-danger error-report-button"
|
class="button is-danger error-report-button"
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="error-report-button"
|
data-testid="error-report-button"
|
||||||
@@ -317,7 +334,7 @@ const submit = async () => {
|
|||||||
<h2 class="title is-4">{{ tr("error_report.title") }}</h2>
|
<h2 class="title is-4">{{ tr("error_report.title") }}</h2>
|
||||||
<p class="subtitle is-6">{{ tr("error_report.subtitle") }}</p>
|
<p class="subtitle is-6">{{ tr("error_report.subtitle") }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="delete" type="button" aria-label="close" :disabled="isSubmitting" @click="close"></button>
|
<button class="delete" type="button" :aria-label="t('common.close')" :disabled="isSubmitting" @click="close"></button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="submitted" class="notification is-success is-light" data-testid="error-report-submitted">
|
<div v-if="submitted" class="notification is-success is-light" data-testid="error-report-submitted">
|
||||||
@@ -332,17 +349,35 @@ const submit = async () => {
|
|||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="label">{{ tr("error_report.before_error") }}</span>
|
<span class="label">{{ tr("error_report.before_error") }}</span>
|
||||||
<textarea v-model="form.before_error" class="textarea" maxlength="4000" required></textarea>
|
<textarea
|
||||||
|
v-model="form.before_error"
|
||||||
|
class="textarea"
|
||||||
|
maxlength="4000"
|
||||||
|
:placeholder="tr('error_report.before_error_placeholder')"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="label">{{ tr("error_report.expected") }}</span>
|
<span class="label">{{ tr("error_report.expected") }}</span>
|
||||||
<textarea v-model="form.expected" class="textarea" maxlength="4000" required></textarea>
|
<textarea
|
||||||
|
v-model="form.expected"
|
||||||
|
class="textarea"
|
||||||
|
maxlength="4000"
|
||||||
|
:placeholder="tr('error_report.expected_placeholder')"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="label">{{ tr("error_report.actual") }}</span>
|
<span class="label">{{ tr("error_report.actual") }}</span>
|
||||||
<textarea v-model="form.actual" class="textarea" maxlength="4000" required></textarea>
|
<textarea
|
||||||
|
v-model="form.actual"
|
||||||
|
class="textarea"
|
||||||
|
maxlength="4000"
|
||||||
|
:placeholder="tr('error_report.actual_placeholder')"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="checkbox error-report-consent">
|
<label class="checkbox error-report-consent">
|
||||||
|
|||||||
@@ -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",
|
iconClass: "fa-calendar-alt",
|
||||||
matcher: (url) => /\/order-bookings(?:[/?#]|$)/i.test(url),
|
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 SHIFT_MULTI_PRESS_WINDOW_MS = 700;
|
||||||
const SYSTEM_SEARCH_CLOSE_EVENT = "system-search:close";
|
const SYSTEM_SEARCH_CLOSE_EVENT = "system-search:close";
|
||||||
|
const FRONTEND_MAINTENANCE_MENU_CLOSE_EVENT = "frontend-maintenance-menu:close";
|
||||||
|
|
||||||
const isExpanded = ref(REQUEST_QUEUE_CONFIG.inspector.expandedByDefault);
|
const isExpanded = ref(REQUEST_QUEUE_CONFIG.inspector.expandedByDefault);
|
||||||
const isShortcutActivated = ref(false);
|
const isShortcutActivated = ref(false);
|
||||||
@@ -61,6 +68,7 @@ const processedRequests = computed(() => requestQueueState.batchCompleted + requ
|
|||||||
const missingPermissions = computed(() => requestQueueState.missingPermissions || []);
|
const missingPermissions = computed(() => requestQueueState.missingPermissions || []);
|
||||||
const activeRequests = computed(() => requestQueueState.activeRequests || []);
|
const activeRequests = computed(() => requestQueueState.activeRequests || []);
|
||||||
const recentRequests = computed(() => requestQueueState.recentRequests || []);
|
const recentRequests = computed(() => requestQueueState.recentRequests || []);
|
||||||
|
const queueRequestInsights = computed(() => requestQueueState.requestInsights || {});
|
||||||
const errorRequests = computed(() => requestQueueState.errorRequests || []);
|
const errorRequests = computed(() => requestQueueState.errorRequests || []);
|
||||||
const networkTotals = computed(() => requestQueueState.networkTotals || {
|
const networkTotals = computed(() => requestQueueState.networkTotals || {
|
||||||
outgoingRequests: 0,
|
outgoingRequests: 0,
|
||||||
@@ -107,7 +115,7 @@ const userTypeLabel = computed(() => (SessionUser.isSubuser.value ? "Subuser" :
|
|||||||
const hasSuperuserToken = computed(() => {
|
const hasSuperuserToken = computed(() => {
|
||||||
try {
|
try {
|
||||||
return Boolean(localStorage.getItem("superuser_token"));
|
return Boolean(localStorage.getItem("superuser_token"));
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -118,6 +126,7 @@ const impersonatedUserRoleId = computed(() => {
|
|||||||
const canGrantMissingPermissions = computed(() =>
|
const canGrantMissingPermissions = computed(() =>
|
||||||
hasSuperuserToken.value && !SessionUser.isSubuser.value && impersonatedUserRoleId.value !== null
|
hasSuperuserToken.value && !SessionUser.isSubuser.value && impersonatedUserRoleId.value !== null
|
||||||
);
|
);
|
||||||
|
const canInspectReleaseRuntime = computed(() => hasSuperuserToken.value || SessionUser.canAccessAdmin?.() === true);
|
||||||
|
|
||||||
const userDetailRows = computed(() => {
|
const userDetailRows = computed(() => {
|
||||||
if (SessionUser.isSubuser.value) {
|
if (SessionUser.isSubuser.value) {
|
||||||
@@ -189,6 +198,152 @@ const formatBytes = (value) => {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
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 formatTimeAgo = (timestamp) => {
|
||||||
const value = Number(timestamp || 0);
|
const value = Number(timestamp || 0);
|
||||||
if (!value || Number.isNaN(value)) {
|
if (!value || Number.isNaN(value)) {
|
||||||
@@ -217,43 +372,67 @@ const getActiveRequestElapsedMs = (request) => Math.max(0, nowMs.value - Number(
|
|||||||
|
|
||||||
const hasInsightEntryChanged = (currentEntry, nextEntry) =>
|
const hasInsightEntryChanged = (currentEntry, nextEntry) =>
|
||||||
Number(currentEntry?.requestDurationMs || 0) !== Number(nextEntry?.requestDurationMs || 0)
|
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?.completedAt || 0) !== Number(nextEntry?.completedAt || 0)
|
||||||
|| Number(currentEntry?.startedAt || 0) !== Number(nextEntry?.startedAt || 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 requestInsights = computed(() => REQUEST_INSIGHT_DEFINITIONS.map((definition) => {
|
||||||
const matchedRequestFromRecent = recentRequests.value.find((request) =>
|
const matchedRequestFromRecent = recentRequests.value.find((request) =>
|
||||||
definition.matcher(String(request?.url || ""))
|
definition.matcher(String(request?.url || ""))
|
||||||
) || null;
|
) || 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;
|
const hasData = matchedRequest !== null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...definition,
|
...definition,
|
||||||
latencyText: hasData ? `${Math.max(0, Number(matchedRequest.requestDurationMs) || 0)} ms` : " ",
|
latencyText: hasData ? formatRequestLatency(definition, matchedRequest) : " ",
|
||||||
|
serverTiming: matchedRequest?.serverTiming || "",
|
||||||
timeAgoText: hasData
|
timeAgoText: hasData
|
||||||
? formatTimeAgo(matchedRequest.completedAt || matchedRequest.startedAt || matchedRequest.queuedAt)
|
? formatTimeAgo(matchedRequest.completedAt || matchedRequest.startedAt || matchedRequest.queuedAt)
|
||||||
: "",
|
: "",
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
|
|
||||||
watch(recentRequests, (requests) => {
|
watch([recentRequests, queueRequestInsights], ([requests, insights]) => {
|
||||||
if (!Array.isArray(requests) || requests.length === 0) {
|
const recentRequestList = Array.isArray(requests) ? requests : [];
|
||||||
|
const insightEntries = insights && typeof insights === "object" ? insights : {};
|
||||||
|
|
||||||
|
if (recentRequestList.length === 0 && Object.keys(insightEntries).length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextHistory = { ...requestInsightHistory.value };
|
const nextHistory = { ...requestInsightHistory.value };
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
REQUEST_INSIGHT_DEFINITIONS.forEach((definition) => {
|
REQUEST_INSIGHT_DEFINITIONS.forEach((definition) => {
|
||||||
const matchedRequest = requests.find((request) =>
|
const matchedRequest = selectNewestInsightEntry(
|
||||||
definition.matcher(String(request?.url || ""))
|
recentRequestList.find((request) =>
|
||||||
|
definition.matcher(String(request?.url || ""))
|
||||||
|
),
|
||||||
|
insightEntries[definition.key],
|
||||||
);
|
);
|
||||||
if (!matchedRequest) {
|
if (!matchedRequest) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextEntry = {
|
const nextEntry = {
|
||||||
|
serverTiming: matchedRequest.serverTiming || null,
|
||||||
requestDurationMs: Math.max(0, Number(matchedRequest.requestDurationMs) || 0),
|
requestDurationMs: Math.max(0, Number(matchedRequest.requestDurationMs) || 0),
|
||||||
|
queueDurationMs: Math.max(0, Number(matchedRequest.queueDurationMs) || 0),
|
||||||
completedAt: matchedRequest.completedAt || null,
|
completedAt: matchedRequest.completedAt || null,
|
||||||
startedAt: matchedRequest.startedAt || null,
|
startedAt: matchedRequest.startedAt || null,
|
||||||
queuedAt: matchedRequest.queuedAt || null,
|
queuedAt: matchedRequest.queuedAt || null,
|
||||||
@@ -284,7 +463,12 @@ const resolvePingUrl = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
|
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 () => {
|
const measurePingLatency = async () => {
|
||||||
if (typeof fetch !== "function") {
|
if (typeof fetch !== "function") {
|
||||||
@@ -322,7 +506,7 @@ const measurePingLatency = async () => {
|
|||||||
queuedAt: startedAt,
|
queuedAt: startedAt,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
pingLatencyMs.value = null;
|
pingLatencyMs.value = null;
|
||||||
pingIsUnavailable.value = true;
|
pingIsUnavailable.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -360,6 +544,9 @@ const handleWindowKeydown = (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (shiftKeyPressCount.value >= 3) {
|
if (shiftKeyPressCount.value >= 3) {
|
||||||
|
event.preventDefault?.();
|
||||||
|
event.stopImmediatePropagation?.();
|
||||||
|
window.dispatchEvent(new CustomEvent(FRONTEND_MAINTENANCE_MENU_CLOSE_EVENT));
|
||||||
closeSystemSearchForSuperuser();
|
closeSystemSearchForSuperuser();
|
||||||
isShortcutActivated.value = true;
|
isShortcutActivated.value = true;
|
||||||
isExpanded.value = true;
|
isExpanded.value = true;
|
||||||
@@ -564,7 +751,7 @@ onBeforeUnmount(() => {
|
|||||||
{{ request.method }}
|
{{ request.method }}
|
||||||
</span>
|
</span>
|
||||||
<span class="request-queue-progress__endpoint" :title="request.url">{{ request.url }}</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) }}
|
{{ formatDuration(request.requestDurationMs) }}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -585,7 +772,9 @@ onBeforeUnmount(() => {
|
|||||||
{{ insight.label }}
|
{{ insight.label }}
|
||||||
</span>
|
</span>
|
||||||
<span class="request-queue-progress__bottom-request-time">{{ insight.timeAgoText }}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -646,9 +835,10 @@ onBeforeUnmount(() => {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
|
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
|
||||||
<div class="request-queue-progress__section-title">Session release</div>
|
<template v-if="canInspectReleaseRuntime">
|
||||||
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
|
<div class="request-queue-progress__section-title">Session release</div>
|
||||||
<ul class="request-queue-progress__meta-list">
|
<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">
|
<li class="request-queue-progress__meta-item">
|
||||||
<span class="request-queue-progress__meta-label">Channel</span>
|
<span class="request-queue-progress__meta-label">Channel</span>
|
||||||
<span
|
<span
|
||||||
@@ -771,11 +961,15 @@ onBeforeUnmount(() => {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</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">
|
<ul class="request-queue-progress__meta-list">
|
||||||
<li class="request-queue-progress__meta-item">
|
<li class="request-queue-progress__meta-item">
|
||||||
<span class="request-queue-progress__meta-label">API URL</span>
|
<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>
|
||||||
<li class="request-queue-progress__meta-item">
|
<li class="request-queue-progress__meta-item">
|
||||||
<span class="request-queue-progress__meta-label">Current host</span>
|
<span class="request-queue-progress__meta-label">Current host</span>
|
||||||
@@ -1466,6 +1660,10 @@ onBeforeUnmount(() => {
|
|||||||
.request-queue-progress__bottom-request-latency {
|
.request-queue-progress__bottom-request-latency {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
max-width: 190px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1485,4 +1683,3 @@ onBeforeUnmount(() => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import MenuDefault from "@/components/menus/MenuDefault.vue";
|
|||||||
import { ref, watch, onMounted } from "vue";
|
import { ref, watch, onMounted } from "vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { isAccessibleVisibleDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
|
import { isAccessibleVisibleNamedDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
|
||||||
// Get the department ID from the URL
|
// Get the department ID from the URL
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
|
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
|
||||||
@@ -26,7 +26,7 @@ const loadDepartments = () => {
|
|||||||
value: department.id,
|
value: department.id,
|
||||||
icon: "fas fa-building",
|
icon: "fas fa-building",
|
||||||
children: [],
|
children: [],
|
||||||
hidden: !isAccessibleVisibleDepartment(department, SessionUser.canAccessAssignedDepartment),
|
hidden: !isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
menu_items.value[1].options = options;
|
menu_items.value[1].options = options;
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ const menu_items = ref([
|
|||||||
children: [],
|
children: [],
|
||||||
hidden: false
|
hidden: false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Chauffører',
|
||||||
|
value: '/subusers',
|
||||||
|
icon: 'fas fa-id-card',
|
||||||
|
children: [],
|
||||||
|
hidden: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: SessionUser.objects.roles.meta.title,
|
label: SessionUser.objects.roles.meta.title,
|
||||||
value: SessionUser.objects.roles.meta.endpoint,
|
value: SessionUser.objects.roles.meta.endpoint,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user