Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
184ef5670c | ||
|
|
8ab8915429 | ||
|
|
a952dd2c61 | ||
|
|
ebcd52a019 | ||
|
|
b2ddf0db5c | ||
|
|
eaf4b965d5 | ||
|
|
6ba1c754a1 | ||
|
|
468613ab68 | ||
|
|
261d84bd01 | ||
|
|
ea8db593c5 | ||
|
|
130cc2fc10 | ||
|
|
3715da8fd3 | ||
|
|
100e477a95 | ||
|
|
1132c8c256 | ||
|
|
42237fcc70 | ||
|
|
ae8b3b6b64 | ||
|
|
8378491b0b | ||
|
|
20274cf8eb | ||
|
|
d25726fafd | ||
|
|
4f1973d048 | ||
|
|
d69c474655 | ||
|
|
56078307da | ||
|
|
e6291cdb8a | ||
|
|
bf0b2152c5 | ||
|
|
9771723992 | ||
|
|
156e50a864 | ||
|
|
5081fe0776 | ||
|
|
656e43d449 | ||
|
|
d135a3f0af | ||
|
|
7bd880497f | ||
|
|
d148c0d6b9 | ||
|
|
75bab7b425 | ||
|
|
8dfaebba00 | ||
|
|
f7f01dcd2f | ||
|
|
fba85a72c1 | ||
|
|
c657ec57ad | ||
|
|
f24df440d0 | ||
|
|
b73f83a4ab | ||
|
|
2700ff3526 | ||
|
|
a261f2f3bd | ||
|
|
81c4ba0800 | ||
|
|
31e72af9bf | ||
|
|
10a52521d5 | ||
|
|
c9b34ba36c | ||
|
|
87ad8f9d31 | ||
|
|
a754cfc7bb | ||
|
|
29bc0e548b | ||
|
|
378598b161 | ||
|
|
e8c1dda3cc | ||
|
|
fabc4ad54a | ||
|
|
b89ae2bb9e | ||
|
|
fb162eef6c | ||
|
|
dc2840e023 | ||
|
|
fe6d9e8d16 | ||
|
|
7ab7d83fb5 | ||
|
|
028dad2183 | ||
|
|
6b0fe60958 | ||
|
|
a7789cb631 | ||
|
|
da65876a64 | ||
|
|
f245161bfa | ||
|
|
bdffdca420 | ||
|
|
5d1199a133 | ||
|
|
4752b3d096 | ||
|
|
fc9c3700a1 | ||
|
|
a15d6841c5 | ||
|
|
9641f67e3a | ||
|
|
de2fa57c63 | ||
|
|
a658fe7863 | ||
|
|
503fb84f10 | ||
|
|
11bb8f824d | ||
|
|
8615cc6678 | ||
|
|
65e4c27f45 | ||
|
|
f9d8b87768 | ||
|
|
586a0cc86d | ||
|
|
e9cbb40559 | ||
|
|
f2c3962749 | ||
|
|
e63e113628 | ||
|
|
4b02de7358 | ||
|
|
61aab76e63 | ||
|
|
e26e37d9d7 | ||
|
|
d411da2a12 | ||
|
|
15b3c726dc | ||
|
|
ee47c801ea | ||
|
|
d0993d2963 |
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(find /c/Users/2jepp/WebstormProjects/pleno-vue -type f \\\\\\(-name *.ts -o -name *.js \\\\\\) -not -path */node_modules* -not -path */dist/* -not -path */.git/* -not -path */dev-dist/*)",
|
||||||
|
"Bash(find /c/Users/2jepp/WebstormProjects/pleno-vue/src -type f \\\\\\(-name *.ts -o -name *.js -o -name *.vue \\\\\\))",
|
||||||
|
"Bash(find /c/Users/2jepp/WebstormProjects/pleno-vue/app -type f \\\\\\(-name *.ts -o -name *.js -o -name *.java -o -name *.kt \\\\\\))",
|
||||||
|
"Bash(grep -r \"getAutomaticFlags\\\\|AutomaticFlags\\\\|automaticFlags\\\\|Automatic.*Flags\" /c/Users/2jepp/WebstormProjects/pleno-vue --include=*.ts --include=*.js --include=*.java --include=*.kt --exclude-dir=node_modules --exclude-dir=dist --exclude-dir=dev-dist --exclude-dir=output --exclude-dir=.git)",
|
||||||
|
"Bash(xargs grep:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.github
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
coverage
|
||||||
|
dev-dist
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
node_modules.*
|
||||||
|
output
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
.ai
|
||||||
|
.ai-workflow
|
||||||
|
.claude
|
||||||
|
.codex
|
||||||
|
.gradle
|
||||||
|
gradle
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.webp binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.otf binary
|
||||||
|
*.pdf binary
|
||||||
|
*.zip binary
|
||||||
|
*.webm binary
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
name: Frontend Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- Automated Tests
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: frontend-release-${{ github.event.workflow_run.head_branch }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-upload-and-verify:
|
||||||
|
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
|
||||||
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
|
env:
|
||||||
|
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||||
|
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
|
||||||
|
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: https://api-v2.truckwash.io/master/frontend
|
||||||
|
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
|
||||||
|
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
|
||||||
|
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
RELEASE_WAIT_INITIAL_SECONDS: 45
|
||||||
|
RELEASE_WAIT_TIMEOUT_SECONDS: 600
|
||||||
|
RELEASE_POLL_INTERVAL_SECONDS: 10
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
|
||||||
|
- name: Check release commit is current
|
||||||
|
id: branch-head
|
||||||
|
run: |
|
||||||
|
latest_sha="$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | awk '{print $1}')"
|
||||||
|
if [[ -z "$latest_sha" ]]; then
|
||||||
|
echo "Could not resolve origin/$RELEASE_BRANCH." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$latest_sha" != "$RELEASE_EXPECTED_COMMIT" ]]; then
|
||||||
|
echo "current=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Skipping stale release for $RELEASE_EXPECTED_COMMIT; origin/$RELEASE_BRANCH is $latest_sha."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "current=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Release commit is current for $RELEASE_BRANCH."
|
||||||
|
env:
|
||||||
|
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Check AI workflow sync
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
run: node scripts/sync-ai-workflow.mjs --check
|
||||||
|
|
||||||
|
- name: Source and i18n checks
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
run: |
|
||||||
|
npm run text:check-encoding
|
||||||
|
npm run i18n:v2:source-check
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
run: npm run test:unit
|
||||||
|
env:
|
||||||
|
VITEST_BATCH_SIZE: 5
|
||||||
|
|
||||||
|
- name: Build release artifact
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Install Playwright Chromium
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
run: node scripts/install-playwright-browsers.mjs chromium
|
||||||
|
|
||||||
|
- name: Production Playwright gate
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
run: npm run test:e2e:prod
|
||||||
|
|
||||||
|
- name: Upload dist artifact
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
|
||||||
|
path: dist
|
||||||
|
retention-days: 3
|
||||||
|
|
||||||
|
- name: Request Release Manager auto sync
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
run: |
|
||||||
|
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||||
|
response_file="$(mktemp)"
|
||||||
|
status_code="$(curl --show-error --silent \
|
||||||
|
--output "$response_file" \
|
||||||
|
--write-out "%{http_code}" \
|
||||||
|
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||||
|
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
|
||||||
|
if [[ "$status_code" =~ ^2 ]]; then
|
||||||
|
cat "$response_file"
|
||||||
|
elif [[ "$status_code" == "504" ]]; then
|
||||||
|
echo "Release Manager auto sync request reached the gateway timeout; continuing to artifact wait."
|
||||||
|
else
|
||||||
|
cat "$response_file" >&2
|
||||||
|
echo "Release Manager auto sync request failed with HTTP $status_code." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||||
|
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||||
|
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||||
|
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Wait for Coolify release artifact
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
run: npm run release:verify-upload
|
||||||
|
|
||||||
|
- name: Public live Playwright gate
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
run: npm run test:e2e:live:public
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --use-system-ca
|
||||||
|
|
||||||
|
- name: Credentialed live Playwright gate
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
run: npm run test:e2e:live:roles
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --use-system-ca
|
||||||
|
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
|
||||||
|
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
|
||||||
|
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
|
||||||
|
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
|
||||||
|
PLAYWRIGHT_OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
|
||||||
|
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||||
|
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
||||||
|
|
||||||
|
- name: Record Release Manager gate
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
run: |
|
||||||
|
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||||
|
release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}"
|
||||||
|
curl --fail --show-error --silent \
|
||||||
|
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||||
|
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
|
||||||
|
env:
|
||||||
|
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||||
|
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||||
|
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||||
|
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Update server version after verification
|
||||||
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
|
run: npm run release:update-server-version
|
||||||
|
env:
|
||||||
|
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
||||||
|
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: failure() && steps.branch-head.outputs.current == 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
|
||||||
|
path: output/playwright
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 3
|
||||||
+83
-18
@@ -16,8 +16,9 @@ concurrency:
|
|||||||
|
|
||||||
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, default]
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -40,6 +41,7 @@ jobs:
|
|||||||
build-and-unit:
|
build-and-unit:
|
||||||
needs: format-tests
|
needs: format-tests
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -53,6 +55,12 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --legacy-peer-deps
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Check i18n source consistency
|
||||||
|
run: npm run i18n:v2:check
|
||||||
|
|
||||||
- name: Build sanity check
|
- name: Build sanity check
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
@@ -61,17 +69,49 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
VITEST_BATCH_SIZE: 5
|
VITEST_BATCH_SIZE: 5
|
||||||
|
|
||||||
e2e-smoke:
|
e2e-pr:
|
||||||
if: github.event_name != 'schedule'
|
if: github.event_name != 'schedule'
|
||||||
needs: build-and-unit
|
needs: build-and-unit
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||||
|
runs-on: [self-hosted, Linux, X64]
|
||||||
|
timeout-minutes: 30
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
max-parallel: 4
|
||||||
matrix:
|
matrix:
|
||||||
|
suite: [core, changed]
|
||||||
project: [chromium-desktop, chromium-mobile]
|
project: [chromium-desktop, chromium-mobile]
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||||
|
PLAYWRIGHT_REPORTER_MODE: line-html
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
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
|
||||||
@@ -83,29 +123,51 @@ jobs:
|
|||||||
run: npm ci --legacy-peer-deps
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: npx playwright install --with-deps chromium
|
run: node scripts/install-playwright-browsers.mjs chromium
|
||||||
|
|
||||||
- name: Run Playwright smoke tests (${{ matrix.project }})
|
- name: Run Playwright smoke tests
|
||||||
run: npx playwright test --grep @smoke --project="${{ matrix.project }}"
|
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-smoke-${{ matrix.project }}
|
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||||
path: |
|
path: |
|
||||||
output/playwright/report
|
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
|
||||||
output/playwright/test-results
|
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 7
|
retention-days: 3
|
||||||
|
|
||||||
e2e-full:
|
e2e-full:
|
||||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
|
if: >
|
||||||
needs: build-and-unit
|
always() &&
|
||||||
|
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
|
||||||
|
needs.build-and-unit.result == 'success' &&
|
||||||
|
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
|
||||||
|
needs: [build-and-unit, e2e-pr]
|
||||||
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
|
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
|
||||||
runs-on: [self-hosted, Linux, X64, default]
|
runs-on: [self-hosted, Linux, X64]
|
||||||
|
timeout-minutes: 60
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
max-parallel: 4
|
||||||
matrix:
|
matrix:
|
||||||
role: [customer, subuser, admin, superuser]
|
role: [customer, subuser, admin, superuser]
|
||||||
browser: [chromium, firefox, webkit]
|
browser: [chromium, firefox, webkit]
|
||||||
@@ -137,13 +199,16 @@ jobs:
|
|||||||
run: npm ci --legacy-peer-deps
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: npx playwright install --with-deps ${{ matrix.browser_install }}
|
run: node scripts/install-playwright-browsers.mjs ${{ matrix.browser_install }}
|
||||||
|
|
||||||
- name: 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 }}
|
||||||
@@ -152,4 +217,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 @@
|
|||||||
|
[]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.0
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"setupCompletedAt": "2026-06-07T11:37:04.444Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
FROM node:24-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY nginx.coolify-frontend.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -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. |
|
||||||
@@ -5,6 +5,7 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_APP_VERSION: string,
|
readonly VITE_APP_VERSION: string,
|
||||||
readonly VITE_COMMIT_HASH: string,
|
readonly VITE_COMMIT_HASH: string,
|
||||||
readonly VITE_IS_DEV: string,
|
readonly VITE_IS_DEV: string,
|
||||||
|
readonly VITE_RELEASE_SOURCE: 'local' | 'deployment' | 'auto',
|
||||||
}
|
}
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv
|
readonly env: ImportMetaEnv
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
+14
-6
@@ -2,11 +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="/favicons/favicon-96x96.png" sizes="96x96" />
|
<script>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicons/favicon.svg" />
|
(function () {
|
||||||
<link rel="shortcut icon" href="/favicons/favicon.ico" />
|
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/);
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png" />
|
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/';
|
||||||
<link rel="manifest" href="/manifest.json" />
|
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">
|
||||||
@@ -16,6 +24,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/releaseBootstrap.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
|
||||||
|
location ~ ^/(?:.+/)?(?<webmanifest_path>(?:assets/)?manifest\.webmanifest)$ {
|
||||||
|
types { application/manifest+json webmanifest; }
|
||||||
|
default_type application/manifest+json;
|
||||||
|
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
try_files /$webmanifest_path =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(release-entry|release-manifest)\.json$ {
|
||||||
|
add_header Cache-Control "no-store";
|
||||||
|
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
|
||||||
|
add_header Cache-Control "no-store";
|
||||||
|
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
try_files /$2.json =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
try_files /$static_asset_path =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(?:.+/)?(?<static_file_path>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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.[^/]+$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(master|beta|canary|internal)/frontend$ {
|
||||||
|
try_files /index.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(master|beta|canary|internal)/frontend/ {
|
||||||
|
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
+872
-5
File diff suppressed because it is too large
Load Diff
@@ -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
+1173
-45
File diff suppressed because it is too large
Load Diff
+32
-4
@@ -7,18 +7,34 @@
|
|||||||
"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",
|
||||||
"text:check-encoding": "node scripts/text-encoding.mjs check",
|
"text:check-encoding": "node scripts/text-encoding.mjs check",
|
||||||
"test:unit": "npm run text:check-encoding && node scripts/run-vitest-unit-batches.mjs",
|
"i18n:v2:compile": "node scripts/i18n-v2-compile-source.mjs",
|
||||||
|
"i18n:v2:source-check": "node scripts/i18n-v2-compile-source.mjs --check",
|
||||||
|
"i18n:v2:check": "npm run i18n:v2:source-check && npm run i18n:v2:global-template-audit && npm run i18n:v2:template-dedupe-audit && npm run i18n:v2:word-audit",
|
||||||
|
"i18n:v2:word-audit": "node scripts/i18n-v2-word-dedupe.mjs",
|
||||||
|
"i18n:v2:word-dedupe": "node scripts/i18n-v2-word-dedupe.mjs --apply",
|
||||||
|
"i18n:v2:global-template-audit": "node scripts/i18n-v2-globalize-templates.mjs",
|
||||||
|
"i18n:v2:globalize-templates": "node scripts/i18n-v2-globalize-templates.mjs --apply",
|
||||||
|
"i18n:v2:template-dedupe-audit": "node scripts/i18n-v2-template-dedupe.mjs",
|
||||||
|
"i18n:v2:template-dedupe": "node scripts/i18n-v2-template-dedupe.mjs --apply",
|
||||||
|
"test:unit": "npm run text:check-encoding && npm run test:unit:fast && npm run test:unit:serial",
|
||||||
|
"test:unit:fast": "node scripts/run-vitest-unit-fast.mjs",
|
||||||
|
"test:unit:serial": "node scripts/run-vitest-unit-batches.mjs --from-list tests/unit/serial-tests.txt",
|
||||||
"test:unit:single": "vitest run",
|
"test:unit:single": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "npm run test:e2e:matrix --",
|
||||||
|
"test:e2e:matrix": "playwright test",
|
||||||
|
"test:e2e:pr": "node scripts/run-playwright-pr.mjs",
|
||||||
|
"test:e2e:changed": "node scripts/run-playwright-pr.mjs --changed-only",
|
||||||
"test:e2e:i18n:views": "playwright test tests/e2e/i18n.views.spec.ts --project=chromium-desktop",
|
"test:e2e:i18n:views": "playwright test tests/e2e/i18n.views.spec.ts --project=chromium-desktop",
|
||||||
"test:e2e:ci": "node scripts/run-playwright-ci-parallel.mjs",
|
"test:e2e:ci": "node scripts/run-playwright-ci-parallel.mjs",
|
||||||
"test:e2e:full:slice": "node scripts/run-playwright-full-slice.mjs",
|
"test:e2e:full:slice": "node scripts/run-playwright-full-slice.mjs",
|
||||||
@@ -28,9 +44,14 @@
|
|||||||
"test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile",
|
"test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile",
|
||||||
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
|
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
|
||||||
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
|
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
|
||||||
|
"test:e2e:live:public": "playwright test --config=playwright.live.config.ts --grep @public-live",
|
||||||
|
"test:e2e:live:roles": "playwright test --config=playwright.live.config.ts --grep @role-live",
|
||||||
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
|
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
|
||||||
|
"release:verify-upload": "node scripts/release/verify-upload.mjs",
|
||||||
|
"release:update-server-version": "node scripts/release/update-server-version.mjs",
|
||||||
|
"release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh",
|
||||||
"test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"",
|
"test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"",
|
||||||
"test:all": "npm run test:unit && npm run test:e2e:smoke",
|
"test:all": "npm run test:unit && npm run test:e2e:pr",
|
||||||
"twa:build": "bubblewrap build",
|
"twa:build": "bubblewrap build",
|
||||||
"twa:update": "bubblewrap update"
|
"twa:update": "bubblewrap update"
|
||||||
},
|
},
|
||||||
@@ -67,6 +88,7 @@
|
|||||||
"chart.js": "^4.4.7",
|
"chart.js": "^4.4.7",
|
||||||
"dayspan-vuetify": "^0.4.0",
|
"dayspan-vuetify": "^0.4.0",
|
||||||
"flow": "^0.2.3",
|
"flow": "^0.2.3",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sass": "^1.81.1",
|
"sass": "^1.81.1",
|
||||||
"sweetalert2": "11.22.4",
|
"sweetalert2": "11.22.4",
|
||||||
@@ -89,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
|
||||||
? {}
|
? {}
|
||||||
|
|||||||
+64
-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();
|
||||||
|
|
||||||
@@ -386,12 +406,36 @@ async function warmModuleGraph(entryUrl, { depth = 2, timeoutMs = 120_000 } = {}
|
|||||||
|
|
||||||
async function warmUpDevServer(url) {
|
async function warmUpDevServer(url) {
|
||||||
const warmupTargets = await buildWarmupTargets();
|
const warmupTargets = await buildWarmupTargets();
|
||||||
|
const selfServeEntryModules = [
|
||||||
|
"/src/views/dashboards/userDashboard/wash/MyWash.vue",
|
||||||
|
"/src/views/dashboards/userDashboard/wash/MyWashStart.vue",
|
||||||
|
"/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue",
|
||||||
|
"/src/views/guest/book/GuestBookExteriorWash.vue",
|
||||||
|
];
|
||||||
|
const smokeRouteEntryModules = [
|
||||||
|
{ path: "/src/components/page/wrappers/LayoutV2.vue", depth: 2 },
|
||||||
|
{ path: "/src/views/pages/LandingPage.vue", depth: 2 },
|
||||||
|
{ path: "/src/views/dashboards/superUserDashboard/selfserve/EdgeAgents.vue", depth: 1 },
|
||||||
|
{ path: "/src/features/edgeGateways/EdgeGatewayManager.vue", depth: 2 },
|
||||||
|
{ path: "/src/features/edgeGateways/EdgeGatewayTerminalPage.vue", depth: 2 },
|
||||||
|
{ path: "/src/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue", depth: 1 },
|
||||||
|
{ path: "/src/features/edgeGateways/EdgeGatewayDepartmentWorkspace.vue", depth: 2 },
|
||||||
|
{ path: "/src/views/dashboards/superUserDashboard/CollectedOrderInvoices.vue", depth: 1 },
|
||||||
|
{ path: "/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionOverview.vue", depth: 2 },
|
||||||
|
{ path: "/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionMonthView.vue", depth: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
for (const target of warmupTargets) {
|
for (const target of warmupTargets) {
|
||||||
await warmUpAsset(new URL(target.pathname, url).toString(), target.expectedContentType);
|
await warmUpAsset(new URL(target.pathname, url).toString(), target.expectedContentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
await warmModuleGraph(new URL("/src/main.js", url).toString());
|
await warmModuleGraph(new URL("/src/main.js", url).toString());
|
||||||
|
for (const entryModule of selfServeEntryModules) {
|
||||||
|
await warmModuleGraph(new URL(entryModule, url).toString(), { depth: 2 });
|
||||||
|
}
|
||||||
|
for (const { path: entryModule, depth } of smokeRouteEntryModules) {
|
||||||
|
await warmModuleGraph(new URL(entryModule, url).toString(), { depth });
|
||||||
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,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();
|
||||||
|
|
||||||
|
|||||||
+51
-20
@@ -1,3 +1,4 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const baseURL = "http://127.0.0.1:4173";
|
const baseURL = "http://127.0.0.1:4173";
|
||||||
@@ -5,6 +6,55 @@ const isCI = !!process.env.CI;
|
|||||||
|
|
||||||
process.env.PLAYWRIGHT_BASE_URL = baseURL;
|
process.env.PLAYWRIGHT_BASE_URL = baseURL;
|
||||||
|
|
||||||
|
const osReleaseValue = (key: string) => {
|
||||||
|
try {
|
||||||
|
const body = fs.readFileSync("/etc/os-release", "utf8");
|
||||||
|
const match = body.match(new RegExp(`^${key}=(.*)$`, "m"));
|
||||||
|
return String(match?.[1] || "").replace(/^"|"$/g, "");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUnsupportedWebKitHost = () => {
|
||||||
|
if (process.platform !== "linux") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const id = osReleaseValue("ID").toLowerCase();
|
||||||
|
const version = Number.parseFloat(osReleaseValue("VERSION_ID"));
|
||||||
|
return id === "ubuntu" && Number.isFinite(version) && version >= 26.04;
|
||||||
|
};
|
||||||
|
|
||||||
|
const webKitOverride = String(process.env.PLAYWRIGHT_PROD_WEBKIT || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const includeWebKit =
|
||||||
|
webKitOverride === "1" || (webKitOverride !== "0" && webKitOverride !== "false" && !isUnsupportedWebKitHost());
|
||||||
|
|
||||||
|
const projects = [
|
||||||
|
{
|
||||||
|
name: "chromium-desktop",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chromium-mobile",
|
||||||
|
use: {
|
||||||
|
...devices["Pixel 5"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (includeWebKit) {
|
||||||
|
projects.push({
|
||||||
|
name: "webkit-desktop",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Safari"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e/release",
|
testDir: "./tests/e2e/release",
|
||||||
testMatch: /.*\.local-prod\.spec\.ts/,
|
testMatch: /.*\.local-prod\.spec\.ts/,
|
||||||
@@ -28,24 +78,5 @@ export default defineConfig({
|
|||||||
timeout: 240_000,
|
timeout: 240_000,
|
||||||
reuseExistingServer: !isCI,
|
reuseExistingServer: !isCI,
|
||||||
},
|
},
|
||||||
projects: [
|
projects,
|
||||||
{
|
|
||||||
name: "chromium-desktop",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Chrome"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "chromium-mobile",
|
|
||||||
use: {
|
|
||||||
...devices["Pixel 5"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "webkit-desktop",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Safari"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<IfModule mod_negotiation.c>
|
||||||
|
Options -MultiViews
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_mime.c>
|
||||||
|
AddType application/manifest+json .webmanifest
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
RewriteRule ^index\.html$ - [L]
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule ^.+/((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ $1 [L]
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
|
||||||
|
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ public/$1 [L]
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
|
||||||
|
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ dist/$1 [L]
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule ^.+/((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
|
||||||
|
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
|
||||||
|
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule ^(?:.*?/)?(?:assets|resources|favicons|icons|img|sounds|\.well-known)/ - [R=404,L]
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule ^(?:.*?/)?(?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule \.[^/]+$ - [R=404,L]
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule . index.html [L]
|
||||||
|
</IfModule>
|
||||||
@@ -2,15 +2,16 @@
|
|||||||
"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": "/favicons/web-app-manifest-192x192.png",
|
"src": "assets/favicons/web-app-manifest-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/favicons/web-app-manifest-512x512.png",
|
"src": "assets/favicons/web-app-manifest-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const projectRoot = path.resolve(__dirname, "..");
|
||||||
|
const sourceDirectory = path.join(projectRoot, "src", "i18n", "source");
|
||||||
|
const generatedDirectory = path.join(projectRoot, "src", "i18n", "generated");
|
||||||
|
const activeLocales = ["da", "en", "sv", "de", "no"];
|
||||||
|
const mode = process.argv.includes("--check") ? "check" : "write";
|
||||||
|
|
||||||
|
const linkTokenPattern = /@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))/gu;
|
||||||
|
|
||||||
|
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
|
||||||
|
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
|
||||||
|
|
||||||
|
const stableJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
||||||
|
|
||||||
|
const writeJson = (filePath, value) => {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, stableJson(value), "utf8");
|
||||||
|
};
|
||||||
|
|
||||||
|
const listJsonFiles = (directory) => {
|
||||||
|
if (!fs.existsSync(directory)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.flatMap((entry) => {
|
||||||
|
const entryPath = path.join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
return listJsonFiles(entryPath);
|
||||||
|
}
|
||||||
|
return entry.isFile() && entry.name.endsWith(".json") ? [entryPath] : [];
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.localeCompare(right, "en", { sensitivity: "base" }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeMessages = (left = {}, right = {}) => {
|
||||||
|
const merged = { ...left };
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(right ?? {})) {
|
||||||
|
if (isPlainObject(value) && isPlainObject(merged[key])) {
|
||||||
|
merged[key] = mergeMessages(merged[key], value);
|
||||||
|
} else {
|
||||||
|
merged[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readJsonFragments = (directory) => {
|
||||||
|
let merged = {};
|
||||||
|
for (const filePath of listJsonFiles(directory)) {
|
||||||
|
merged = mergeMessages(merged, readJson(filePath));
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapSourcePathToRuntimePath = (keyPath) => {
|
||||||
|
if (keyPath.startsWith("terms.glossary.")) {
|
||||||
|
return `words.generated.${keyPath.slice("terms.glossary.".length)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyPath.startsWith("terms.")) {
|
||||||
|
return `words.${keyPath.slice("terms.".length)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyPath.startsWith("phrases.compat.")) {
|
||||||
|
return `templates.generated.compat.${keyPath.slice("phrases.compat.".length)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyPath.startsWith("phrases.")) {
|
||||||
|
return `templates.${keyPath.slice("phrases.".length)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rewriteLinks = (value) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.replace(linkTokenPattern, (token, modifier = "", literalTarget, pathTarget) => {
|
||||||
|
const target = literalTarget ?? pathTarget;
|
||||||
|
const mappedTarget = mapSourcePathToRuntimePath(target);
|
||||||
|
const quoteTarget = literalTarget || /[^\p{L}\p{N}_.-]/u.test(mappedTarget);
|
||||||
|
return quoteTarget ? `@${modifier}:{'${mappedTarget}'}` : `@${modifier}:${mappedTarget}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry) => rewriteLinks(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, rewriteLinks(entry)]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const compileWords = (sourceTerms) => {
|
||||||
|
const { glossary, ...terms } = sourceTerms;
|
||||||
|
return {
|
||||||
|
grammar: terms.grammar ?? {},
|
||||||
|
actions: terms.actions ?? {},
|
||||||
|
entities: terms.entities ?? {},
|
||||||
|
statuses: terms.statuses ?? {},
|
||||||
|
domain: terms.domain ?? {},
|
||||||
|
services: terms.services ?? {},
|
||||||
|
units: terms.units ?? {},
|
||||||
|
replication: terms.replication ?? {},
|
||||||
|
generated: glossary ?? {},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const compileTemplates = (sourcePhrases) => {
|
||||||
|
const { compat, ...phrases } = sourcePhrases;
|
||||||
|
return {
|
||||||
|
...phrases,
|
||||||
|
generated: {
|
||||||
|
compat: compat ?? {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const compileLocale = (locale) => {
|
||||||
|
const localeSourceDirectory = path.join(sourceDirectory, locale);
|
||||||
|
const sourceTerms = readJsonFragments(path.join(localeSourceDirectory, "terms"));
|
||||||
|
const sourcePhrases = readJsonFragments(path.join(localeSourceDirectory, "phrases"));
|
||||||
|
|
||||||
|
return rewriteLinks({
|
||||||
|
words: compileWords(sourceTerms),
|
||||||
|
templates: compileTemplates(sourcePhrases),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const compileGlobal = () => {
|
||||||
|
const shared = readJsonFragments(path.join(sourceDirectory, "global", "shared"));
|
||||||
|
const locales = {};
|
||||||
|
|
||||||
|
for (const locale of activeLocales) {
|
||||||
|
locales[locale] = readJsonFragments(path.join(sourceDirectory, "global", "locales", locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rewriteLinks({
|
||||||
|
shared,
|
||||||
|
locales,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const compiledFiles = new Map([
|
||||||
|
[path.join(generatedDirectory, "global-v2.json"), compileGlobal()],
|
||||||
|
...activeLocales.map((locale) => [path.join(generatedDirectory, `${locale}-v2.json`), compileLocale(locale)]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (mode === "check") {
|
||||||
|
const mismatches = [];
|
||||||
|
for (const [filePath, value] of compiledFiles.entries()) {
|
||||||
|
const expected = stableJson(value);
|
||||||
|
const actual = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "") : "";
|
||||||
|
if (actual !== expected) {
|
||||||
|
mismatches.push(path.relative(projectRoot, filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mismatches.length > 0) {
|
||||||
|
console.error("Generated i18n v2 runtime files are out of date:");
|
||||||
|
for (const mismatch of mismatches) {
|
||||||
|
console.error(` ${mismatch}`);
|
||||||
|
}
|
||||||
|
console.error("Run npm run i18n:v2:compile.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Generated i18n v2 runtime files are up to date.");
|
||||||
|
} else {
|
||||||
|
for (const [filePath, value] of compiledFiles.entries()) {
|
||||||
|
writeJson(filePath, value);
|
||||||
|
}
|
||||||
|
console.log(`Compiled i18n v2 runtime files to ${path.relative(projectRoot, generatedDirectory)}.`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const projectRoot = path.resolve(__dirname, "..");
|
||||||
|
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
|
||||||
|
const activeLocales = ["da", "en", "sv", "de", "no"];
|
||||||
|
const mode = process.argv.includes("--apply") ? "apply" : "check";
|
||||||
|
|
||||||
|
const templatePrefix = "templates.generated.compat";
|
||||||
|
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)/gu;
|
||||||
|
const exactLinkPattern = /^@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))$/u;
|
||||||
|
|
||||||
|
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
|
||||||
|
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
|
||||||
|
|
||||||
|
const writeJson = (filePath, value) => {
|
||||||
|
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
|
||||||
|
|
||||||
|
const flattenStringEntries = (value, prefix = "") => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return [{ key: prefix, value }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(value).flatMap(([key, entry]) =>
|
||||||
|
flattenStringEntries(entry, prefix ? `${prefix}.${key}` : key)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValueAtPath = (value, keyPath) => {
|
||||||
|
let current = value;
|
||||||
|
for (const segment of keyPath.split(".")) {
|
||||||
|
if (!isPlainObject(current) || !(segment in current)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureObjectAtPath = (value, keyPath) => {
|
||||||
|
let current = value;
|
||||||
|
for (const segment of keyPath.split(".")) {
|
||||||
|
if (!isPlainObject(current[segment])) {
|
||||||
|
current[segment] = {};
|
||||||
|
}
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setValueAtPath = (value, keyPath, nextValue) => {
|
||||||
|
const segments = keyPath.split(".");
|
||||||
|
const parent = ensureObjectAtPath(value, segments.slice(0, -1).join("."));
|
||||||
|
parent[segments.at(-1)] = nextValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteValueAtPath = (value, keyPath) => {
|
||||||
|
const segments = keyPath.split(".");
|
||||||
|
const parents = [];
|
||||||
|
let current = value;
|
||||||
|
|
||||||
|
for (const segment of segments.slice(0, -1)) {
|
||||||
|
if (!isPlainObject(current) || !(segment in current)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
parents.push([current, segment]);
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(current) || !(segments.at(-1) in current)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete current[segments.at(-1)];
|
||||||
|
|
||||||
|
for (let index = parents.length - 1; index >= 0; index -= 1) {
|
||||||
|
const [parent, segment] = parents[index];
|
||||||
|
const child = parent[segment];
|
||||||
|
if (isPlainObject(child) && Object.keys(child).length === 0) {
|
||||||
|
delete parent[segment];
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeMessages = (sharedMessages = {}, localeMessages = {}) => {
|
||||||
|
const mergedMessages = { ...sharedMessages };
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(localeMessages ?? {})) {
|
||||||
|
if (isPlainObject(value) && isPlainObject(mergedMessages[key])) {
|
||||||
|
mergedMessages[key] = mergeMessages(mergedMessages[key], value);
|
||||||
|
} else {
|
||||||
|
mergedMessages[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedMessages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTextModifier = (value, modifier) => {
|
||||||
|
switch (modifier) {
|
||||||
|
case ".capitalize":
|
||||||
|
return `${value.charAt(0).toLocaleUpperCase()}${value.slice(1)}`;
|
||||||
|
case ".upper":
|
||||||
|
return value.toLocaleUpperCase();
|
||||||
|
case ".lower":
|
||||||
|
return value.toLocaleLowerCase();
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveLinkedMessage = (messages, keyPath, seen = new Set()) => {
|
||||||
|
if (seen.has(keyPath)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
seen.add(keyPath);
|
||||||
|
|
||||||
|
const value = getValueAtPath(messages, keyPath);
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactLink = value.trim().match(exactLinkPattern);
|
||||||
|
if (exactLink) {
|
||||||
|
const target = exactLink.groups.literal ?? exactLink.groups.path;
|
||||||
|
const resolved = resolveLinkedMessage(messages, target, seen);
|
||||||
|
return resolved === undefined ? undefined : applyTextModifier(resolved, exactLink.groups.modifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.replace(linkTokenPattern, (token) => {
|
||||||
|
const match = token.match(exactLinkPattern);
|
||||||
|
if (!match?.groups) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = match.groups.literal ?? match.groups.path;
|
||||||
|
const resolved = resolveLinkedMessage(messages, target, new Set(seen));
|
||||||
|
return resolved === undefined ? token : applyTextModifier(resolved, match.groups.modifier);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const readRawLocales = () =>
|
||||||
|
Object.fromEntries(
|
||||||
|
activeLocales.map((locale) => [locale, readJson(path.join(localesDirectory, `${locale}-v2.json`))])
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildMergedLocales = (globalMessages, rawLocales) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
activeLocales.map((locale) => [
|
||||||
|
locale,
|
||||||
|
mergeMessages(
|
||||||
|
mergeMessages(globalMessages.shared ?? {}, globalMessages.locales?.[locale] ?? {}),
|
||||||
|
rawLocales[locale]
|
||||||
|
),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCatalogPhraseKey = (keyPath) => !keyPath.startsWith("words.") && !keyPath.startsWith("templates.");
|
||||||
|
|
||||||
|
const exactLinkTarget = (value) => {
|
||||||
|
const match = value.trim().match(exactLinkPattern);
|
||||||
|
return match?.groups?.literal ?? match?.groups?.path;
|
||||||
|
};
|
||||||
|
|
||||||
|
const templateKeyFor = (keyPath) => `${templatePrefix}.${keyPath}`;
|
||||||
|
|
||||||
|
const linkedTemplateFor = (keyPath) => `@:{'${templateKeyFor(keyPath)}'}`;
|
||||||
|
|
||||||
|
const sourceValueFor = (globalMessages, rawLocales, locale, keyPath) => {
|
||||||
|
const rawValue = getValueAtPath(rawLocales[locale], keyPath);
|
||||||
|
if (typeof rawValue === "string") {
|
||||||
|
return rawValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localeValue = getValueAtPath(globalMessages.locales?.[locale] ?? {}, keyPath);
|
||||||
|
if (typeof localeValue === "string") {
|
||||||
|
return localeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedValue = getValueAtPath(globalMessages.shared ?? {}, keyPath);
|
||||||
|
return typeof sharedValue === "string" ? sharedValue : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectCandidateKeys = (globalMessages, rawLocales) => {
|
||||||
|
const keys = new Set();
|
||||||
|
|
||||||
|
for (const locale of activeLocales) {
|
||||||
|
for (const entry of flattenStringEntries(rawLocales[locale]).filter((entry) => isCatalogPhraseKey(entry.key))) {
|
||||||
|
keys.add(entry.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of flattenStringEntries(globalMessages.locales?.[locale] ?? {}).filter((entry) =>
|
||||||
|
isCatalogPhraseKey(entry.key)
|
||||||
|
)) {
|
||||||
|
const target = exactLinkTarget(entry.value);
|
||||||
|
if (target !== templateKeyFor(entry.key)) {
|
||||||
|
keys.add(entry.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...keys].sort((left, right) => left.localeCompare(right, "en", { sensitivity: "base" }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const reorderLocaleRoot = (localeMessages) => {
|
||||||
|
const { words, templates, ...rest } = localeMessages;
|
||||||
|
return { words, templates, ...rest };
|
||||||
|
};
|
||||||
|
|
||||||
|
const pruneLocaleMap = (locales) => {
|
||||||
|
const nextLocales = {};
|
||||||
|
for (const locale of activeLocales) {
|
||||||
|
nextLocales[locale] = isPlainObject(locales?.[locale]) ? locales[locale] : {};
|
||||||
|
}
|
||||||
|
return nextLocales;
|
||||||
|
};
|
||||||
|
|
||||||
|
const transformCatalogs = (originalGlobalMessages, originalRawLocales) => {
|
||||||
|
const globalMessages = cloneJson(originalGlobalMessages);
|
||||||
|
const rawLocales = cloneJson(originalRawLocales);
|
||||||
|
globalMessages.shared = globalMessages.shared ?? {};
|
||||||
|
globalMessages.locales = pruneLocaleMap(globalMessages.locales ?? {});
|
||||||
|
|
||||||
|
const originalMergedLocales = buildMergedLocales(originalGlobalMessages, originalRawLocales);
|
||||||
|
const candidateKeys = collectCandidateKeys(originalGlobalMessages, originalRawLocales);
|
||||||
|
let directSharedAliasCount = 0;
|
||||||
|
let generatedTemplateCount = 0;
|
||||||
|
let removedRawEntryCount = 0;
|
||||||
|
let removedLocaleEntryCount = 0;
|
||||||
|
|
||||||
|
for (const keyPath of candidateKeys) {
|
||||||
|
const valuesByLocale = Object.fromEntries(
|
||||||
|
activeLocales.map((locale) => {
|
||||||
|
const sourceValue = sourceValueFor(originalGlobalMessages, originalRawLocales, locale, keyPath);
|
||||||
|
if (typeof sourceValue === "string") {
|
||||||
|
return [locale, sourceValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedValue = getValueAtPath(originalMergedLocales[locale], keyPath);
|
||||||
|
return [locale, typeof mergedValue === "string" ? mergedValue : undefined];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const values = activeLocales.map((locale) => valuesByLocale[locale]);
|
||||||
|
if (values.some((value) => typeof value !== "string")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstValue = values[0];
|
||||||
|
const allValuesIdentical = values.every((value) => value === firstValue);
|
||||||
|
const canUseDirectSharedAlias =
|
||||||
|
allValuesIdentical &&
|
||||||
|
!firstValue.includes("words.generated.") &&
|
||||||
|
!firstValue.includes(templatePrefix) &&
|
||||||
|
exactLinkTarget(firstValue) !== keyPath;
|
||||||
|
|
||||||
|
if (canUseDirectSharedAlias) {
|
||||||
|
setValueAtPath(globalMessages.shared, keyPath, firstValue);
|
||||||
|
directSharedAliasCount += 1;
|
||||||
|
} else {
|
||||||
|
setValueAtPath(globalMessages.shared, keyPath, linkedTemplateFor(keyPath));
|
||||||
|
for (const locale of activeLocales) {
|
||||||
|
setValueAtPath(rawLocales[locale], templateKeyFor(keyPath), valuesByLocale[locale]);
|
||||||
|
}
|
||||||
|
generatedTemplateCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const locale of activeLocales) {
|
||||||
|
if (deleteValueAtPath(rawLocales[locale], keyPath)) {
|
||||||
|
removedRawEntryCount += 1;
|
||||||
|
}
|
||||||
|
if (deleteValueAtPath(globalMessages.locales[locale], keyPath)) {
|
||||||
|
removedLocaleEntryCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const locale of activeLocales) {
|
||||||
|
rawLocales[locale] = reorderLocaleRoot(rawLocales[locale]);
|
||||||
|
globalMessages.locales[locale] = isPlainObject(globalMessages.locales[locale])
|
||||||
|
? globalMessages.locales[locale]
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMergedLocales = buildMergedLocales(globalMessages, rawLocales);
|
||||||
|
const mismatches = [];
|
||||||
|
for (const locale of activeLocales) {
|
||||||
|
for (const entry of flattenStringEntries(originalMergedLocales[locale])) {
|
||||||
|
const expected = resolveLinkedMessage(originalMergedLocales[locale], entry.key);
|
||||||
|
const actual = resolveLinkedMessage(nextMergedLocales[locale], entry.key);
|
||||||
|
if (actual !== expected) {
|
||||||
|
mismatches.push(
|
||||||
|
`${locale}.${entry.key}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingRawPhraseEntries = activeLocales.flatMap((locale) =>
|
||||||
|
flattenStringEntries(rawLocales[locale])
|
||||||
|
.filter((entry) => isCatalogPhraseKey(entry.key))
|
||||||
|
.map((entry) => `${locale}.${entry.key}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const remainingLocalePhraseEntries = activeLocales.flatMap((locale) =>
|
||||||
|
flattenStringEntries(globalMessages.locales?.[locale] ?? {})
|
||||||
|
.filter((entry) => isCatalogPhraseKey(entry.key))
|
||||||
|
.map((entry) => `${locale}.${entry.key}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
globalMessages,
|
||||||
|
rawLocales,
|
||||||
|
candidateCount: candidateKeys.length,
|
||||||
|
directSharedAliasCount,
|
||||||
|
generatedTemplateCount,
|
||||||
|
removedRawEntryCount,
|
||||||
|
removedLocaleEntryCount,
|
||||||
|
mismatches,
|
||||||
|
remainingRawPhraseEntries,
|
||||||
|
remainingLocalePhraseEntries,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const globalPath = path.join(localesDirectory, "global-v2.json");
|
||||||
|
const originalGlobalMessages = readJson(globalPath);
|
||||||
|
const originalRawLocales = readRawLocales();
|
||||||
|
const result = transformCatalogs(originalGlobalMessages, originalRawLocales);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
[
|
||||||
|
`candidate phrase keys: ${result.candidateCount}`,
|
||||||
|
`direct shared aliases: ${result.directSharedAliasCount}`,
|
||||||
|
`generated shared template aliases: ${result.generatedTemplateCount}`,
|
||||||
|
`removed raw locale entries: ${result.removedRawEntryCount}`,
|
||||||
|
`removed global locale entries: ${result.removedLocaleEntryCount}`,
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
if (result.mismatches.length > 0) {
|
||||||
|
hasErrors = true;
|
||||||
|
console.error(`transformed output changed ${result.mismatches.length} strings`);
|
||||||
|
for (const mismatch of result.mismatches.slice(0, 30)) {
|
||||||
|
console.error(` ${mismatch}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.remainingRawPhraseEntries.length > 0) {
|
||||||
|
hasErrors = true;
|
||||||
|
console.error(`raw locale phrase entries remain outside words/templates: ${result.remainingRawPhraseEntries.length}`);
|
||||||
|
for (const entry of result.remainingRawPhraseEntries.slice(0, 30)) {
|
||||||
|
console.error(` ${entry}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.remainingLocalePhraseEntries.length > 0) {
|
||||||
|
hasErrors = true;
|
||||||
|
console.error(`global-v2 locale phrase entries remain: ${result.remainingLocalePhraseEntries.length}`);
|
||||||
|
for (const entry of result.remainingLocalePhraseEntries.slice(0, 30)) {
|
||||||
|
console.error(` ${entry}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "apply") {
|
||||||
|
writeJson(globalPath, result.globalMessages);
|
||||||
|
for (const locale of activeLocales) {
|
||||||
|
writeJson(path.join(localesDirectory, `${locale}-v2.json`), result.rawLocales[locale]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const projectRoot = path.resolve(__dirname, "..");
|
||||||
|
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
|
||||||
|
const activeLocales = ["da", "en", "sv", "de", "no"];
|
||||||
|
const mode = process.argv.includes("--apply") ? "apply" : "check";
|
||||||
|
|
||||||
|
const templatePrefix = "templates.generated.compat";
|
||||||
|
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*\}/g;
|
||||||
|
const exactLinkPattern = /^@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)$/u;
|
||||||
|
|
||||||
|
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
|
||||||
|
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
|
||||||
|
|
||||||
|
const writeJson = (filePath, value) => {
|
||||||
|
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
|
||||||
|
|
||||||
|
const flattenStringEntries = (value, prefix = "") => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return [{ key: prefix, value }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(value).flatMap(([key, entry]) =>
|
||||||
|
flattenStringEntries(entry, prefix ? `${prefix}.${key}` : key)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValueAtPath = (value, keyPath) => {
|
||||||
|
let current = value;
|
||||||
|
for (const segment of keyPath.split(".")) {
|
||||||
|
if (!isPlainObject(current) || !(segment in current)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureObjectAtPath = (value, keyPath) => {
|
||||||
|
let current = value;
|
||||||
|
if (!keyPath) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const segment of keyPath.split(".")) {
|
||||||
|
if (!isPlainObject(current[segment])) {
|
||||||
|
current[segment] = {};
|
||||||
|
}
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setValueAtPath = (value, keyPath, nextValue) => {
|
||||||
|
const segments = keyPath.split(".");
|
||||||
|
const parent = ensureObjectAtPath(value, segments.slice(0, -1).join("."));
|
||||||
|
parent[segments.at(-1)] = nextValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteValueAtPath = (value, keyPath) => {
|
||||||
|
const segments = keyPath.split(".");
|
||||||
|
const parents = [];
|
||||||
|
let current = value;
|
||||||
|
|
||||||
|
for (const segment of segments.slice(0, -1)) {
|
||||||
|
if (!isPlainObject(current) || !(segment in current)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
parents.push([current, segment]);
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(current) || !(segments.at(-1) in current)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete current[segments.at(-1)];
|
||||||
|
|
||||||
|
for (let index = parents.length - 1; index >= 0; index -= 1) {
|
||||||
|
const [parent, segment] = parents[index];
|
||||||
|
const child = parent[segment];
|
||||||
|
if (isPlainObject(child) && Object.keys(child).length === 0) {
|
||||||
|
delete parent[segment];
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePhrase = (value) =>
|
||||||
|
value
|
||||||
|
.normalize("NFKC")
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.replace(placeholderPattern, "{}")
|
||||||
|
.replace(/[\p{P}\p{S}]+/gu, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const linkToTemplate = (suffix) => `@:{'${templatePrefix}.${suffix}'}`;
|
||||||
|
|
||||||
|
const canonicalPriority = (suffix) => {
|
||||||
|
if (suffix.startsWith("common.")) return 0;
|
||||||
|
if (suffix.startsWith("global.")) return 1;
|
||||||
|
if (suffix.startsWith("nav.")) return 2;
|
||||||
|
if (suffix.startsWith("tables.common.")) return 3;
|
||||||
|
if (suffix.startsWith("objects.columns.")) return 4;
|
||||||
|
if (suffix.startsWith("auth.")) return 5;
|
||||||
|
if (suffix.startsWith("products.")) return 6;
|
||||||
|
return 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseCanonicalSuffix = (suffixes) =>
|
||||||
|
[...suffixes].sort(
|
||||||
|
(left, right) =>
|
||||||
|
canonicalPriority(left) - canonicalPriority(right) ||
|
||||||
|
left.split(".").length - right.split(".").length ||
|
||||||
|
left.length - right.length ||
|
||||||
|
left.localeCompare(right, "en", { sensitivity: "base" })
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
const createDisjointSet = (items) => {
|
||||||
|
const parent = new Map(items.map((item) => [item, item]));
|
||||||
|
|
||||||
|
const find = (item) => {
|
||||||
|
const currentParent = parent.get(item);
|
||||||
|
if (currentParent === item) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
const root = find(currentParent);
|
||||||
|
parent.set(item, root);
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
|
const union = (left, right) => {
|
||||||
|
const leftRoot = find(left);
|
||||||
|
const rightRoot = find(right);
|
||||||
|
if (leftRoot !== rightRoot) {
|
||||||
|
parent.set(rightRoot, leftRoot);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups = () => {
|
||||||
|
const grouped = new Map();
|
||||||
|
for (const item of items) {
|
||||||
|
const root = find(item);
|
||||||
|
const group = grouped.get(root) ?? [];
|
||||||
|
group.push(item);
|
||||||
|
grouped.set(root, group);
|
||||||
|
}
|
||||||
|
return [...grouped.values()];
|
||||||
|
};
|
||||||
|
|
||||||
|
return { union, groups };
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceTemplatePathReferences = (value, replacements) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
let nextValue = value;
|
||||||
|
for (const [fromSuffix, toSuffix] of replacements.entries()) {
|
||||||
|
nextValue = nextValue.replaceAll(`${templatePrefix}.${fromSuffix}`, `${templatePrefix}.${toSuffix}`);
|
||||||
|
}
|
||||||
|
return nextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry) => replaceTemplatePathReferences(entry, replacements));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, entry]) => [key, replaceTemplatePathReferences(entry, replacements)])
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readRawLocales = () =>
|
||||||
|
Object.fromEntries(
|
||||||
|
activeLocales.map((locale) => [locale, readJson(path.join(localesDirectory, `${locale}-v2.json`))])
|
||||||
|
);
|
||||||
|
|
||||||
|
const collectTemplateSuffixes = (rawLocales) => {
|
||||||
|
const suffixes = new Set();
|
||||||
|
for (const locale of activeLocales) {
|
||||||
|
for (const entry of flattenStringEntries(getValueAtPath(rawLocales[locale], templatePrefix) ?? {})) {
|
||||||
|
suffixes.add(entry.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...suffixes].sort((left, right) => left.localeCompare(right, "en", { sensitivity: "base" }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const transformCatalogs = (originalGlobalMessages, originalRawLocales) => {
|
||||||
|
const rawLocales = cloneJson(originalRawLocales);
|
||||||
|
let globalMessages = cloneJson(originalGlobalMessages);
|
||||||
|
const suffixes = collectTemplateSuffixes(rawLocales);
|
||||||
|
const disjointSet = createDisjointSet(suffixes);
|
||||||
|
|
||||||
|
for (const locale of activeLocales) {
|
||||||
|
const groupedByLocalePhrase = new Map();
|
||||||
|
for (const suffix of suffixes) {
|
||||||
|
const value = getValueAtPath(rawLocales[locale], `${templatePrefix}.${suffix}`);
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = normalizePhrase(value);
|
||||||
|
if (signature.length < 4) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = groupedByLocalePhrase.get(signature) ?? [];
|
||||||
|
group.push(suffix);
|
||||||
|
groupedByLocalePhrase.set(signature, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of groupedByLocalePhrase.values()) {
|
||||||
|
if (group.length <= 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [first, ...rest] = group;
|
||||||
|
for (const suffix of rest) {
|
||||||
|
disjointSet.union(first, suffix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateGroups = disjointSet.groups().filter((group) => group.length > 1);
|
||||||
|
const replacements = new Map();
|
||||||
|
|
||||||
|
for (const group of duplicateGroups) {
|
||||||
|
const canonicalSuffix = chooseCanonicalSuffix(group);
|
||||||
|
for (const suffix of group) {
|
||||||
|
if (suffix !== canonicalSuffix) {
|
||||||
|
replacements.set(suffix, canonicalSuffix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [fromSuffix, toSuffix] of [...replacements.entries()]) {
|
||||||
|
const seen = new Set([fromSuffix]);
|
||||||
|
let finalSuffix = toSuffix;
|
||||||
|
while (replacements.has(finalSuffix) && !seen.has(finalSuffix)) {
|
||||||
|
seen.add(finalSuffix);
|
||||||
|
finalSuffix = replacements.get(finalSuffix);
|
||||||
|
}
|
||||||
|
replacements.set(fromSuffix, finalSuffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replacements.size > 0) {
|
||||||
|
globalMessages = replaceTemplatePathReferences(globalMessages, replacements);
|
||||||
|
for (const locale of activeLocales) {
|
||||||
|
rawLocales[locale] = replaceTemplatePathReferences(rawLocales[locale], replacements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let removedTemplateCount = 0;
|
||||||
|
let repointedAliasCount = 0;
|
||||||
|
for (const [fromSuffix, toSuffix] of replacements.entries()) {
|
||||||
|
setValueAtPath(globalMessages.shared, fromSuffix, linkToTemplate(toSuffix));
|
||||||
|
repointedAliasCount += 1;
|
||||||
|
|
||||||
|
for (const locale of activeLocales) {
|
||||||
|
if (deleteValueAtPath(rawLocales[locale], `${templatePrefix}.${fromSuffix}`)) {
|
||||||
|
removedTemplateCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingDuplicateGroups = [];
|
||||||
|
for (const locale of activeLocales) {
|
||||||
|
const nonLinkedTemplates = flattenStringEntries(getValueAtPath(rawLocales[locale], templatePrefix) ?? {}).filter(
|
||||||
|
(entry) => !exactLinkPattern.test(entry.value.trim())
|
||||||
|
);
|
||||||
|
const byPhrase = new Map();
|
||||||
|
for (const entry of nonLinkedTemplates) {
|
||||||
|
const signature = normalizePhrase(entry.value);
|
||||||
|
const group = byPhrase.get(signature) ?? [];
|
||||||
|
group.push(entry.key);
|
||||||
|
byPhrase.set(signature, group);
|
||||||
|
}
|
||||||
|
for (const [signature, group] of byPhrase.entries()) {
|
||||||
|
if (signature.length >= 4 && group.length > 1) {
|
||||||
|
remainingDuplicateGroups.push(`${locale}: ${signature} <- ${group.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
globalMessages,
|
||||||
|
rawLocales,
|
||||||
|
duplicateGroupCount: duplicateGroups.length,
|
||||||
|
replacementCount: replacements.size,
|
||||||
|
repointedAliasCount,
|
||||||
|
removedTemplateCount,
|
||||||
|
remainingDuplicateGroups,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const globalPath = path.join(localesDirectory, "global-v2.json");
|
||||||
|
const originalGlobalMessages = readJson(globalPath);
|
||||||
|
const originalRawLocales = readRawLocales();
|
||||||
|
const result = transformCatalogs(originalGlobalMessages, originalRawLocales);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
[
|
||||||
|
`duplicate generated template groups: ${result.duplicateGroupCount}`,
|
||||||
|
`template replacements: ${result.replacementCount}`,
|
||||||
|
`repointed shared aliases: ${result.repointedAliasCount}`,
|
||||||
|
`removed locale template entries: ${result.removedTemplateCount}`,
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.remainingDuplicateGroups.length > 0) {
|
||||||
|
console.error(`near-duplicate generated templates remain: ${result.remainingDuplicateGroups.length}`);
|
||||||
|
for (const group of result.remainingDuplicateGroups.slice(0, 30)) {
|
||||||
|
console.error(` ${group}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "apply") {
|
||||||
|
writeJson(globalPath, result.globalMessages);
|
||||||
|
for (const locale of activeLocales) {
|
||||||
|
writeJson(path.join(localesDirectory, `${locale}-v2.json`), result.rawLocales[locale]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const projectRoot = path.resolve(__dirname, "..");
|
||||||
|
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
|
||||||
|
const activeLocales = ["da", "en", "sv", "de", "no"];
|
||||||
|
|
||||||
|
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)/gu;
|
||||||
|
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*\}/g;
|
||||||
|
const wordTokenPattern = /[\p{L}\p{N}]+/gu;
|
||||||
|
const linkedMessagePattern = /^@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))$/u;
|
||||||
|
|
||||||
|
const mode = process.argv.includes("--apply") ? "apply" : "check";
|
||||||
|
|
||||||
|
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
|
||||||
|
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
|
||||||
|
|
||||||
|
const writeJson = (filePath, value) => {
|
||||||
|
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||||
|
};
|
||||||
|
|
||||||
|
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
|
||||||
|
|
||||||
|
const flattenStringEntries = (value, prefix = "") => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return [{ key: prefix, value }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(value).flatMap(([key, entry]) =>
|
||||||
|
flattenStringEntries(entry, prefix ? `${prefix}.${key}` : key)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValueAtPath = (value, keyPath) => {
|
||||||
|
let current = value;
|
||||||
|
for (const segment of keyPath.split(".")) {
|
||||||
|
if (!isPlainObject(current) || !(segment in current)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setValueAtPath = (value, keyPath, nextValue) => {
|
||||||
|
const segments = keyPath.split(".");
|
||||||
|
let current = value;
|
||||||
|
for (const segment of segments.slice(0, -1)) {
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
current[segments.at(-1)] = nextValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const compareStringValues = (left, right) => left.localeCompare(right, "en", { sensitivity: "base" });
|
||||||
|
|
||||||
|
const protectSegments = (value) => {
|
||||||
|
const protectedRanges = [];
|
||||||
|
for (const pattern of [linkTokenPattern, placeholderPattern]) {
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
for (const match of value.matchAll(pattern)) {
|
||||||
|
protectedRanges.push({ start: match.index ?? 0, end: (match.index ?? 0) + match[0].length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protectedRanges.sort((a, b) => a.start - b.start || b.end - a.end);
|
||||||
|
const mergedRanges = [];
|
||||||
|
for (const range of protectedRanges) {
|
||||||
|
const previous = mergedRanges.at(-1);
|
||||||
|
if (previous && range.start <= previous.end) {
|
||||||
|
previous.end = Math.max(previous.end, range.end);
|
||||||
|
} else {
|
||||||
|
mergedRanges.push({ ...range });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (const range of mergedRanges) {
|
||||||
|
if (offset < range.start) {
|
||||||
|
segments.push({ protected: false, value: value.slice(offset, range.start) });
|
||||||
|
}
|
||||||
|
segments.push({ protected: true, value: value.slice(range.start, range.end) });
|
||||||
|
offset = range.end;
|
||||||
|
}
|
||||||
|
if (offset < value.length) {
|
||||||
|
segments.push({ protected: false, value: value.slice(offset) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeToken = (token) => token.normalize("NFKC").toLocaleLowerCase();
|
||||||
|
|
||||||
|
const isAuditedToken = (token) => /\p{L}/u.test(token);
|
||||||
|
|
||||||
|
const collectTokens = (value) => {
|
||||||
|
const tokens = [];
|
||||||
|
for (const segment of protectSegments(value)) {
|
||||||
|
if (segment.protected) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
wordTokenPattern.lastIndex = 0;
|
||||||
|
for (const match of segment.value.matchAll(wordTokenPattern)) {
|
||||||
|
const token = match[0];
|
||||||
|
if (isAuditedToken(token)) {
|
||||||
|
tokens.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripMarks = (value) => value.normalize("NFKD").replace(/\p{M}/gu, "");
|
||||||
|
|
||||||
|
const slugify = (token, usedKeys) => {
|
||||||
|
const base =
|
||||||
|
stripMarks(token)
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
.replace(/_{2,}/g, "_") || "word";
|
||||||
|
const prefixed = /^\p{N}/u.test(base) ? `word_${base}` : base;
|
||||||
|
let key = prefixed;
|
||||||
|
let suffix = 2;
|
||||||
|
while (usedKeys.has(key)) {
|
||||||
|
key = `${prefixed}_${suffix}`;
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
usedKeys.add(key);
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCapitalizedToken = (token) => {
|
||||||
|
const lower = token.toLocaleLowerCase();
|
||||||
|
return token === `${lower.charAt(0).toLocaleUpperCase()}${lower.slice(1)}` && token !== lower;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUpperToken = (token) => token.length > 1 && token === token.toLocaleUpperCase() && token !== token.toLocaleLowerCase();
|
||||||
|
|
||||||
|
const isLowerToken = (token) => token === token.toLocaleLowerCase() && token !== token.toLocaleUpperCase();
|
||||||
|
|
||||||
|
const linkedWord = (keyPath, modifier = "") => `@${modifier}:{'${keyPath}'}`;
|
||||||
|
|
||||||
|
const applyTextModifier = (value, modifier) => {
|
||||||
|
switch (modifier) {
|
||||||
|
case ".capitalize":
|
||||||
|
return `${value.charAt(0).toLocaleUpperCase()}${value.slice(1)}`;
|
||||||
|
case ".upper":
|
||||||
|
return value.toLocaleUpperCase();
|
||||||
|
case ".lower":
|
||||||
|
return value.toLocaleLowerCase();
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const modifierForToken = (token, word) => {
|
||||||
|
if (normalizeToken(token) !== normalizeToken(word.value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateModifiers = ["", ".upper", ".capitalize", ".lower"];
|
||||||
|
for (const modifier of candidateModifiers) {
|
||||||
|
if (applyTextModifier(word.value, modifier) === token) {
|
||||||
|
return modifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceRepeatedTokens = (value, tokenToWord) =>
|
||||||
|
protectSegments(value)
|
||||||
|
.map((segment) => {
|
||||||
|
if (segment.protected) {
|
||||||
|
return segment.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return segment.value.replace(wordTokenPattern, (token) => {
|
||||||
|
const normalized = normalizeToken(token);
|
||||||
|
const word = tokenToWord.get(normalized);
|
||||||
|
if (!word || !isAuditedToken(token)) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactVariant = word.variants?.get(token);
|
||||||
|
if (exactVariant) {
|
||||||
|
return linkedWord(exactVariant.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifier = modifierForToken(token, word);
|
||||||
|
if (modifier !== null) {
|
||||||
|
return linkedWord(word.path, modifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const applyLinkedModifier = (value, modifier) => {
|
||||||
|
return applyTextModifier(value, modifier);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveLinkedMessage = (messages, keyPath, seen = new Set()) => {
|
||||||
|
if (seen.has(keyPath)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
seen.add(keyPath);
|
||||||
|
|
||||||
|
const value = getValueAtPath(messages, keyPath);
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactLink = value.trim().match(linkedMessagePattern);
|
||||||
|
if (exactLink) {
|
||||||
|
const resolved = resolveLinkedMessage(messages, exactLink.groups.literal ?? exactLink.groups.path, seen);
|
||||||
|
return resolved === undefined ? undefined : applyLinkedModifier(resolved, exactLink.groups.modifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.replace(linkTokenPattern, (token) => {
|
||||||
|
const match = token.match(linkedMessagePattern);
|
||||||
|
if (!match?.groups) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveLinkedMessage(messages, match.groups.literal ?? match.groups.path, new Set(seen));
|
||||||
|
if (resolved === undefined) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return applyLinkedModifier(resolved, match.groups.modifier);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const reorderLocaleRoot = (localeMessages) => {
|
||||||
|
const { words, templates, ...rest } = localeMessages;
|
||||||
|
return { words, templates, ...rest };
|
||||||
|
};
|
||||||
|
|
||||||
|
const transformLocale = (locale, originalMessages) => {
|
||||||
|
const messages = cloneJson(originalMessages);
|
||||||
|
const entries = flattenStringEntries(messages).filter((entry) => !entry.key.startsWith("words."));
|
||||||
|
const tokenStats = new Map();
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
for (const token of collectTokens(entry.value)) {
|
||||||
|
const normalized = normalizeToken(token);
|
||||||
|
const stat = tokenStats.get(normalized) ?? { count: 0, casings: new Map() };
|
||||||
|
stat.count += 1;
|
||||||
|
stat.casings.set(token, (stat.casings.get(token) ?? 0) + 1);
|
||||||
|
tokenStats.set(normalized, stat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repeatedTokens = [...tokenStats.entries()]
|
||||||
|
.filter(([, stat]) => stat.count > 1)
|
||||||
|
.sort(([left], [right]) => compareStringValues(left, right));
|
||||||
|
const existingWords = messages.words ?? {};
|
||||||
|
const generatedWords = {};
|
||||||
|
const tokenToWord = new Map();
|
||||||
|
const usedKeys = new Set(Object.keys(existingWords.generated ?? {}));
|
||||||
|
|
||||||
|
for (const [normalized, stat] of repeatedTokens) {
|
||||||
|
const existingWordEntry = flattenStringEntries(existingWords).find(
|
||||||
|
(entry) => normalizeToken(entry.value) === normalized
|
||||||
|
);
|
||||||
|
if (existingWordEntry) {
|
||||||
|
tokenToWord.set(normalized, { path: `words.${existingWordEntry.key}`, value: existingWordEntry.value });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const casings = [...stat.casings.entries()].sort(
|
||||||
|
([leftToken, leftCount], [rightToken, rightCount]) =>
|
||||||
|
rightCount - leftCount || compareStringValues(leftToken, rightToken)
|
||||||
|
);
|
||||||
|
const lowerCaseToken = casings.find(([token]) => token === normalized)?.[0];
|
||||||
|
const canonical = lowerCaseToken ?? casings[0][0];
|
||||||
|
const key = slugify(normalized, usedKeys);
|
||||||
|
generatedWords[key] = canonical;
|
||||||
|
const word = { path: `words.generated.${key}`, value: canonical, variants: new Map() };
|
||||||
|
|
||||||
|
for (const [casing] of casings) {
|
||||||
|
if (modifierForToken(casing, word) !== null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantKey = slugify(`${normalized}_${casing}`, usedKeys);
|
||||||
|
generatedWords[variantKey] = casing;
|
||||||
|
word.variants.set(casing, { path: `words.generated.${variantKey}`, value: casing });
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenToWord.set(normalized, word);
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.words = {
|
||||||
|
grammar: messages.words?.grammar ?? {},
|
||||||
|
actions: messages.words?.actions ?? {},
|
||||||
|
entities: messages.words?.entities ?? {},
|
||||||
|
statuses: messages.words?.statuses ?? {},
|
||||||
|
domain: messages.words?.domain ?? {},
|
||||||
|
services: messages.words?.services ?? {},
|
||||||
|
units: messages.words?.units ?? {},
|
||||||
|
...(messages.words ?? {}),
|
||||||
|
generated: {
|
||||||
|
...(messages.words?.generated ?? {}),
|
||||||
|
...Object.fromEntries(Object.entries(generatedWords).sort(([left], [right]) => compareStringValues(left, right))),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const nextValue = replaceRepeatedTokens(entry.value, tokenToWord);
|
||||||
|
if (nextValue !== entry.value) {
|
||||||
|
setValueAtPath(messages, entry.key, nextValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mismatches = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const previousResolved = resolveLinkedMessage(originalMessages, entry.key);
|
||||||
|
const resolved = resolveLinkedMessage(messages, entry.key);
|
||||||
|
if (resolved !== previousResolved) {
|
||||||
|
mismatches.push(`${entry.key}: expected ${JSON.stringify(previousResolved)}, got ${JSON.stringify(resolved)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: reorderLocaleRoot(messages),
|
||||||
|
generatedWordCount: Object.keys(generatedWords).length,
|
||||||
|
repeatedTokenCount: repeatedTokens.length,
|
||||||
|
mismatches,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const countRepeatedTokensOutsideWords = (messages) => {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const entry of flattenStringEntries(messages).filter((entry) => !entry.key.startsWith("words."))) {
|
||||||
|
for (const token of collectTokens(entry.value)) {
|
||||||
|
const normalized = normalizeToken(token);
|
||||||
|
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...counts.entries()].filter(([, count]) => count > 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
for (const locale of activeLocales) {
|
||||||
|
const filePath = path.join(localesDirectory, `${locale}-v2.json`);
|
||||||
|
const originalMessages = readJson(filePath);
|
||||||
|
const result = transformLocale(locale, originalMessages);
|
||||||
|
const repeatedAfter = countRepeatedTokensOutsideWords(result.messages);
|
||||||
|
|
||||||
|
if (result.mismatches.length > 0) {
|
||||||
|
hasErrors = true;
|
||||||
|
console.error(`${locale}: transformed output changed ${result.mismatches.length} strings`);
|
||||||
|
for (const mismatch of result.mismatches.slice(0, 20)) {
|
||||||
|
console.error(` ${mismatch}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repeatedAfter.length > 0) {
|
||||||
|
hasErrors = true;
|
||||||
|
console.error(`${locale}: ${repeatedAfter.length} repeated token groups remain outside words`);
|
||||||
|
for (const [token, count] of repeatedAfter.slice(0, 20)) {
|
||||||
|
console.error(` ${token}: ${count}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${locale}: ${result.repeatedTokenCount} repeated token groups, ${result.generatedWordCount} new generated word entries`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mode === "apply" && !hasErrors) {
|
||||||
|
writeJson(filePath, result.messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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 isUnsupportedWithDepsFailure = (result) => {
|
||||||
|
const output = outputText(result);
|
||||||
|
return (
|
||||||
|
result.status !== 0 &&
|
||||||
|
/Cannot install dependencies for .* with Playwright/i.test(output) &&
|
||||||
|
/Playwright does not support .* on /i.test(output)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
|
||||||
|
writeOutput(withDepsResult);
|
||||||
|
|
||||||
|
if (withDepsResult.status === 0) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUnsupportedWithDepsFailure(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 download using Playwright fallback archive ${fallbackHostPlatform}.`,
|
||||||
|
"The self-hosted runner image must provide the required browser system libraries.",
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
|
||||||
|
const browserOnlyResult = runPlaywrightInstall(requestedBrowsers, {
|
||||||
|
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE: fallbackHostPlatform,
|
||||||
|
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: "1",
|
||||||
|
});
|
||||||
|
writeOutput(browserOnlyResult);
|
||||||
|
process.exit(browserOnlyResult.status ?? 1);
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
export const chromiumProjects = ["chromium-desktop", "chromium-mobile"];
|
||||||
|
export const prGrep = "@pr";
|
||||||
|
export const smokeGrep = "@smoke";
|
||||||
|
|
||||||
|
export const fallbackChangePatterns = [
|
||||||
|
/^package(?:-lock)?\.json$/u,
|
||||||
|
/^vite\.config\.js$/u,
|
||||||
|
/^playwright(?:\..+)?\.config\.(?:js|ts)$/u,
|
||||||
|
/^playwright\.global-(?:setup|teardown)\.mjs$/u,
|
||||||
|
/^scripts\/run-playwright/u,
|
||||||
|
/^tests\/e2e\/(?:support|fixtures)\//u,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const sourceMappings = [
|
||||||
|
{
|
||||||
|
name: "auth",
|
||||||
|
patterns: [/^src\/(?:views|components|middleware)\/.*auth/iu, /^src\/views\/auth\//u, /^src\/components\/session\//u],
|
||||||
|
specs: ["tests/e2e/auth.smoke.spec.js", "tests/e2e/userAuth.spec.ts"],
|
||||||
|
projects: chromiumProjects,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "public-routes",
|
||||||
|
patterns: [/^src\/views\/pages\//u, /^src\/components\/page\//u, /^src\/router\.js$/u],
|
||||||
|
specs: ["tests/e2e/navigation.smoke.spec.js"],
|
||||||
|
projects: chromiumProjects,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "booking",
|
||||||
|
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
|
||||||
|
specs: [
|
||||||
|
"tests/e2e/booking-selfserve.smoke.spec.js",
|
||||||
|
"tests/e2e/userBookWash.spec.ts",
|
||||||
|
"tests/e2e/userBookings.spec.ts",
|
||||||
|
],
|
||||||
|
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",
|
||||||
|
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u],
|
||||||
|
specs: ["tests/e2e/pos-flow.spec.js", "tests/e2e/pos-mobile-order-flow.spec.js", "tests/e2e/admin-pos-orders.spec.ts"],
|
||||||
|
projects: chromiumProjects,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "edge-gateways",
|
||||||
|
patterns: [/edge[-/]?gateways?/iu, /edgeGateway/iu],
|
||||||
|
specs: ["tests/e2e/edge-gateways.smoke.spec.js", "tests/e2e/edge-gateways.routes.spec.js"],
|
||||||
|
projects: chromiumProjects,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invoicing",
|
||||||
|
patterns: [/invoic/iu, /economic[-/]?queue/iu, /collected-order/iu],
|
||||||
|
specs: [
|
||||||
|
"tests/e2e/invoicing-period.smoke.spec.js",
|
||||||
|
"tests/e2e/invoice-distribution.smoke.spec.js",
|
||||||
|
"tests/e2e/invoice-transfer-queue-history.spec.js",
|
||||||
|
],
|
||||||
|
projects: chromiumProjects,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "workfeed",
|
||||||
|
patterns: [/workfeed/iu],
|
||||||
|
specs: ["tests/e2e/workfeed-config.smoke.spec.js"],
|
||||||
|
projects: chromiumProjects,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "system-status",
|
||||||
|
patterns: [/system[-/]?status/iu, /systemDatabase/iu, /replication/iu],
|
||||||
|
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
|
||||||
|
projects: chromiumProjects,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "self-serve",
|
||||||
|
patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu],
|
||||||
|
specs: ["tests/e2e/self-serve-wash.spec.js", "tests/e2e/self-serve-studio-flow.spec.js"],
|
||||||
|
projects: chromiumProjects,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "i18n",
|
||||||
|
patterns: [/^src\/i18n\//u, /i18n/iu, /locales/iu],
|
||||||
|
specs: ["tests/e2e/i18n.smoke.spec.ts"],
|
||||||
|
projects: chromiumProjects,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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);
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
|
function gitCommit() {
|
||||||
|
try {
|
||||||
|
return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const token = process.env.SERVER_UPDATE_TOKEN;
|
||||||
|
const required = process.env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
|
||||||
|
if (!token) {
|
||||||
|
if (required) {
|
||||||
|
throw new Error("SERVER_UPDATE_TOKEN is required after deploy verification.");
|
||||||
|
}
|
||||||
|
console.log("SERVER_UPDATE_TOKEN is not set. Skipping server version update.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = process.env.RELEASE_VERSION || process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || gitCommit();
|
||||||
|
if (!version) {
|
||||||
|
throw new Error("Could not determine release version for server update.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.SERVER_UPDATE_URL || "https://api-v2.truckwash.io/master/api/worker/update-version";
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
url.searchParams.set("version", version);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Server version update failed with HTTP ${response.status}: ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Server version updated to ${version}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.stack || error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DIST_DIR="${DIST_DIR:-dist}"
|
||||||
|
REMOTE_ROOT="${RELEASE_DEPLOY_REMOTE_ROOT:-/}"
|
||||||
|
HOST="${RELEASE_DEPLOY_HOST:-}"
|
||||||
|
USER_NAME="${RELEASE_DEPLOY_USER:-}"
|
||||||
|
PASSWORD="${RELEASE_DEPLOY_PASSWORD:-}"
|
||||||
|
DEPLOY_URL="${RELEASE_DEPLOY_URL:-}"
|
||||||
|
|
||||||
|
if [[ ! -d "$DIST_DIR" ]]; then
|
||||||
|
echo "Missing dist directory: $DIST_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v lftp >/dev/null 2>&1; then
|
||||||
|
echo "lftp is required for FTP/SFTP uploads." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$DEPLOY_URL" ]]; then
|
||||||
|
if [[ -z "$HOST" || -z "$USER_NAME" || -z "$PASSWORD" ]]; then
|
||||||
|
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DEPLOY_URL="ftp://${HOST}"
|
||||||
|
elif [[ -n "$USER_NAME" || -n "$PASSWORD" ]]; then
|
||||||
|
if [[ -z "$USER_NAME" || -z "$PASSWORD" ]]; then
|
||||||
|
echo "Set both RELEASE_DEPLOY_USER and RELEASE_DEPLOY_PASSWORD when providing deploy credentials separately." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
lftp_quote() {
|
||||||
|
local value="${1//\'/\'\\\'\'}"
|
||||||
|
printf "'%s'" "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_lftp() {
|
||||||
|
local transfer_command="$1"
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'set ftp:ssl-allow true\n'
|
||||||
|
printf 'set ftp:ssl-force true\n'
|
||||||
|
printf 'set ftp:ssl-protect-data true\n'
|
||||||
|
printf 'set net:max-retries 3\n'
|
||||||
|
printf 'set net:timeout 20\n'
|
||||||
|
|
||||||
|
if [[ -n "$USER_NAME" && -n "$PASSWORD" ]]; then
|
||||||
|
printf 'open -u %s,%s %s\n' "$(lftp_quote "$USER_NAME")" "$(lftp_quote "$PASSWORD")" "$(lftp_quote "$DEPLOY_URL")"
|
||||||
|
else
|
||||||
|
printf 'open %s\n' "$(lftp_quote "$DEPLOY_URL")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'cd %s\n' "$(lftp_quote "$REMOTE_ROOT")"
|
||||||
|
printf '%s\n' "$transfer_command"
|
||||||
|
printf 'bye\n'
|
||||||
|
} | lftp -f /dev/stdin
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_file() {
|
||||||
|
local source_file="$1"
|
||||||
|
local remote_file="$2"
|
||||||
|
if [[ -f "$source_file" ]]; then
|
||||||
|
run_lftp "put -O $(lftp_quote "$(dirname "$remote_file")") $(lftp_quote "$source_file") -o $(lftp_quote "$(basename "$remote_file")")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
for directory in assets resources favicons icons img sounds .well-known; do
|
||||||
|
if [[ -d "$DIST_DIR/$directory" ]]; then
|
||||||
|
run_lftp "mirror -R --only-newer --parallel=4 $(lftp_quote "$DIST_DIR/$directory") $(lftp_quote "$directory")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
find "$DIST_DIR" -maxdepth 1 -type f \
|
||||||
|
! -name 'index.html' \
|
||||||
|
! -name 'release-entry.json' \
|
||||||
|
! -name 'release-manifest.json' \
|
||||||
|
-print0 | while IFS= read -r -d '' file; do
|
||||||
|
upload_file "$file" "$(basename "$file")"
|
||||||
|
done
|
||||||
|
|
||||||
|
upload_file "$DIST_DIR/release-manifest.json" "release-manifest.json"
|
||||||
|
upload_file "$DIST_DIR/release-entry.json" "release-entry.json"
|
||||||
|
upload_file "$DIST_DIR/index.html" "index.html"
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
function numberEnv(name, fallback) {
|
||||||
|
const value = Number.parseInt(process.env[name] || "", 10);
|
||||||
|
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanEnv(name) {
|
||||||
|
return /^(1|true|yes)$/i.test(process.env[name] || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendGithubEnv(values) {
|
||||||
|
const envFile = process.env.GITHUB_ENV;
|
||||||
|
if (!envFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = Object.entries(values)
|
||||||
|
.filter(([, value]) => value)
|
||||||
|
.map(([key, value]) => `${key}=${String(value).replace(/\r?\n/g, "")}`);
|
||||||
|
if (lines.length > 0) {
|
||||||
|
fs.appendFileSync(envFile, `${lines.join("\n")}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredUrl() {
|
||||||
|
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
|
||||||
|
if (!value) {
|
||||||
|
throw new Error("RELEASE_BASE_URL or PLAYWRIGHT_BASE_URL is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.endsWith("/") ? value : `${value}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathUrl(baseUrl, assetPath) {
|
||||||
|
const path = String(assetPath || "").replace(/^\/+/, "");
|
||||||
|
return new URL(path, baseUrl).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBytes(url) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Pragma: "no-cache",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const bytes = Buffer.from(await response.arrayBuffer());
|
||||||
|
return {
|
||||||
|
response,
|
||||||
|
bytes,
|
||||||
|
text: () => bytes.toString("utf8"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(baseUrl, assetPath) {
|
||||||
|
const url = pathUrl(baseUrl, assetPath);
|
||||||
|
const result = await fetchBytes(url);
|
||||||
|
const contentType = result.response.headers.get("content-type") || "";
|
||||||
|
if (!result.response.ok) {
|
||||||
|
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
|
||||||
|
}
|
||||||
|
if (contentType.includes("text/html")) {
|
||||||
|
throw new Error(`${assetPath} was served as HTML`);
|
||||||
|
}
|
||||||
|
if (result.bytes.length === 0) {
|
||||||
|
throw new Error(`${assetPath} was empty`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(result.text());
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`${assetPath} did not contain valid JSON: ${error instanceof Error ? error.message : error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareCommit(actual, expected) {
|
||||||
|
if (!expected) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actual === expected || actual.startsWith(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unique(values) {
|
||||||
|
return Array.from(new Set(values.filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(bytes) {
|
||||||
|
return crypto.createHash("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRejectHtml(assetPath) {
|
||||||
|
return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAsset(baseUrl, assetPath, expectedHash) {
|
||||||
|
const url = pathUrl(baseUrl, assetPath);
|
||||||
|
const result = await fetchBytes(url);
|
||||||
|
const contentType = result.response.headers.get("content-type") || "";
|
||||||
|
|
||||||
|
if (!result.response.ok) {
|
||||||
|
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
|
||||||
|
}
|
||||||
|
if (result.bytes.length === 0) {
|
||||||
|
throw new Error(`${assetPath} was empty`);
|
||||||
|
}
|
||||||
|
if (shouldRejectHtml(assetPath) && contentType.includes("text/html")) {
|
||||||
|
throw new Error(`${assetPath} was served as HTML (${contentType})`);
|
||||||
|
}
|
||||||
|
if (expectedHash?.sha256) {
|
||||||
|
const actualHash = sha256(result.bytes);
|
||||||
|
if (actualHash !== expectedHash.sha256) {
|
||||||
|
throw new Error(`${assetPath} sha256 mismatch: expected ${expectedHash.sha256}, got ${actualHash}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyShell(baseUrl, shellPath) {
|
||||||
|
const result = await fetchBytes(pathUrl(baseUrl, shellPath));
|
||||||
|
const contentType = result.response.headers.get("content-type") || "";
|
||||||
|
const body = result.text();
|
||||||
|
|
||||||
|
if (!result.response.ok) {
|
||||||
|
throw new Error(`${shellPath} returned HTTP ${result.response.status}`);
|
||||||
|
}
|
||||||
|
if (!contentType.includes("text/html")) {
|
||||||
|
throw new Error(`${shellPath} did not return HTML (${contentType})`);
|
||||||
|
}
|
||||||
|
if (body.replace(/\s+/g, "").length < 40) {
|
||||||
|
throw new Error(`${shellPath} returned an empty app shell`);
|
||||||
|
}
|
||||||
|
if (!body.includes('<div id="app"></div>')) {
|
||||||
|
throw new Error(`${shellPath} did not include the Vue app root`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyRelease(baseUrl) {
|
||||||
|
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
|
||||||
|
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
|
||||||
|
const strictBuildId = booleanEnv("RELEASE_STRICT_BUILD_ID");
|
||||||
|
const manifest = await fetchJson(baseUrl, "release-manifest.json");
|
||||||
|
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
|
||||||
|
|
||||||
|
if (!manifest.build_id) {
|
||||||
|
throw new Error("release-manifest.json is missing build_id");
|
||||||
|
}
|
||||||
|
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
|
||||||
|
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
|
||||||
|
}
|
||||||
|
if (strictBuildId && expectedBuildId && manifest.build_id !== expectedBuildId) {
|
||||||
|
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
|
||||||
|
}
|
||||||
|
if (releaseEntry.entry !== manifest.entry) {
|
||||||
|
throw new Error("release-entry.json entry does not match release-manifest.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseCss = JSON.stringify(releaseEntry.css || []);
|
||||||
|
const manifestCss = JSON.stringify(manifest.css || []);
|
||||||
|
if (releaseCss !== manifestCss) {
|
||||||
|
throw new Error("release-entry.json css does not match release-manifest.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellPaths = unique((process.env.RELEASE_SHELL_PATHS || "/,/guest/book/wash").split(",").map((value) => value.trim()));
|
||||||
|
for (const shellPath of shellPaths) {
|
||||||
|
await verifyShell(baseUrl, shellPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetUrls = unique([
|
||||||
|
"release-manifest.json",
|
||||||
|
"release-entry.json",
|
||||||
|
manifest.entry,
|
||||||
|
...(manifest.css || []),
|
||||||
|
...(manifest.index_asset_urls || []),
|
||||||
|
...(manifest.pwa_asset_urls || []),
|
||||||
|
...(manifest.asset_urls || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const assetUrl of assetUrls) {
|
||||||
|
if (assetUrl === "/index.html") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await verifyAsset(baseUrl, assetUrl, manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
build_id: manifest.build_id,
|
||||||
|
commit_sha: manifest.commit_sha,
|
||||||
|
assets: assetUrls.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const baseUrl = requiredUrl();
|
||||||
|
const initialWaitSeconds = numberEnv("RELEASE_WAIT_INITIAL_SECONDS", 30);
|
||||||
|
const timeoutSeconds = numberEnv("RELEASE_WAIT_TIMEOUT_SECONDS", 300);
|
||||||
|
const pollIntervalSeconds = numberEnv("RELEASE_POLL_INTERVAL_SECONDS", 10);
|
||||||
|
|
||||||
|
if (initialWaitSeconds > 0) {
|
||||||
|
console.log(`Waiting ${initialWaitSeconds}s before polling ${baseUrl}`);
|
||||||
|
await sleep(initialWaitSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||||
|
let attempt = 0;
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
while (Date.now() <= deadline) {
|
||||||
|
attempt += 1;
|
||||||
|
try {
|
||||||
|
const result = await verifyRelease(baseUrl);
|
||||||
|
appendGithubEnv({
|
||||||
|
RELEASE_VERIFIED_BUILD_ID: result.build_id,
|
||||||
|
RELEASE_VERIFIED_COMMIT: result.commit_sha,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.log(`Release upload not ready on attempt ${attempt}: ${message}`);
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(pollIntervalSeconds * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error("Release upload verification timed out.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.stack || error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,30 +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",
|
||||||
|
"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",
|
||||||
@@ -52,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",
|
||||||
@@ -61,23 +73,41 @@ const ownedFilesByRole = {
|
|||||||
"pos.visual.spec.js",
|
"pos.visual.spec.js",
|
||||||
],
|
],
|
||||||
superuser: [
|
superuser: [
|
||||||
|
"coolify-infrastructure.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-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",
|
||||||
|
"release-manager.spec.js",
|
||||||
|
"self-serve-sessions.spec.js",
|
||||||
|
"self-serve-studio-audit-navigation.spec.js",
|
||||||
|
"self-serve-studio-flow.spec.js",
|
||||||
|
"session-bootstrap.spec.ts",
|
||||||
"superuser-bookings.spec.ts",
|
"superuser-bookings.spec.ts",
|
||||||
"superuser-customer-complaints.spec.ts",
|
"superuser-customer-complaints.spec.ts",
|
||||||
|
"superuser-customers-mass-import.spec.ts",
|
||||||
|
"superuser-department-branding.spec.js",
|
||||||
"superuser-department-gates.spec.ts",
|
"superuser-department-gates.spec.ts",
|
||||||
|
"superuser-department-lanes.spec.ts",
|
||||||
|
"superuser-departments-archive.spec.ts",
|
||||||
"superuser-drafts.spec.ts",
|
"superuser-drafts.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] },
|
||||||
@@ -104,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] },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -188,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) => {
|
||||||
@@ -210,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);
|
||||||
|
|
||||||
@@ -297,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);
|
||||||
@@ -330,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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import { execFile, spawn } from "node:child_process";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { chromiumProjects, fallbackChangePatterns, prGrep, smokeGrep, sourceMappings } from "./playwright-pr-mapping.mjs";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const workingDirectory = process.cwd();
|
||||||
|
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const activeChildren = new Set();
|
||||||
|
let isShuttingDown = false;
|
||||||
|
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
void shutdown(signal).finally(() => {
|
||||||
|
process.exit(130);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(rawArgs) {
|
||||||
|
const parsed = {
|
||||||
|
help: false,
|
||||||
|
coreOnly: false,
|
||||||
|
changedOnly: false,
|
||||||
|
listOnly: false,
|
||||||
|
base: "",
|
||||||
|
head: "",
|
||||||
|
projects: [],
|
||||||
|
forwardedArgs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < rawArgs.length; index += 1) {
|
||||||
|
const value = rawArgs[index];
|
||||||
|
|
||||||
|
if (value === "--") {
|
||||||
|
parsed.forwardedArgs.push(...rawArgs.slice(index + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "--help" || value === "-h") {
|
||||||
|
parsed.help = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "--core-only") {
|
||||||
|
parsed.coreOnly = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "--changed-only") {
|
||||||
|
parsed.changedOnly = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "--list-only") {
|
||||||
|
parsed.listOnly = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "--base" && rawArgs[index + 1] !== undefined) {
|
||||||
|
parsed.base = rawArgs[index + 1];
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith("--base=")) {
|
||||||
|
parsed.base = value.slice("--base=".length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "--head" && rawArgs[index + 1] !== undefined) {
|
||||||
|
parsed.head = rawArgs[index + 1];
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith("--head=")) {
|
||||||
|
parsed.head = value.slice("--head=".length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "--project" && rawArgs[index + 1] !== undefined) {
|
||||||
|
parsed.projects.push(rawArgs[index + 1]);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith("--project=")) {
|
||||||
|
parsed.projects.push(value.slice("--project=".length));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.forwardedArgs.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.log(`
|
||||||
|
Playwright PR runner
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
node scripts/run-playwright-pr.mjs [options] [-- <playwright args>]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--base <ref> Base ref for changed-area detection
|
||||||
|
--head <ref> Head ref for changed-area detection. Default: HEAD
|
||||||
|
--project <name> Restrict to one Chromium Playwright project. Can be repeated.
|
||||||
|
--core-only Run only the core @pr gate
|
||||||
|
--changed-only Run changed-area selection without the core @pr gate
|
||||||
|
--list-only List selected tests instead of running them
|
||||||
|
-h, --help Show help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
npm run test:e2e:pr
|
||||||
|
npm run test:e2e:pr -- --core-only --project=chromium-desktop
|
||||||
|
npm run test:e2e:changed -- --base=HEAD~1 --head=HEAD
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(value) {
|
||||||
|
return value.replace(/\\/g, "/").replace(/^\.\//u, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZeroSha(value) {
|
||||||
|
return /^0{40}$/u.test(value || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedProjects(projects = args.projects) {
|
||||||
|
return projects.length > 0 ? projects : chromiumProjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unique(values) {
|
||||||
|
return [...new Set(values)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeNamespace(value) {
|
||||||
|
return value.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArtifactNamespace(suffix) {
|
||||||
|
const baseNamespace = process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "pr";
|
||||||
|
return sanitizeNamespace(`${baseNamespace}-${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProjectArgs(projects) {
|
||||||
|
return projects.flatMap((project) => ["--project", project]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPlaywright({ label, commandArgs, artifactSuffix }) {
|
||||||
|
const finalArgs = ["test", ...commandArgs, ...args.forwardedArgs];
|
||||||
|
|
||||||
|
if (args.listOnly && !finalArgs.includes("--list")) {
|
||||||
|
finalArgs.push("--list");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[playwright-pr] ${args.listOnly ? "Listing" : "Running"} ${label}: playwright ${finalArgs.join(" ")}`);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(process.execPath, [playwrightCliPath, ...finalArgs], {
|
||||||
|
cwd: workingDirectory,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PLAYWRIGHT: "1",
|
||||||
|
PLAYWRIGHT_ARTIFACT_NAMESPACE: getArtifactNamespace(artifactSuffix),
|
||||||
|
PLAYWRIGHT_REPORTER_MODE: "line-html",
|
||||||
|
PLAYWRIGHT_WORKERS: process.env.PLAYWRIGHT_WORKERS || "1",
|
||||||
|
},
|
||||||
|
stdio: "inherit",
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
activeChildren.add(child);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
activeChildren.delete(child);
|
||||||
|
resolve(code ?? 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryGitDiff(rangeArgs) {
|
||||||
|
const { stdout } = await execFileAsync("git", ["diff", "--name-only", "--diff-filter=ACMR", ...rangeArgs], {
|
||||||
|
cwd: workingDirectory,
|
||||||
|
maxBuffer: 16 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
return stdout
|
||||||
|
.split(/\r?\n/u)
|
||||||
|
.map((line) => normalizePath(line.trim()))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChangedFiles() {
|
||||||
|
const base = args.base.trim();
|
||||||
|
const head = args.head.trim() || "HEAD";
|
||||||
|
|
||||||
|
if (base && !isZeroSha(base)) {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
files: await tryGitDiff([`${base}...${head}`]),
|
||||||
|
source: `${base}...${head}`,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
files: await tryGitDiff([`${base}..${head}`]),
|
||||||
|
source: `${base}..${head}`,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
files: [],
|
||||||
|
source: `${base}...${head}`,
|
||||||
|
unavailable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
files: await tryGitDiff(["HEAD~1...HEAD"]),
|
||||||
|
source: "HEAD~1...HEAD",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
files: [],
|
||||||
|
source: "HEAD~1...HEAD",
|
||||||
|
unavailable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSpec(selection, spec, projects) {
|
||||||
|
const normalizedSpec = normalizePath(spec);
|
||||||
|
const existingProjects = selection.specProjects.get(normalizedSpec) || new Set();
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
existingProjects.add(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
selection.specProjects.set(normalizedSpec, existingProjects);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isE2eSpec(file) {
|
||||||
|
return /^tests\/e2e\/.+\.spec\.(?:js|ts)$/u.test(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldFallback(file) {
|
||||||
|
return fallbackChangePatterns.some((pattern) => pattern.test(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFrontendSource(file) {
|
||||||
|
return /^src\//u.test(file) || /^public\//u.test(file) || /^index\.html$/u.test(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectChangedTests(changedFiles) {
|
||||||
|
const selection = {
|
||||||
|
specProjects: new Map(),
|
||||||
|
mappedFiles: [],
|
||||||
|
unmappedFiles: [],
|
||||||
|
fallback: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedProjects = getSelectedProjects();
|
||||||
|
|
||||||
|
for (const rawFile of changedFiles) {
|
||||||
|
const file = normalizePath(rawFile);
|
||||||
|
|
||||||
|
if (isE2eSpec(file)) {
|
||||||
|
addSpec(selection, file, selectedProjects);
|
||||||
|
selection.mappedFiles.push(file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldFallback(file)) {
|
||||||
|
selection.fallback = true;
|
||||||
|
selection.unmappedFiles.push(file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFrontendSource(file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = sourceMappings.filter((mapping) => mapping.patterns.some((pattern) => pattern.test(file)));
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
selection.fallback = true;
|
||||||
|
selection.unmappedFiles.push(file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
selection.mappedFiles.push(file);
|
||||||
|
for (const mapping of matches) {
|
||||||
|
const projects = mapping.projects.filter((project) => selectedProjects.includes(project));
|
||||||
|
for (const spec of mapping.specs) {
|
||||||
|
addSpec(selection, spec, projects.length > 0 ? projects : selectedProjects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupSpecsByProjects(specProjects) {
|
||||||
|
const groups = new Map();
|
||||||
|
|
||||||
|
for (const [spec, projects] of specProjects.entries()) {
|
||||||
|
const projectList = unique([...projects]).sort();
|
||||||
|
const key = projectList.join(",");
|
||||||
|
const existing = groups.get(key) || {
|
||||||
|
projects: projectList,
|
||||||
|
specs: [],
|
||||||
|
};
|
||||||
|
existing.specs.push(spec);
|
||||||
|
groups.set(key, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...groups.values()].map((group) => ({
|
||||||
|
...group,
|
||||||
|
specs: group.specs.sort(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCorePrGate() {
|
||||||
|
const projects = getSelectedProjects();
|
||||||
|
for (const project of projects) {
|
||||||
|
const code = await runPlaywright({
|
||||||
|
label: `core ${prGrep} gate (${project})`,
|
||||||
|
artifactSuffix: `core-${project}`,
|
||||||
|
commandArgs: ["--grep", prGrep, "--project", project],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runChangedSelection(selection) {
|
||||||
|
const projects = getSelectedProjects();
|
||||||
|
|
||||||
|
if (selection.fallback) {
|
||||||
|
console.log(
|
||||||
|
`[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(", ")}`
|
||||||
|
);
|
||||||
|
for (const project of projects) {
|
||||||
|
const code = await runPlaywright({
|
||||||
|
label: `fallback ${smokeGrep} gate (${project})`,
|
||||||
|
artifactSuffix: `smoke-fallback-${project}`,
|
||||||
|
commandArgs: ["--grep", smokeGrep, "--grep-invert", prGrep, "--project", project],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = groupSpecsByProjects(selection.specProjects);
|
||||||
|
if (groups.length === 0) {
|
||||||
|
console.log("[playwright-pr] No changed-area E2E specs selected.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, group] of groups.entries()) {
|
||||||
|
for (const project of group.projects) {
|
||||||
|
const code = await runPlaywright({
|
||||||
|
label: `changed-area specs ${index + 1}/${groups.length} (${project})`,
|
||||||
|
artifactSuffix: `changed-${index + 1}-${project}`,
|
||||||
|
commandArgs: [...group.specs, "--project", project],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shutdown(signal) {
|
||||||
|
if (isShuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isShuttingDown = true;
|
||||||
|
console.error(`Received ${signal}. Stopping active Playwright PR children...`);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
[...activeChildren].map(
|
||||||
|
(child) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
if (!child.pid) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
|
||||||
|
stdio: "ignore",
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
killer.on("close", resolve);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
resolve();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (args.help) {
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.coreOnly && args.changedOnly) {
|
||||||
|
throw new Error("--core-only and --changed-only cannot be used together.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.access(playwrightCliPath);
|
||||||
|
|
||||||
|
if (!args.changedOnly) {
|
||||||
|
const coreCode = await runCorePrGate();
|
||||||
|
if (coreCode !== 0) {
|
||||||
|
process.exitCode = coreCode;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.coreOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = await getChangedFiles();
|
||||||
|
if (changed.unavailable) {
|
||||||
|
console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[playwright-pr] Changed-area diff ${changed.source}: ${changed.files.length} file(s).`);
|
||||||
|
const selection = selectChangedTests(changed.files);
|
||||||
|
const changedCode = await runChangedSelection(selection);
|
||||||
|
if (changedCode !== 0) {
|
||||||
|
process.exitCode = changedCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { 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";
|
||||||
|
|
||||||
const workingDirectory = process.cwd();
|
const workingDirectory = await fs.realpath(process.cwd());
|
||||||
const vitestCliPath = path.join(workingDirectory, "node_modules", "vitest", "vitest.mjs");
|
const vitestCliPath = path.join(workingDirectory, "node_modules", "vitest", "vitest.mjs");
|
||||||
const unitTestsRoot = path.join(workingDirectory, "tests", "unit");
|
const unitTestsRoot = path.join(workingDirectory, "tests", "unit");
|
||||||
const args = parseArgs(process.argv.slice(2));
|
const args = parseArgs(process.argv.slice(2));
|
||||||
@@ -23,6 +23,7 @@ function parseArgs(rawArgs) {
|
|||||||
const parsed = {
|
const parsed = {
|
||||||
help: false,
|
help: false,
|
||||||
batchSize: null,
|
batchSize: null,
|
||||||
|
fromList: null,
|
||||||
specFilters: [],
|
specFilters: [],
|
||||||
forwardedArgs: [],
|
forwardedArgs: [],
|
||||||
};
|
};
|
||||||
@@ -51,6 +52,17 @@ function parseArgs(rawArgs) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value === "--from-list" && rawArgs[index + 1]) {
|
||||||
|
parsed.fromList = rawArgs[index + 1];
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith("--from-list=")) {
|
||||||
|
parsed.fromList = value.slice("--from-list=".length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (value.startsWith("-")) {
|
if (value.startsWith("-")) {
|
||||||
parsed.forwardedArgs.push(...rawArgs.slice(index));
|
parsed.forwardedArgs.push(...rawArgs.slice(index));
|
||||||
break;
|
break;
|
||||||
@@ -80,15 +92,31 @@ Usage:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--batch-size <n> Spec files per batch. Default: 5
|
--batch-size <n> Spec files per batch. Default: 5
|
||||||
|
--from-list <file> Read additional spec filters from a newline-delimited file
|
||||||
-h, --help Show help
|
-h, --help Show help
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
npm run test:unit
|
npm run test:unit
|
||||||
|
npm run test:unit:serial
|
||||||
npm run test:unit -- tests/unit/select-vehicle-form-pos.spec.js
|
npm run test:unit -- tests/unit/select-vehicle-form-pos.spec.js
|
||||||
npm run test:unit -- tests/unit/select-vehicle-form-pos.spec.js -- --reporter=verbose
|
npm run test:unit -- tests/unit/select-vehicle-form-pos.spec.js -- --reporter=verbose
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readSpecFilterList(listFile) {
|
||||||
|
if (!listFile) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const listPath = path.isAbsolute(listFile) ? listFile : path.join(workingDirectory, listFile);
|
||||||
|
const contents = await fs.readFile(listPath, "utf8");
|
||||||
|
|
||||||
|
return contents
|
||||||
|
.split(/\r?\n/u)
|
||||||
|
.map((line) => line.replace(/#.*/u, "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
async function collectSpecFiles(targetDirectory) {
|
async function collectSpecFiles(targetDirectory) {
|
||||||
const directoryEntries = await fs.readdir(targetDirectory, { withFileTypes: true });
|
const directoryEntries = await fs.readdir(targetDirectory, { withFileTypes: true });
|
||||||
const files = [];
|
const files = [];
|
||||||
@@ -234,7 +262,8 @@ async function main() {
|
|||||||
await fs.access(vitestCliPath);
|
await fs.access(vitestCliPath);
|
||||||
|
|
||||||
const discoveredFiles = await collectSpecFiles(unitTestsRoot);
|
const discoveredFiles = await collectSpecFiles(unitTestsRoot);
|
||||||
const selectedFiles = applyFilters(discoveredFiles, args.specFilters);
|
const listFilters = await readSpecFilterList(args.fromList);
|
||||||
|
const selectedFiles = applyFilters(discoveredFiles, [...listFilters, ...args.specFilters]);
|
||||||
|
|
||||||
if (selectedFiles.length === 0) {
|
if (selectedFiles.length === 0) {
|
||||||
throw new Error("No unit spec files matched the provided filters.");
|
throw new Error("No unit spec files matched the provided filters.");
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const workingDirectory = await fs.realpath(process.cwd());
|
||||||
|
const vitestCliPath = path.join(workingDirectory, "node_modules", "vitest", "vitest.mjs");
|
||||||
|
const unitTestsRoot = path.join(workingDirectory, "tests", "unit");
|
||||||
|
const serialListPath = path.join(unitTestsRoot, "serial-tests.txt");
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
let activeChild = null;
|
||||||
|
let isShuttingDown = false;
|
||||||
|
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
void shutdown(signal).finally(() => {
|
||||||
|
process.exit(130);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(rawArgs) {
|
||||||
|
const parsed = {
|
||||||
|
help: false,
|
||||||
|
specFilters: [],
|
||||||
|
forwardedArgs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < rawArgs.length; index += 1) {
|
||||||
|
const value = rawArgs[index];
|
||||||
|
|
||||||
|
if (value === "--") {
|
||||||
|
parsed.forwardedArgs.push(...rawArgs.slice(index + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "--help" || value === "-h") {
|
||||||
|
parsed.help = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith("-")) {
|
||||||
|
parsed.forwardedArgs.push(...rawArgs.slice(index));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.specFilters.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePathSegments(value) {
|
||||||
|
return value.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.log(`
|
||||||
|
Vitest fast unit runner
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
node scripts/run-vitest-unit-fast.mjs [spec-filter ...] [-- <vitest args>]
|
||||||
|
|
||||||
|
Runs all unit specs except entries in tests/unit/serial-tests.txt.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
VITEST_FAST_MAX_WORKERS Optional max workers for the fast pass
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
npm run test:unit:fast
|
||||||
|
npm run test:unit:fast -- tests/unit/workfeed
|
||||||
|
npm run test:unit:fast -- --reporter=verbose
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectSpecFiles(targetDirectory) {
|
||||||
|
const directoryEntries = await fs.readdir(targetDirectory, { withFileTypes: true });
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
for (const entry of directoryEntries) {
|
||||||
|
const absolutePath = path.join(targetDirectory, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...(await collectSpecFiles(absolutePath)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isFile() && entry.name.endsWith(".spec.js")) {
|
||||||
|
files.push(absolutePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSerialAllowlist() {
|
||||||
|
const contents = await fs.readFile(serialListPath, "utf8");
|
||||||
|
|
||||||
|
return new Set(
|
||||||
|
contents
|
||||||
|
.split(/\r?\n/u)
|
||||||
|
.map((line) => line.replace(/#.*/u, "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(normalizePathSegments)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(relativePath, rawFilter) {
|
||||||
|
const normalizedRelativePath = normalizePathSegments(relativePath);
|
||||||
|
const normalizedFilter = normalizePathSegments(rawFilter.trim());
|
||||||
|
|
||||||
|
if (!normalizedFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalizedRelativePath === normalizedFilter ||
|
||||||
|
normalizedRelativePath.endsWith(normalizedFilter) ||
|
||||||
|
normalizedRelativePath.includes(normalizedFilter)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters(files, specFilters) {
|
||||||
|
if (specFilters.length === 0) {
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.filter((absolutePath) => {
|
||||||
|
const relativePath = path.relative(workingDirectory, absolutePath);
|
||||||
|
return specFilters.some((filter) => matchesFilter(relativePath, filter));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasForwardedArg(name) {
|
||||||
|
return args.forwardedArgs.some((value) => value === name || value.startsWith(`${name}=`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMaxWorkers() {
|
||||||
|
return (process.env.VITEST_FAST_MAX_WORKERS || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runVitest(files) {
|
||||||
|
const enforcedArgs = [];
|
||||||
|
|
||||||
|
const configuredMaxWorkers = resolveMaxWorkers();
|
||||||
|
if (configuredMaxWorkers && !hasForwardedArg("--maxWorkers")) {
|
||||||
|
enforcedArgs.push(`--maxWorkers=${configuredMaxWorkers}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
activeChild = spawn(process.execPath, [vitestCliPath, "run", ...files, ...enforcedArgs, ...args.forwardedArgs], {
|
||||||
|
cwd: workingDirectory,
|
||||||
|
env: process.env,
|
||||||
|
stdio: "inherit",
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
activeChild.on("close", (code) => {
|
||||||
|
activeChild = null;
|
||||||
|
resolve(code ?? 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shutdown(signal) {
|
||||||
|
if (isShuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isShuttingDown = true;
|
||||||
|
console.error(`Received ${signal}. Stopping active Vitest fast run...`);
|
||||||
|
|
||||||
|
if (activeChild?.pid) {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
spawn("taskkill", ["/PID", String(activeChild.pid), "/T", "/F"], {
|
||||||
|
stdio: "ignore",
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
activeChild.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (args.help) {
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.access(vitestCliPath);
|
||||||
|
|
||||||
|
const serialAllowlist = await readSerialAllowlist();
|
||||||
|
const discoveredFiles = await collectSpecFiles(unitTestsRoot);
|
||||||
|
const fastFiles = discoveredFiles.filter((absolutePath) => {
|
||||||
|
const relativePath = normalizePathSegments(path.relative(workingDirectory, absolutePath));
|
||||||
|
return !serialAllowlist.has(relativePath);
|
||||||
|
});
|
||||||
|
const selectedFiles = applyFilters(fastFiles, args.specFilters);
|
||||||
|
|
||||||
|
if (selectedFiles.length === 0) {
|
||||||
|
console.log("No parallel-safe unit spec files matched the provided filters.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Running ${selectedFiles.length} parallel-safe unit spec file(s); ${serialAllowlist.size} spec file(s) remain in the serial allowlist.`
|
||||||
|
);
|
||||||
|
|
||||||
|
const code = await runVitest(selectedFiles);
|
||||||
|
if (code !== 0) {
|
||||||
|
process.exitCode = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
+48
-25
@@ -1,41 +1,52 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, defineAsyncComponent, onMounted, watch } from "vue";
|
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, watch } from "vue";
|
||||||
// Set the favicon to the /favicon.ico file
|
// Set the favicon to the /favicon.ico file
|
||||||
import { useHead } from "@vueuse/head";
|
import { useHead } from "@vueuse/head";
|
||||||
import favicon from '@/assets/favicon.ico';
|
import favicon from "@/assets/favicon.ico";
|
||||||
import {useRoute} from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import LayoutV2 from "@/components/page/wrappers/LayoutV2.vue";
|
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { hasStoredSessionToken } from "@/services/sessionStorage.js";
|
import { hasStoredSessionToken } from "@/services/sessionStorage.js";
|
||||||
|
import {
|
||||||
|
releaseChannelSwitchedStatus,
|
||||||
|
releaseChannelUnavailableStatus,
|
||||||
|
startReleaseChannelAvailabilityClock,
|
||||||
|
stopReleaseChannelAvailabilityClock,
|
||||||
|
} from "@/services/releaseChannelAvailability.js";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t, te, locale } = useI18n({ useScope: "global" });
|
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 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 FrontendMaintenanceMenu = defineAsyncComponent(() => import("@/components/global/FrontendMaintenanceMenu.vue"));
|
||||||
|
const ReleaseChannelUnavailable = defineAsyncComponent(() =>
|
||||||
|
import("@/components/release/ReleaseChannelUnavailable.vue")
|
||||||
|
);
|
||||||
|
const ReleaseChannelSwitched = defineAsyncComponent(() => import("@/components/release/ReleaseChannelSwitched.vue"));
|
||||||
|
|
||||||
const routeTitleI18nKeys = {
|
const routeTitleI18nKeys = {
|
||||||
"about-us": ["header.about_us"],
|
"about-us": ["header.about_us"],
|
||||||
"book-a-demo": ["about_us.solutions.operator.book_demo"],
|
"book-a-demo": ["about_us.solutions.operator.book_demo"],
|
||||||
"customer-creation": ["header.customer_creation"],
|
"customer-creation": ["header.customer_creation"],
|
||||||
"login": ["header.customer_login"],
|
login: ["header.customer_login"],
|
||||||
"subuserlogin": ["header.customer_login"],
|
subuserlogin: ["header.customer_login"],
|
||||||
"adminlogin": ["header.operator_login"],
|
adminlogin: ["header.operator_login"],
|
||||||
"guestHome": ["guest.home.title"],
|
guestHome: ["guest.home.title"],
|
||||||
"PwaDownload": ["guest.pwa.title"],
|
PwaDownload: ["guest.pwa.title"],
|
||||||
"connectivity-issue": ["connectivity.title"],
|
"connectivity-issue": ["connectivity.title"],
|
||||||
"selfservestudio": ["admin.self_serve.title"]
|
selfservestudio: ["admin.self_serve.title"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const toReadableTitle = (value) => {
|
const toReadableTitle = (value) => {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
return value
|
return value
|
||||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||||
.replace(/[-_]+/g, " ")
|
.replace(/[-_]+/g, " ")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveRouteTitle = () => {
|
const resolveRouteTitle = () => {
|
||||||
@@ -49,7 +60,7 @@ const resolveRouteTitle = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const routeName = typeof route.name === "string" ? route.name : "";
|
const routeName = typeof route.name === "string" ? route.name : "";
|
||||||
const knownKeys = routeName ? (routeTitleI18nKeys[routeName] || []) : [];
|
const knownKeys = routeName ? routeTitleI18nKeys[routeName] || [] : [];
|
||||||
for (const key of knownKeys) {
|
for (const key of knownKeys) {
|
||||||
if (te(key)) return t(key);
|
if (te(key)) return t(key);
|
||||||
}
|
}
|
||||||
@@ -63,6 +74,13 @@ const updateDocumentTitle = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const shouldRenderRequestQueueProgress = computed(() => hasStoredSessionToken());
|
const shouldRenderRequestQueueProgress = computed(() => hasStoredSessionToken());
|
||||||
|
const shouldRenderReleaseChannelUnavailable = computed(() => releaseChannelUnavailableStatus.value.shouldBlock);
|
||||||
|
const shouldRenderReleaseChannelSwitched = computed(
|
||||||
|
() => !shouldRenderReleaseChannelUnavailable.value && releaseChannelSwitchedStatus.value.shouldShow
|
||||||
|
);
|
||||||
|
const shouldHideGlobalOverlays = computed(
|
||||||
|
() => shouldRenderReleaseChannelUnavailable.value || shouldRenderReleaseChannelSwitched.value
|
||||||
|
);
|
||||||
|
|
||||||
const initiateStoredSession = async () => {
|
const initiateStoredSession = async () => {
|
||||||
if (!hasStoredSessionToken()) {
|
if (!hasStoredSessionToken()) {
|
||||||
@@ -78,19 +96,24 @@ const initiateStoredSession = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
link: [
|
link: [{ rel: "icon", type: "image/x-icon", href: favicon }],
|
||||||
{ rel: 'icon', type: 'image/x-icon', href: favicon }
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
startReleaseChannelAvailabilityClock();
|
||||||
void initiateStoredSession();
|
void initiateStoredSession();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopReleaseChannelAvailabilityClock();
|
||||||
|
});
|
||||||
|
|
||||||
watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
|
watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LayoutV2 v-if="route.meta?.template === 'with-header'">
|
<ReleaseChannelUnavailable v-if="shouldRenderReleaseChannelUnavailable" />
|
||||||
|
<ReleaseChannelSwitched v-else-if="shouldRenderReleaseChannelSwitched" />
|
||||||
|
<LayoutV2 v-else-if="route.meta?.template === 'with-header'">
|
||||||
<router-view />
|
<router-view />
|
||||||
</LayoutV2>
|
</LayoutV2>
|
||||||
<main v-else-if="route.meta?.template === 'clear-main'">
|
<main v-else-if="route.meta?.template === 'clear-main'">
|
||||||
@@ -100,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" />
|
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
||||||
|
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
|
||||||
|
<FrontendMaintenanceMenu />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -760,6 +760,7 @@
|
|||||||
|
|
||||||
.pos-step-one-insights {
|
.pos-step-one-insights {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
align-items: stretch;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||||
}
|
}
|
||||||
@@ -903,6 +904,7 @@
|
|||||||
|
|
||||||
.pos-step-one-insight-card__section {
|
.pos-step-one-insight-card__section {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -970,6 +972,7 @@
|
|||||||
.pos-step-one-empty-state,
|
.pos-step-one-empty-state,
|
||||||
.pos-step-one-loading-state {
|
.pos-step-one-loading-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 4rem;
|
min-height: 4rem;
|
||||||
|
|||||||
@@ -74,10 +74,7 @@ watch(locale, (newLocale) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 16px;
|
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1.5px solid rgba(255, 255, 255, 0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -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,26 +1,15 @@
|
|||||||
<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>
|
||||||
<div>
|
<div>
|
||||||
<PageTitle :title="t('superuser.pages.departments.title')" :subtitle="t('superuser.pages.departments.subtitle')">
|
<PageTitle :title="t('common.departments')" :subtitle="t('superuser.pages.departments.subtitle')">
|
||||||
<template #buttons>
|
<template #buttons>
|
||||||
<button class="button is-dark" @click="showCreateDepartmentForm">
|
<button class="button is-dark" @click="showCreateDepartmentForm">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
@@ -36,4 +25,4 @@ const redirect = (path) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<PageTitle :title="t('superuser.pages.products.title')" :subtitle="t('superuser.pages.products.subtitle')">
|
<PageTitle :title="t('common.products')" :subtitle="t('superuser.pages.products.subtitle')">
|
||||||
<template #buttons>
|
<template #buttons>
|
||||||
<button class="button is-dark" @click="SessionUser.objects.products.showCreateObjectForm(loadList)">
|
<button class="button is-dark" @click="SessionUser.objects.products.showCreateObjectForm(loadList)">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ const props = defineProps({
|
|||||||
hasBorder: {
|
hasBorder: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
headerTestId: {
|
||||||
|
type: String,
|
||||||
|
default: ""
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
/**
|
/**
|
||||||
@@ -95,7 +99,7 @@ watch(() => props.forceState, (newVal) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Default slot for header -->
|
<!-- Default slot for header -->
|
||||||
<div class="card-header is-radiusless" @click="toggle" :class="{ 'is-clickable': props.toggleable }" v-if="$slots.header && !props.hideHeader">
|
<div class="card-header is-radiusless" @click="toggle" :class="{ 'is-clickable': props.toggleable }" v-if="$slots.header && !props.hideHeader" :data-testid="props.headerTestId || undefined">
|
||||||
<slot name="header"></slot>
|
<slot name="header"></slot>
|
||||||
</div>
|
</div>
|
||||||
<!-- Default slot for any content -->
|
<!-- Default slot for any content -->
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -119,9 +119,25 @@ const formatDateInputValue = (date: Date) => {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return date.toISOString().split("T")[0];
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseDateInputValue = (value: string) => {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||||
|
if (!match) {
|
||||||
|
return new Date(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSameDateInputValue = (left: Date, right: Date) => (
|
||||||
|
formatDateInputValue(left) === formatDateInputValue(right)
|
||||||
|
);
|
||||||
|
|
||||||
const updateSelection = () => {
|
const updateSelection = () => {
|
||||||
emits("update:selection", { startDate: startDate.value, endDate: endDate.value });
|
emits("update:selection", { startDate: startDate.value, endDate: endDate.value });
|
||||||
props.onSelectionChange(startDate.value, endDate.value);
|
props.onSelectionChange(startDate.value, endDate.value);
|
||||||
@@ -177,25 +193,38 @@ const isSelectionValid = computed(() => (
|
|||||||
));
|
));
|
||||||
|
|
||||||
const handleStartDateChange = (event) => {
|
const handleStartDateChange = (event) => {
|
||||||
const newDate = new Date(event.target.value);
|
const newDate = parseDateInputValue(event.target.value);
|
||||||
if (Number.isNaN(newDate.getTime())) {
|
if (Number.isNaN(newDate.getTime())) {
|
||||||
console.warn("Invalid start date:", event.target.value);
|
console.warn("Invalid start date:", event.target.value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSameDateInputValue(newDate, startDate.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
applyDateRange(newDate, endDate.value);
|
applyDateRange(newDate, endDate.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndDateChange = (event) => {
|
const handleEndDateChange = (event) => {
|
||||||
const newDate = new Date(event.target.value);
|
const newDate = parseDateInputValue(event.target.value);
|
||||||
if (Number.isNaN(newDate.getTime())) {
|
if (Number.isNaN(newDate.getTime())) {
|
||||||
console.warn("Invalid end date:", event.target.value);
|
console.warn("Invalid end date:", event.target.value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSameDateInputValue(newDate, endDate.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
applyDateRange(startDate.value, newDate);
|
applyDateRange(startDate.value, newDate);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFullMonthRange = (year: number, month: number): DateRange => ({
|
||||||
|
startDate: new Date(year, month - 1, 1, 0, 0, 0, 0),
|
||||||
|
endDate: new Date(year, month, 0, 23, 59, 59, 999),
|
||||||
|
});
|
||||||
|
|
||||||
const setMonth = (month: number) => {
|
const setMonth = (month: number) => {
|
||||||
if (month < 1 || month > 12) {
|
if (month < 1 || month > 12) {
|
||||||
console.warn("Invalid month selection:", month);
|
console.warn("Invalid month selection:", month);
|
||||||
@@ -203,9 +232,8 @@ const setMonth = (month: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const year = startDate.value.getFullYear();
|
const year = startDate.value.getFullYear();
|
||||||
const startOfMonth = new Date(year, month - 1, 1);
|
const range = getFullMonthRange(year, month);
|
||||||
const endOfMonth = new Date(year, month, 0);
|
applyDateRange(range.startDate, range.endDate);
|
||||||
applyDateRange(startOfMonth, endOfMonth);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMonthChange = (event) => {
|
const handleMonthChange = (event) => {
|
||||||
@@ -217,6 +245,10 @@ const handleMonthChange = (event) => {
|
|||||||
setMonth(month);
|
setMonth(month);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setCurrentSelectionEntireMonth = () => {
|
||||||
|
setMonth(currentMonthSelection.value);
|
||||||
|
};
|
||||||
|
|
||||||
const handleYearChange = (event) => {
|
const handleYearChange = (event) => {
|
||||||
const year = parseInt(event.target.value, 10);
|
const year = parseInt(event.target.value, 10);
|
||||||
if (Number.isNaN(year)) {
|
if (Number.isNaN(year)) {
|
||||||
@@ -307,24 +339,14 @@ const shortcuts = computed<Shortcut[]>(() => {
|
|||||||
label: SessionUser.objects.global.language.text.this_month,
|
label: SessionUser.objects.global.language.text.this_month,
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const firstDayOfThisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
return getFullMonthRange(today.getFullYear(), today.getMonth() + 1);
|
||||||
const lastDayOfThisMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
|
||||||
return {
|
|
||||||
startDate: new Date(firstDayOfThisMonth.setHours(23, 59, 59, 999)),
|
|
||||||
endDate: new Date(lastDayOfThisMonth.setHours(23, 59, 59, 999)),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: SessionUser.objects.global.language.text.last_month,
|
label: SessionUser.objects.global.language.text.last_month,
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const firstDayOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
return getFullMonthRange(today.getFullYear(), today.getMonth());
|
||||||
const lastDayOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
|
|
||||||
return {
|
|
||||||
startDate: new Date(firstDayOfLastMonth.setHours(23, 59, 59, 999)),
|
|
||||||
endDate: new Date(lastDayOfLastMonth.setHours(23, 59, 59, 999)),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -346,16 +368,7 @@ const shortcuts = computed<Shortcut[]>(() => {
|
|||||||
label: SessionUser.objects.global.language.text.same_month_last_year,
|
label: SessionUser.objects.global.language.text.same_month_last_year,
|
||||||
getRange: () => {
|
getRange: () => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const firstDayOfThisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
return getFullMonthRange(today.getFullYear() - 1, today.getMonth() + 1);
|
||||||
const lastDayOfThisMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
|
||||||
const firstDayOfLastYearSameMonth = new Date(firstDayOfThisMonth);
|
|
||||||
firstDayOfLastYearSameMonth.setFullYear(firstDayOfThisMonth.getFullYear() - 1);
|
|
||||||
const lastDayOfLastYearSameMonth = new Date(lastDayOfThisMonth);
|
|
||||||
lastDayOfLastYearSameMonth.setFullYear(lastDayOfThisMonth.getFullYear() - 1);
|
|
||||||
return {
|
|
||||||
startDate: new Date(firstDayOfLastYearSameMonth.setHours(23, 59, 59, 999)),
|
|
||||||
endDate: new Date(lastDayOfLastYearSameMonth.setHours(23, 59, 59, 999)),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -402,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>
|
||||||
@@ -419,6 +432,7 @@ const handleShortcutSelection = (event) => {
|
|||||||
class="input"
|
class="input"
|
||||||
data-testid="date-period-start"
|
data-testid="date-period-start"
|
||||||
:value="formatDateInputValue(startDate)"
|
:value="formatDateInputValue(startDate)"
|
||||||
|
@input="handleStartDateChange"
|
||||||
@change="handleStartDateChange"
|
@change="handleStartDateChange"
|
||||||
:disabled="props.isDisabled || props.isReadonly"
|
:disabled="props.isDisabled || props.isReadonly"
|
||||||
:class="{ 'is-danger': !isSelectionValid }"
|
:class="{ 'is-danger': !isSelectionValid }"
|
||||||
@@ -434,6 +448,7 @@ const handleShortcutSelection = (event) => {
|
|||||||
class="input"
|
class="input"
|
||||||
data-testid="date-period-end"
|
data-testid="date-period-end"
|
||||||
:value="formatDateInputValue(endDate)"
|
:value="formatDateInputValue(endDate)"
|
||||||
|
@input="handleEndDateChange"
|
||||||
@change="handleEndDateChange"
|
@change="handleEndDateChange"
|
||||||
:disabled="props.isDisabled || props.isReadonly"
|
:disabled="props.isDisabled || props.isReadonly"
|
||||||
:class="{ 'is-danger': !isSelectionValid }"
|
:class="{ 'is-danger': !isSelectionValid }"
|
||||||
@@ -494,9 +509,10 @@ const handleShortcutSelection = (event) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="date-period-selector__mobile-actions" v-if="visibility.showUpdateButton">
|
<div class="date-period-selector__mobile-actions">
|
||||||
<slot name="right"></slot>
|
<slot name="right"></slot>
|
||||||
<button
|
<button
|
||||||
|
v-if="visibility.showUpdateButton"
|
||||||
class="button is-primary is-fullwidth"
|
class="button is-primary is-fullwidth"
|
||||||
type="button"
|
type="button"
|
||||||
@click="updateSelection"
|
@click="updateSelection"
|
||||||
@@ -516,6 +532,7 @@ const handleShortcutSelection = (event) => {
|
|||||||
class="input"
|
class="input"
|
||||||
data-testid="date-period-start"
|
data-testid="date-period-start"
|
||||||
:value="formatDateInputValue(startDate)"
|
:value="formatDateInputValue(startDate)"
|
||||||
|
@input="handleStartDateChange"
|
||||||
@change="handleStartDateChange"
|
@change="handleStartDateChange"
|
||||||
:disabled="props.isDisabled || props.isReadonly"
|
:disabled="props.isDisabled || props.isReadonly"
|
||||||
:class="{ 'is-danger': !isSelectionValid }"
|
:class="{ 'is-danger': !isSelectionValid }"
|
||||||
@@ -530,6 +547,7 @@ const handleShortcutSelection = (event) => {
|
|||||||
class="input"
|
class="input"
|
||||||
data-testid="date-period-end"
|
data-testid="date-period-end"
|
||||||
:value="formatDateInputValue(endDate)"
|
:value="formatDateInputValue(endDate)"
|
||||||
|
@input="handleEndDateChange"
|
||||||
@change="handleEndDateChange"
|
@change="handleEndDateChange"
|
||||||
:disabled="props.isDisabled || props.isReadonly"
|
:disabled="props.isDisabled || props.isReadonly"
|
||||||
:class="{ 'is-danger': !isSelectionValid }"
|
:class="{ 'is-danger': !isSelectionValid }"
|
||||||
@@ -648,7 +666,7 @@ const handleShortcutSelection = (event) => {
|
|||||||
iconPack="fas"
|
iconPack="fas"
|
||||||
has-icon
|
has-icon
|
||||||
>
|
>
|
||||||
Selection is not an entire month <span class="has-text-weight-bold is-clickable" @click="setMonth(currentMonthSelection)">Click to set entire month</span>.
|
Selection is not an entire month <span class="has-text-weight-bold is-clickable" data-testid="date-period-set-entire-month" @click="setCurrentSelectionEntireMonth">Click to set entire month</span>.
|
||||||
When a partial month selection is made, it may not accurately represent the intended time period. Consider selecting an entire month for more precise results.
|
When a partial month selection is made, it may not accurately represent the intended time period. Consider selecting an entire month for more precise results.
|
||||||
</b-message>
|
</b-message>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ if (departments.value.length === 0) {
|
|||||||
<th>{{ $t('objects.columns.department') }}</th>
|
<th>{{ $t('objects.columns.department') }}</th>
|
||||||
<th>{{ $t('tables.bookings.reg_tractor') }}</th>
|
<th>{{ $t('tables.bookings.reg_tractor') }}</th>
|
||||||
<th>{{ $t('tables.bookings.reg_trailer') }}</th>
|
<th>{{ $t('tables.bookings.reg_trailer') }}</th>
|
||||||
<th>{{ $t('objects.columns.reference') }}</th>
|
<th>{{ $t('common.reference') }}</th>
|
||||||
<th>{{ $t('objects.columns.notes') }}</th>
|
<th>{{ $t('objects.columns.notes') }}</th>
|
||||||
<th>{{ $t('objects.columns.date') }}</th>
|
<th>{{ $t('common.date') }}</th>
|
||||||
<th>{{ $t('objects.columns.status') }}</th>
|
<th>{{ $t('common.status') }}</th>
|
||||||
<th>{{ $t('objects.bookings.columns.contact_email') }}</th>
|
<th>{{ $t('objects.bookings.columns.contact_email') }}</th>
|
||||||
<th>{{ $t('objects.bookings.columns.wash_certificate_email') }}</th>
|
<th>{{ $t('objects.bookings.columns.wash_certificate_email') }}</th>
|
||||||
<th>{{ $t('tables.actions') }}</th>
|
<th>{{ $t('tables.actions') }}</th>
|
||||||
@@ -53,7 +53,7 @@ if (departments.value.length === 0) {
|
|||||||
<td>{{ booking.washCertificateEmail }}</td>
|
<td>{{ booking.washCertificateEmail }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button class="button is-small is-dark" @click="redirectDepartmentOrderPage(booking.id, booking.department_id)">{{ $t('global.show') }}</button>
|
<button class="button is-small is-dark" @click="redirectDepartmentOrderPage(booking.id, booking.department_id)">{{ $t('common.show') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ const modules = computed(() => [
|
|||||||
},
|
},
|
||||||
count: 0,
|
count: 0,
|
||||||
hasSwitch: true,
|
hasSwitch: true,
|
||||||
|
switchLoading: isLoadingSelfServeEnabled.value || isSavingSelfServeEnabled.value,
|
||||||
switchValue: selfServeEnabled,
|
switchValue: selfServeEnabled,
|
||||||
switchDisabled: isLoadingSelfServeEnabled.value || isSavingSelfServeEnabled.value,
|
switchDisabled: isLoadingSelfServeEnabled.value || isSavingSelfServeEnabled.value,
|
||||||
onToggle: async (newValue) => {
|
onToggle: async (newValue) => {
|
||||||
@@ -118,8 +119,8 @@ const modules = computed(() => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6,
|
||||||
name: 'Self-Serve Studio',
|
name: 'Døgnvask konfiguration',
|
||||||
description: 'Configure questions, conditions, rules and tasks',
|
description: 'Gå til konfiguration af Døgnvask',
|
||||||
icon: "fas fa-project-diagram",
|
icon: "fas fa-project-diagram",
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
router.push(buildDepartmentModulePath('/modules/self-serve/studio'));
|
router.push(buildDepartmentModulePath('/modules/self-serve/studio'));
|
||||||
@@ -225,9 +226,17 @@ setInterval(() => {
|
|||||||
<i :class="module.icon"></i>
|
<i :class="module.icon"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-header-title">{{ module.name }}</p>
|
<p class="card-header-title department-module-card__header-title">{{ module.name }}</p>
|
||||||
<div class="is-pulled-right card-header-title pr-0">
|
<div class="department-module-card__actions" data-testid="department-module-card-actions">
|
||||||
<span class="button is-small is-dark department-module-card__count" @click.stop="module.onClickCount" v-if="module.count > 0">{{ module.count }}</span>
|
<span class="button is-small is-dark department-module-card__count" @click.stop="module.onClickCount" v-if="module.count > 0">{{ module.count }}</span>
|
||||||
|
<span
|
||||||
|
v-else-if="module?.switchLoading"
|
||||||
|
class="tag is-rounded is-small department-module-card__state-loading"
|
||||||
|
data-testid="department-module-self-serve-state-loading"
|
||||||
|
role="status"
|
||||||
|
:aria-label="t('global.loading')"
|
||||||
|
@click.stop
|
||||||
|
></span>
|
||||||
<span class="button is-small is-outlined department-module-card__switch" v-else-if="module?.hasSwitch" @click.stop>
|
<span class="button is-small is-outlined department-module-card__switch" v-else-if="module?.hasSwitch" @click.stop>
|
||||||
<b-switch
|
<b-switch
|
||||||
size="is-small"
|
size="is-small"
|
||||||
@@ -253,19 +262,72 @@ setInterval(() => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.department-module-card .card-header-title {
|
.department-module-card__header-title {
|
||||||
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.department-module-card__actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-left: auto;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.department-module-card__count,
|
.department-module-card__count,
|
||||||
.department-module-card__switch {
|
.department-module-card__switch,
|
||||||
|
.department-module-card__state-loading {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.department-module-card__state-loading {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
min-height: 1.25rem;
|
||||||
|
padding: 0 0.5rem !important;
|
||||||
|
background-color: #e5e7eb !important;
|
||||||
|
border: 1px solid #d1d5db !important;
|
||||||
|
color: transparent !important;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle, rgba(156, 163, 175, 0.95) 0.16rem, transparent 0.18rem),
|
||||||
|
radial-gradient(circle, rgba(156, 163, 175, 0.95) 0.16rem, transparent 0.18rem),
|
||||||
|
radial-gradient(circle, rgba(156, 163, 175, 0.95) 0.16rem, transparent 0.18rem);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 0.4rem 0.4rem;
|
||||||
|
background-position:
|
||||||
|
calc(50% - 0.5rem) 50%,
|
||||||
|
50% 50%,
|
||||||
|
calc(50% + 0.5rem) 50%;
|
||||||
|
animation: department-module-state-loading-pulse 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes department-module-state-loading-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.68;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.department-module-card__state-loading {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.department-module-card__title {
|
.department-module-card__title {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@ const hasUpdatePermission = () => {
|
|||||||
:editFunction="su_object.showEditObjectFieldForm"
|
:editFunction="su_object.showEditObjectFieldForm"
|
||||||
column="enabled"
|
column="enabled"
|
||||||
:parse-function="(value) => {
|
:parse-function="(value) => {
|
||||||
return value ? t('global.yes') : t('global.no');
|
return value ? t('common.yes') : t('common.no');
|
||||||
}"
|
}"
|
||||||
:permission-check-function="hasUpdatePermission"
|
:permission-check-function="hasUpdatePermission"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ const getScanBadges = (scan) => {
|
|||||||
if (!scan.seen_before) {
|
if (!scan.seen_before) {
|
||||||
badges.push({
|
badges.push({
|
||||||
key: 'unknown',
|
key: 'unknown',
|
||||||
text: t('admin.pos.status_unknown'),
|
text: t('common.new'),
|
||||||
className: 'pos-scan-badge--unknown',
|
className: 'pos-scan-badge--unknown',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -560,7 +560,7 @@ setTimeout(() => {
|
|||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fas fa-redo"></i>
|
<i class="fas fa-redo"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ $t('admin.pos.try_again') }}</span>
|
<span>{{ $t('common.retry') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
import { showEditOrderItemForm } from "@/components/forms/superUser/editOrderItemForm.vue";
|
import { showEditOrderItemForm } from "@/components/forms/superUser/editOrderItemForm.vue";
|
||||||
import { removeOrderItem } from "@/components/shop/OrdersItems.vue";
|
import { removeOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||||
import { computed, ref, watch, useSlots } from "vue";
|
import { computed, ref, watch, useSlots } from "vue";
|
||||||
@@ -26,6 +27,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const emits = defineEmits(["update:orderItems", "onDestroyItem"]);
|
const emits = defineEmits(["update:orderItems", "onDestroyItem"]);
|
||||||
|
const route = useRoute();
|
||||||
|
const highlightOrderItemId = computed(() => {
|
||||||
|
const parsed = Number.parseInt(String(route.query.highlightOrderItem || ""), 10);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||||
|
});
|
||||||
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
|
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.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 ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||||
@@ -69,6 +75,10 @@ const handleCustomerWishesFieldSaved = async (fieldKey) => {
|
|||||||
|
|
||||||
await loadOrder();
|
await loadOrder();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOrderNoteSaved = (savedValue) => {
|
||||||
|
order_notes.value = savedValue;
|
||||||
|
};
|
||||||
// Check if the size is large
|
// Check if the size is large
|
||||||
const isLarge = props.isLarge !== undefined ? props.isLarge : false;
|
const isLarge = props.isLarge !== undefined ? props.isLarge : false;
|
||||||
const isOrderDetailVariant = computed(() => props.variant === "order-detail");
|
const isOrderDetailVariant = computed(() => props.variant === "order-detail");
|
||||||
@@ -502,6 +512,7 @@ const deleteOrderItem = async (orderItemId) => {
|
|||||||
:total-price="totalPrice"
|
:total-price="totalPrice"
|
||||||
:show-actions="Boolean(editPossible)"
|
:show-actions="Boolean(editPossible)"
|
||||||
:can-edit-order-items="canEditOrderItems"
|
:can-edit-order-items="canEditOrderItems"
|
||||||
|
:highlight-order-item-id="highlightOrderItemId"
|
||||||
@edit="openOrderItemEditor"
|
@edit="openOrderItemEditor"
|
||||||
@delete="deleteOrderItem"
|
@delete="deleteOrderItem"
|
||||||
/>
|
/>
|
||||||
@@ -518,6 +529,7 @@ const deleteOrderItem = async (orderItemId) => {
|
|||||||
:total-price="totalPrice"
|
:total-price="totalPrice"
|
||||||
:show-actions="Boolean(editPossible)"
|
:show-actions="Boolean(editPossible)"
|
||||||
:can-edit-order-items="canEditOrderItems"
|
:can-edit-order-items="canEditOrderItems"
|
||||||
|
:highlight-order-item-id="highlightOrderItemId"
|
||||||
@edit="openOrderItemEditor"
|
@edit="openOrderItemEditor"
|
||||||
@delete="deleteOrderItem"
|
@delete="deleteOrderItem"
|
||||||
/>
|
/>
|
||||||
@@ -528,14 +540,14 @@ const deleteOrderItem = async (orderItemId) => {
|
|||||||
<table v-if="false" class="table is-fullwidth pos-order-items__table">
|
<table v-if="false" class="table is-fullwidth pos-order-items__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ $t("admin.pos.product") }}</th>
|
<th>{{ $t("common.product") }}</th>
|
||||||
<th style="width: 20%; text-align: right">{{ $t("admin.pos.price_dkk") }}</th>
|
<th style="width: 20%; text-align: right">{{ $t("admin.pos.price_dkk") }}</th>
|
||||||
<th
|
<th
|
||||||
v-if="editPossible"
|
v-if="editPossible"
|
||||||
class="pos-order-items__actions-header"
|
class="pos-order-items__actions-header"
|
||||||
:style="{ width: isOrderDetailVariant ? '18%' : '15%' }"
|
:style="{ width: isOrderDetailVariant ? '18%' : '15%' }"
|
||||||
>
|
>
|
||||||
{{ isOrderDetailVariant ? $t("global.actions") : "" }}
|
{{ isOrderDetailVariant ? $t("common.actions") : "" }}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -920,7 +932,12 @@ const deleteOrderItem = async (orderItemId) => {
|
|||||||
data-testid="pos-order-metadata-note"
|
data-testid="pos-order-metadata-note"
|
||||||
>
|
>
|
||||||
<p class="pos-order-metadata-label">{{ $t("admin.pos.order_note") }}</p>
|
<p class="pos-order-metadata-label">{{ $t("admin.pos.order_note") }}</p>
|
||||||
<POSOrderNote v-bind:order_id="activeOrderId" v-bind:loadOrder="loadOrder" v-bind:note="order_notes" />
|
<POSOrderNote
|
||||||
|
v-bind:order_id="activeOrderId"
|
||||||
|
v-bind:loadOrder="loadOrder"
|
||||||
|
v-bind:note="order_notes"
|
||||||
|
v-bind:onSaved="handleOrderNoteSaved"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentB
|
|||||||
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
|
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const emit = defineEmits(["update:activeTab"]);
|
||||||
|
|
||||||
const panel_tabs = ref([
|
const panel_tabs = ref([
|
||||||
{
|
{
|
||||||
@@ -151,6 +152,7 @@ const rootClasses = computed(() => ({
|
|||||||
}));
|
}));
|
||||||
const isSidebarVariant = computed(() => props.variant === "sidebar");
|
const isSidebarVariant = computed(() => props.variant === "sidebar");
|
||||||
const isRulesTabActive = computed(() => panel_tabs.value[1]?.active === true);
|
const isRulesTabActive = computed(() => panel_tabs.value[1]?.active === true);
|
||||||
|
const activeTabKey = computed(() => panel_tabs.value.find((tab) => tab.active)?.key ?? "details");
|
||||||
|
|
||||||
const setActiveTab = (selectedIndex) => {
|
const setActiveTab = (selectedIndex) => {
|
||||||
panel_tabs.value.forEach((tab, index) => {
|
panel_tabs.value.forEach((tab, index) => {
|
||||||
@@ -171,6 +173,14 @@ const hasAttribute = (prop) => {
|
|||||||
return customer_attributes.value.some((attribute) => attribute.attribute === prop);
|
return customer_attributes.value.some((attribute) => attribute.attribute === prop);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
activeTabKey,
|
||||||
|
(tabKey) => {
|
||||||
|
emit("update:activeTab", tabKey);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
isRulesTabActive,
|
isRulesTabActive,
|
||||||
(isActive) => {
|
(isActive) => {
|
||||||
@@ -255,52 +265,62 @@ const customer_data_has_empty_details = () => {
|
|||||||
/>
|
/>
|
||||||
<!-- Attributes -->
|
<!-- Attributes -->
|
||||||
<RequiresPermission permission="list_customer_attributes">
|
<RequiresPermission permission="list_customer_attributes">
|
||||||
<div
|
<template v-if="panel_tabs[1].active">
|
||||||
class="panel-block pos-selected-customer__row"
|
<div
|
||||||
v-if="panel_tabs[1].active"
|
v-for="attribute in attributes"
|
||||||
v-for="attribute in attributes"
|
:key="attribute.attribute"
|
||||||
:key="attribute.attribute"
|
class="panel-block pos-selected-customer__row"
|
||||||
>
|
data-testid="pos-customer-rule-row"
|
||||||
<span class="panel-icon pos-selected-customer__icon">
|
:data-rule-attribute="attribute.attribute"
|
||||||
<i :class="attribute.icon" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="pos-selected-customer__label"
|
|
||||||
@mouseover="showPopper(popperBox(attribute.name, attribute.description), $event.target)"
|
|
||||||
@mouseleave="removePopperIfOpen()"
|
|
||||||
>{{ attribute.name }}</span
|
|
||||||
>
|
>
|
||||||
<span class="pos-selected-customer__value">
|
<span class="panel-icon pos-selected-customer__icon">
|
||||||
|
<i :class="attribute.icon" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="hasAttribute(attribute.prop)"
|
class="pos-selected-customer__label"
|
||||||
@click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())"
|
@mouseover="showPopper(popperBox(attribute.name, attribute.description), $event.target)"
|
||||||
|
@mouseleave="removePopperIfOpen()"
|
||||||
|
>{{ attribute.name }}</span
|
||||||
>
|
>
|
||||||
<span class="field">
|
<span class="pos-selected-customer__value">
|
||||||
<input
|
<span
|
||||||
:id="attribute.prop"
|
v-if="hasAttribute(attribute.prop)"
|
||||||
type="checkbox"
|
@click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())"
|
||||||
:name="attribute.prop"
|
>
|
||||||
class="switch"
|
<span class="field">
|
||||||
checked="checked"
|
<input
|
||||||
:disabled="!SessionUser.hasPermission('delete_customer_attribute')"
|
:id="attribute.prop"
|
||||||
/>
|
type="checkbox"
|
||||||
<label :for="attribute.prop"></label>
|
:name="attribute.prop"
|
||||||
|
class="switch"
|
||||||
|
checked="checked"
|
||||||
|
:disabled="!SessionUser.hasPermission('delete_customer_attribute')"
|
||||||
|
/>
|
||||||
|
<label :for="attribute.prop"></label>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span v-else @click="createAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
|
||||||
|
<span class="field">
|
||||||
|
<input
|
||||||
|
:id="attribute.prop"
|
||||||
|
type="checkbox"
|
||||||
|
:name="attribute.prop"
|
||||||
|
class="switch"
|
||||||
|
:disabled="!SessionUser.hasPermission('add_customer_attribute')"
|
||||||
|
/>
|
||||||
|
<label :for="attribute.prop"></label>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-else @click="createAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
|
</div>
|
||||||
<span class="field">
|
<div
|
||||||
<input
|
v-if="$slots['rules-footer']"
|
||||||
:id="attribute.prop"
|
class="pos-selected-customer__rules-footer"
|
||||||
type="checkbox"
|
data-testid="pos-customer-rules-footer"
|
||||||
:name="attribute.prop"
|
>
|
||||||
class="switch"
|
<slot name="rules-footer"></slot>
|
||||||
:disabled="!SessionUser.hasPermission('add_customer_attribute')"
|
</div>
|
||||||
/>
|
</template>
|
||||||
<label :for="attribute.prop"></label>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</RequiresPermission>
|
</RequiresPermission>
|
||||||
<!-- Shortcuts -->
|
<!-- Shortcuts -->
|
||||||
<template v-if="panel_tabs[2].active" v-for="shortcut in shortcuts" :key="shortcut.name">
|
<template v-if="panel_tabs[2].active" v-for="shortcut in shortcuts" :key="shortcut.name">
|
||||||
@@ -425,6 +445,14 @@ const customer_data_has_empty_details = () => {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pos-selected-customer__rules-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid #eef3f8;
|
||||||
|
}
|
||||||
|
|
||||||
.pos-selected-customer--sidebar .pos-selected-customer__title,
|
.pos-selected-customer--sidebar .pos-selected-customer__title,
|
||||||
.pos-selected-customer--order-detail .pos-selected-customer__title {
|
.pos-selected-customer--order-detail .pos-selected-customer__title {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -121,7 +121,7 @@ const createCustomerWishField = ({
|
|||||||
|
|
||||||
const referenceField = createCustomerWishField({
|
const referenceField = createCustomerWishField({
|
||||||
key: "reference",
|
key: "reference",
|
||||||
label: computed(() => t("pos.order.reference")),
|
label: computed(() => t("common.reference")),
|
||||||
source: () => props.reference,
|
source: () => props.reference,
|
||||||
saveValue: (value) => SessionUser.objects.orders.set.reference(props.order_id, value),
|
saveValue: (value) => SessionUser.objects.orders.set.reference(props.order_id, value),
|
||||||
testIdBase: "pos-order-customer-wishes-reference",
|
testIdBase: "pos-order-customer-wishes-reference",
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ const props = defineProps({
|
|||||||
loadOrder: {
|
loadOrder: {
|
||||||
type: Function,
|
type: Function,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
onSaved: {
|
||||||
|
type: Function,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,6 +33,11 @@ const autosave = useOrderMetadataAutosave({
|
|||||||
await SessionUser.objects.orders.set.notes(props.order_id, value);
|
await SessionUser.objects.orders.set.notes(props.order_id, value);
|
||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
|
onSaved: async (savedValue, requestedValue) => {
|
||||||
|
if (typeof props.onSaved === "function") {
|
||||||
|
await props.onSaved(savedValue, requestedValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const previewValue = computed(() => autosave.draft.value);
|
const previewValue = computed(() => autosave.draft.value);
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ watch(normalizedOrderId, async (nextOrderId, previousOrderId) => {
|
|||||||
class="pos-inline-tag"
|
class="pos-inline-tag"
|
||||||
data-testid="pos-order-inline-created"
|
data-testid="pos-order-inline-created"
|
||||||
>
|
>
|
||||||
<span class="pos-inline-tag__label">{{ $t('pos.order.created') }}:</span>
|
<span class="pos-inline-tag__label">{{ $t('common.created') }}:</span>
|
||||||
<span class="pos-inline-tag__value">{{ resolvedCreatedAt }}</span>
|
<span class="pos-inline-tag__value">{{ resolvedCreatedAt }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
highlightOrderItemId: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emits = defineEmits(['edit', 'delete']);
|
const emits = defineEmits(['edit', 'delete']);
|
||||||
@@ -42,6 +46,7 @@ const getRowClass = (row) => {
|
|||||||
'pos-order-items__row--related': row.isRelated,
|
'pos-order-items__row--related': row.isRelated,
|
||||||
'pos-order-items__row--group-parent': row.hasRelatedItems,
|
'pos-order-items__row--group-parent': row.hasRelatedItems,
|
||||||
'pos-order-items__row--group-middle': row.isRelated && !row.isLastRelatedItem,
|
'pos-order-items__row--group-middle': row.isRelated && !row.isLastRelatedItem,
|
||||||
|
'pos-order-items__row--highlight': Number(row?.orderItem?.id) === Number(props.highlightOrderItemId),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,7 +107,7 @@ const getActionsCellAttrs = () => ({
|
|||||||
>
|
>
|
||||||
<b-table-column
|
<b-table-column
|
||||||
field="product"
|
field="product"
|
||||||
:label="$t('admin.pos.product')"
|
:label="$t('common.product')"
|
||||||
v-slot="tableProps"
|
v-slot="tableProps"
|
||||||
>
|
>
|
||||||
<div class="pos-order-items__product-cell" :data-testid="`pos-order-item-name-${tableProps.row.orderItem.id}`">
|
<div class="pos-order-items__product-cell" :data-testid="`pos-order-item-name-${tableProps.row.orderItem.id}`">
|
||||||
@@ -187,7 +192,7 @@ const getActionsCellAttrs = () => ({
|
|||||||
<b-table-column
|
<b-table-column
|
||||||
v-if="showActions"
|
v-if="showActions"
|
||||||
field="actions"
|
field="actions"
|
||||||
:label="$t('global.actions')"
|
:label="$t('common.actions')"
|
||||||
custom-key="actions"
|
custom-key="actions"
|
||||||
header-class="pos-order-items__actions-header"
|
header-class="pos-order-items__actions-header"
|
||||||
:th-attrs="getActionsHeaderAttrs"
|
:th-attrs="getActionsHeaderAttrs"
|
||||||
@@ -293,6 +298,11 @@ const getActionsCellAttrs = () => ({
|
|||||||
border-bottom-color: transparent;
|
border-bottom-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pos-order-items__detail-table :deep(.table tbody tr.pos-order-items__row--highlight td) {
|
||||||
|
background: #fff3cd !important;
|
||||||
|
box-shadow: inset 3px 0 0 #ffdd57;
|
||||||
|
}
|
||||||
|
|
||||||
.pos-order-items__detail-table :deep(.table tfoot td) {
|
.pos-order-items__detail-table :deep(.table tfoot td) {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-top: 1px solid #dfe7f1;
|
border-top: 1px solid #dfe7f1;
|
||||||
|
|||||||
@@ -116,12 +116,12 @@ const saveChanges = async () => {
|
|||||||
>
|
>
|
||||||
<div class="pos-order-item-edit-modal__header">
|
<div class="pos-order-item-edit-modal__header">
|
||||||
<p class="title is-5 mb-1">{{ t('admin.pos.edit') }}</p>
|
<p class="title is-5 mb-1">{{ t('admin.pos.edit') }}</p>
|
||||||
<p class="is-size-7 has-text-grey mb-0">{{ t('admin.pos.product') }} #{{ orderItem.id }}</p>
|
<p class="is-size-7 has-text-grey mb-0">{{ t('common.product') }} #{{ orderItem.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline">
|
<div class="columns is-multiline">
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<label class="label" for="pos-order-item-edit-name">{{ t('admin.pos.product') }}</label>
|
<label class="label" for="pos-order-item-edit-name">{{ t('common.product') }}</label>
|
||||||
<input
|
<input
|
||||||
id="pos-order-item-edit-name"
|
id="pos-order-item-edit-name"
|
||||||
class="input"
|
class="input"
|
||||||
@@ -133,7 +133,7 @@ const saveChanges = async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12-mobile is-6-tablet">
|
<div class="column is-12-mobile is-6-tablet">
|
||||||
<label class="label" for="pos-order-item-edit-price">{{ t('global.price') }}</label>
|
<label class="label" for="pos-order-item-edit-price">{{ t('common.price') }}</label>
|
||||||
<input
|
<input
|
||||||
id="pos-order-item-edit-price"
|
id="pos-order-item-edit-price"
|
||||||
v-model="form.price"
|
v-model="form.price"
|
||||||
@@ -147,7 +147,7 @@ const saveChanges = async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12-mobile is-6-tablet">
|
<div class="column is-12-mobile is-6-tablet">
|
||||||
<label class="label" for="pos-order-item-edit-quantity">{{ t('global.quantity') }}</label>
|
<label class="label" for="pos-order-item-edit-quantity">{{ t('common.quantity') }}</label>
|
||||||
<input
|
<input
|
||||||
id="pos-order-item-edit-quantity"
|
id="pos-order-item-edit-quantity"
|
||||||
v-model="form.quantity"
|
v-model="form.quantity"
|
||||||
@@ -173,7 +173,7 @@ const saveChanges = async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<label class="label" for="pos-order-item-edit-reference">{{ t('objects.columns.reference') }}</label>
|
<label class="label" for="pos-order-item-edit-reference">{{ t('common.reference') }}</label>
|
||||||
<input
|
<input
|
||||||
id="pos-order-item-edit-reference"
|
id="pos-order-item-edit-reference"
|
||||||
v-model="form.reference"
|
v-model="form.reference"
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
highlightOrderItemId: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emits = defineEmits(['edit', 'delete']);
|
const emits = defineEmits(['edit', 'delete']);
|
||||||
@@ -44,6 +48,7 @@ const getRowClass = (row) => {
|
|||||||
'pos-order-items__row--related': row.isRelated,
|
'pos-order-items__row--related': row.isRelated,
|
||||||
'pos-order-items__row--group-parent': row.hasRelatedItems,
|
'pos-order-items__row--group-parent': row.hasRelatedItems,
|
||||||
'pos-order-items__row--group-middle': row.isRelated && !row.isLastRelatedItem,
|
'pos-order-items__row--group-middle': row.isRelated && !row.isLastRelatedItem,
|
||||||
|
'pos-order-items__row--highlight': Number(row?.orderItem?.id) === Number(props.highlightOrderItemId),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -59,7 +64,7 @@ const getRowClass = (row) => {
|
|||||||
>
|
>
|
||||||
<b-table-column
|
<b-table-column
|
||||||
field="product"
|
field="product"
|
||||||
:label="$t('admin.pos.product')"
|
:label="$t('common.product')"
|
||||||
v-slot="tableProps"
|
v-slot="tableProps"
|
||||||
>
|
>
|
||||||
<div class="pos-order-items__product-cell" :data-testid="`pos-order-item-name-${tableProps.row.orderItem.id}`">
|
<div class="pos-order-items__product-cell" :data-testid="`pos-order-item-name-${tableProps.row.orderItem.id}`">
|
||||||
@@ -156,7 +161,9 @@ const getRowClass = (row) => {
|
|||||||
>
|
>
|
||||||
<div class="pos-order-items__action-cell">
|
<div class="pos-order-items__action-cell">
|
||||||
<div class="buttons is-narrow">
|
<div class="buttons is-narrow">
|
||||||
<ActionSettingsWheelButton>
|
<ActionSettingsWheelButton
|
||||||
|
:flag-target="{ target_type: 'order_item', target_id: tableProps.row.orderItem.id }"
|
||||||
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<ActionSettingsWheelItem
|
<ActionSettingsWheelItem
|
||||||
:label="$t('admin.pos.delete')"
|
:label="$t('admin.pos.delete')"
|
||||||
@@ -232,6 +239,11 @@ const getRowClass = (row) => {
|
|||||||
border-bottom-color: transparent;
|
border-bottom-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pos-order-items__default-table :deep(.table tbody tr.pos-order-items__row--highlight td) {
|
||||||
|
background: #fff3cd !important;
|
||||||
|
box-shadow: inset 3px 0 0 #ffdd57;
|
||||||
|
}
|
||||||
|
|
||||||
.pos-order-items__default-table :deep(.table tfoot td) {
|
.pos-order-items__default-table :deep(.table tfoot td) {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ const props = defineProps({
|
|||||||
console.warn('setItemValue function not provided', orderItem, column, value);
|
console.warn('setItemValue function not provided', orderItem, column, value);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
highlightOrderItemId: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const calculateTax = (price) => {
|
const calculateTax = (price) => {
|
||||||
@@ -76,9 +80,9 @@ const getOrderItemBasePrice = (orderItem) => {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>{{ $t('tables.products.name') }}</th>
|
<th>{{ $t('tables.products.name') }}</th>
|
||||||
<th>{{ $t('global.note') }}</th>
|
<th>{{ $t('global.note') }}</th>
|
||||||
<th>{{ $t('objects.columns.reference') }}</th>
|
<th>{{ $t('common.reference') }}</th>
|
||||||
<th>{{ $t('global.quantity') }}</th>
|
<th>{{ $t('common.quantity') }}</th>
|
||||||
<th style="text-align: right;">{{ $t('global.price') }}</th>
|
<th style="text-align: right;">{{ $t('common.price') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -88,7 +92,13 @@ const getOrderItemBasePrice = (orderItem) => {
|
|||||||
<div class="skeleton-lines"><div style="width: 100%;"></div></div>
|
<div class="skeleton-lines"><div style="width: 100%;"></div></div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="orderItem in props.orderItems" :key="orderItem.id" v-else>
|
<tr
|
||||||
|
v-for="orderItem in props.orderItems"
|
||||||
|
:key="orderItem.id"
|
||||||
|
v-else
|
||||||
|
:id="`order-item-${orderItem.id}`"
|
||||||
|
:class="{ 'order-item-highlight': Number(orderItem.id) === Number(props.highlightOrderItemId) }"
|
||||||
|
>
|
||||||
<td>{{ getOrderItemProductName(orderItem) }}</td>
|
<td>{{ getOrderItemProductName(orderItem) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="text" v-if="props.allowEdit" v-model="orderItem.notes" @input="props.setItemValue(orderItem, 'notes', orderItem.notes)" class="input is-small" />
|
<input type="text" v-if="props.allowEdit" v-model="orderItem.notes" @input="props.setItemValue(orderItem, 'notes', orderItem.notes)" class="input is-small" />
|
||||||
@@ -111,7 +121,7 @@ const getOrderItemBasePrice = (orderItem) => {
|
|||||||
<tfoot>
|
<tfoot>
|
||||||
<!-- Tax -->
|
<!-- Tax -->
|
||||||
<tr v-if="props.taxPercentage > 0">
|
<tr v-if="props.taxPercentage > 0">
|
||||||
<td colspan="4">{{ $t('global.tax') }} ({{ props.taxPercentage }}%)</td>
|
<td colspan="4">{{ $t('common.tax') }} ({{ props.taxPercentage }}%)</td>
|
||||||
<td style="text-align: right;" v-if="props.isLoading">
|
<td style="text-align: right;" v-if="props.isLoading">
|
||||||
<!-- Skeleton-lines 1 row -->
|
<!-- Skeleton-lines 1 row -->
|
||||||
<div class="skeleton-lines"><div style="width: 100%;"></div></div>
|
<div class="skeleton-lines"><div style="width: 100%;"></div></div>
|
||||||
@@ -147,5 +157,8 @@ const getOrderItemBasePrice = (orderItem) => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.order-item-highlight > td {
|
||||||
|
background: #fff3cd !important;
|
||||||
|
box-shadow: inset 3px 0 0 #ffdd57;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -294,7 +304,7 @@ const uploadOrderAttachmentFile = async (file) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error uploading order attachment:", error);
|
console.error("Error uploading order attachment:", error);
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
title: t("admin.pos.settings_wheel.error"),
|
title: t("common.error"),
|
||||||
text: t("admin.pos.attachments_upload_error"),
|
text: t("admin.pos.attachments_upload_error"),
|
||||||
icon: "error",
|
icon: "error",
|
||||||
});
|
});
|
||||||
@@ -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'"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const { t } = useI18n();
|
|||||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||||
import OrderAttachmentsActionButton from "@/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue";
|
import OrderAttachmentsActionButton from "@/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue";
|
||||||
import AssignDraftOrderCustomerModal from "@/components/displays/modals/AssignDraftOrderCustomerModal.vue";
|
import AssignDraftOrderCustomerModal from "@/components/displays/modals/AssignDraftOrderCustomerModal.vue";
|
||||||
|
import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
orders: {
|
orders: {
|
||||||
@@ -44,13 +45,19 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
invoicePeriodFlags: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
const emit = defineEmits(["flagStatusChanged", "flagCreated"]);
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { departments, getDepartments, isLoading, getDepartmentName } from "@/components/pagination/departmentTabs.vue";
|
import { departments, getDepartments, isLoading, getDepartmentName } from "@/components/pagination/departmentTabs.vue";
|
||||||
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
|
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { orderBy, orderDirection, setOrder, loadList } from "@/components/pagination/paginatedList.vue";
|
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||||
|
const { orderBy, orderDirection, setOrder, loadList } = usePaginatedListInstance();
|
||||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||||
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
|
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
|
||||||
import InvoiceMultipleCollectionsModal from "@/components/displays/modals/InvoiceMultipleCollectionsModal.vue";
|
import InvoiceMultipleCollectionsModal from "@/components/displays/modals/InvoiceMultipleCollectionsModal.vue";
|
||||||
@@ -268,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;
|
||||||
}
|
}
|
||||||
@@ -397,6 +408,20 @@ const tableHeaders = ref([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const isTableHeaderVisible = (header) => {
|
||||||
|
return header.visible === undefined || header.visible(null) === true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const invoiceCollectionSummaryColumnSpan = computed(() => {
|
||||||
|
const invoiceControlColumns =
|
||||||
|
(props.invoiceView && props.allowSelectMultiple ? 1 : 0) + (props.invoiceView ? 1 : 0);
|
||||||
|
const leadingOrderColumns = tableHeaders.value.filter(
|
||||||
|
(header) => isTableHeaderVisible(header) && !["total_net_amount", "actions"].includes(header.name)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return invoiceControlColumns + leadingOrderColumns;
|
||||||
|
});
|
||||||
|
|
||||||
const active_dropdown_object_ids = ref([]);
|
const active_dropdown_object_ids = ref([]);
|
||||||
const toggleDropdown = (object) => {
|
const toggleDropdown = (object) => {
|
||||||
// Check if the object is already in the active dropdown list
|
// Check if the object is already in the active dropdown list
|
||||||
@@ -471,6 +496,18 @@ const invoiceCollectionQueueSuccess = invoiceQueue.invoiceCollectionQueueSuccess
|
|||||||
const invoiceCollectionQueue = invoiceQueue.invoiceCollectionQueue;
|
const invoiceCollectionQueue = invoiceQueue.invoiceCollectionQueue;
|
||||||
const collectionQueueLog = invoiceQueue.collectionQueueLog;
|
const collectionQueueLog = invoiceQueue.collectionQueueLog;
|
||||||
const isInvoiceMultipleCollectionsModalOpen = ref(false);
|
const isInvoiceMultipleCollectionsModalOpen = ref(false);
|
||||||
|
const isSuperuserInvoicesRoute = computed(() => (
|
||||||
|
route.path === "/superuser/invoices" || route.path.startsWith("/superuser/invoices/")
|
||||||
|
));
|
||||||
|
const legacyInvoiceProgressCount = computed(() => (
|
||||||
|
invoiceCollectionQueueSuccess.value.length
|
||||||
|
+ invoiceCollectionQueueFailed.value.length
|
||||||
|
+ invoiceCollectionQueueInProgress.value.length
|
||||||
|
+ invoiceCollectionQueue.value.length
|
||||||
|
));
|
||||||
|
const shouldShowLegacyInvoiceProgressBar = computed(() => {
|
||||||
|
return !isSuperuserInvoicesRoute.value && legacyInvoiceProgressCount.value > 0;
|
||||||
|
});
|
||||||
const isInvoiceQueueBusy = computed(() => {
|
const isInvoiceQueueBusy = computed(() => {
|
||||||
return invoiceCollectionQueue.value.length > 0 || invoiceCollectionQueueInProgress.value.length > 0;
|
return invoiceCollectionQueue.value.length > 0 || invoiceCollectionQueueInProgress.value.length > 0;
|
||||||
});
|
});
|
||||||
@@ -616,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],
|
||||||
() => {
|
() => {
|
||||||
@@ -666,6 +738,66 @@ const canSeePoField = () => {
|
|||||||
//return (SessionUser.hasAttribute('usePONumbers') || SessionUser.hasPermission('department'))
|
//return (SessionUser.hasAttribute('usePONumbers') || SessionUser.hasPermission('department'))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sortInvoicePeriodFlags = (flags = []) => [...flags].sort((left, right) => {
|
||||||
|
const sourceOrder = { manual: 0, automatic: 1 };
|
||||||
|
const leftSource = sourceOrder[left?.source] ?? 99;
|
||||||
|
const rightSource = sourceOrder[right?.source] ?? 99;
|
||||||
|
if (leftSource !== rightSource) {
|
||||||
|
return leftSource - rightSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(left?.created_at || left?.fingerprint || "").localeCompare(
|
||||||
|
String(right?.created_at || right?.fingerprint || "")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const isActiveInvoicePeriodFlag = (flag) => String(flag?.status || "active") === "active";
|
||||||
|
const getOrderLineFlags = (order) => sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
|
||||||
|
if (!isActiveInvoicePeriodFlag(flag)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetType = String(flag?.target_type || "");
|
||||||
|
if (!["order", "order_field"].includes(targetType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagOrderId = Number(flag?.order_id || flag?.target_id || 0);
|
||||||
|
return flagOrderId > 0 && flagOrderId === Number(order?.id || 0);
|
||||||
|
}));
|
||||||
|
const getOrderItemLineFlags = (order) => sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
|
||||||
|
if (!isActiveInvoicePeriodFlag(flag)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetType = String(flag?.target_type || "");
|
||||||
|
if (!["order_item", "order_item_field"].includes(targetType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(flag?.order_id || flag?.context?.order_id || 0) === Number(order?.id || 0);
|
||||||
|
}));
|
||||||
|
const getOrderContainedFlags = (order) => sortInvoicePeriodFlags([
|
||||||
|
...getOrderLineFlags(order),
|
||||||
|
...getOrderItemLineFlags(order),
|
||||||
|
]);
|
||||||
|
const getOrderIndicatorIconClass = (order) => {
|
||||||
|
if (getOrderContainedFlags(order).length > 0) {
|
||||||
|
return "fas fa-flag";
|
||||||
|
}
|
||||||
|
|
||||||
|
return isPendingHandheld(order) ? "fas fa-mobile-alt" : "fas fa-circle";
|
||||||
|
};
|
||||||
|
const getOrderIndicatorColorClass = (order) => {
|
||||||
|
const flags = getOrderContainedFlags(order);
|
||||||
|
if (flags.length === 0) {
|
||||||
|
return getOrderInvoiceStatusBarColor(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning";
|
||||||
|
};
|
||||||
|
const emitFlagStatusChanged = (flag) => emit("flagStatusChanged", flag);
|
||||||
|
const emitFlagCreated = (flag) => emit("flagCreated", flag);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Order actions modal
|
* Order actions modal
|
||||||
*/
|
*/
|
||||||
@@ -764,7 +896,7 @@ const formatCashierName = (order) => {
|
|||||||
@click="invoiceSelectedCollections()"
|
@click="invoiceSelectedCollections()"
|
||||||
:disabled="selectedInvoiceCollections.length === 0 || isInvoiceQueueBusy"
|
:disabled="selectedInvoiceCollections.length === 0 || isInvoiceQueueBusy"
|
||||||
>
|
>
|
||||||
{{ $t("global.invoice") }} {{ $t("global.selected_multiple") }} ({{
|
{{ $t("common.invoice") }} {{ $t("global.selected_multiple") }} ({{
|
||||||
selectedInvoiceCollections.length
|
selectedInvoiceCollections.length
|
||||||
}})
|
}})
|
||||||
</button>
|
</button>
|
||||||
@@ -777,8 +909,8 @@ const formatCashierName = (order) => {
|
|||||||
'is-dark': isInvoiceCollectionSelectedAll(),
|
'is-dark': isInvoiceCollectionSelectedAll(),
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ isInvoiceCollectionSelectedAll() ? $t("global.unselect") : $t("global.select") }}
|
{{ isInvoiceCollectionSelectedAll() ? $t("global.unselect") : $t("common.select") }}
|
||||||
{{ $t("global.all").toLowerCase() }}
|
{{ $t("common.all").toLowerCase() }}
|
||||||
</button>
|
</button>
|
||||||
<!-- Expand / Collapse all invoice collections -->
|
<!-- Expand / Collapse all invoice collections -->
|
||||||
<button
|
<button
|
||||||
@@ -790,7 +922,7 @@ const formatCashierName = (order) => {
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ isAutoExpandAll() ? $t("global.collapse") : $t("global.expand") }}
|
{{ isAutoExpandAll() ? $t("global.collapse") : $t("global.expand") }}
|
||||||
{{ $t("global.all").toLowerCase() }}
|
{{ $t("common.all").toLowerCase() }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -847,8 +979,11 @@ const formatCashierName = (order) => {
|
|||||||
isFirstOrderInCollection(order.invoice_collection_id, order)
|
isFirstOrderInCollection(order.invoice_collection_id, order)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<tr>
|
<tr :data-testid="`pos-order-invoice-collection-row-${order.invoice_collection_id}`">
|
||||||
<td colspan="13">
|
<td
|
||||||
|
:colspan="invoiceCollectionSummaryColumnSpan"
|
||||||
|
:data-testid="`pos-order-invoice-collection-summary-${order.invoice_collection_id}`"
|
||||||
|
>
|
||||||
<div class="has-text-weight-bold">
|
<div class="has-text-weight-bold">
|
||||||
Faktura samling ID: {{ order.invoice_collection_id }}
|
Faktura samling ID: {{ order.invoice_collection_id }}
|
||||||
<span class="has-text-grey"
|
<span class="has-text-grey"
|
||||||
@@ -860,8 +995,10 @@ const formatCashierName = (order) => {
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<!-- Spacer -->
|
<td
|
||||||
<td colspan="2">
|
class="has-text-right"
|
||||||
|
:data-testid="`pos-order-invoice-collection-total-${order.invoice_collection_id}`"
|
||||||
|
>
|
||||||
<!-- Total net amount for the invoice collection -->
|
<!-- Total net amount for the invoice collection -->
|
||||||
<span class="has-text-weight-bold">
|
<span class="has-text-weight-bold">
|
||||||
{{
|
{{
|
||||||
@@ -901,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>
|
||||||
@@ -927,7 +1071,11 @@ const formatCashierName = (order) => {
|
|||||||
</template>
|
</template>
|
||||||
<template v-if="props.invoiceView">
|
<template v-if="props.invoiceView">
|
||||||
<td class="is-narrow">
|
<td class="is-narrow">
|
||||||
<button class="button is-small" @click="toggleDropdown(order)">
|
<button
|
||||||
|
class="button is-small"
|
||||||
|
:data-testid="`pos-order-expand-${order.id}`"
|
||||||
|
@click="toggleDropdown(order)"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
class="icon"
|
class="icon"
|
||||||
:class="isDropdownActive(order) ? 'has-text-link' : ''"
|
:class="isDropdownActive(order) ? 'has-text-link' : ''"
|
||||||
@@ -941,8 +1089,8 @@ const formatCashierName = (order) => {
|
|||||||
</template>
|
</template>
|
||||||
<td>
|
<td>
|
||||||
<ColorIndicator
|
<ColorIndicator
|
||||||
v-bind:color_class="getOrderInvoiceStatusBarColor(order)"
|
v-bind:color_class="getOrderIndicatorColorClass(order)"
|
||||||
v-bind:icon_class="isPendingHandheld(order) ? 'fas fa-mobile-alt' : 'fas fa-circle'"
|
v-bind:icon_class="getOrderIndicatorIconClass(order)"
|
||||||
v-bind:is_narrow="true"
|
v-bind:is_narrow="true"
|
||||||
v-bind:label="{
|
v-bind:label="{
|
||||||
text: order.id,
|
text: order.id,
|
||||||
@@ -1158,9 +1306,15 @@ 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"
|
||||||
:data-testid="`pos-order-list-settings-${order.id}`"
|
:data-testid="`pos-order-list-settings-${order.id}`"
|
||||||
>
|
>
|
||||||
<template v-slot:actions>
|
<template v-slot:actions>
|
||||||
@@ -1185,13 +1339,29 @@ const formatCashierName = (order) => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr
|
||||||
|
v-if="getOrderLineFlags(order).length > 0"
|
||||||
|
:data-testid="`pos-order-flags-row-${order.id}`"
|
||||||
|
>
|
||||||
|
<td colspan="100%">
|
||||||
|
<InvoicingPeriodFlagList
|
||||||
|
compact
|
||||||
|
:flags="getOrderLineFlags(order)"
|
||||||
|
@statusChanged="emitFlagStatusChanged"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<!-- Dropdown content (if the invoice view is enabled,and the dropdown is active) -->
|
<!-- Dropdown content (if the invoice view is enabled,and the dropdown is active) -->
|
||||||
<tr v-if="props.invoiceView && isDropdownActive(order)">
|
<tr v-if="props.invoiceView && isDropdownActive(order)">
|
||||||
<!-- Order line items -->
|
<!-- Order line items -->
|
||||||
<td colspan="100%">
|
<td colspan="100%">
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<OrderContentTable :order-id="order.id" />
|
<OrderContentTable
|
||||||
|
:order-id="order.id"
|
||||||
|
:invoice-period-flags="getOrderItemLineFlags(order)"
|
||||||
|
@flagStatusChanged="emitFlagStatusChanged"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1347,6 +1517,11 @@ const formatCashierName = (order) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<InvoicingPeriodFlagList
|
||||||
|
compact
|
||||||
|
:flags="getOrderLineFlags(order)"
|
||||||
|
@statusChanged="emitFlagStatusChanged"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div>
|
<div>
|
||||||
@@ -1377,9 +1552,15 @@ 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"
|
||||||
:displayActionsDirectly="true"
|
:displayActionsDirectly="true"
|
||||||
>
|
>
|
||||||
<template #actions></template>
|
<template #actions></template>
|
||||||
@@ -1392,7 +1573,11 @@ const formatCashierName = (order) => {
|
|||||||
</div>
|
</div>
|
||||||
<!-- Order line items -->
|
<!-- Order line items -->
|
||||||
<div class="content" v-if="props.invoiceView && isAutoExpandAll()">
|
<div class="content" v-if="props.invoiceView && isAutoExpandAll()">
|
||||||
<OrderContentTable :order-id="order.id" />
|
<OrderContentTable
|
||||||
|
:order-id="order.id"
|
||||||
|
:invoice-period-flags="getOrderItemLineFlags(order)"
|
||||||
|
@flagStatusChanged="emitFlagStatusChanged"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1457,7 +1642,47 @@ const formatCashierName = (order) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Orders as cards -->
|
<!-- Orders as cards -->
|
||||||
<template v-for="order in getVisibleOrders" :key="order.id">
|
<template
|
||||||
|
v-for="order in props.groupInvoiceCollection ? getVisibleOrdersSortedByInvoiceCollection : getVisibleOrders"
|
||||||
|
:key="order.id"
|
||||||
|
>
|
||||||
|
<!-- If grouping by invoice collection is enabled, show the invoice collection id -->
|
||||||
|
<template
|
||||||
|
v-if="
|
||||||
|
props.groupInvoiceCollection &&
|
||||||
|
doesOrderHaveInvoiceCollection(order) &&
|
||||||
|
isFirstOrderInCollection(order.invoice_collection_id, order)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="column is-full"
|
||||||
|
:data-testid="`pos-order-invoice-collection-row-${order.invoice_collection_id}`"
|
||||||
|
>
|
||||||
|
<div class="message is-light mb-0">
|
||||||
|
<div
|
||||||
|
class="message-body is-flex is-justify-content-space-between is-align-items-center p-2"
|
||||||
|
:data-testid="`pos-order-invoice-collection-summary-${order.invoice_collection_id}`"
|
||||||
|
>
|
||||||
|
<div class="has-text-weight-bold">
|
||||||
|
Faktura samling ID: {{ order.invoice_collection_id }}
|
||||||
|
</div>
|
||||||
|
<div class="has-text-weight-bold">
|
||||||
|
<span :data-testid="`pos-order-invoice-collection-total-${order.invoice_collection_id}`"
|
||||||
|
>{{
|
||||||
|
SessionUser.functions.currency.toLocal(
|
||||||
|
getOrdersByInvoiceCollection(order.invoice_collection_id)
|
||||||
|
.reduce((sum, order) => sum + order.total_net_amount, 0)
|
||||||
|
.toFixed(2)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
kr.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="column is-full">
|
<div class="column is-full">
|
||||||
<WhiteBoxCard
|
<WhiteBoxCard
|
||||||
:forceStateFooter="true"
|
:forceStateFooter="true"
|
||||||
@@ -1468,6 +1693,7 @@ const formatCashierName = (order) => {
|
|||||||
:has-hover-effect="true"
|
:has-hover-effect="true"
|
||||||
:hasSelectionStyle="false"
|
:hasSelectionStyle="false"
|
||||||
:data-testid="`pos-order-list-card-${order.id}`"
|
:data-testid="`pos-order-list-card-${order.id}`"
|
||||||
|
:headerTestId="`pos-order-expand-${order.id}`"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div
|
<div
|
||||||
@@ -1478,9 +1704,17 @@ const formatCashierName = (order) => {
|
|||||||
<div>
|
<div>
|
||||||
<small>
|
<small>
|
||||||
<!-- ID and status icon -->
|
<!-- ID and status icon -->
|
||||||
<span class="icon mr-1" :class="getOrderInvoiceStatusBarColor(order)">
|
<ColorIndicator
|
||||||
<i :class="isPendingHandheld(order) ? 'fas fa-mobile-alt' : 'fas fa-circle'"></i>
|
class="mr-1"
|
||||||
</span>
|
style="display: inline-flex"
|
||||||
|
v-bind:color_class="getOrderIndicatorColorClass(order)"
|
||||||
|
v-bind:icon_class="getOrderIndicatorIconClass(order)"
|
||||||
|
v-bind:is_narrow="true"
|
||||||
|
v-bind:visibility="{
|
||||||
|
icon: true,
|
||||||
|
dropdown: false,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
{{ order.reg_1 }}<span v-if="order.reg_2">, {{ order.reg_2 }}</span
|
{{ order.reg_1 }}<span v-if="order.reg_2">, {{ order.reg_2 }}</span
|
||||||
><span v-if="order.reg_3">, {{ order.reg_3 }}</span>
|
><span v-if="order.reg_3">, {{ order.reg_3 }}</span>
|
||||||
<span class="has-text-grey-light">#{{ order.id }}</span></small
|
<span class="has-text-grey-light">#{{ order.id }}</span></small
|
||||||
@@ -1596,6 +1830,8 @@ const formatCashierName = (order) => {
|
|||||||
:order-id="order.id"
|
:order-id="order.id"
|
||||||
:summaryMode="true"
|
:summaryMode="true"
|
||||||
:display-price="SessionUser.canAccessAdmin()"
|
:display-price="SessionUser.canAccessAdmin()"
|
||||||
|
:invoice-period-flags="getOrderItemLineFlags(order)"
|
||||||
|
@flagStatusChanged="emitFlagStatusChanged"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-12 has-text-centered">
|
<div class="column is-12 has-text-centered">
|
||||||
@@ -1605,7 +1841,7 @@ const formatCashierName = (order) => {
|
|||||||
<br />
|
<br />
|
||||||
<!-- Cashier • Time ago -->
|
<!-- Cashier • Time ago -->
|
||||||
<small class="has-text-grey"
|
<small class="has-text-grey"
|
||||||
>{{ order.cashier_name }} • {{ SessionUser.functions.date.timeAgo(order.created_at) }}</small
|
>{{ order.cashier_name }} • {{ SessionUser.functions.date.toLocalTimeHours(order.created_at) }}</small
|
||||||
>
|
>
|
||||||
<!-- Reg 1 • Reg 2 • Reg 3 -->
|
<!-- Reg 1 • Reg 2 • Reg 3 -->
|
||||||
<br />
|
<br />
|
||||||
@@ -1703,6 +1939,11 @@ const formatCashierName = (order) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<InvoicingPeriodFlagList
|
||||||
|
compact
|
||||||
|
:flags="getOrderLineFlags(order)"
|
||||||
|
@statusChanged="emitFlagStatusChanged"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #content> </template>
|
<template #content> </template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -1765,6 +2006,7 @@ const formatCashierName = (order) => {
|
|||||||
/>
|
/>
|
||||||
<!-- bottom progress bar -->
|
<!-- bottom progress bar -->
|
||||||
<InvoicingBillingPeriodInvoiceProgressBar
|
<InvoicingBillingPeriodInvoiceProgressBar
|
||||||
|
v-if="shouldShowLegacyInvoiceProgressBar"
|
||||||
v-bind:max-progress="
|
v-bind:max-progress="
|
||||||
invoiceCollectionQueueSuccess.length +
|
invoiceCollectionQueueSuccess.length +
|
||||||
invoiceCollectionQueueFailed.length +
|
invoiceCollectionQueueFailed.length +
|
||||||
@@ -1806,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"
|
||||||
@@ -1813,6 +2056,7 @@ const formatCashierName = (order) => {
|
|||||||
v-bind:department_id="selectedOrderForActionsMenu.department_id"
|
v-bind:department_id="selectedOrderForActionsMenu.department_id"
|
||||||
:refreshFunction="loadList"
|
:refreshFunction="loadList"
|
||||||
@deleted="loadList()"
|
@deleted="loadList()"
|
||||||
|
@flag-created="emitFlagCreated"
|
||||||
:displayActionsDirectly="true"
|
:displayActionsDirectly="true"
|
||||||
>
|
>
|
||||||
<template #actions></template>
|
<template #actions></template>
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ import {
|
|||||||
getCurrentStep,
|
getCurrentStep,
|
||||||
clearCache,
|
clearCache,
|
||||||
nextStep,
|
nextStep,
|
||||||
|
copyLastWashItemsToCurrentOrder,
|
||||||
|
order_id,
|
||||||
|
customer_id,
|
||||||
|
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";
|
||||||
@@ -24,6 +29,7 @@ import SelectVehicleFormPOS from "@/components/forms/department/pos/SelectVehicl
|
|||||||
import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/PosLastScannedLicensePlatesV2.vue";
|
import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/PosLastScannedLicensePlatesV2.vue";
|
||||||
import PosDesktopOrderBookingSelectorModal from "@/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue";
|
import PosDesktopOrderBookingSelectorModal from "@/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue";
|
||||||
import PosDesktopCustomerConflictModal from "@/components/displays/department/pos/steps/elements/PosDesktopCustomerConflictModal.vue";
|
import PosDesktopCustomerConflictModal from "@/components/displays/department/pos/steps/elements/PosDesktopCustomerConflictModal.vue";
|
||||||
|
import PosDesktopDuplicateWarning from "@/components/displays/department/pos/steps/elements/PosDesktopDuplicateWarning.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
|
import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
|
||||||
import {
|
import {
|
||||||
@@ -58,6 +64,8 @@ const right_tabs = ref([
|
|||||||
|
|
||||||
const getPreferredRailTab = () => (customer_name.value ? "customer" : "license_plates");
|
const getPreferredRailTab = () => (customer_name.value ? "customer" : "license_plates");
|
||||||
const forceActiveTab = ref(getPreferredRailTab());
|
const forceActiveTab = ref(getPreferredRailTab());
|
||||||
|
const activeRailTab = ref(getPreferredRailTab());
|
||||||
|
const customerPanelActiveTab = ref("details");
|
||||||
const selectVehicleFormRef = ref(null);
|
const selectVehicleFormRef = ref(null);
|
||||||
|
|
||||||
const createEmptyDesktopStep1Context = () => ({
|
const createEmptyDesktopStep1Context = () => ({
|
||||||
@@ -83,23 +91,44 @@ const duplicateOrders = ref([]);
|
|||||||
const duplicateWarningKey = ref("");
|
const duplicateWarningKey = ref("");
|
||||||
const duplicateDetailsExpanded = ref(false);
|
const duplicateDetailsExpanded = ref(false);
|
||||||
const pendingNextResolution = ref(false);
|
const pendingNextResolution = 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) => {
|
||||||
forceActiveTab.value = tab;
|
forceActiveTab.value = tab;
|
||||||
|
activeRailTab.value = tab;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCustomerPanelTab = (tab) => {
|
||||||
|
customerPanelActiveTab.value = tab || "details";
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
customer_name,
|
customer_name,
|
||||||
(newValue) => {
|
(newValue) => {
|
||||||
forceActiveTab.value = newValue ? "customer" : "license_plates";
|
const nextRailTab = newValue ? "customer" : "license_plates";
|
||||||
|
forceActiveTab.value = nextRailTab;
|
||||||
|
activeRailTab.value = nextRailTab;
|
||||||
|
if (!newValue) {
|
||||||
|
customerPanelActiveTab.value = "details";
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
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();
|
||||||
@@ -249,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("admin.pos.order_booking_selector.reference_label")}: ${
|
`${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("admin.pos.order_booking_selector.services_label")}: ${
|
|
||||||
getOrderBookingServiceText(booking) || t("admin.pos.not_found")
|
|
||||||
}`,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -266,7 +291,7 @@ const bookingSelectionObjects = computed(() => {
|
|||||||
content: contentSegments.join(" • "),
|
content: contentSegments.join(" • "),
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
label: t("admin.pos.order_booking_selector.use_booking"),
|
label: t("common.select"),
|
||||||
action: () => handleBookingSelection(booking),
|
action: () => handleBookingSelection(booking),
|
||||||
color: "primary",
|
color: "primary",
|
||||||
testId: `pos-desktop-order-booking-use-${booking.id}`,
|
testId: `pos-desktop-order-booking-use-${booking.id}`,
|
||||||
@@ -307,6 +332,18 @@ const isInlineDuplicateWarningVisible = computed(() => {
|
|||||||
return duplicateWarningKey.value === getDuplicateStateKey(normalizedContext);
|
return duplicateWarningKey.value === getDuplicateStateKey(normalizedContext);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isCustomerRulesPanelActive = computed(
|
||||||
|
() => activeRailTab.value === "customer" && customerPanelActiveTab.value === "rules"
|
||||||
|
);
|
||||||
|
const shouldShowDuplicateWarningInRulesFooter = computed(
|
||||||
|
() => isInlineDuplicateWarningVisible.value && isCustomerRulesPanelActive.value
|
||||||
|
);
|
||||||
|
const shouldShowDuplicateWarningInActionRail = computed(
|
||||||
|
() => isInlineDuplicateWarningVisible.value && !isCustomerRulesPanelActive.value
|
||||||
|
);
|
||||||
|
const shouldShowRulesFooterActions = computed(() => isCustomerRulesPanelActive.value);
|
||||||
|
const shouldShowActionRailControls = computed(() => !isCustomerRulesPanelActive.value);
|
||||||
|
|
||||||
const duplicateDetailsToggleLabel = computed(() =>
|
const duplicateDetailsToggleLabel = computed(() =>
|
||||||
duplicateDetailsExpanded.value ? t("admin.pos.hide_order_details") : t("admin.pos.show_order_details")
|
duplicateDetailsExpanded.value ? t("admin.pos.hide_order_details") : t("admin.pos.show_order_details")
|
||||||
);
|
);
|
||||||
@@ -604,11 +641,57 @@ const handleCustomerConflictCancel = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pushDesktopStepTwoRoute = () => {
|
||||||
|
if (!order_id.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDesktopLastWashCopy = async (payload = {}) => {
|
||||||
|
if (isDesktopLastWashCopying.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDesktopLastWashCopying.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await desktopStep1CoordinationPromise;
|
||||||
|
desktopStep1CoordinationPromise = coordinateDesktopStep1({
|
||||||
|
reason: "copy_last_wash",
|
||||||
|
finalizeReg1Input: true,
|
||||||
|
});
|
||||||
|
const result = await desktopStep1CoordinationPromise;
|
||||||
|
if (!result.canProceed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const didCopy = await copyLastWashItemsToCurrentOrder(payload?.items || [], {
|
||||||
|
sourceReference: payload?.order?.reference,
|
||||||
|
});
|
||||||
|
if (!didCopy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
step.value = 2;
|
||||||
|
pushDesktopStepTwoRoute();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Unable to copy last wash to current order:", error);
|
||||||
|
} finally {
|
||||||
|
isDesktopLastWashCopying.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
|
setDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
if (focusOnReg1TimeoutId !== null) {
|
||||||
|
clearTimeout(focusOnReg1TimeoutId);
|
||||||
|
focusOnReg1TimeoutId = null;
|
||||||
|
}
|
||||||
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
|
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -631,8 +714,10 @@ watch(
|
|||||||
<template #default>
|
<template #default>
|
||||||
<SelectVehicleFormPOS
|
<SelectVehicleFormPOS
|
||||||
ref="selectVehicleFormRef"
|
ref="selectVehicleFormRef"
|
||||||
|
:is-desktop-last-wash-copying="isDesktopLastWashCopying"
|
||||||
@update:desktopStep1Context="setDesktopStep1Context"
|
@update:desktopStep1Context="setDesktopStep1Context"
|
||||||
@commit:desktopStep1="handleDesktopStep1Commit"
|
@commit:desktopStep1="handleDesktopStep1Commit"
|
||||||
|
@copy-last-wash="handleDesktopLastWashCopy"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</WhiteBox>
|
</WhiteBox>
|
||||||
@@ -649,7 +734,23 @@ watch(
|
|||||||
@update:activeTab="setTab"
|
@update:activeTab="setTab"
|
||||||
>
|
>
|
||||||
<template #customer>
|
<template #customer>
|
||||||
<PosSelectedCustomer variant="sidebar" />
|
<PosSelectedCustomer variant="sidebar" @update:activeTab="setCustomerPanelTab">
|
||||||
|
<template v-if="shouldShowRulesFooterActions" #rules-footer>
|
||||||
|
<PosDesktopDuplicateWarning
|
||||||
|
v-if="shouldShowDuplicateWarningInRulesFooter"
|
||||||
|
:title="t('admin.pos.warning')"
|
||||||
|
:message="t('admin.pos.duplicate_order_warning')"
|
||||||
|
:toggle-label="duplicateDetailsToggleLabel"
|
||||||
|
:details-expanded="duplicateDetailsExpanded"
|
||||||
|
:duplicate-orders="duplicateDetailsObjects"
|
||||||
|
@toggle-details="toggleDuplicateDetails"
|
||||||
|
/>
|
||||||
|
<NextStepError class="is-fullwidth" :clear-automatically="true" :clear-delay="8000" />
|
||||||
|
<ButtonsBox class="pos-actions pos-actions--stacked">
|
||||||
|
<NextStep class="is-fullwidth" tabindex="6" />
|
||||||
|
</ButtonsBox>
|
||||||
|
</template>
|
||||||
|
</PosSelectedCustomer>
|
||||||
</template>
|
</template>
|
||||||
<template #license_plates>
|
<template #license_plates>
|
||||||
<PosLastScannedLicensePlatesV2 />
|
<PosLastScannedLicensePlatesV2 />
|
||||||
@@ -660,64 +761,18 @@ 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 class="pos-shell-actions__rail">
|
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail">
|
||||||
<div
|
<PosDesktopDuplicateWarning
|
||||||
v-if="isInlineDuplicateWarningVisible"
|
v-if="shouldShowDuplicateWarningInActionRail"
|
||||||
class="notification is-warning is-light pos-duplicate-warning"
|
:title="t('admin.pos.warning')"
|
||||||
data-testid="pos-desktop-duplicate-warning-inline"
|
:message="t('admin.pos.duplicate_order_warning')"
|
||||||
>
|
:toggle-label="duplicateDetailsToggleLabel"
|
||||||
<div class="pos-duplicate-warning__header">
|
:details-expanded="duplicateDetailsExpanded"
|
||||||
<div class="pos-duplicate-warning__header-copy">
|
:duplicate-orders="duplicateDetailsObjects"
|
||||||
<span class="icon pos-duplicate-warning__icon" aria-hidden="true">
|
@toggle-details="toggleDuplicateDetails"
|
||||||
<i class="fas fa-triangle-exclamation"></i>
|
/>
|
||||||
</span>
|
|
||||||
<p class="pos-duplicate-warning__title">{{ t("admin.pos.warning") }}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="button is-small is-warning is-light"
|
|
||||||
data-testid="pos-desktop-duplicate-warning-toggle-details"
|
|
||||||
@click="toggleDuplicateDetails"
|
|
||||||
>
|
|
||||||
{{ duplicateDetailsToggleLabel }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="pos-duplicate-warning__message">
|
|
||||||
{{ t("admin.pos.duplicate_order_warning") }}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
v-if="duplicateDetailsExpanded"
|
|
||||||
class="pos-duplicate-warning__details"
|
|
||||||
data-testid="pos-desktop-duplicate-warning-details-inline"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="duplicateOrder in duplicateDetailsObjects"
|
|
||||||
:key="duplicateOrder.id"
|
|
||||||
class="pos-duplicate-warning__detail-card"
|
|
||||||
:data-testid="`pos-desktop-duplicate-warning-detail-${duplicateOrder.id}`"
|
|
||||||
>
|
|
||||||
<div class="pos-duplicate-warning__detail-copy">
|
|
||||||
<p class="pos-duplicate-warning__detail-title">{{ duplicateOrder.label }}</p>
|
|
||||||
<p class="pos-duplicate-warning__detail-content">{{ duplicateOrder.content }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="buttons pos-duplicate-warning__detail-actions">
|
|
||||||
<button
|
|
||||||
v-for="button in duplicateOrder.buttons"
|
|
||||||
:key="button.label"
|
|
||||||
type="button"
|
|
||||||
class="button is-small"
|
|
||||||
:class="`is-${button.color || 'dark'}`"
|
|
||||||
:data-testid="button.testId"
|
|
||||||
@click="button.action()"
|
|
||||||
>
|
|
||||||
{{ button.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<NextStepError class="is-fullwidth" :clear-automatically="true" :clear-delay="8000" />
|
<NextStepError class="is-fullwidth" :clear-automatically="true" :clear-delay="8000" />
|
||||||
<ButtonsBox class="pos-actions pos-actions--stacked">
|
<ButtonsBox class="pos-actions pos-actions--stacked">
|
||||||
<NextStep class="is-fullwidth" tabindex="6" />
|
<NextStep class="is-fullwidth" tabindex="6" />
|
||||||
|
|||||||
+1
-1
@@ -126,7 +126,7 @@ const closeModal = () => {
|
|||||||
data-testid="pos-desktop-customer-conflict-cancel"
|
data-testid="pos-desktop-customer-conflict-cancel"
|
||||||
@click="emit('cancel')"
|
@click="emit('cancel')"
|
||||||
>
|
>
|
||||||
{{ t("admin.pos.customer_conflict.cancel") }}
|
{{ t("common.cancel") }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="button is-primary is-light"
|
class="button is-primary is-light"
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
toggleLabel: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
detailsExpanded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
duplicateOrders: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["toggle-details"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="notification is-warning is-light pos-duplicate-warning"
|
||||||
|
data-testid="pos-desktop-duplicate-warning-inline"
|
||||||
|
>
|
||||||
|
<div class="pos-duplicate-warning__header">
|
||||||
|
<div class="pos-duplicate-warning__header-copy">
|
||||||
|
<span class="icon pos-duplicate-warning__icon" aria-hidden="true">
|
||||||
|
<i class="fas fa-triangle-exclamation"></i>
|
||||||
|
</span>
|
||||||
|
<p class="pos-duplicate-warning__title">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button is-small is-warning is-light"
|
||||||
|
data-testid="pos-desktop-duplicate-warning-toggle-details"
|
||||||
|
@click="emit('toggle-details')"
|
||||||
|
>
|
||||||
|
{{ toggleLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="pos-duplicate-warning__message">
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="detailsExpanded"
|
||||||
|
class="pos-duplicate-warning__details"
|
||||||
|
data-testid="pos-desktop-duplicate-warning-details-inline"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="duplicateOrder in duplicateOrders"
|
||||||
|
:key="duplicateOrder.id"
|
||||||
|
class="pos-duplicate-warning__detail-card"
|
||||||
|
:data-testid="`pos-desktop-duplicate-warning-detail-${duplicateOrder.id}`"
|
||||||
|
>
|
||||||
|
<div class="pos-duplicate-warning__detail-copy">
|
||||||
|
<p class="pos-duplicate-warning__detail-title">{{ duplicateOrder.label }}</p>
|
||||||
|
<p class="pos-duplicate-warning__detail-content">{{ duplicateOrder.content }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="buttons pos-duplicate-warning__detail-actions">
|
||||||
|
<button
|
||||||
|
v-for="button in duplicateOrder.buttons"
|
||||||
|
:key="button.label"
|
||||||
|
type="button"
|
||||||
|
class="button is-small"
|
||||||
|
:class="`is-${button.color || 'dark'}`"
|
||||||
|
:data-testid="button.testId"
|
||||||
|
@click="button.action()"
|
||||||
|
>
|
||||||
|
{{ button.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -8,8 +8,13 @@ const props = defineProps({
|
|||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
copying: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["copy"]);
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
|
|
||||||
const order = ref(null);
|
const order = ref(null);
|
||||||
@@ -78,12 +83,52 @@ const groupedOrderItems = computed(() => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getOrderItemProductId = (item) => parsePositiveInteger(item?.product?.id ?? item?.product_id);
|
||||||
|
const getOrderItemQuantity = (item) => {
|
||||||
|
const parsedQuantity = Number.parseInt(String(item?.quantity ?? 1), 10);
|
||||||
|
return Number.isInteger(parsedQuantity) && parsedQuantity > 0 ? parsedQuantity : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUsableOrderItems = computed(() => {
|
||||||
|
return groupedOrderItems.value.some((group) => getOrderItemProductId(group.item) && getOrderItemQuantity(group.item));
|
||||||
|
});
|
||||||
|
|
||||||
|
const isCopyDisabled = computed(() => loadState.value !== "loaded" || props.copying || !hasUsableOrderItems.value);
|
||||||
|
const copyButtonLabel = computed(() => {
|
||||||
|
const lastWashLabel = SessionUser.objects.global.language.last_wash || t("common.last_wash");
|
||||||
|
return `${SessionUser.objects.global.language.copy || t("common.copy")} ${lastWashLabel.toLowerCase()}`;
|
||||||
|
});
|
||||||
|
const copyButtonText = computed(() => (props.copying ? t("pos.copying_last_wash") : copyButtonLabel.value));
|
||||||
|
const copyButtonState = computed(() => {
|
||||||
|
if (props.copying) {
|
||||||
|
return "copying";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCopyDisabled.value) {
|
||||||
|
return "disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "ready";
|
||||||
|
});
|
||||||
|
|
||||||
|
const onCopy = () => {
|
||||||
|
if (isCopyDisabled.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("copy", {
|
||||||
|
order: order.value,
|
||||||
|
orderId: normalizedOrderId.value,
|
||||||
|
items: orderItems.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const lastWashMetadata = computed(() => {
|
const lastWashMetadata = computed(() => {
|
||||||
const currentOrder = order.value || {};
|
const currentOrder = order.value || {};
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "created",
|
key: "created",
|
||||||
label: t("pos.order.created"),
|
label: t("common.created"),
|
||||||
value: formatDateTime(currentOrder.created_at),
|
value: formatDateTime(currentOrder.created_at),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -145,7 +190,7 @@ watch(normalizedOrderId, (nextOrderId, previousOrderId) => {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<section class="pos-step-one-insight-card__section">
|
<section class="pos-step-one-insight-card__section" data-testid="pos-desktop-last-wash-section">
|
||||||
<div class="pos-step-one-insight-card__section-header">
|
<div class="pos-step-one-insight-card__section-header">
|
||||||
<h4 class="pos-step-one-insight-card__section-title">Indhold</h4>
|
<h4 class="pos-step-one-insight-card__section-title">Indhold</h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,12 +226,15 @@ watch(normalizedOrderId, (nextOrderId, previousOrderId) => {
|
|||||||
type="button"
|
type="button"
|
||||||
class="button is-small is-light pos-step-one-copy-action"
|
class="button is-small is-light pos-step-one-copy-action"
|
||||||
data-testid="pos-desktop-last-wash-copy"
|
data-testid="pos-desktop-last-wash-copy"
|
||||||
disabled
|
:data-state="copyButtonState"
|
||||||
|
:disabled="isCopyDisabled"
|
||||||
|
:aria-busy="props.copying ? 'true' : 'false'"
|
||||||
|
@click="onCopy"
|
||||||
>
|
>
|
||||||
<span class="icon is-small">
|
<span class="icon is-small">
|
||||||
<i class="fas fa-copy" aria-hidden="true"></i>
|
<i :class="props.copying ? 'fas fa-spinner fa-spin' : 'fas fa-copy'" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ `${SessionUser.objects.global.language.copy} ${SessionUser.objects.global.language.last_wash.toLowerCase()}` }}</span>
|
<span>{{ copyButtonText }}</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+7
-6
@@ -394,7 +394,7 @@ const closeModal = () => {
|
|||||||
class="tag pos-desktop-order-booking-card__today-tag"
|
class="tag pos-desktop-order-booking-card__today-tag"
|
||||||
:data-testid="`pos-desktop-order-booking-today-${booking.id}`"
|
:data-testid="`pos-desktop-order-booking-today-${booking.id}`"
|
||||||
>
|
>
|
||||||
{{ t("global.time.today") }}
|
{{ t("common.today") }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="didBookingDetailFail(booking)" class="tag is-light pos-desktop-order-booking-card__tag">
|
<span v-if="didBookingDetailFail(booking)" class="tag is-light pos-desktop-order-booking-card__tag">
|
||||||
{{ t("admin.pos.not_found") }}
|
{{ t("admin.pos.not_found") }}
|
||||||
@@ -413,6 +413,7 @@ const closeModal = () => {
|
|||||||
:refreshFunction="emitRefreshBookings"
|
:refreshFunction="emitRefreshBookings"
|
||||||
:icon="'fas fa-ellipsis-v'"
|
:icon="'fas fa-ellipsis-v'"
|
||||||
trigger-button-variant="text"
|
trigger-button-variant="text"
|
||||||
|
allow-booking-completion
|
||||||
>
|
>
|
||||||
<template #actions />
|
<template #actions />
|
||||||
</ActionSettingsWheelButton>
|
</ActionSettingsWheelButton>
|
||||||
@@ -430,7 +431,7 @@ const closeModal = () => {
|
|||||||
<dd>{{ getBookingPlates(booking) || t("admin.pos.not_found") }}</dd>
|
<dd>{{ getBookingPlates(booking) || t("admin.pos.not_found") }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="pos-desktop-order-booking-card__meta-item">
|
<div class="pos-desktop-order-booking-card__meta-item">
|
||||||
<dt>{{ t("admin.pos.order_booking_selector.reference_label") }}</dt>
|
<dt>{{ t("common.reference") }}</dt>
|
||||||
<dd>{{ getBookingReference(booking) || "—" }}</dd>
|
<dd>{{ getBookingReference(booking) || "—" }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="getBookingPo(booking)" class="pos-desktop-order-booking-card__meta-item">
|
<div v-if="getBookingPo(booking)" class="pos-desktop-order-booking-card__meta-item">
|
||||||
@@ -454,7 +455,7 @@ const closeModal = () => {
|
|||||||
>
|
>
|
||||||
<div class="pos-desktop-order-booking-card__section-header">
|
<div class="pos-desktop-order-booking-card__section-header">
|
||||||
<p class="pos-desktop-order-booking-card__section-title">
|
<p class="pos-desktop-order-booking-card__section-title">
|
||||||
{{ t("admin.pos.order_booking_selector.services_label") }}
|
{{ t("common.services") }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
|
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
|
||||||
@@ -475,8 +476,8 @@ const closeModal = () => {
|
|||||||
class="pos-desktop-order-booking-card__item-preview"
|
class="pos-desktop-order-booking-card__item-preview"
|
||||||
>
|
>
|
||||||
<div class="pos-desktop-order-booking-card__item-preview-head">
|
<div class="pos-desktop-order-booking-card__item-preview-head">
|
||||||
<span>{{ t("global.quantity") }}</span>
|
<span>{{ t("common.quantity") }}</span>
|
||||||
<span>{{ t("admin.pos.order_booking_selector.services_label") }}</span>
|
<span>{{ t("common.services") }}</span>
|
||||||
<span>{{ t("admin.pos.price_dkk") }}</span>
|
<span>{{ t("admin.pos.price_dkk") }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -528,7 +529,7 @@ const closeModal = () => {
|
|||||||
:data-testid="`pos-desktop-order-booking-use-${booking.id}`"
|
:data-testid="`pos-desktop-order-booking-use-${booking.id}`"
|
||||||
@click="onSelect(booking)"
|
@click="onSelect(booking)"
|
||||||
>
|
>
|
||||||
{{ t("admin.pos.order_booking_selector.use_booking") }}
|
{{ t("common.select") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
+57
-5
@@ -2,12 +2,21 @@
|
|||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
|
import {
|
||||||
|
getOrderBookingItemPreviewRows,
|
||||||
|
getOrderBookingReferenceValue,
|
||||||
|
getOrderBookingServiceLabels,
|
||||||
|
} from "@/components/displays/department/pos/utils/orderBookingDisplay.js";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
vehicle: {
|
vehicle: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
booking: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -19,6 +28,8 @@ const parsePositiveInteger = (value) => {
|
|||||||
|
|
||||||
const normalizeStatus = (value) => String(value ?? "").trim().toLowerCase();
|
const normalizeStatus = (value) => String(value ?? "").trim().toLowerCase();
|
||||||
|
|
||||||
|
const selectedBooking = computed(() => (props.booking?.id ? props.booking : null));
|
||||||
|
|
||||||
const getVehicleStatusLabel = (vehicle) => {
|
const getVehicleStatusLabel = (vehicle) => {
|
||||||
if (!vehicle) {
|
if (!vehicle) {
|
||||||
return SessionUser.objects.global.language.no_data;
|
return SessionUser.objects.global.language.no_data;
|
||||||
@@ -78,6 +89,11 @@ const getVehicleStatusTone = (vehicle) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const vehicleTypeName = computed(() => {
|
const vehicleTypeName = computed(() => {
|
||||||
|
const bookingPrimaryService = bookingSummaryRows.value.find((row) => !row.isAddon)?.label || bookingSummaryRows.value[0]?.label;
|
||||||
|
if (bookingPrimaryService) {
|
||||||
|
return bookingPrimaryService;
|
||||||
|
}
|
||||||
|
|
||||||
const vehicleTypeId = parsePositiveInteger(props.vehicle?.type);
|
const vehicleTypeId = parsePositiveInteger(props.vehicle?.type);
|
||||||
if (!vehicleTypeId) {
|
if (!vehicleTypeId) {
|
||||||
return SessionUser.objects.global.language.no_data;
|
return SessionUser.objects.global.language.no_data;
|
||||||
@@ -105,17 +121,24 @@ const vehicleMetadata = computed(() => [
|
|||||||
{
|
{
|
||||||
key: "reg",
|
key: "reg",
|
||||||
label: SessionUser.objects.orders.columns.reg_1.label,
|
label: SessionUser.objects.orders.columns.reg_1.label,
|
||||||
value: String(props.vehicle?.reg ?? "").trim() || SessionUser.objects.global.language.no_data,
|
value:
|
||||||
|
String(selectedBooking.value?.reg_1 ?? props.vehicle?.reg ?? "").trim() ||
|
||||||
|
SessionUser.objects.global.language.no_data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "customer",
|
key: "customer",
|
||||||
label: t("admin.pos.customer"),
|
label: t("admin.pos.customer"),
|
||||||
value: String(props.vehicle?.customer_name ?? "").trim() || SessionUser.objects.global.language.no_data,
|
value:
|
||||||
|
String(selectedBooking.value?.customer_name ?? props.vehicle?.customer_name ?? "").trim() ||
|
||||||
|
SessionUser.objects.global.language.no_data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "reference",
|
key: "reference",
|
||||||
label: SessionUser.objects.orders.columns.reference.label,
|
label: SessionUser.objects.orders.columns.reference.label,
|
||||||
value: String(props.vehicle?.reference ?? "").trim() || SessionUser.objects.global.language.no_data,
|
value:
|
||||||
|
getOrderBookingReferenceValue(selectedBooking.value) ||
|
||||||
|
String(props.vehicle?.reference ?? "").trim() ||
|
||||||
|
SessionUser.objects.global.language.no_data,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
@@ -124,7 +147,36 @@ const vehicleMetadata = computed(() => [
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const bookingSummaryRows = computed(() => {
|
||||||
|
if (!selectedBooking.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewRows = getOrderBookingItemPreviewRows(selectedBooking.value);
|
||||||
|
if (previewRows.length > 0) {
|
||||||
|
return previewRows.map((row) => ({
|
||||||
|
key: row.key,
|
||||||
|
label: row.label,
|
||||||
|
quantity: row.quantity,
|
||||||
|
isAddon: row.depth > 0,
|
||||||
|
includeInInvoice: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return getOrderBookingServiceLabels(selectedBooking.value).map((label, index) => ({
|
||||||
|
key: `booking-service-${index}`,
|
||||||
|
label,
|
||||||
|
quantity: 1,
|
||||||
|
isAddon: index > 0,
|
||||||
|
includeInInvoice: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
const vehicleSummaryRows = computed(() => {
|
const vehicleSummaryRows = computed(() => {
|
||||||
|
if (bookingSummaryRows.value.length > 0) {
|
||||||
|
return bookingSummaryRows.value;
|
||||||
|
}
|
||||||
|
|
||||||
if (!props.vehicle) {
|
if (!props.vehicle) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -166,7 +218,7 @@ const hasVehicleSummaryRows = computed(() => vehicleSummaryRows.value.length > 0
|
|||||||
<p class="pos-step-one-insight-card__eyebrow">{{ SessionUser.objects.vehicles.meta.labels.single }}</p>
|
<p class="pos-step-one-insight-card__eyebrow">{{ SessionUser.objects.vehicles.meta.labels.single }}</p>
|
||||||
<h3 class="pos-step-one-insight-card__title">{{ vehicleTypeName }}</h3>
|
<h3 class="pos-step-one-insight-card__title">{{ vehicleTypeName }}</h3>
|
||||||
<p class="pos-step-one-insight-card__subtitle">
|
<p class="pos-step-one-insight-card__subtitle">
|
||||||
{{ String(vehicle?.reg ?? "").trim() || SessionUser.objects.global.language.no_data }}
|
{{ String(selectedBooking?.reg_1 ?? vehicle?.reg ?? "").trim() || SessionUser.objects.global.language.no_data }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="pos-step-one-insight-card__pills">
|
<div class="pos-step-one-insight-card__pills">
|
||||||
@@ -196,7 +248,7 @@ const hasVehicleSummaryRows = computed(() => vehicleSummaryRows.value.length > 0
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<section class="pos-step-one-insight-card__section">
|
<section class="pos-step-one-insight-card__section" data-testid="pos-desktop-vehicle-summary-section">
|
||||||
<div class="pos-step-one-insight-card__section-header">
|
<div class="pos-step-one-insight-card__section-header">
|
||||||
<h4 class="pos-step-one-insight-card__section-title">Ydelse og tilvalg</h4>
|
<h4 class="pos-step-one-insight-card__section-title">Ydelse og tilvalg</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
+9
-2
@@ -320,16 +320,23 @@ const noop = () => {};
|
|||||||
|
|
||||||
.vehicle-card__media {
|
.vehicle-card__media {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
background: rgba(255, 255, 255, 0.66);
|
||||||
|
border: 1px solid rgba(31, 91, 183, 0.12);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 5.25rem;
|
||||||
|
height: 5.25rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
width: 5.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vehicle-card__image {
|
.vehicle-card__image {
|
||||||
height: 100%;
|
height: auto;
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
width: 100%;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vehicle-card__details {
|
.vehicle-card__details {
|
||||||
|
|||||||
+61
-6
@@ -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 -->
|
||||||
@@ -326,7 +381,7 @@ const onClickAttachWashCertificate = () => {
|
|||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="card-footer-item">
|
<div class="card-footer-item">
|
||||||
{{ t('admin.pos.created') }}: {{ new Date(attachment.created_at).toLocaleString() }}
|
{{ t('common.created') }}: {{ new Date(attachment.created_at).toLocaleString() }}
|
||||||
</div>
|
</div>
|
||||||
<a class="card-footer-item" v-if="!attachment.deleted_at" @click="onClickDelete(attachment.id)">
|
<a class="card-footer-item" v-if="!attachment.deleted_at" @click="onClickDelete(attachment.id)">
|
||||||
<!-- Delete button or action can be placed here -->
|
<!-- Delete button or action can be placed here -->
|
||||||
|
|||||||
+12
-13
@@ -179,24 +179,23 @@ const resetAll = () => {
|
|||||||
reset_all_values();
|
reset_all_values();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickClearAllButton = () => {
|
const onClickClearAllButton = async () => {
|
||||||
// Check if the session storage contains the pos_order_id
|
// Check if the session storage contains the pos_order_id
|
||||||
if (localStorage.getItem("pos_order_id")) {
|
if (localStorage.getItem("pos_order_id")) {
|
||||||
order_id.value = parseInt(localStorage.getItem("pos_order_id") || "0");
|
order_id.value = parseInt(localStorage.getItem("pos_order_id") || "0");
|
||||||
}
|
}
|
||||||
// 1. Delete the order (If any)
|
// 1. Delete the order (If any)
|
||||||
if (order_id.value) {
|
if (order_id.value) {
|
||||||
SessionUser.objects.orders.delete
|
try {
|
||||||
.single(order_id.value)
|
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(order_id.value);
|
||||||
.then(() => {
|
if (!deleted) {
|
||||||
console.warn("Order deleted successfully");
|
return;
|
||||||
})
|
}
|
||||||
.catch((error) => {
|
console.warn("Order deleted successfully");
|
||||||
console.error("Error deleting order:", error);
|
} catch (error) {
|
||||||
})
|
console.error("Error deleting order:", error);
|
||||||
.finally(() => {
|
}
|
||||||
resetAll();
|
resetAll();
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
resetAll();
|
resetAll();
|
||||||
}
|
}
|
||||||
@@ -294,7 +293,7 @@ const isAttachmentsVisible = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="column is-narrow pl-0">
|
<div class="column is-narrow pl-0">
|
||||||
<p class="is-size-7 has-text-weight-semibold">
|
<p class="is-size-7 has-text-weight-semibold">
|
||||||
{{ t("admin.pos.reference") }}
|
{{ t("common.reference") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+27
-35
@@ -1,46 +1,38 @@
|
|||||||
<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 onClickClearAll = () => {
|
const toPositiveInteger = (value: unknown) => {
|
||||||
// 1. Delete the order (If any)
|
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||||
// Check localstorage for pos_order_id
|
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||||
if (localStorage.getItem("pos_order_id")) {
|
};
|
||||||
order_id.value = parseInt(localStorage.getItem("pos_order_id") || "0");
|
|
||||||
}
|
const onClickClearAll = async () => {
|
||||||
if (order_id.value) {
|
const storedOrderId = getStoredPosOrderId();
|
||||||
SessionUser.objects.orders.delete
|
const currentOrderId = toPositiveInteger(order_id.value);
|
||||||
.single(order_id.value)
|
const activeDraftOrderId = storedOrderId && currentOrderId === storedOrderId ? storedOrderId : null;
|
||||||
.then(() => {
|
|
||||||
// 2. Reset the POS
|
// 1. Delete only the active mobile draft order, never a historical route-loaded order.
|
||||||
resetPos();
|
if (activeDraftOrderId) {
|
||||||
// 3. Reset all values
|
try {
|
||||||
reset_all_values();
|
order_id.value = activeDraftOrderId;
|
||||||
})
|
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(activeDraftOrderId);
|
||||||
.catch((error) => {
|
if (!deleted) {
|
||||||
console.error("Error deleting order:", error);
|
return;
|
||||||
resetPos();
|
}
|
||||||
reset_all_values();
|
} catch (error) {
|
||||||
});
|
console.error("Error deleting order:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// 2. Reset the POS
|
||||||
|
resetPos();
|
||||||
|
// 3. Reset all values
|
||||||
|
reset_all_values();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -54,7 +46,7 @@ const onClickClearAll = () => {
|
|||||||
>
|
>
|
||||||
<div class="columns is-mobile is-vcentered has-text-centered">
|
<div class="columns is-mobile is-vcentered has-text-centered">
|
||||||
<!-- Clear all -->
|
<!-- Clear all -->
|
||||||
<div class="column is-12 has-text-centered" @click="onClickClearAll()">
|
<div class="column is-12 has-text-centered">
|
||||||
<div class="columns is-mobile is-vcentered is-centered">
|
<div class="columns is-mobile is-vcentered is-centered">
|
||||||
<div class="column is-narrow pr-1">
|
<div class="column is-narrow pr-1">
|
||||||
<span class="icon is-small">
|
<span class="icon is-small">
|
||||||
|
|||||||
+47
-34
@@ -132,7 +132,7 @@ const promptForReference = async (initialValue = "") => {
|
|||||||
title: t("admin.pos.reference_required_title"),
|
title: t("admin.pos.reference_required_title"),
|
||||||
text: t("admin.pos.reference_required_text"),
|
text: t("admin.pos.reference_required_text"),
|
||||||
input: "text",
|
input: "text",
|
||||||
inputLabel: t("admin.pos.reference"),
|
inputLabel: t("common.reference"),
|
||||||
inputValue: initialValue,
|
inputValue: initialValue,
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
confirmButtonText: t("admin.pos.confirm"),
|
confirmButtonText: t("admin.pos.confirm"),
|
||||||
@@ -177,7 +177,6 @@ const validateReferenceRequirements = async () => {
|
|||||||
reference.value = enteredRef;
|
reference.value = enteredRef;
|
||||||
metadata.setReference(enteredRef);
|
metadata.setReference(enteredRef);
|
||||||
console.warn("New reference set:", enteredRef);
|
console.warn("New reference set:", enteredRef);
|
||||||
onClick(); // Retry the click action
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
console.warn("Reference number is required but was not provided. Aborting operation.");
|
console.warn("Reference number is required but was not provided. Aborting operation.");
|
||||||
@@ -188,7 +187,7 @@ const validateReferenceRequirements = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const step1 = () => {
|
const step1 = async () => {
|
||||||
/** This function can be used to perform any specific actions for step 1 */
|
/** This function can be used to perform any specific actions for step 1 */
|
||||||
// Set registration numbers
|
// Set registration numbers
|
||||||
reg_1.value = vehicles.vehicle_1.value?.reg || "";
|
reg_1.value = vehicles.vehicle_1.value?.reg || "";
|
||||||
@@ -202,18 +201,14 @@ const step1 = () => {
|
|||||||
mergeSafetySeal();
|
mergeSafetySeal();
|
||||||
// We only need to check if the customer requires reference, if the field is empty.
|
// We only need to check if the customer requires reference, if the field is empty.
|
||||||
if (!isNonEmptyString(reference.value)) {
|
if (!isNonEmptyString(reference.value)) {
|
||||||
validateReferenceRequirements().then((isValid) => {
|
const isValid = await validateReferenceRequirements();
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
// The requirements were not met, do not proceed.
|
return false;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
// Proceed with setting reference and notes
|
|
||||||
finalizeStep1();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Reference is already filled, just finalize step 1
|
|
||||||
finalizeStep1();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reference is resolved, persist metadata and pending attachments.
|
||||||
|
return finalizeStep1();
|
||||||
};
|
};
|
||||||
const mergeReferences = () => {
|
const mergeReferences = () => {
|
||||||
if (metadata.getReference() && metadata.getReference().length >= 1) {
|
if (metadata.getReference() && metadata.getReference().length >= 1) {
|
||||||
@@ -246,25 +241,34 @@ const mergeSafetySeal = () => {
|
|||||||
syncMobileSafetySealState(resolvedSafetySeal);
|
syncMobileSafetySealState(resolvedSafetySeal);
|
||||||
updateSafetySeal();
|
updateSafetySeal();
|
||||||
};
|
};
|
||||||
const finalizeStep1 = () => {
|
const finalizeStep1 = async () => {
|
||||||
mergeReferences();
|
mergeReferences();
|
||||||
mergeNotes();
|
mergeNotes();
|
||||||
mergeSafetySeal();
|
mergeSafetySeal();
|
||||||
// Upload the attachments if there are any (And the order id has been created)
|
// Upload the attachments if there are any (And the order id has been created)
|
||||||
if (attachments.getBase64().length > 0 && order_id.value > 0) {
|
if (attachments.getBase64().length > 0 && order_id.value > 0) {
|
||||||
for (const attachment of attachments.getBase64()) {
|
const uploadResults = await Promise.all(
|
||||||
uploadAttachment(attachment)
|
attachments.getBase64().map(async (attachment: any) => {
|
||||||
.then(() => {
|
try {
|
||||||
console.warn("Attachment uploaded successfully");
|
const didUpload = await uploadAttachment(attachment);
|
||||||
// Remove the attachment from the list
|
if (didUpload) {
|
||||||
attachments.removeBase64(attachment);
|
console.warn("Attachment uploaded successfully");
|
||||||
})
|
attachments.removeBase64(attachment);
|
||||||
.catch((error: any) => {
|
}
|
||||||
|
return didUpload;
|
||||||
|
} catch (error: any) {
|
||||||
errors.value.push(error);
|
errors.value.push(error);
|
||||||
console.warn("An error occurred while uploading the attachment:", error);
|
console.warn("An error occurred while uploading the attachment:", error);
|
||||||
});
|
return false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uploadResults.some((didUpload) => didUpload === false)) {
|
||||||
|
console.warn("One or more attachments could not be uploaded before completion.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const addTransactionToHistory = async (orderId: number) => {
|
const addTransactionToHistory = async (orderId: number) => {
|
||||||
@@ -295,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.`,
|
||||||
@@ -320,16 +322,24 @@ const step2 = async () => {
|
|||||||
|
|
||||||
const selectedBookingId = getSelectedBookingId();
|
const selectedBookingId = getSelectedBookingId();
|
||||||
const hasWashCertificateInBasket = Boolean(transactionItems.containsWashCertificate?.());
|
const hasWashCertificateInBasket = Boolean(transactionItems.containsWashCertificate?.());
|
||||||
|
const resolvedSafetySeal = getResolvedMobileSafetySeal();
|
||||||
|
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
|
||||||
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
|
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
|
||||||
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
|
||||||
|
syncMobileSafetySealState(resolvedSafetySeal);
|
||||||
|
await completeStep2Order({
|
||||||
|
bookingSafetySeal: resolvedSafetySeal,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (requiresBookingCompletionPopup) {
|
if (requiresBookingCompletionPopup) {
|
||||||
const onCompleteBookingWithSafetySeal = async ({ safetySeal = "" } = {}) => {
|
const onCompleteBookingWithSafetySeal = async ({ safetySeal = "" } = {}) => {
|
||||||
syncMobileSafetySealState(safetySeal);
|
syncMobileSafetySealState(safetySeal);
|
||||||
await completeStep2Order({
|
await completeStep2Order({
|
||||||
bookingSafetySeal: String(safetySeal ?? ""),
|
bookingSafetySeal: String(safetySeal ?? ""),
|
||||||
markOrderCompleted: false,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -343,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);
|
||||||
@@ -401,12 +409,15 @@ const onClick = async () => {
|
|||||||
// Proceed to the next step
|
// Proceed to the next step
|
||||||
switch (step.value) {
|
switch (step.value) {
|
||||||
case 1:
|
case 1:
|
||||||
step1();
|
if (!(await step1())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const restoredOrderId = await restoreStoredPosOrderId({
|
const restoredOrderId = await restoreStoredPosOrderId({
|
||||||
validateOrder: true,
|
validateOrder: true,
|
||||||
customerId: getCustomerId(),
|
customerId: getCustomerId(),
|
||||||
departmentId: POSDepartmentProcess.getDepartment(),
|
departmentId: POSDepartmentProcess.getDepartment(),
|
||||||
allowCompleted: false,
|
allowCompleted: false,
|
||||||
|
syncDepartment: true,
|
||||||
});
|
});
|
||||||
if (restoredOrderId) {
|
if (restoredOrderId) {
|
||||||
console.warn("Order ID retrieved from local storage:", restoredOrderId);
|
console.warn("Order ID retrieved from local storage:", restoredOrderId);
|
||||||
@@ -424,7 +435,9 @@ const onClick = async () => {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
step1();
|
if (!(await step1())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await step2();
|
await step2();
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
|
|||||||
+7
-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;
|
||||||
@@ -28,6 +31,9 @@ watch(note, (newNote) => {
|
|||||||
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 +131,4 @@ input.is-searched {
|
|||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+35
-33
@@ -4,11 +4,15 @@ import { metadata, popups } from "../objects/PosDepartmentStepMobileFlow.vue";
|
|||||||
import { getResolvedMobileSafetySeal } from "../objects/mobileOrderCompletion.js";
|
import { getResolvedMobileSafetySeal } from "../objects/mobileOrderCompletion.js";
|
||||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'close'): void;
|
(e: "close"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const popupProps = computed(() => popups.get()?.props || {});
|
const popupProps = computed(() => popups.get()?.props || {});
|
||||||
const isSubmitting = ref(false);
|
type SubmitAction = "without_certificate" | "with_certificate";
|
||||||
|
|
||||||
|
const submittingAction = ref<SubmitAction | null>(null);
|
||||||
|
const isSubmitting = computed(() => submittingAction.value !== null);
|
||||||
|
const isSubmittingAction = (action: SubmitAction) => submittingAction.value === action;
|
||||||
const resolveExistingSafetySeal = () => {
|
const resolveExistingSafetySeal = () => {
|
||||||
const popupSafetySeal = popupProps.value?.safetySeal;
|
const popupSafetySeal = popupProps.value?.safetySeal;
|
||||||
if (popupSafetySeal !== null && popupSafetySeal !== undefined) {
|
if (popupSafetySeal !== null && popupSafetySeal !== undefined) {
|
||||||
@@ -20,11 +24,17 @@ const resolveExistingSafetySeal = () => {
|
|||||||
const safetySealNumber = ref(resolveExistingSafetySeal());
|
const safetySealNumber = ref(resolveExistingSafetySeal());
|
||||||
|
|
||||||
const closePopup = () => {
|
const closePopup = () => {
|
||||||
emit('close');
|
emit("close");
|
||||||
};
|
};
|
||||||
|
|
||||||
const runPopupCompletionCallback = async (callback: any, safetySeal: string) => {
|
const closePopupIfStillActive = () => {
|
||||||
isSubmitting.value = true;
|
if (popups.get()?.id === "complete_booking") {
|
||||||
|
closePopup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runPopupCompletionCallback = async (callback: any, safetySeal: string, action: SubmitAction) => {
|
||||||
|
submittingAction.value = action;
|
||||||
try {
|
try {
|
||||||
await Promise.resolve(
|
await Promise.resolve(
|
||||||
callback({
|
callback({
|
||||||
@@ -32,26 +42,26 @@ const runPopupCompletionCallback = async (callback: any, safetySeal: string) =>
|
|||||||
safetySeal,
|
safetySeal,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
closePopup();
|
closePopupIfStillActive();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to complete booking from popup callback:", error);
|
console.warn("Failed to complete booking from popup callback:", error);
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false;
|
submittingAction.value = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitBookingCompletionFallback = async (safetySeal: string) => {
|
const submitBookingCompletionFallback = async (safetySeal: string, action: SubmitAction) => {
|
||||||
isSubmitting.value = true;
|
submittingAction.value = action;
|
||||||
try {
|
try {
|
||||||
await SessionUser.objects.order_bookings.functions.complete(
|
await SessionUser.objects.order_bookings.functions.complete(
|
||||||
metadata.getBookingId(),
|
metadata.getBookingId(),
|
||||||
safetySeal === "" ? null : safetySeal
|
safetySeal === "" ? null : safetySeal
|
||||||
);
|
);
|
||||||
closePopup();
|
closePopupIfStillActive();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to complete booking from popup fallback action:", error);
|
console.warn("Failed to complete booking from popup fallback action:", error);
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false;
|
submittingAction.value = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,10 +72,10 @@ const completeWithoutCertificate = async () => {
|
|||||||
|
|
||||||
const callback = popupProps.value?.onCompleteWithoutCertificate || popupProps.value?.onComplete;
|
const callback = popupProps.value?.onCompleteWithoutCertificate || popupProps.value?.onComplete;
|
||||||
if (typeof callback === "function") {
|
if (typeof callback === "function") {
|
||||||
await runPopupCompletionCallback(callback, "");
|
await runPopupCompletionCallback(callback, "", "without_certificate");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await submitBookingCompletionFallback("");
|
await submitBookingCompletionFallback("", "without_certificate");
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeWithCertificate = async () => {
|
const completeWithCertificate = async () => {
|
||||||
@@ -76,10 +86,10 @@ const completeWithCertificate = async () => {
|
|||||||
const normalizedSafetySeal = String(safetySealNumber.value ?? "").trim() || resolveExistingSafetySeal();
|
const normalizedSafetySeal = String(safetySealNumber.value ?? "").trim() || resolveExistingSafetySeal();
|
||||||
const callback = popupProps.value?.onCompleteWithCertificate || popupProps.value?.onComplete;
|
const callback = popupProps.value?.onCompleteWithCertificate || popupProps.value?.onComplete;
|
||||||
if (typeof callback === "function") {
|
if (typeof callback === "function") {
|
||||||
await runPopupCompletionCallback(callback, normalizedSafetySeal);
|
await runPopupCompletionCallback(callback, normalizedSafetySeal, "with_certificate");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await submitBookingCompletionFallback(normalizedSafetySeal);
|
await submitBookingCompletionFallback(normalizedSafetySeal, "with_certificate");
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -104,29 +114,22 @@ const completeWithCertificate = async () => {
|
|||||||
<button
|
<button
|
||||||
class="button is-light"
|
class="button is-light"
|
||||||
data-testid="pos-mobile-booking-complete-without-certificate"
|
data-testid="pos-mobile-booking-complete-without-certificate"
|
||||||
:class="{ 'is-loading': isSubmitting }"
|
:class="{ 'is-loading': isSubmittingAction('without_certificate') }"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="completeWithoutCertificate"
|
@click="completeWithoutCertificate"
|
||||||
>
|
>
|
||||||
Intet vaskecertifikat
|
Intet vaskecertifikat
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="button is-primary"
|
class="button is-primary"
|
||||||
data-testid="pos-mobile-booking-complete-with-certificate"
|
data-testid="pos-mobile-booking-complete-with-certificate"
|
||||||
:class="{ 'is-loading': isSubmitting }"
|
:class="{ 'is-loading': isSubmittingAction('with_certificate') }"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="completeWithCertificate"
|
@click="completeWithCertificate"
|
||||||
>
|
>
|
||||||
Generer vaskecertifikat
|
Generer vaskecertifikat
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!--<FormDisplay
|
|
||||||
:form_identifier="'GENERATE_BOOKING_WASH_CERTIFICATE'"
|
|
||||||
v-bind:validator_options="{validateBookingId: {default: metadata.getBookingId(), locked: true, visible: false}}"
|
|
||||||
:onSuccessfulSubmission="onSuccessfulCertificateSubmission"
|
|
||||||
:showSuccessSubmitAlert="false"
|
|
||||||
:showResetButton="false"
|
|
||||||
/>-->
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -152,7 +155,7 @@ input {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
|
|
||||||
background: #FFFFFF;
|
background: #ffffff;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
@@ -185,8 +188,8 @@ input:focus-visible {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
background: #FFFFFF;
|
background: #ffffff;
|
||||||
border: 1px solid #A6A5A5;
|
border: 1px solid #a6a5a5;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
/* Inside auto layout */
|
/* Inside auto layout */
|
||||||
@@ -211,7 +214,7 @@ input.is-searched {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
|
|
||||||
background: #FFFFFF;
|
background: #ffffff;
|
||||||
border: 1px solid #000000;
|
border: 1px solid #000000;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
@@ -221,6 +224,5 @@ input.is-searched {
|
|||||||
order: 1;
|
order: 1;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+7
-6
@@ -339,7 +339,7 @@ const isBookingScheduledForToday = (booking: any) => {
|
|||||||
class="tag booking-option__today-tag"
|
class="tag booking-option__today-tag"
|
||||||
:data-testid="`pos-mobile-order-booking-today-${booking.id}`"
|
:data-testid="`pos-mobile-order-booking-today-${booking.id}`"
|
||||||
>
|
>
|
||||||
{{ t("global.time.today") }}
|
{{ t("common.today") }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="didBookingDetailFail(booking)" class="tag is-light booking-option__tag">
|
<span v-if="didBookingDetailFail(booking)" class="tag is-light booking-option__tag">
|
||||||
{{ t("admin.pos.not_found") }}
|
{{ t("admin.pos.not_found") }}
|
||||||
@@ -355,6 +355,7 @@ const isBookingScheduledForToday = (booking: any) => {
|
|||||||
:refreshFunction="emitRefreshBookings"
|
:refreshFunction="emitRefreshBookings"
|
||||||
:icon="'fas fa-ellipsis-v'"
|
:icon="'fas fa-ellipsis-v'"
|
||||||
trigger-button-variant="text"
|
trigger-button-variant="text"
|
||||||
|
allow-booking-completion
|
||||||
>
|
>
|
||||||
<template #actions />
|
<template #actions />
|
||||||
</ActionSettingsWheelButton>
|
</ActionSettingsWheelButton>
|
||||||
@@ -372,7 +373,7 @@ const isBookingScheduledForToday = (booking: any) => {
|
|||||||
<dd>{{ getBookingPlates(booking) || t("admin.pos.not_found") }}</dd>
|
<dd>{{ getBookingPlates(booking) || t("admin.pos.not_found") }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="booking-option__meta-item">
|
<div class="booking-option__meta-item">
|
||||||
<dt>{{ t("admin.pos.order_booking_selector.reference_label") }}</dt>
|
<dt>{{ t("common.reference") }}</dt>
|
||||||
<dd>{{ getBookingReference(booking) || "-" }}</dd>
|
<dd>{{ getBookingReference(booking) || "-" }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="getBookingPo(booking)" class="booking-option__meta-item">
|
<div v-if="getBookingPo(booking)" class="booking-option__meta-item">
|
||||||
@@ -391,7 +392,7 @@ const isBookingScheduledForToday = (booking: any) => {
|
|||||||
:data-testid="`pos-mobile-order-booking-services-${booking.id}`"
|
:data-testid="`pos-mobile-order-booking-services-${booking.id}`"
|
||||||
>
|
>
|
||||||
<div class="booking-option__section-header">
|
<div class="booking-option__section-header">
|
||||||
<p class="booking-option__section-title">{{ t("admin.pos.order_booking_selector.services_label") }}</p>
|
<p class="booking-option__section-title">{{ t("common.services") }}</p>
|
||||||
<div
|
<div
|
||||||
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
|
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
|
||||||
class="booking-option__section-total"
|
class="booking-option__section-total"
|
||||||
@@ -408,8 +409,8 @@ const isBookingScheduledForToday = (booking: any) => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-else-if="getBookingItemPreviewRows(booking).length > 0" class="booking-option__item-preview">
|
<div v-else-if="getBookingItemPreviewRows(booking).length > 0" class="booking-option__item-preview">
|
||||||
<div class="booking-option__item-preview-head">
|
<div class="booking-option__item-preview-head">
|
||||||
<span>{{ t("global.quantity") }}</span>
|
<span>{{ t("common.quantity") }}</span>
|
||||||
<span>{{ t("admin.pos.order_booking_selector.services_label") }}</span>
|
<span>{{ t("common.services") }}</span>
|
||||||
<span>{{ t("admin.pos.price_dkk") }}</span>
|
<span>{{ t("admin.pos.price_dkk") }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -455,7 +456,7 @@ const isBookingScheduledForToday = (booking: any) => {
|
|||||||
:data-testid="`pos-mobile-order-booking-use-${booking.id}`"
|
:data-testid="`pos-mobile-order-booking-use-${booking.id}`"
|
||||||
@click="onSelect(booking)"
|
@click="onSelect(booking)"
|
||||||
>
|
>
|
||||||
{{ t("admin.pos.order_booking_selector.use_booking") }}
|
{{ t("common.select") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ export type PosDepartment = {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
order_priority?: number | null;
|
||||||
|
priority_order?: number | null;
|
||||||
branding?: number;
|
branding?: number;
|
||||||
latitude?: number;
|
latitude?: number;
|
||||||
longitude?: number;
|
longitude?: number;
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+12
-4
@@ -133,7 +133,7 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
|
|||||||
return {
|
return {
|
||||||
/** General purpose action buttons (used by PosAddons)*/
|
/** General purpose action buttons (used by PosAddons)*/
|
||||||
confirm: {
|
confirm: {
|
||||||
label: t("admin.pos.action_buttons.confirm"),
|
label: t("common.confirm"),
|
||||||
description: t("admin.pos.action_buttons.confirm_desc"),
|
description: t("admin.pos.action_buttons.confirm_desc"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
clearPopup(); // Close the popup
|
clearPopup(); // Close the popup
|
||||||
@@ -141,7 +141,7 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
|
|||||||
color: "primary",
|
color: "primary",
|
||||||
},
|
},
|
||||||
cancel: {
|
cancel: {
|
||||||
label: t("admin.pos.action_buttons.cancel"),
|
label: t("common.cancel"),
|
||||||
description: t("admin.pos.action_buttons.cancel_desc"),
|
description: t("admin.pos.action_buttons.cancel_desc"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
clearPopup(); // Close the popup
|
clearPopup(); // Close the popup
|
||||||
@@ -149,7 +149,7 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
|
|||||||
color: "light",
|
color: "light",
|
||||||
},
|
},
|
||||||
close: {
|
close: {
|
||||||
label: t("admin.pos.action_buttons.close"),
|
label: t("common.close"),
|
||||||
description: t("admin.pos.action_buttons.close_desc"),
|
description: t("admin.pos.action_buttons.close_desc"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
clearPopup(); // Close the popup
|
clearPopup(); // Close the popup
|
||||||
@@ -788,7 +788,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",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
order_notes,
|
order_notes,
|
||||||
order_safety_seal,
|
order_safety_seal,
|
||||||
reference,
|
reference,
|
||||||
|
clearStoredPosOrderId,
|
||||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||||
import { metadata } from "./PosDepartmentStepMobileFlow.vue";
|
import { metadata } from "./PosDepartmentStepMobileFlow.vue";
|
||||||
|
|
||||||
@@ -115,5 +116,6 @@ export const finalizeCurrentMobileOrder = async (options = {}) => {
|
|||||||
if (normalizedOptions.markOrderCompleted) {
|
if (normalizedOptions.markOrderCompleted) {
|
||||||
await SessionUser.objects.orders.functions.mark_as_completed(normalizedOrderId);
|
await SessionUser.objects.orders.functions.mark_as_completed(normalizedOrderId);
|
||||||
}
|
}
|
||||||
|
clearStoredPosOrderId();
|
||||||
return normalizedOrderId;
|
return normalizedOrderId;
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -183,7 +183,7 @@ const handleMouseWheel = (e: WheelEvent) => {
|
|||||||
<span class="icon is-small">
|
<span class="icon is-small">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</span>
|
</span>
|
||||||
<small>{{ t("global.close") }}</small>
|
<small>{{ t("common.close") }}</small>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ const tableHeaders = ref([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'reference',
|
name: 'reference',
|
||||||
title: t('objects.columns.reference'),
|
title: t('common.reference'),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -296,7 +296,7 @@ const tableHeaders = ref([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'created_at',
|
name: 'created_at',
|
||||||
title: t('objects.columns.date'),
|
title: t('common.date'),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
export const XLVASK_USAGE_AMOUNT_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
const CACHE_PREFIX = "xlvask-usage-amount:";
|
||||||
|
const memoryCache = new Map();
|
||||||
|
|
||||||
|
const getStorageValue = (key) => {
|
||||||
|
try {
|
||||||
|
if (typeof window === "undefined" || !window.localStorage) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return window.localStorage.getItem(key) || "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hashCacheScopePart = (value) => {
|
||||||
|
let hash = 5381;
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return (hash >>> 0).toString(36);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAuthenticatedCacheScope = () => {
|
||||||
|
const token = getStorageValue("token");
|
||||||
|
if (!token) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
hashCacheScopePart(token),
|
||||||
|
getStorageValue("is_subuser") === "true" ? "subuser" : "user",
|
||||||
|
getStorageValue("selected_customer_number"),
|
||||||
|
]
|
||||||
|
.map((part) => encodeURIComponent(String(part ?? "")))
|
||||||
|
.join("|");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScopedCacheKey = (cacheKey) => {
|
||||||
|
const scope = getAuthenticatedCacheScope();
|
||||||
|
return scope ? `${scope}:${cacheKey}` : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeSessionStorage = () => {
|
||||||
|
try {
|
||||||
|
if (typeof window === "undefined" || !window.sessionStorage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return window.sessionStorage;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeUsageLogId = (objectOrId) => {
|
||||||
|
const value = typeof objectOrId === "object" ? objectOrId?.usage_log_id ?? objectOrId?.id : objectOrId;
|
||||||
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFreshEntry = (entry) => {
|
||||||
|
const storedAt = Number(entry?.storedAt ?? 0);
|
||||||
|
return storedAt > 0 && Date.now() - storedAt <= XLVASK_USAGE_AMOUNT_CACHE_TTL_MS;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildXlvaskUsageAmountCacheKey = (objectOrId) => {
|
||||||
|
const usageLogId = normalizeUsageLogId(objectOrId);
|
||||||
|
return usageLogId > 0 ? String(usageLogId) : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCachedXlvaskUsageAmount = (objectOrId) => {
|
||||||
|
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||||
|
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||||
|
if (!scopedCacheKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoryEntry = memoryCache.get(scopedCacheKey);
|
||||||
|
if (isFreshEntry(memoryEntry)) {
|
||||||
|
return memoryEntry.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memoryEntry) {
|
||||||
|
memoryCache.delete(scopedCacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = safeSessionStorage();
|
||||||
|
if (!storage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = `${CACHE_PREFIX}${scopedCacheKey}`;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
|
||||||
|
if (!isFreshEntry(parsed)) {
|
||||||
|
storage.removeItem(storageKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
memoryCache.set(scopedCacheKey, parsed);
|
||||||
|
return parsed.data;
|
||||||
|
} catch {
|
||||||
|
storage.removeItem(storageKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
|
||||||
|
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||||
|
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||||
|
if (!scopedCacheKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
storedAt: Date.now(),
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
memoryCache.set(scopedCacheKey, entry);
|
||||||
|
|
||||||
|
const storage = safeSessionStorage();
|
||||||
|
if (!storage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
storage.setItem(`${CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
|
||||||
|
} catch {
|
||||||
|
// Best-effort cache. Quota errors should not block XL Vask usage rows.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearCachedXlvaskUsageAmount = (objectOrId = null) => {
|
||||||
|
const storage = safeSessionStorage();
|
||||||
|
const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId);
|
||||||
|
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
|
||||||
|
|
||||||
|
if (scopedCacheKey) {
|
||||||
|
memoryCache.delete(scopedCacheKey);
|
||||||
|
try {
|
||||||
|
storage?.removeItem(`${CACHE_PREFIX}${scopedCacheKey}`);
|
||||||
|
} catch {
|
||||||
|
// Best-effort cache cleanup.
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryCache.clear();
|
||||||
|
if (!storage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object.keys(storage)
|
||||||
|
.filter((key) => key.startsWith(CACHE_PREFIX))
|
||||||
|
.forEach((key) => storage.removeItem(key));
|
||||||
|
} catch {
|
||||||
|
// Best-effort cache cleanup.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,19 @@
|
|||||||
|
const hasLinkedOrder = (object) => Number(object?.linked_order_id) > 0;
|
||||||
|
|
||||||
export const isUsageOrderAttachedToOrder = (object) => {
|
export const isUsageOrderAttachedToOrder = (object) => {
|
||||||
|
if (hasLinkedOrder(object)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const automation = object?.automation;
|
||||||
|
const automationStatus = automation?.status;
|
||||||
|
if (
|
||||||
|
["auto_accepted", "accepted"].includes(automationStatus) &&
|
||||||
|
["attach_order", "create_order"].includes(automation?.action)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const duplicates = Array.isArray(object?.duplicates) ? object.duplicates : [];
|
const duplicates = Array.isArray(object?.duplicates) ? object.duplicates : [];
|
||||||
return duplicates.some((duplicate) => duplicate?.wash_id === object?.wash_id);
|
return duplicates.some((duplicate) => duplicate?.wash_id === object?.wash_id);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
highlightUsageLogId: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
import {computed, ref, watch} from 'vue';
|
import {computed, ref, watch} from 'vue';
|
||||||
import { departments, getDepartments, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
|
import { departments, getDepartments, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
|
||||||
@@ -28,6 +32,11 @@ import {
|
|||||||
filterUsageOrdersByAttachment,
|
filterUsageOrdersByAttachment,
|
||||||
isUsageOrderAttachedToOrder
|
isUsageOrderAttachedToOrder
|
||||||
} from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
|
} from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
|
||||||
|
import {
|
||||||
|
clearCachedXlvaskUsageAmount,
|
||||||
|
getCachedXlvaskUsageAmount,
|
||||||
|
setCachedXlvaskUsageAmount
|
||||||
|
} from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
|
||||||
|
|
||||||
const redirectDepartmentOrderPage = (orderId, departmentId) => {
|
const redirectDepartmentOrderPage = (orderId, departmentId) => {
|
||||||
// Send the user to the order page (In a new tab)
|
// Send the user to the order page (In a new tab)
|
||||||
@@ -204,25 +213,57 @@ const humanReadableTimeAgo = (dateString) => {
|
|||||||
return 'lige nu';
|
return 'lige nu';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyGeneratedContent = (object, data) => {
|
||||||
|
const orderItems = Array.isArray(data?.order_items) ? data.order_items : [];
|
||||||
|
const potentialDuplicates = Array.isArray(data?.potential_duplicates) ? data.potential_duplicates : [];
|
||||||
|
|
||||||
|
object.items = orderItems;
|
||||||
|
if (potentialDuplicates.length > 0) {
|
||||||
|
object.selectedDuplicateId = parseInt(potentialDuplicates[0].id);
|
||||||
|
object.duplicates = potentialDuplicates;
|
||||||
|
}
|
||||||
|
if (data?.automation !== undefined) {
|
||||||
|
object.automation = data.automation;
|
||||||
|
}
|
||||||
|
object.fast_link_key = undefined;
|
||||||
|
object.loading = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyCachedGeneratedContent = (object) => {
|
||||||
|
const cached = getCachedXlvaskUsageAmount(object);
|
||||||
|
if (!cached) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyGeneratedContent(object, cached);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const generateContent = (object) => {
|
const generateContent = (object) => {
|
||||||
|
if (applyCachedGeneratedContent(object)) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
if (!object.fast_link_key || object.loading) {
|
if (!object.fast_link_key || object.loading) {
|
||||||
return; // If no fast link key, do not generate content
|
return Promise.resolve(); // If no fast link key, do not generate content
|
||||||
}
|
}
|
||||||
object.loading = true; // Set loading state to true
|
object.loading = true; // Set loading state to true
|
||||||
// Generate content based on the object
|
// Generate content based on the object
|
||||||
SessionUser.request(
|
return SessionUser.request(
|
||||||
'/modules/xlvask/services/usage/orders/fast-link?fast_link_key=' + object.fast_link_key,
|
'/modules/xlvask/services/usage/orders/fast-link?fast_link_key=' + object.fast_link_key,
|
||||||
'GET'
|
'GET'
|
||||||
).then((response) => {
|
).then((response) => {
|
||||||
const data = response.data.data;
|
const data = response.data.data;
|
||||||
object.fast_link_key = undefined; // Clear the fast link key after use
|
applyGeneratedContent(object, data);
|
||||||
object.items = data.order_items;
|
setCachedXlvaskUsageAmount(object, {
|
||||||
object.selectedDuplicateId = data.potential_duplicates?.length ? parseInt(data.potential_duplicates[0].id) : null;
|
order_items: object.items,
|
||||||
object.duplicates = data.potential_duplicates;
|
potential_duplicates: object.duplicates,
|
||||||
object.loading = false; // Set loading state to false
|
automation: object.automation,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
console.error('Error generating content:', error);
|
console.error('Error generating content:', error);
|
||||||
|
object.loading = false;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -249,9 +290,20 @@ const getPriceFromOrderItems = (orderItems) => {
|
|||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getObjectPrice = (object) => {
|
||||||
|
if (doesObjectHaveItems(object)) {
|
||||||
|
return getPriceFromOrderItems(object.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedAmount = Number(object?.total_net_amount ?? 0);
|
||||||
|
return Number.isFinite(parsedAmount) ? parsedAmount : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const doesObjectHaveAmount = (object) => getObjectPrice(object) > 0;
|
||||||
|
|
||||||
const doesObjectPriceMatchPotentialDuplicate = (object, potentialDuplicate) => {
|
const doesObjectPriceMatchPotentialDuplicate = (object, potentialDuplicate) => {
|
||||||
// Check if the price of the object matches the potential duplicate
|
// Check if the price of the object matches the potential duplicate
|
||||||
return getPriceFromOrderItems(object.items) === potentialDuplicate.total_net_amount ?? 0;
|
return getObjectPrice(object) === Number(potentialDuplicate?.total_net_amount ?? 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPrimaryItemFromOrderItems = (orderItems) => {
|
const getPrimaryItemFromOrderItems = (orderItems) => {
|
||||||
@@ -276,6 +328,14 @@ const getProductName = (orderItem) => {
|
|||||||
return product ? product.name : 'Ukendt produkt';
|
return product ? product.name : 'Ukendt produkt';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getObjectPrimaryProductName = (object) => {
|
||||||
|
if (doesObjectHaveItems(object)) {
|
||||||
|
return getProductName(getPrimaryItemFromOrderItems(object.items));
|
||||||
|
}
|
||||||
|
|
||||||
|
return object?.xlvask_primary_product_name || object?.primary_product_name || '';
|
||||||
|
};
|
||||||
|
|
||||||
const doesObjectHaveItems = (object) => {
|
const doesObjectHaveItems = (object) => {
|
||||||
// Check if the object has items
|
// Check if the object has items
|
||||||
return object.items && Array.isArray(object.items) && object.items.length > 0;
|
return object.items && Array.isArray(object.items) && object.items.length > 0;
|
||||||
@@ -288,12 +348,12 @@ const automaticallyLoadContent = (object) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically load content for each object when the objects prop changes
|
const isHighlightedUsageLog = (object) => Number(object?.id || 0) === Number(props.highlightUsageLogId || 0);
|
||||||
|
|
||||||
|
// Reuse cached detail payloads immediately, but only fetch one-time fast-link details on demand.
|
||||||
watch(() => props.objects, (newObjects) => {
|
watch(() => props.objects, (newObjects) => {
|
||||||
newObjects.forEach(object => {
|
newObjects.forEach(object => {
|
||||||
if (object.fast_link_key && !object.loading) {
|
applyCachedGeneratedContent(object);
|
||||||
generateContent(object);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, { immediate: true, deep: true });
|
}, { immediate: true, deep: true });
|
||||||
|
|
||||||
@@ -416,7 +476,7 @@ const isObjectAttachedToOrder = (object) => {
|
|||||||
const doesObjectHaveExactMatch = (object) => {
|
const doesObjectHaveExactMatch = (object) => {
|
||||||
// Check if the object has any duplicates with the same order price
|
// Check if the object has any duplicates with the same order price
|
||||||
return object.duplicates?.some(duplicate => {
|
return object.duplicates?.some(duplicate => {
|
||||||
return getPriceFromOrderItems(object.items) === duplicate.total_net_amount;
|
return getObjectPrice(object) === Number(duplicate.total_net_amount ?? 0);
|
||||||
}) || false;
|
}) || false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -425,6 +485,151 @@ const doesObjectHaveDuplicates = (object) => {
|
|||||||
return object.duplicates && Array.isArray(object.duplicates) && object.duplicates.length > 0;
|
return object.duplicates && Array.isArray(object.duplicates) && object.duplicates.length > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasAutomationState = (object) => {
|
||||||
|
const status = object?.automation?.status;
|
||||||
|
return !!status && status !== 'none' && status !== 'failed';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAutomationActionable = (object) => {
|
||||||
|
return object?.automation?.can_accept || object?.automation?.can_deny;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAutomationOrderId = (object) => {
|
||||||
|
return object?.automation?.matched_order_id
|
||||||
|
?? object?.automation?.created_order_id
|
||||||
|
?? object?.automation?.candidate_order?.id
|
||||||
|
?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAutomationLabel = (object) => {
|
||||||
|
const automation = object?.automation ?? {};
|
||||||
|
const orderId = getAutomationOrderId(object);
|
||||||
|
const orderSuffix = orderId ? ` #${orderId}` : '';
|
||||||
|
|
||||||
|
if (automation.status === 'suggested') {
|
||||||
|
return automation.action === 'create_order'
|
||||||
|
? 'Foreslået: Opret ordre'
|
||||||
|
: `Foreslået: Tilknyt ordre${orderSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (automation.status === 'auto_accepted') {
|
||||||
|
return automation.action === 'create_order'
|
||||||
|
? `Oprettet automatisk${orderSuffix}`
|
||||||
|
: `Tilknyttet automatisk${orderSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (automation.status === 'accepted') {
|
||||||
|
return automation.action === 'create_order'
|
||||||
|
? `Oprettet${orderSuffix}`
|
||||||
|
: `Accepteret${orderSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (automation.status === 'denied') {
|
||||||
|
return 'Afvist';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAutomationReason = (object) => {
|
||||||
|
return object?.automation?.reason || object?.automation?.error || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAutomationConfidence = (object) => {
|
||||||
|
const confidence = Number(object?.automation?.confidence ?? 0);
|
||||||
|
if (!Number.isFinite(confidence) || confidence <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.round(confidence * 100)}% sikkerhed`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAutomationTagClass = (object) => {
|
||||||
|
const status = object?.automation?.status;
|
||||||
|
if (status === 'auto_accepted' || status === 'accepted') {
|
||||||
|
return 'is-success';
|
||||||
|
}
|
||||||
|
if (status === 'denied') {
|
||||||
|
return 'is-light';
|
||||||
|
}
|
||||||
|
if (status === 'failed') {
|
||||||
|
return 'is-danger';
|
||||||
|
}
|
||||||
|
return object?.automation?.source === 'openai' ? 'is-info' : 'is-warning';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAutomationMessageClass = (object) => {
|
||||||
|
const status = object?.automation?.status;
|
||||||
|
if (status === 'auto_accepted' || status === 'accepted') {
|
||||||
|
return 'is-success';
|
||||||
|
}
|
||||||
|
if (status === 'denied') {
|
||||||
|
return 'is-light';
|
||||||
|
}
|
||||||
|
if (status === 'failed') {
|
||||||
|
return 'is-danger';
|
||||||
|
}
|
||||||
|
return object?.automation?.source === 'openai' ? 'is-info' : 'is-warning';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAutomationIconClass = (object) => {
|
||||||
|
const automation = object?.automation ?? {};
|
||||||
|
if (automation.status === 'denied') {
|
||||||
|
return 'fa-ban';
|
||||||
|
}
|
||||||
|
if (automation.status === 'failed') {
|
||||||
|
return 'fa-exclamation-triangle';
|
||||||
|
}
|
||||||
|
if (automation.action === 'create_order') {
|
||||||
|
return 'fa-plus-circle';
|
||||||
|
}
|
||||||
|
return 'fa-link';
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAutomationDecision = (object, decision) => {
|
||||||
|
if (!object?.id || object.automationLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
object.automationLoading = true;
|
||||||
|
SessionUser.request(
|
||||||
|
`/modules/xlvask/services/usage/orders/${object.id}/automation/${decision}`,
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
suggestion_id: object.automation?.id ?? null,
|
||||||
|
}
|
||||||
|
).then((response) => {
|
||||||
|
object.automation = response.data.data;
|
||||||
|
clearCachedXlvaskUsageAmount(object);
|
||||||
|
window.dispatchEvent(new CustomEvent('xlvask-usage-order-updated', {
|
||||||
|
detail: {
|
||||||
|
usageLogId: object.id,
|
||||||
|
decision,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}).catch((error) => {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'Fejl',
|
||||||
|
text: decision === 'accept'
|
||||||
|
? 'Forslaget kunne ikke accepteres.'
|
||||||
|
: 'Forslaget kunne ikke afvises.',
|
||||||
|
icon: 'error',
|
||||||
|
confirmButtonText: 'OK',
|
||||||
|
});
|
||||||
|
console.error(error);
|
||||||
|
}).finally(() => {
|
||||||
|
object.automationLoading = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const acceptAutomation = (object) => {
|
||||||
|
setAutomationDecision(object, 'accept');
|
||||||
|
};
|
||||||
|
|
||||||
|
const denyAutomation = (object) => {
|
||||||
|
setAutomationDecision(object, 'deny');
|
||||||
|
};
|
||||||
|
|
||||||
const filteredObjects = computed(() => {
|
const filteredObjects = computed(() => {
|
||||||
return filterUsageOrdersByAttachment(props.objects, props.showOnlyUnattachedVehicle);
|
return filterUsageOrdersByAttachment(props.objects, props.showOnlyUnattachedVehicle);
|
||||||
});
|
});
|
||||||
@@ -539,47 +744,100 @@ const filteredObjects = computed(() => {
|
|||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
<template v-for="object in filteredObjects" :key="object.id">
|
<template v-for="object in filteredObjects" :key="object.id">
|
||||||
<WhiteBoxCard v-bind:loading="object.loading ?? false" :toggleable="true" @mouseover="automaticallyLoadContent(object)" @click="automaticallyLoadContent(object)" :default-open="object.isOpen ?? false">
|
<WhiteBoxCard
|
||||||
|
v-bind:loading="object.loading ?? false"
|
||||||
|
:toggleable="true"
|
||||||
|
:class="{ 'xlvask-usage-card--highlighted': isHighlightedUsageLog(object) }"
|
||||||
|
@mouseover="automaticallyLoadContent(object)"
|
||||||
|
@click="automaticallyLoadContent(object)"
|
||||||
|
:default-open="isHighlightedUsageLog(object) || (object.isOpen ?? false)"
|
||||||
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<p class="card-header-title">
|
<div class="card-header-title xlvask-usage-card-header">
|
||||||
<!-- Registration Number -->
|
<div class="xlvask-usage-card-main">
|
||||||
<span class="has-text-weight-bold">{{ object.reg_1 }}</span>
|
<div class="xlvask-usage-card-primary">
|
||||||
<!-- Time Ago -->
|
<span class="has-text-weight-bold xlvask-usage-registration">{{ object.reg_1 }}</span>
|
||||||
<span class="tag is-light is-size-7 ml-2">
|
<span class="tag is-light is-size-7 xlvask-usage-chip">
|
||||||
{{ humanReadableTimeAgo(object.created_at) }}
|
{{ humanReadableTimeAgo(object.created_at) }}
|
||||||
</span>
|
</span>
|
||||||
<!-- Primary Item Name -->
|
<span class="tag is-light is-size-7 xlvask-usage-chip" v-if="getObjectPrimaryProductName(object)">
|
||||||
<span class="tag is-light is-size-7 ml-2" v-if="doesObjectHaveItems(object)">
|
{{ getObjectPrimaryProductName(object) }}
|
||||||
{{ getProductName(getPrimaryItemFromOrderItems(object.items)) }}
|
</span>
|
||||||
</span>
|
<span class="tag is-light is-size-7 xlvask-usage-chip xlvask-usage-price" v-if="doesObjectHaveAmount(object)">
|
||||||
<!-- Price -->
|
{{ SessionUser.functions.currency.toLocal(getObjectPrice(object).toFixed(2)) }}
|
||||||
<span class="tag is-light is-size-7 ml-2" v-if="doesObjectHaveItems(object)">
|
</span>
|
||||||
{{ SessionUser.functions.currency.toLocal(getPriceFromOrderItems(object.items).toFixed(2)) }}
|
</div>
|
||||||
</span>
|
<div class="xlvask-usage-card-status">
|
||||||
<!-- Linked to order status -->
|
<span class="tag is-light is-size-7 xlvask-usage-chip" :class="{ 'is-grey': !isObjectAttachedToOrder(object), 'is-success': isObjectAttachedToOrder(object) }">
|
||||||
<span class="tag is-light is-size-7 ml-2" :class="{ 'is-grey': !isObjectAttachedToOrder(object), 'is-success': isObjectAttachedToOrder(object) }">
|
<span class="icon is-small"><i class="fas" :class="isObjectAttachedToOrder(object) ? 'fa-link' : 'fa-unlink'"></i></span>
|
||||||
<span class="icon is-small"><i class="fas" :class="isObjectAttachedToOrder(object) ? 'fa-link' : 'fa-unlink'"></i></span>
|
<span class="has-text-weight-bold xlvask-usage-chip-text">{{ isObjectAttachedToOrder(object) ? 'Tilknyttet ordre' : 'Ikke tilknyttet ordre' }}</span>
|
||||||
<span class="has-text-weight-bold">{{ isObjectAttachedToOrder(object) ? 'Tilknyttet ordre' : 'Ikke tilknyttet ordre' }}</span>
|
</span>
|
||||||
</span>
|
<span
|
||||||
<!-- Potential duplicates found?->Exact match found ?-->
|
v-if="hasAutomationState(object)"
|
||||||
<span class="tag is-light is-size-7 ml-2" :class="{ 'is-success': doesObjectHaveExactMatch(object), 'is-warning': !doesObjectHaveExactMatch(object) }" v-if="doesObjectHaveDuplicates(object)">
|
class="tag is-light is-size-7 xlvask-usage-chip xlvask-usage-automation-chip"
|
||||||
<span class="icon is-small"><i class="fas" :class="doesObjectHaveExactMatch(object) ? 'fa-check' : 'fa-exclamation-triangle'"></i></span>
|
:class="getAutomationTagClass(object)"
|
||||||
<span class="has-text-weight-bold">{{ (doesObjectHaveExactMatch(object) ? SessionUser.objects.global.language.price_match : SessionUser.objects.global.language.price_unmatch) }}</span>
|
:data-testid="'xlvask-automation-suggestion-' + object.id"
|
||||||
</span>
|
>
|
||||||
<!-- Department and Lane -->
|
<span class="icon is-small"><i class="fas" :class="getAutomationIconClass(object)"></i></span>
|
||||||
<span class="tags has-addons ml-2">
|
<span class="has-text-weight-bold xlvask-usage-chip-text">{{ getAutomationLabel(object) }}</span>
|
||||||
<span class="tag">{{ getDepartmentName(object.department_id) }}</span>
|
</span>
|
||||||
<span class="tag is-info is-light">{{ object.lane }}</span>
|
<span class="tag is-light is-size-7 xlvask-usage-chip xlvask-usage-warning-chip" :class="{ 'is-success': doesObjectHaveExactMatch(object), 'is-warning': !doesObjectHaveExactMatch(object) }" v-if="doesObjectHaveDuplicates(object)">
|
||||||
</span>
|
<span class="icon is-small"><i class="fas" :class="doesObjectHaveExactMatch(object) ? 'fa-check' : 'fa-exclamation-triangle'"></i></span>
|
||||||
</p>
|
<span class="has-text-weight-bold xlvask-usage-chip-text">{{ (doesObjectHaveExactMatch(object) ? SessionUser.objects.global.language.price_match : SessionUser.objects.global.language.price_unmatch) }}</span>
|
||||||
<p class="card-header-title is-justify-content-end">
|
</span>
|
||||||
<span class="has-text-weight-bold">{{ object.customer_name }}</span>
|
<span class="tags has-addons xlvask-usage-location-tags">
|
||||||
</p>
|
<span class="tag xlvask-usage-chip">{{ getDepartmentName(object.department_id) }}</span>
|
||||||
|
<span class="tag is-info is-light xlvask-usage-chip">{{ object.lane }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="xlvask-usage-card-customer" :title="object.customer_name">
|
||||||
|
{{ object.customer_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="columns">
|
<div class="columns xlvask-usage-content-columns">
|
||||||
<div class="column is-eight">
|
<div class="column is-10 xlvask-usage-main-column">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
<article
|
||||||
|
v-if="hasAutomationState(object)"
|
||||||
|
class="message mb-3"
|
||||||
|
:class="getAutomationMessageClass(object)"
|
||||||
|
>
|
||||||
|
<div class="message-body">
|
||||||
|
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
|
||||||
|
<div>
|
||||||
|
<p class="has-text-weight-bold mb-1">
|
||||||
|
<span class="icon is-small"><i class="fas" :class="getAutomationIconClass(object)"></i></span>
|
||||||
|
<span>{{ getAutomationLabel(object) }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="getAutomationReason(object)" class="mb-1">{{ getAutomationReason(object) }}</p>
|
||||||
|
<p v-if="getAutomationConfidence(object)" class="is-size-7 has-text-grey">{{ getAutomationConfidence(object) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="buttons mb-0" v-if="isAutomationActionable(object)">
|
||||||
|
<button
|
||||||
|
class="button is-small is-success"
|
||||||
|
:class="{ 'is-loading': object.automationLoading }"
|
||||||
|
:disabled="object.automationLoading"
|
||||||
|
:data-testid="'xlvask-automation-accept-' + object.id"
|
||||||
|
@click.stop="acceptAutomation(object)"
|
||||||
|
>
|
||||||
|
Accepter
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button is-small is-light"
|
||||||
|
:class="{ 'is-loading': object.automationLoading }"
|
||||||
|
:disabled="object.automationLoading"
|
||||||
|
:data-testid="'xlvask-automation-deny-' + object.id"
|
||||||
|
@click.stop="denyAutomation(object)"
|
||||||
|
>
|
||||||
|
Afvis
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
<div class="message" :class="{ 'is-warning': (!isObjectAttachedToOrder(object)), 'is-success': isObjectAttachedToOrder(object) }">
|
<div class="message" :class="{ 'is-warning': (!isObjectAttachedToOrder(object)), 'is-success': isObjectAttachedToOrder(object) }">
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
<template v-if="object.wash_id">
|
<template v-if="object.wash_id">
|
||||||
@@ -615,9 +873,8 @@ const filteredObjects = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="!object.loading">{{generateContent(object)}}</span>
|
|
||||||
<template v-if="object.items">
|
<template v-if="object.items">
|
||||||
<div class="columns">
|
<div class="columns xlvask-order-comparison-columns">
|
||||||
<div class="column" :class="{ 'is-12': (!object.duplicates?.length && !isComparingOrders(object)), 'is-6': (object.duplicates?.length && isComparingOrders(object)) }">
|
<div class="column" :class="{ 'is-12': (!object.duplicates?.length && !isComparingOrders(object)), 'is-6': (object.duplicates?.length && isComparingOrders(object)) }">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<input class="input" type="text" readonly :value="getDepartmentName(object.department_id) + ' - ' + object.created_at + ' (' + SessionUser.objects.global.language.generated + ')'"/>
|
<input class="input" type="text" readonly :value="getDepartmentName(object.department_id) + ' - ' + object.created_at + ' (' + SessionUser.objects.global.language.generated + ')'"/>
|
||||||
@@ -666,7 +923,7 @@ const filteredObjects = computed(() => {
|
|||||||
<span><br>{{ object.wash_id }}</span>
|
<span><br>{{ object.wash_id }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-2">
|
<div class="column is-2 xlvask-usage-actions-column">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<template v-if="isObjectAttachedToOrder(object)">
|
<template v-if="isObjectAttachedToOrder(object)">
|
||||||
@@ -729,4 +986,187 @@ const filteredObjects = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.xlvask-usage-card--highlighted {
|
||||||
|
border-left: 4px solid #3273dc;
|
||||||
|
box-shadow: 0 0 0 2px rgba(50, 115, 220, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-card-header {
|
||||||
|
align-items: start;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem 1rem;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(9rem, 15rem);
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-card-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-card-primary,
|
||||||
|
.xlvask-usage-card-status {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-registration {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-card-primary > .tag,
|
||||||
|
.xlvask-usage-card-status > .tag,
|
||||||
|
.xlvask-usage-card-status > .tags {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-chip {
|
||||||
|
height: auto;
|
||||||
|
line-height: 1.2;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 1.6rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-chip .icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-chip-text {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-automation-chip .xlvask-usage-chip-text {
|
||||||
|
max-width: 18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-warning-chip .xlvask-usage-chip-text {
|
||||||
|
max-width: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-location-tags {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-location-tags .tag:first-child {
|
||||||
|
display: block;
|
||||||
|
max-width: 10rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-card-customer {
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-content-columns,
|
||||||
|
.xlvask-order-comparison-columns {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-main-column,
|
||||||
|
.xlvask-usage-actions-column,
|
||||||
|
.xlvask-order-comparison-columns > .column {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-main-column .content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-order-comparison-columns .input,
|
||||||
|
.xlvask-order-comparison-columns :deep(table) {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-order-comparison-columns :deep(.table-container) {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 1500px) {
|
||||||
|
.xlvask-usage-card-header {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(8rem, 12rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-automation-chip .xlvask-usage-chip-text {
|
||||||
|
max-width: 13rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-warning-chip .xlvask-usage-chip-text,
|
||||||
|
.xlvask-usage-location-tags .tag:first-child {
|
||||||
|
max-width: 9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 1350px) {
|
||||||
|
.xlvask-usage-card-header {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-card-customer {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-order-comparison-columns {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-order-comparison-columns > .column {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 1215px) {
|
||||||
|
.xlvask-usage-content-columns {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-actions-column .buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-usage-actions-column .button {
|
||||||
|
flex: 1 1 10rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.xlvask-usage-chip-text,
|
||||||
|
.xlvask-usage-automation-chip .xlvask-usage-chip-text,
|
||||||
|
.xlvask-usage-warning-chip .xlvask-usage-chip-text,
|
||||||
|
.xlvask-usage-location-tags .tag:first-child {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const handleFileUpload = async (event) => {
|
|||||||
await fetchAttachments();
|
await fetchAttachments();
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: "success",
|
icon: "success",
|
||||||
title: t('modals.success'),
|
title: t('common.success'),
|
||||||
text: t('modals.file_uploaded'),
|
text: t('modals.file_uploaded'),
|
||||||
timer: 1500,
|
timer: 1500,
|
||||||
showConfirmButton: false,
|
showConfirmButton: false,
|
||||||
@@ -59,7 +59,7 @@ const handleFileUpload = async (event) => {
|
|||||||
console.error("Error uploading attachment:", error);
|
console.error("Error uploading attachment:", error);
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: "error",
|
icon: "error",
|
||||||
title: t('modals.error'),
|
title: t('common.error'),
|
||||||
text: t('modals.upload_error'),
|
text: t('modals.upload_error'),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -96,7 +96,7 @@ const deleteAttachment = async (attachmentId) => {
|
|||||||
console.error("Error deleting attachment:", error);
|
console.error("Error deleting attachment:", error);
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: "error",
|
icon: "error",
|
||||||
title: t('modals.error'),
|
title: t('common.error'),
|
||||||
text: t('modals.delete_error'),
|
text: t('modals.delete_error'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ const downloadAttachment = async (attachmentId) => {
|
|||||||
console.error("Error downloading attachment:", error);
|
console.error("Error downloading attachment:", error);
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: "error",
|
icon: "error",
|
||||||
title: t('modals.error'),
|
title: t('common.error'),
|
||||||
text: t('modals.download_error'),
|
text: t('modals.download_error'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ onMounted(() => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ t('modals.filename') }}</th>
|
<th>{{ t('modals.filename') }}</th>
|
||||||
<th class="has-text-right">{{ t('modals.actions') }}</th>
|
<th class="has-text-right">{{ t('common.actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const { loadList, loadSwitch, metaCurrentPage, metaItemsPerPage, setList } = use
|
|||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import SelfServeTaskAttachmentsModal from "@/components/displays/department/tables/SelfServeTaskAttachmentsModal.vue";
|
import SelfServeTaskAttachmentsModal from "@/components/displays/department/tables/SelfServeTaskAttachmentsModal.vue";
|
||||||
|
import {
|
||||||
|
SELF_SERVE_TASK_BUTTON_OPTIONS,
|
||||||
|
normalizeSelfServeTaskButtons,
|
||||||
|
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
|
||||||
|
|
||||||
// Define the props
|
// Define the props
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -36,24 +40,15 @@ const closeAttachmentsModal = () => {
|
|||||||
|
|
||||||
const editButtonsFunction = (id, column, value, onAfterSubmit) => {
|
const editButtonsFunction = (id, column, value, onAfterSubmit) => {
|
||||||
editingButtonsId.value = id;
|
editingButtonsId.value = id;
|
||||||
try {
|
currentButtonsSelection.value = normalizeSelfServeTaskButtons(value);
|
||||||
const parsed = JSON.stringify(value);
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
currentButtonsSelection.value = parsed;
|
|
||||||
} else {
|
|
||||||
console.warn('Parsed buttons value is not an array:', parsed);
|
|
||||||
currentButtonsSelection.value = [];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Error parsing buttons value:', e);
|
|
||||||
currentButtonsSelection.value = [];
|
|
||||||
}
|
|
||||||
showButtonsEditModal.value = true;
|
showButtonsEditModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveButtonsEdit = async () => {
|
const saveButtonsEdit = async () => {
|
||||||
console.warn('Saving buttons with selection:', currentButtonsSelection.value);
|
await SessionUser.objects.self_serve_tasks.set.buttons(
|
||||||
await SessionUser.objects.self_serve_tasks.set.buttons(editingButtonsId.value, currentButtonsSelection.value);
|
editingButtonsId.value,
|
||||||
|
normalizeSelfServeTaskButtons(currentButtonsSelection.value)
|
||||||
|
);
|
||||||
showButtonsEditModal.value = false;
|
showButtonsEditModal.value = false;
|
||||||
loadList();
|
loadList();
|
||||||
};
|
};
|
||||||
@@ -195,7 +190,7 @@ const getScopeLabel = (object) => {
|
|||||||
<td>{{ object.order_priority }}</td>
|
<td>{{ object.order_priority }}</td>
|
||||||
<td>{{ object.task }}</td>
|
<td>{{ object.task }}</td>
|
||||||
<td>{{ object.description }}</td>
|
<td>{{ object.description }}</td>
|
||||||
<td>{{ object.condition_id ? SessionUser.objects.self_serve_conditions.functions.getConditionName(object.condition_id) : $t('global.all') }}</td>
|
<td>{{ object.condition_id ? SessionUser.objects.self_serve_conditions.functions.getConditionName(object.condition_id) : $t('common.all') }}</td>
|
||||||
<td>{{ getScopeLabel(object) }}</td>
|
<td>{{ getScopeLabel(object) }}</td>
|
||||||
<td>{{ SessionUser.objects.self_serve_tasks.columns.services.parse(object.services) }}</td>
|
<td>{{ SessionUser.objects.self_serve_tasks.columns.services.parse(object.services) }}</td>
|
||||||
<td>{{ SessionUser.objects.self_serve_tasks.columns.buttons.parse(object.buttons) }}</td>
|
<td>{{ SessionUser.objects.self_serve_tasks.columns.buttons.parse(object.buttons) }}</td>
|
||||||
@@ -237,15 +232,15 @@ const getScopeLabel = (object) => {
|
|||||||
<button class="delete" @click="cancelButtonsEdit" aria-label="close"></button>
|
<button class="delete" @click="cancelButtonsEdit" aria-label="close"></button>
|
||||||
</header>
|
</header>
|
||||||
<section class="modal-card-body">
|
<section class="modal-card-body">
|
||||||
<div v-for="n in 12" :key="n" class="field mb-3">
|
<div v-for="option in SELF_SERVE_TASK_BUTTON_OPTIONS" :key="String(option.id)" class="field mb-3">
|
||||||
<input
|
<input
|
||||||
class="is-checkradio"
|
class="is-checkradio"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:id="`btn-${n-1}`"
|
:id="`btn-${option.id}`"
|
||||||
v-model="currentButtonsSelection"
|
v-model="currentButtonsSelection"
|
||||||
:value="n-1"
|
:value="option.id"
|
||||||
>
|
>
|
||||||
<label :for="`btn-${n-1}`">Button {{ n-1 }}</label>
|
<label :for="`btn-${option.id}`">{{ option.name }}</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<footer class="modal-card-foot">
|
<footer class="modal-card-foot">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user