Compare commits
207
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c06808af33 | ||
|
|
a9a5137515 | ||
|
|
a0c8574399 | ||
|
|
71b8272eb8 | ||
|
|
95a8f114bc | ||
|
|
6b91097f98 | ||
|
|
00151ec237 | ||
|
|
b5a8199472 | ||
|
|
c8f917ac30 | ||
|
|
586aba8531 | ||
|
|
bf7c01ce25 | ||
|
|
1236770513 | ||
|
|
3d8203945d | ||
|
|
2be33c89a7 | ||
|
|
631e9c4488 | ||
|
|
1afdda2650 | ||
|
|
6f23941ea4 | ||
|
|
983585ede7 | ||
|
|
b01f400d2c | ||
|
|
ecec624aea | ||
|
|
bbbad2c634 | ||
|
|
4963fed0b5 | ||
|
|
a4676f8f87 | ||
|
|
7533622fb1 | ||
|
|
90518308f8 | ||
|
|
e88c2d14d5 | ||
|
|
c14f0cc1c4 | ||
|
|
a93b0dc954 | ||
|
|
6549eb5015 | ||
|
|
0f37b0632a | ||
|
|
7d9d928a2d | ||
|
|
ce049d9196 | ||
|
|
8f3ec2cf09 | ||
|
|
ecfe27571c | ||
|
|
53d1ae114f | ||
|
|
653aa63899 | ||
|
|
8a25dcd97d | ||
|
|
3e8a7ee3db | ||
|
|
295f69ee41 | ||
|
|
ade565cbca | ||
|
|
828e2fd312 | ||
|
|
2969305dd5 | ||
|
|
b39b458f4f | ||
|
|
f040614f46 | ||
|
|
e8bd2f5478 | ||
|
|
64aa94ebc3 | ||
|
|
157c3dbf60 | ||
|
|
2bf1f5b385 | ||
|
|
78648a0b7f | ||
|
|
90427c631f | ||
|
|
29c6c9d135 | ||
|
|
8eb315f439 | ||
|
|
2b6129db29 | ||
|
|
1705d0d0ed | ||
|
|
beaf95a5b1 | ||
|
|
19501896e1 | ||
|
|
ce9a209d73 | ||
|
|
0fe2e19211 | ||
|
|
d354ce7737 | ||
|
|
3e84421913 | ||
|
|
7368c476f1 | ||
|
|
21be6c0efd | ||
|
|
173a0becdf | ||
|
|
4d810247c5 | ||
|
|
30d23aeaf8 | ||
|
|
2c22a548e1 | ||
|
|
5b181087ff | ||
|
|
c184054380 | ||
|
|
5b440a6948 | ||
|
|
844d57b1a5 | ||
|
|
caec066c6c | ||
|
|
2877979660 | ||
|
|
d03b7d86bf | ||
|
|
2c0f012446 | ||
|
|
620c4f0859 | ||
|
|
de28f2cd49 | ||
|
|
81bd4c8583 | ||
|
|
97bcf73db8 | ||
|
|
c6531076ae | ||
|
|
1b97332c38 | ||
|
|
477688d16e | ||
|
|
bc043b95f9 | ||
|
|
1e6590e2df | ||
|
|
8b8780bbaf | ||
|
|
5a0d1b7712 | ||
|
|
3cc0a5bb15 | ||
|
|
ff944ad1f2 | ||
|
|
1ee2da7ad8 | ||
|
|
f372e59a07 | ||
|
|
eeef782705 | ||
|
|
44031da7b1 | ||
|
|
943bc8ec97 | ||
|
|
0949d861f9 | ||
|
|
2903cdf57d | ||
|
|
66682040e7 | ||
|
|
2685888e24 | ||
|
|
43cf2b302d | ||
|
|
add9839916 | ||
|
|
f9bde54956 | ||
|
|
6ea51a70d8 | ||
|
|
9cccf49a4d | ||
|
|
af7d894f5c | ||
|
|
3ac49b4c65 | ||
|
|
cb81058611 | ||
|
|
23f5fdfcbf | ||
|
|
159da161d7 | ||
|
|
076bddab90 | ||
|
|
2011887a4b | ||
|
|
b4c5c8a62f | ||
|
|
ae75a954a4 | ||
|
|
861e263dfa | ||
|
|
73b838cf28 | ||
|
|
3f1502184a | ||
|
|
a73c28795b | ||
|
|
17036a0ae7 | ||
|
|
1493261ef7 | ||
|
|
cef187956e | ||
|
|
9745b106e6 | ||
|
|
27bd9df995 | ||
|
|
70440e8855 | ||
|
|
60742bf947 | ||
|
|
acbeef438b | ||
|
|
5b411de9e6 | ||
|
|
79e6ec61c2 | ||
|
|
87b8814bcf | ||
|
|
2e6b420913 | ||
|
|
23a72b08cf | ||
|
|
8db78ec937 | ||
|
|
835438049c | ||
|
|
6a8e19b3e2 | ||
|
|
a02ee3a700 | ||
|
|
95a371375f | ||
|
|
efed5ac557 | ||
|
|
7e9489573a | ||
|
|
8f7e8b565b | ||
|
|
bd0f212662 | ||
|
|
8917893f43 | ||
|
|
d3ed03a4a1 | ||
|
|
e785275c24 | ||
|
|
65b173094f | ||
|
|
9a62417981 | ||
|
|
ed1a23bac6 | ||
|
|
dc130229b5 | ||
|
|
e53b751f81 | ||
|
|
f619487f6f | ||
|
|
25f9d7c071 | ||
|
|
ad33cfedf8 | ||
|
|
36dfed1c3c | ||
|
|
b6f6cfd0aa | ||
|
|
abc033bb05 | ||
|
|
6223fc8af4 | ||
|
|
356bf7147e | ||
|
|
c68215a451 | ||
|
|
5b3e59bbb4 | ||
|
|
f6050740ec | ||
|
|
260fb8d8e9 | ||
|
|
b7b19d1ad9 | ||
|
|
16eb2730b8 | ||
|
|
412339744f | ||
|
|
aba9f63788 | ||
|
|
cef7a38b0e | ||
|
|
b28b95d40f | ||
|
|
cc260cf6c3 | ||
|
|
489a64d5ee | ||
|
|
6ee08b6333 | ||
|
|
7515db2d80 | ||
|
|
2b3cf2010a | ||
|
|
dfe8eaa882 | ||
|
|
16baa28b85 | ||
|
|
96fdb4573d | ||
|
|
53ee2a0271 | ||
|
|
93bf309626 | ||
|
|
965e79e5f5 | ||
|
|
bc62040670 | ||
|
|
e259b20548 | ||
|
|
3c4fdaeb3a | ||
|
|
024125ff1f | ||
|
|
77ecd5e656 | ||
|
|
843ab12ffa | ||
|
|
d42fb2da18 | ||
|
|
8b631898b2 | ||
|
|
880031de1a | ||
|
|
2977523d0b | ||
|
|
31c0aa739e | ||
|
|
6c3e04e8b8 | ||
|
|
253dc1d357 | ||
|
|
bade01b92a | ||
|
|
b1a2d4bcda | ||
|
|
a80422dab8 | ||
|
|
70bba3f77c | ||
|
|
a9972c5203 | ||
|
|
4e65aad0fc | ||
|
|
2a9a2ea93d | ||
|
|
5cd7598e7e | ||
|
|
d197f7f8cf | ||
|
|
c97d5c09d7 | ||
|
|
75b7aad56a | ||
|
|
e1e3187d69 | ||
|
|
db2040570e | ||
|
|
203123e73b | ||
|
|
80fcf8e4ce | ||
|
|
4852a8cc6b | ||
|
|
e48c52a9b7 | ||
|
|
7c383a1663 | ||
|
|
7196bd1ca8 | ||
|
|
0ce9dc85ce | ||
|
|
0d8de69fed |
@@ -1,28 +1,28 @@
|
||||
name: Qodana Configuration Upload
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, dev ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
upload-qodana-config:
|
||||
runs-on: ubuntu-latest
|
||||
upload-qodana-config:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
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
|
||||
- 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
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
name: Qodana Configuration Upload
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, dev ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
upload-qodana-config:
|
||||
runs-on: ubuntu-latest
|
||||
upload-qodana-config:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
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
|
||||
- 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
|
||||
|
||||
+38
-11
@@ -3,20 +3,45 @@ name: Automated Tests
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-unit:
|
||||
format-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Check frontend test formatting
|
||||
run: npm run format:tests:check
|
||||
|
||||
build-and-unit:
|
||||
needs: format-tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -29,6 +54,8 @@ jobs:
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
env:
|
||||
VITEST_BATCH_SIZE: 5
|
||||
|
||||
e2e-smoke:
|
||||
if: github.event_name != 'schedule'
|
||||
@@ -40,10 +67,10 @@ jobs:
|
||||
project: [chromium-desktop, chromium-mobile]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -58,7 +85,7 @@ jobs:
|
||||
run: npx playwright test --grep @smoke --project="${{ matrix.project }}"
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-smoke-${{ matrix.project }}
|
||||
@@ -69,15 +96,15 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
e2e-full:
|
||||
if: github.event_name == 'schedule' || github.ref == 'refs/heads/main'
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
|
||||
needs: build-and-unit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -92,7 +119,7 @@ jobs:
|
||||
run: npm run test:e2e:ci
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-full
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
cd -- "$(dirname -- "$0")/.."
|
||||
npm run format:tests:commit
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 120,
|
||||
"semi": true
|
||||
}
|
||||
@@ -28,6 +28,41 @@ npm run dev
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Playwright Batched Chromium Runs
|
||||
|
||||
Run default e2e tests in deterministic 25-test shards across `chromium-desktop` and `chromium-mobile`:
|
||||
|
||||
```sh
|
||||
npm run test:e2e:batched:chromium
|
||||
```
|
||||
|
||||
Run the same flow and automatically re-run failed shards with `PLAYWRIGHT_WORKERS=1`:
|
||||
|
||||
```sh
|
||||
npm run test:e2e:batched:chromium:rerun-failed
|
||||
```
|
||||
|
||||
Optional overrides:
|
||||
|
||||
```sh
|
||||
PLAYWRIGHT_BATCH_SIZE=25
|
||||
PLAYWRIGHT_BATCH_WORKERS=2
|
||||
PLAYWRIGHT_BATCH_DEV_PORT=5193
|
||||
```
|
||||
|
||||
You can also forward Playwright args:
|
||||
|
||||
```sh
|
||||
npm run test:e2e:batched:chromium -- --grep @smoke
|
||||
```
|
||||
|
||||
Artifacts and summaries:
|
||||
- `output/playwright/batched-chromium/last-run.json`
|
||||
- `output/playwright/batched-chromium/failed-shards.json`
|
||||
- `output/playwright/batched-chromium/report-index.html`
|
||||
- `output/playwright/batched-chromium-shard-<i>-of-<n>/report/index.html`
|
||||
- `output/playwright/batched-chromium-rerun-shard-<i>-of-<n>/report/index.html`
|
||||
|
||||
## Bubblewrap (TWA) Build and Install
|
||||
|
||||
To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands:
|
||||
|
||||
+7212
-207
File diff suppressed because it is too large
Load Diff
Generated
+4162
-2011
File diff suppressed because it is too large
Load Diff
+31
-6
@@ -7,11 +7,26 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"format:tests": "prettier --write \"tests/**/*.{js,ts}\"",
|
||||
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
|
||||
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
|
||||
"prepare": "node scripts/prepare-husky.mjs",
|
||||
"postinstall": "node scripts/postinstall-sync-playwright-root-links.mjs",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest run",
|
||||
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
|
||||
"test:unit": "node scripts/run-vitest-unit-batches.mjs",
|
||||
"test:unit:single": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ci": "playwright test --reporter=line,html",
|
||||
"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:serial": "playwright test --reporter=line,html",
|
||||
"test:e2e:batched:chromium": "node scripts/run-playwright-batched-chromium.mjs",
|
||||
"test:e2e:batched:chromium:rerun-failed": "node scripts/run-playwright-batched-chromium.mjs --rerun-failed",
|
||||
"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:live": "playwright test --config=playwright.live.config.ts",
|
||||
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
|
||||
"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",
|
||||
"twa:build": "bubblewrap build",
|
||||
"twa:update": "bubblewrap update"
|
||||
@@ -33,7 +48,9 @@
|
||||
"@vueuse/core": "^12.7.0",
|
||||
"@vueuse/head": "^2.0.0",
|
||||
"@vueuse/integrations": "^13.7.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"apexcharts": "^5.10.4",
|
||||
"axios": "1.13.5",
|
||||
"buefy": "^3.0.3",
|
||||
"bulma": "^1.0.2",
|
||||
@@ -61,16 +78,24 @@
|
||||
"vue-qrcode-reader": "^5.7.3",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-toast-notification": "^3.1.3",
|
||||
"vue3-apexcharts": "^1.11.1",
|
||||
"vue3-cookies": "^1.0.6",
|
||||
"vuex": "^4.1.0"
|
||||
"vuex": "^4.1.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"xterm": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@creativebulma/bulma-divider": "^1.1.0",
|
||||
"@event-calendar/core": "^4.1.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/event-calendar__core": "^3.7.0",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vitejs/plugin-vue-jsx": "^4.1.2",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.0.0",
|
||||
"otpauth": "^9.5.0",
|
||||
"prettier": "2.8.8",
|
||||
"sass-embedded": "^1.81.0",
|
||||
"vite": "7.1.11",
|
||||
"vite-plugin-pwa": "^1.0.2",
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:4173";
|
||||
const isCI = !!process.env.CI;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
timeout: 60_000,
|
||||
fullyParallel: true,
|
||||
forbidOnly: isCI,
|
||||
retries: isCI ? 2 : 0,
|
||||
workers: isCI ? 2 : undefined,
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { open: "never", outputFolder: "output/playwright/report" }]
|
||||
],
|
||||
outputDir: "output/playwright/test-results",
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure"
|
||||
},
|
||||
webServer: {
|
||||
command: "npm run dev -- --host 127.0.0.1 --port 4173",
|
||||
port: 4173,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: !isCI
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
use: {
|
||||
...devices["Desktop Chrome"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "chromium-mobile",
|
||||
use: {
|
||||
...devices["Pixel 7"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "firefox-desktop",
|
||||
use: {
|
||||
...devices["Desktop Firefox"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "webkit-mobile",
|
||||
use: {
|
||||
...devices["iPhone 13"]
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${devPort}`;
|
||||
const isCI = !!process.env.CI;
|
||||
const artifactNamespace = (process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "").trim();
|
||||
const artifactRoot = artifactNamespace
|
||||
? path.join("output", "playwright", artifactNamespace)
|
||||
: path.join("output", "playwright");
|
||||
const htmlReportOutputFolder = path.join(artifactRoot, "report");
|
||||
const configuredWorkers = Number(process.env.PLAYWRIGHT_WORKERS || 2);
|
||||
const workers = Number.isFinite(configuredWorkers) && configuredWorkers > 0 ? configuredWorkers : 2;
|
||||
const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim();
|
||||
const reporter =
|
||||
reporterMode === "line-html"
|
||||
? [
|
||||
["line"],
|
||||
["html", { open: "never", outputFolder: htmlReportOutputFolder }],
|
||||
]
|
||||
: [
|
||||
["list"],
|
||||
["html", { open: "never", outputFolder: htmlReportOutputFolder }],
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
testIgnore: ["**/release/**"],
|
||||
timeout: 60_000,
|
||||
fullyParallel: true,
|
||||
forbidOnly: isCI,
|
||||
retries: isCI ? 2 : 0,
|
||||
workers,
|
||||
...(process.env.PLAYWRIGHT_BASE_URL
|
||||
? {}
|
||||
: {
|
||||
globalSetup: "./playwright.global-setup.mjs",
|
||||
globalTeardown: "./playwright.global-teardown.mjs",
|
||||
}),
|
||||
reporter,
|
||||
outputDir: path.join(artifactRoot, "test-results"),
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure"
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
use: {
|
||||
...devices["Desktop Chrome"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "chromium-mobile",
|
||||
use: {
|
||||
...devices["Pixel 5"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "firefox-desktop",
|
||||
use: {
|
||||
...devices["Desktop Firefox"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "webkit-desktop",
|
||||
use: {
|
||||
...devices["Desktop Safari"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "webkit-mobile",
|
||||
use: {
|
||||
...devices["iPhone 12"]
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
|
||||
const baseURL = `http://localhost:${devPort}`;
|
||||
const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`)
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`);
|
||||
|
||||
async function getListeningProcessOnWindows(port) {
|
||||
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const match = stdout.match(new RegExp(`^\\s*TCP\\s+[^\\s]+:${port}\\s+[^\\s]+\\s+LISTENING\\s+(\\d+)\\s*$`, "mi"));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pid = Number(match[1]);
|
||||
const command = `Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object -ExpandProperty CommandLine`;
|
||||
const processResult = await execFileAsync("powershell", ["-NoProfile", "-Command", command], {
|
||||
cwd: process.cwd(),
|
||||
}).catch(() => ({ stdout: "" }));
|
||||
|
||||
return {
|
||||
Id: pid,
|
||||
CommandLine: processResult.stdout.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function getListeningProcessOnUnix(port) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp", "-Fc"], {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const pidMatch = stdout.match(/^p(\d+)$/m);
|
||||
const commandMatch = stdout.match(/^c(.+)$/m);
|
||||
if (!pidMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
Id: Number(pidMatch[1]),
|
||||
CommandLine: commandMatch ? commandMatch[1] : "",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getListeningProcess(port) {
|
||||
if (process.platform === "win32") {
|
||||
return getListeningProcessOnWindows(port);
|
||||
}
|
||||
|
||||
return getListeningProcessOnUnix(port);
|
||||
}
|
||||
|
||||
async function killProcessTree(pid) {
|
||||
if (!pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"], { cwd: process.cwd() }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-pid, "SIGTERM");
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServerReady(url, timeoutMs = 120_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url, { redirect: "manual" });
|
||||
if (response.status < 500) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// keep polling until ready
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for the local Playwright dev server at ${url}.`);
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
if (process.env.PLAYWRIGHT_BASE_URL) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(pidFile), { recursive: true });
|
||||
|
||||
const existing = await getListeningProcess(devPort);
|
||||
if (existing) {
|
||||
if (!/vite(?:\.js)?/i.test(existing.CommandLine || "")) {
|
||||
throw new Error(`Port ${devPort} is already in use by a non-Vite process: ${existing.CommandLine}`);
|
||||
}
|
||||
|
||||
await killProcessTree(existing.Id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
const serverProcess =
|
||||
process.platform === "win32"
|
||||
? spawn(
|
||||
"cmd.exe",
|
||||
["/d", "/s", "/c", `npm.cmd run dev -- --host localhost --port ${devPort} --strictPort`],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT: "1",
|
||||
},
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
}
|
||||
)
|
||||
: spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT: "1",
|
||||
},
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
serverProcess.unref();
|
||||
|
||||
await fs.writeFile(pidFile, JSON.stringify({ pid: serverProcess.pid, port: devPort }), "utf8");
|
||||
|
||||
try {
|
||||
await waitForServerReady(baseURL);
|
||||
} catch (error) {
|
||||
await killProcessTree(serverProcess.pid);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
|
||||
const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`)
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`);
|
||||
|
||||
async function killProcessTree(pid) {
|
||||
if (!pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"], { cwd: process.cwd() }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-pid, "SIGTERM");
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function globalTeardown() {
|
||||
if (process.env.PLAYWRIGHT_BASE_URL) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await fs.readFile(pidFile, "utf8");
|
||||
const { pid } = JSON.parse(file);
|
||||
await killProcessTree(pid);
|
||||
} catch {
|
||||
// ignore missing or malformed state
|
||||
}
|
||||
|
||||
await fs.rm(pidFile, { force: true }).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL;
|
||||
const isCI = !!process.env.CI;
|
||||
|
||||
if (!baseURL) {
|
||||
throw new Error("PLAYWRIGHT_BASE_URL is required for the live smoke gate.");
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e/release",
|
||||
testMatch: /.*\.live-smoke\.spec\.ts/,
|
||||
timeout: 60_000,
|
||||
fullyParallel: false,
|
||||
forbidOnly: isCI,
|
||||
retries: isCI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { open: "never", outputFolder: "output/playwright/live/report" }],
|
||||
],
|
||||
outputDir: "output/playwright/live/test-results",
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = "http://localhost:4173";
|
||||
const isCI = !!process.env.CI;
|
||||
|
||||
process.env.PLAYWRIGHT_BASE_URL = baseURL;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e/release",
|
||||
testMatch: /.*\.local-prod\.spec\.ts/,
|
||||
timeout: 60_000,
|
||||
fullyParallel: true,
|
||||
forbidOnly: isCI,
|
||||
retries: isCI ? 2 : 0,
|
||||
workers: isCI ? 2 : 3,
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { open: "never", outputFolder: "output/playwright/prod/report" }],
|
||||
],
|
||||
outputDir: "output/playwright/prod/test-results",
|
||||
use: {
|
||||
baseURL,
|
||||
serviceWorkers: "block",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
webServer: {
|
||||
command: "npm run preview:prod",
|
||||
url: baseURL,
|
||||
timeout: 240_000,
|
||||
reuseExistingServer: !isCI,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chromium-mobile",
|
||||
use: {
|
||||
...devices["Pixel 5"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "webkit-desktop",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
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,111 @@
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, "..");
|
||||
const gitPathspecs = [":(glob)tests/**/*.js", ":(glob)tests/**/*.ts"];
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function runNpmScript(scriptName) {
|
||||
if (process.env.npm_execpath) {
|
||||
return run(process.execPath, [process.env.npm_execpath, "run", scriptName], { stdio: "inherit" });
|
||||
}
|
||||
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
return run(npmCommand, ["run", scriptName], {
|
||||
shell: process.platform === "win32",
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
function parseLines(output) {
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const worktreeResult = run("git", ["rev-parse", "--show-toplevel"]);
|
||||
|
||||
if (worktreeResult.status !== 0) {
|
||||
fail("Pre-commit formatting must run inside the front-end-vue Git worktree.");
|
||||
}
|
||||
|
||||
const gitRoot = path.resolve(worktreeResult.stdout.trim());
|
||||
|
||||
if (gitRoot !== repoRoot) {
|
||||
fail(`Expected front-end-vue to be the Git root, but git reported: ${gitRoot}`);
|
||||
}
|
||||
|
||||
const unstagedTrackedResult = run("git", ["diff", "--name-only", "--", ...gitPathspecs]);
|
||||
|
||||
if (unstagedTrackedResult.status !== 0) {
|
||||
fail("Unable to inspect unstaged test-file changes before running Prettier.");
|
||||
}
|
||||
|
||||
const untrackedResult = run("git", ["ls-files", "--others", "--exclude-standard", "--", ...gitPathspecs]);
|
||||
|
||||
if (untrackedResult.status !== 0) {
|
||||
fail("Unable to inspect untracked test files before running Prettier.");
|
||||
}
|
||||
|
||||
const unsafePaths = [
|
||||
...parseLines(unstagedTrackedResult.stdout),
|
||||
...parseLines(untrackedResult.stdout),
|
||||
];
|
||||
|
||||
if (unsafePaths.length > 0) {
|
||||
const formattedPaths = unsafePaths.map((filePath) => ` - ${filePath}`).join("\n");
|
||||
fail(
|
||||
[
|
||||
"Pre-commit formatting aborted because tests contain unstaged changes.",
|
||||
"Stage or discard these files before committing so the hook does not add unrelated work:",
|
||||
formattedPaths,
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Running Prettier for frontend test files...");
|
||||
const formatResult = runNpmScript("format:tests");
|
||||
|
||||
if (formatResult.status !== 0) {
|
||||
process.exit(formatResult.status ?? 1);
|
||||
}
|
||||
|
||||
console.log("Restaging formatted frontend test files...");
|
||||
const knownFilesResult = run("git", ["ls-files", "--cached", "--others", "--exclude-standard", "--", ...gitPathspecs]);
|
||||
|
||||
if (knownFilesResult.status !== 0) {
|
||||
fail("Prettier completed, but the hook could not resolve test files for restaging.");
|
||||
}
|
||||
|
||||
const filesToStage = parseLines(knownFilesResult.stdout);
|
||||
|
||||
if (filesToStage.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const addResult = run("git", ["add", "--", ...filesToStage], { stdio: "inherit" });
|
||||
|
||||
if (addResult.status !== 0) {
|
||||
fail("Prettier completed, but git add failed while restaging formatted test files.");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, "..");
|
||||
const huskyBin = path.join(repoRoot, "node_modules", "husky", "bin.js");
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
...options,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const worktreeResult = run("git", ["rev-parse", "--show-toplevel"]);
|
||||
|
||||
if (worktreeResult.status !== 0) {
|
||||
console.log("Skipping Husky install because front-end-vue is not attached to a Git worktree.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const gitRoot = path.resolve(worktreeResult.stdout.trim());
|
||||
|
||||
if (gitRoot !== repoRoot) {
|
||||
console.log(`Skipping Husky install because Git root is ${gitRoot}, not ${repoRoot}.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const huskyResult = run(process.execPath, [huskyBin], { stdio: "inherit" });
|
||||
process.exit(huskyResult.status ?? 1);
|
||||
@@ -0,0 +1,747 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const workingDirectory = process.cwd();
|
||||
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
|
||||
const projects = ["chromium-desktop", "chromium-mobile"];
|
||||
const outputDirectory = path.join(workingDirectory, "output", "playwright", "batched-chromium");
|
||||
const testListDirectory = path.join(outputDirectory, "test-lists");
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const batchSize = normalizePositiveInt(args.batchSize ?? process.env.PLAYWRIGHT_BATCH_SIZE, 25);
|
||||
const shardWorkers = normalizePositiveInt(
|
||||
args.workers ?? process.env.PLAYWRIGHT_BATCH_WORKERS ?? process.env.PLAYWRIGHT_WORKERS,
|
||||
2
|
||||
);
|
||||
const devPort = normalizePositiveInt(args.port ?? process.env.PLAYWRIGHT_BATCH_DEV_PORT ?? process.env.PLAYWRIGHT_DEV_PORT, 5193);
|
||||
const baseURL = `http://localhost:${devPort}`;
|
||||
|
||||
let activePlaywrightChild = null;
|
||||
let activeDevServerProcess = 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 = {
|
||||
rerunFailed: false,
|
||||
dryRun: false,
|
||||
help: false,
|
||||
batchSize: null,
|
||||
workers: null,
|
||||
port: null,
|
||||
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 === "--rerun-failed") {
|
||||
parsed.rerunFailed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "--dry-run") {
|
||||
parsed.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "--help" || value === "-h") {
|
||||
parsed.help = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "--batch-size" && rawArgs[index + 1]) {
|
||||
parsed.batchSize = rawArgs[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.startsWith("--batch-size=")) {
|
||||
parsed.batchSize = value.slice("--batch-size=".length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "--workers" && rawArgs[index + 1]) {
|
||||
parsed.workers = rawArgs[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.startsWith("--workers=")) {
|
||||
parsed.workers = value.slice("--workers=".length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "--port" && rawArgs[index + 1]) {
|
||||
parsed.port = rawArgs[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.startsWith("--port=")) {
|
||||
parsed.port = value.slice("--port=".length);
|
||||
continue;
|
||||
}
|
||||
|
||||
parsed.forwardedArgs.push(value);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizePositiveInt(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function stripAnsi(input) {
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
function formatDuration(durationMs) {
|
||||
return `${(durationMs / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function chunkTests(testLines, size) {
|
||||
const chunks = [];
|
||||
for (let index = 0; index < testLines.length; index += size) {
|
||||
const shard = Math.floor(index / size) + 1;
|
||||
const lines = testLines.slice(index, index + size);
|
||||
chunks.push({
|
||||
shard,
|
||||
start: index + 1,
|
||||
end: index + lines.length,
|
||||
lines,
|
||||
});
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Playwright batched chromium runner
|
||||
|
||||
Usage:
|
||||
node scripts/run-playwright-batched-chromium.mjs [options] [-- <playwright args>]
|
||||
|
||||
Options:
|
||||
--batch-size <n> Tests per chunk. Default: 25
|
||||
--workers <n> PLAYWRIGHT_WORKERS for primary runs. Default: 2
|
||||
--port <n> Vite dev server port. Default: 5193
|
||||
--rerun-failed Re-run failed chunks with PLAYWRIGHT_WORKERS=1
|
||||
--dry-run Compute and print chunk plan without executing tests
|
||||
-h, --help Show help
|
||||
`);
|
||||
}
|
||||
|
||||
function validateForwardedArgs(forwardedArgs) {
|
||||
for (const value of forwardedArgs) {
|
||||
if (value === "--list" || value.startsWith("--list=")) {
|
||||
throw new Error("--list is managed by this script and cannot be forwarded.");
|
||||
}
|
||||
|
||||
if (value === "--project" || value.startsWith("--project=")) {
|
||||
throw new Error("--project is managed by this script and cannot be forwarded.");
|
||||
}
|
||||
|
||||
if (value === "--shard" || value.startsWith("--shard=")) {
|
||||
throw new Error("--shard is managed by this script and cannot be forwarded.");
|
||||
}
|
||||
|
||||
if (value === "--test-list" || value.startsWith("--test-list=")) {
|
||||
throw new Error("--test-list is managed by this script and cannot be forwarded.");
|
||||
}
|
||||
|
||||
if (value === "--test-list-invert" || value.startsWith("--test-list-invert=")) {
|
||||
throw new Error("--test-list-invert is managed by this script and cannot be forwarded.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prefixStream(stream, prefix) {
|
||||
const lineReader = readline.createInterface({ input: stream });
|
||||
lineReader.on("line", (line) => {
|
||||
process.stdout.write(`[${prefix}] ${line}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
async function getListeningProcessOnWindows(port) {
|
||||
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], { cwd: workingDirectory });
|
||||
const match = stdout.match(new RegExp(`^\\s*TCP\\s+[^\\s]+:${port}\\s+[^\\s]+\\s+LISTENING\\s+(\\d+)\\s*$`, "mi"));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pid = Number(match[1]);
|
||||
const command = `Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object -ExpandProperty CommandLine`;
|
||||
const processResult = await execFileAsync("powershell", ["-NoProfile", "-Command", command], {
|
||||
cwd: workingDirectory,
|
||||
}).catch(() => ({ stdout: "" }));
|
||||
|
||||
return {
|
||||
id: pid,
|
||||
commandLine: processResult.stdout.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function getListeningProcessOnUnix(port) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp", "-Fc"], {
|
||||
cwd: workingDirectory,
|
||||
});
|
||||
const pidMatch = stdout.match(/^p(\d+)$/m);
|
||||
const commandMatch = stdout.match(/^c(.+)$/m);
|
||||
if (!pidMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(pidMatch[1]),
|
||||
commandLine: commandMatch ? commandMatch[1] : "",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getListeningProcess(port) {
|
||||
if (process.platform === "win32") {
|
||||
return getListeningProcessOnWindows(port);
|
||||
}
|
||||
|
||||
return getListeningProcessOnUnix(port);
|
||||
}
|
||||
|
||||
async function killProcessTree(pid) {
|
||||
if (!pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"], { cwd: workingDirectory }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-pid, "SIGTERM");
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServerReady(url, timeoutMs = 120_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url, { redirect: "manual" });
|
||||
if (response.status < 500) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// keep polling
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for Vite server at ${url}.`);
|
||||
}
|
||||
|
||||
async function startDevServer() {
|
||||
const existingProcess = await getListeningProcess(devPort);
|
||||
if (existingProcess) {
|
||||
if (!/vite(?:\.js)?/i.test(existingProcess.commandLine || "")) {
|
||||
throw new Error(`Port ${devPort} is already in use by a non-Vite process: ${existingProcess.commandLine}`);
|
||||
}
|
||||
|
||||
await killProcessTree(existingProcess.id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
activeDevServerProcess =
|
||||
process.platform === "win32"
|
||||
? spawn(
|
||||
"cmd.exe",
|
||||
["/d", "/s", "/c", `npm.cmd run dev -- --host localhost --port ${devPort} --strictPort`],
|
||||
{
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
}
|
||||
)
|
||||
: spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], {
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
prefixStream(activeDevServerProcess.stdout, "dev-server");
|
||||
prefixStream(activeDevServerProcess.stderr, "dev-server:err");
|
||||
|
||||
await waitForServerReady(baseURL);
|
||||
console.log(`Vite dev server is ready at ${baseURL}.`);
|
||||
}
|
||||
|
||||
async function stopDevServer() {
|
||||
if (!activeDevServerProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
await killProcessTree(activeDevServerProcess.pid);
|
||||
activeDevServerProcess = null;
|
||||
}
|
||||
|
||||
async function runPlaywrightCommand(commandArgs, envOverrides = {}, inheritOutput = false) {
|
||||
if (inheritOutput) {
|
||||
return new Promise((resolve) => {
|
||||
activePlaywrightChild = spawn(process.execPath, [playwrightCliPath, ...commandArgs], {
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
...envOverrides,
|
||||
},
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
activePlaywrightChild.on("close", (code) => {
|
||||
activePlaywrightChild = null;
|
||||
resolve({
|
||||
code: code ?? 1,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await execFileAsync(process.execPath, [playwrightCliPath, ...commandArgs], {
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
...envOverrides,
|
||||
},
|
||||
maxBuffer: 30 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
return {
|
||||
code: 0,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
} catch (error) {
|
||||
const stdout = typeof error.stdout === "string" ? error.stdout : "";
|
||||
const stderr = typeof error.stderr === "string" ? error.stderr : "";
|
||||
const code = typeof error.code === "number" ? error.code : 1;
|
||||
return {
|
||||
code,
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function preflightCollectTests(forwardedArgs) {
|
||||
const commandArgs = [
|
||||
"test",
|
||||
...projects.flatMap((project) => ["--project", project]),
|
||||
"--list",
|
||||
...forwardedArgs,
|
||||
];
|
||||
const result = await runPlaywrightCommand(
|
||||
commandArgs,
|
||||
{
|
||||
PLAYWRIGHT_BASE_URL: baseURL,
|
||||
PLAYWRIGHT: "1",
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const combinedOutput = stripAnsi(`${result.stdout}\n${result.stderr}`);
|
||||
const totalMatch = combinedOutput.match(/Total:\s+(\d+)\s+tests?/i);
|
||||
if (!totalMatch) {
|
||||
throw new Error(`Failed to parse Playwright test count from --list output.\n${combinedOutput}`);
|
||||
}
|
||||
|
||||
const listedTests = combinedOutput
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("["));
|
||||
|
||||
const parsedTotal = Number(totalMatch[1]);
|
||||
if (listedTests.length === 0) {
|
||||
throw new Error("Playwright --list returned zero runnable tests.");
|
||||
}
|
||||
|
||||
if (listedTests.length !== parsedTotal) {
|
||||
console.warn(
|
||||
`Warning: --list reported ${parsedTotal} tests but ${listedTests.length} test lines were parsed. Using parsed lines.`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
totalTests: listedTests.length,
|
||||
listedTests,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeTestListFile(passLabel, chunk, totalShards) {
|
||||
await fs.mkdir(testListDirectory, { recursive: true });
|
||||
const filename = `${passLabel}-shard-${chunk.shard}-of-${totalShards}.txt`;
|
||||
const targetPath = path.join(testListDirectory, filename);
|
||||
await fs.writeFile(targetPath, `${chunk.lines.join("\n")}\n`, "utf8");
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
async function runShard({ chunk, totalShards, workers, passLabel, forwardedArgs }) {
|
||||
const testListPath = await writeTestListFile(passLabel, chunk, totalShards);
|
||||
const commandArgs = [
|
||||
"test",
|
||||
...projects.flatMap((project) => ["--project", project]),
|
||||
"--test-list",
|
||||
testListPath,
|
||||
...forwardedArgs,
|
||||
];
|
||||
|
||||
const isRerun = passLabel === "rerun";
|
||||
const artifactNamespace = isRerun
|
||||
? `batched-chromium-rerun-shard-${chunk.shard}-of-${totalShards}`
|
||||
: `batched-chromium-shard-${chunk.shard}-of-${totalShards}`;
|
||||
|
||||
console.log(
|
||||
`[${passLabel}] Running shard ${chunk.shard}/${totalShards} (${chunk.start}-${chunk.end}, ${chunk.lines.length} tests, workers=${workers})`
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const result = await runPlaywrightCommand(
|
||||
commandArgs,
|
||||
{
|
||||
PLAYWRIGHT_BASE_URL: baseURL,
|
||||
PLAYWRIGHT_DEV_PORT: String(devPort),
|
||||
PLAYWRIGHT_WORKERS: String(workers),
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: artifactNamespace,
|
||||
PLAYWRIGHT_REPORTER_MODE: "line-html",
|
||||
PLAYWRIGHT: "1",
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
return {
|
||||
pass: passLabel,
|
||||
shard: chunk.shard,
|
||||
totalShards,
|
||||
tests: chunk.lines.length,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
workers,
|
||||
code: result.code,
|
||||
durationMs: Date.now() - startedAt,
|
||||
artifactNamespace,
|
||||
testListPath: path.relative(workingDirectory, testListPath),
|
||||
};
|
||||
}
|
||||
|
||||
function printSummary(primaryResults, rerunResults) {
|
||||
const rows = [...primaryResults, ...rerunResults];
|
||||
console.log("\nBatched Chromium Summary");
|
||||
console.log("pass\tshard\tstatus\ttests\tworkers\tduration\tartifact");
|
||||
for (const row of rows) {
|
||||
const status = row.code === 0 ? "passed" : "failed";
|
||||
console.log(
|
||||
`${row.pass}\t${row.shard}/${row.totalShards}\t${status}\t${row.start}-${row.end} (${row.tests})\t${row.workers}\t${formatDuration(row.durationMs)}\t${row.artifactNamespace}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRunArtifacts({ totalTests, totalShards, chunks, primaryResults, rerunResults }) {
|
||||
const failedPrimaryShards = primaryResults.filter((result) => result.code !== 0).map((result) => result.shard);
|
||||
const failedRerunShards = rerunResults.filter((result) => result.code !== 0).map((result) => result.shard);
|
||||
|
||||
await fs.mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
const runReport = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
baseURL,
|
||||
projects,
|
||||
batchSize,
|
||||
totalTests,
|
||||
totalShards,
|
||||
primaryWorkers: shardWorkers,
|
||||
rerunFailed: args.rerunFailed,
|
||||
forwardedArgs: args.forwardedArgs,
|
||||
chunks: chunks.map((chunk) => ({
|
||||
shard: chunk.shard,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
tests: chunk.lines.length,
|
||||
})),
|
||||
primaryResults,
|
||||
rerunResults,
|
||||
failedPrimaryShards,
|
||||
failedRerunShards,
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(outputDirectory, "last-run.json"), JSON.stringify(runReport, null, 2), "utf8");
|
||||
await fs.writeFile(path.join(outputDirectory, "failed-shards.json"), JSON.stringify(failedPrimaryShards, null, 2), "utf8");
|
||||
|
||||
const primaryRows = primaryResults
|
||||
.map((result) => {
|
||||
const status = result.code === 0 ? "passed" : "failed";
|
||||
const color = result.code === 0 ? "#166534" : "#991b1b";
|
||||
return `<tr>
|
||||
<td>${result.shard}/${result.totalShards}</td>
|
||||
<td>${result.start}-${result.end} (${result.tests})</td>
|
||||
<td style="color:${color};font-weight:600">${status}</td>
|
||||
<td>${result.workers}</td>
|
||||
<td>${formatDuration(result.durationMs)}</td>
|
||||
<td>${result.testListPath}</td>
|
||||
<td><a href="../${result.artifactNamespace}/report/index.html">Open report</a></td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const rerunRows =
|
||||
rerunResults.length === 0
|
||||
? "<tr><td colspan=\"7\">No rerun shards executed.</td></tr>"
|
||||
: rerunResults
|
||||
.map((result) => {
|
||||
const status = result.code === 0 ? "passed" : "failed";
|
||||
const color = result.code === 0 ? "#166534" : "#991b1b";
|
||||
return `<tr>
|
||||
<td>${result.shard}/${result.totalShards}</td>
|
||||
<td>${result.start}-${result.end} (${result.tests})</td>
|
||||
<td style="color:${color};font-weight:600">${status}</td>
|
||||
<td>${result.workers}</td>
|
||||
<td>${formatDuration(result.durationMs)}</td>
|
||||
<td>${result.testListPath}</td>
|
||||
<td><a href="../${result.artifactNamespace}/report/index.html">Open report</a></td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Batched Chromium Playwright Report Index</title>
|
||||
<style>
|
||||
body { font-family: Segoe UI, Arial, sans-serif; margin: 32px; color: #111827; }
|
||||
h1, h2 { margin-bottom: 8px; }
|
||||
p { color: #4b5563; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 16px 0 28px; }
|
||||
th, td { border: 1px solid #d1d5db; padding: 8px 10px; text-align: left; }
|
||||
th { background: #f3f4f6; }
|
||||
a { color: #1d4ed8; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code { background: #f3f4f6; padding: 2px 6px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Batched Chromium Playwright Reports</h1>
|
||||
<p>Total tests: <strong>${totalTests}</strong> | Batch size: <strong>${batchSize}</strong> | Shards: <strong>${totalShards}</strong></p>
|
||||
<p>JSON summary: <code>output/playwright/batched-chromium/last-run.json</code></p>
|
||||
|
||||
<h2>Primary pass</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Shard</th>
|
||||
<th>Test range</th>
|
||||
<th>Status</th>
|
||||
<th>Workers</th>
|
||||
<th>Duration</th>
|
||||
<th>Test list</th>
|
||||
<th>Report</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${primaryRows}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Rerun pass</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Shard</th>
|
||||
<th>Test range</th>
|
||||
<th>Status</th>
|
||||
<th>Workers</th>
|
||||
<th>Duration</th>
|
||||
<th>Test list</th>
|
||||
<th>Report</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rerunRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
await fs.writeFile(path.join(outputDirectory, "report-index.html"), html, "utf8");
|
||||
}
|
||||
|
||||
async function shutdown(signal) {
|
||||
if (isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
isShuttingDown = true;
|
||||
console.error(`Received ${signal}. Stopping active Playwright chunk and dev server...`);
|
||||
|
||||
await Promise.all([
|
||||
killProcessTree(activePlaywrightChild?.pid),
|
||||
killProcessTree(activeDevServerProcess?.pid),
|
||||
]);
|
||||
|
||||
activePlaywrightChild = null;
|
||||
activeDevServerProcess = null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (args.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
validateForwardedArgs(args.forwardedArgs);
|
||||
await fs.access(playwrightCliPath);
|
||||
|
||||
const preflight = await preflightCollectTests(args.forwardedArgs);
|
||||
const chunks = chunkTests(preflight.listedTests, batchSize);
|
||||
const totalShards = chunks.length;
|
||||
|
||||
console.log(
|
||||
`Batched Chromium preflight: ${preflight.totalTests} tests across ${projects.join(", ")} -> ${totalShards} shards at ${batchSize} tests/chunk.`
|
||||
);
|
||||
|
||||
if (args.dryRun) {
|
||||
await fs.mkdir(outputDirectory, { recursive: true });
|
||||
const dryRunFile = path.join(outputDirectory, "dry-run.json");
|
||||
await fs.writeFile(
|
||||
dryRunFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
generatedAt: new Date().toISOString(),
|
||||
baseURL,
|
||||
projects,
|
||||
batchSize,
|
||||
shardWorkers,
|
||||
totalTests: preflight.totalTests,
|
||||
totalShards,
|
||||
rerunFailed: args.rerunFailed,
|
||||
forwardedArgs: args.forwardedArgs,
|
||||
shards: chunks.map((chunk) => ({
|
||||
shard: chunk.shard,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
tests: chunk.lines.length,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
console.log(`Dry run complete. Summary written to ${path.relative(workingDirectory, dryRunFile)}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await startDevServer();
|
||||
|
||||
const primaryResults = [];
|
||||
for (const chunk of chunks) {
|
||||
primaryResults.push(
|
||||
await runShard({
|
||||
chunk,
|
||||
totalShards,
|
||||
workers: shardWorkers,
|
||||
passLabel: "primary",
|
||||
forwardedArgs: args.forwardedArgs,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const failedPrimaryShards = primaryResults.filter((result) => result.code !== 0).map((result) => result.shard);
|
||||
const rerunResults = [];
|
||||
|
||||
if (args.rerunFailed && failedPrimaryShards.length > 0) {
|
||||
console.log(`\nRe-running failed chunks with workers=1: ${failedPrimaryShards.join(", ")}`);
|
||||
for (const shard of failedPrimaryShards) {
|
||||
const chunk = chunks[shard - 1];
|
||||
rerunResults.push(
|
||||
await runShard({
|
||||
chunk,
|
||||
totalShards,
|
||||
workers: 1,
|
||||
passLabel: "rerun",
|
||||
forwardedArgs: args.forwardedArgs,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
printSummary(primaryResults, rerunResults);
|
||||
await writeRunArtifacts({
|
||||
totalTests: preflight.totalTests,
|
||||
totalShards,
|
||||
chunks,
|
||||
primaryResults,
|
||||
rerunResults,
|
||||
});
|
||||
await stopDevServer();
|
||||
|
||||
const failedRerunShards = rerunResults.filter((result) => result.code !== 0).map((result) => result.shard);
|
||||
|
||||
if (failedPrimaryShards.length === 0) {
|
||||
console.log("\nAll batched Chromium chunks passed.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`\nPrimary failed shards: ${failedPrimaryShards.join(", ")}`);
|
||||
if (args.rerunFailed) {
|
||||
if (failedRerunShards.length > 0) {
|
||||
console.error(`Rerun failed shards: ${failedRerunShards.join(", ")}`);
|
||||
} else {
|
||||
console.error("All failed shards passed during rerun. Treating this as flaky and returning non-zero.");
|
||||
}
|
||||
}
|
||||
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
await main().catch(async (error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
await stopDevServer();
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const forwardedArgs = process.argv.slice(2);
|
||||
const workingDirectory = process.cwd();
|
||||
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
|
||||
const basePort = normalizePositiveInt(process.env.PLAYWRIGHT_PARALLEL_BASE_PORT, 5191);
|
||||
const perProcessWorkers = normalizePositiveInt(process.env.PLAYWRIGHT_PARALLEL_WORKERS, 1);
|
||||
const reportIndexDirectory = path.join(workingDirectory, "output", "playwright", "ci-parallel-report");
|
||||
const execFileAsync = promisify(execFile);
|
||||
const activeChildren = new Set();
|
||||
let isShuttingDown = false;
|
||||
|
||||
const groups = [
|
||||
{
|
||||
name: "chromium",
|
||||
projects: ["chromium-desktop", "chromium-mobile"],
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
projects: ["firefox-desktop"],
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
projects: ["webkit-desktop", "webkit-mobile"],
|
||||
},
|
||||
];
|
||||
|
||||
function normalizePositiveInt(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function getArtifactNamespace(group) {
|
||||
return `ci-parallel-${group.name}`;
|
||||
}
|
||||
|
||||
function getDevServerPidFile(artifactNamespace) {
|
||||
return path.join(workingDirectory, "output", "playwright", `dev-server-${artifactNamespace}.json`);
|
||||
}
|
||||
|
||||
async function killProcessTree(pid) {
|
||||
if (!pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"]).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-pid, "SIGTERM");
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupDevServerArtifacts(artifactNamespace) {
|
||||
const pidFile = getDevServerPidFile(artifactNamespace);
|
||||
|
||||
try {
|
||||
const file = await fs.readFile(pidFile, "utf8");
|
||||
const { pid } = JSON.parse(file);
|
||||
await killProcessTree(pid);
|
||||
} catch {
|
||||
// ignore missing pid files or already-exited processes
|
||||
}
|
||||
|
||||
await fs.rm(pidFile, { force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
function prefixStream(stream, prefix) {
|
||||
const lineReader = readline.createInterface({ input: stream });
|
||||
lineReader.on("line", (line) => {
|
||||
process.stdout.write(`[${prefix}] ${line}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
function spawnGroup(group, index) {
|
||||
const devPort = basePort + index;
|
||||
const artifactNamespace = getArtifactNamespace(group);
|
||||
const args = [
|
||||
"test",
|
||||
...group.projects.flatMap((project) => ["--project", project]),
|
||||
...forwardedArgs,
|
||||
];
|
||||
|
||||
const child = spawn(process.execPath, [playwrightCliPath, ...args], {
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT_BASE_URL: "",
|
||||
PLAYWRIGHT_DEV_PORT: String(devPort),
|
||||
PLAYWRIGHT_WORKERS: String(perProcessWorkers),
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: artifactNamespace,
|
||||
PLAYWRIGHT_REPORTER_MODE: "line-html",
|
||||
PLAYWRIGHT: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
activeChildren.add(child);
|
||||
|
||||
prefixStream(child.stdout, group.name);
|
||||
prefixStream(child.stderr, `${group.name}:err`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
child.on("close", (code) => {
|
||||
activeChildren.delete(child);
|
||||
resolve({
|
||||
name: group.name,
|
||||
code: code ?? 1,
|
||||
artifactNamespace,
|
||||
projects: group.projects,
|
||||
port: devPort,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function writeCombinedReportIndex(results) {
|
||||
await fs.rm(reportIndexDirectory, { recursive: true, force: true });
|
||||
await fs.mkdir(reportIndexDirectory, { recursive: true });
|
||||
|
||||
const rows = results
|
||||
.map((result) => {
|
||||
const status = result.code === 0 ? "passed" : "failed";
|
||||
const statusColor = result.code === 0 ? "#166534" : "#991b1b";
|
||||
const reportHref = `../${result.artifactNamespace}/report/index.html`;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${result.name}</td>
|
||||
<td>${result.projects.join(", ")}</td>
|
||||
<td>${result.port}</td>
|
||||
<td style="color: ${statusColor}; font-weight: 600;">${status}</td>
|
||||
<td><a href="${reportHref}">Open report</a></td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Playwright Parallel Reports</title>
|
||||
<style>
|
||||
body { font-family: Segoe UI, Arial, sans-serif; margin: 32px; color: #111827; }
|
||||
h1 { margin-bottom: 8px; }
|
||||
p { color: #4b5563; }
|
||||
table { border-collapse: collapse; width: 100%; margin-top: 24px; }
|
||||
th, td { border: 1px solid #d1d5db; padding: 10px 12px; text-align: left; }
|
||||
th { background: #f3f4f6; }
|
||||
a { color: #1d4ed8; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Playwright Parallel Reports</h1>
|
||||
<p>Each child run used its own Vite port, dev-server pid file, and artifact directory.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Group</th>
|
||||
<th>Projects</th>
|
||||
<th>Port</th>
|
||||
<th>Status</th>
|
||||
<th>Report</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
await fs.writeFile(path.join(reportIndexDirectory, "index.html"), html, "utf8");
|
||||
}
|
||||
|
||||
async function shutdown(signal) {
|
||||
if (isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
isShuttingDown = true;
|
||||
process.stderr.write(`Received ${signal}. Stopping parallel Playwright children...\n`);
|
||||
|
||||
await Promise.all([...activeChildren].map((child) => killProcessTree(child.pid)));
|
||||
await Promise.all(groups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group))));
|
||||
}
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
void shutdown(signal).finally(() => {
|
||||
process.exit(130);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(
|
||||
`Starting Playwright CI in parallel with ${groups.length} processes and ${perProcessWorkers} worker(s) per process.`
|
||||
);
|
||||
|
||||
await Promise.all(groups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group))));
|
||||
const results = await Promise.all(groups.map((group, index) => spawnGroup(group, index)));
|
||||
await writeCombinedReportIndex(results);
|
||||
await Promise.all(groups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group))));
|
||||
|
||||
const failedRuns = results.filter((result) => result.code !== 0);
|
||||
if (failedRuns.length > 0) {
|
||||
console.error(
|
||||
`Parallel Playwright CI failed for: ${failedRuns.map((result) => `${result.name} (${result.code})`).join(", ")}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Parallel Playwright CI passed. Combined report index: ${path.relative(workingDirectory, path.join(reportIndexDirectory, "index.html"))}`
|
||||
);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,265 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const workingDirectory = process.cwd();
|
||||
const vitestCliPath = path.join(workingDirectory, "node_modules", "vitest", "vitest.mjs");
|
||||
const unitTestsRoot = path.join(workingDirectory, "tests", "unit");
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const batchSize = normalizePositiveInt(args.batchSize ?? process.env.VITEST_BATCH_SIZE, 5);
|
||||
|
||||
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,
|
||||
batchSize: null,
|
||||
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 === "--batch-size" && rawArgs[index + 1]) {
|
||||
parsed.batchSize = rawArgs[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.startsWith("--batch-size=")) {
|
||||
parsed.batchSize = value.slice("--batch-size=".length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.startsWith("-")) {
|
||||
parsed.forwardedArgs.push(...rawArgs.slice(index));
|
||||
break;
|
||||
}
|
||||
|
||||
parsed.specFilters.push(value);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizePositiveInt(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizePathSegments(value) {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Vitest unit batch runner
|
||||
|
||||
Usage:
|
||||
node scripts/run-vitest-unit-batches.mjs [spec-filter ...] [options] [-- <vitest args>]
|
||||
|
||||
Options:
|
||||
--batch-size <n> Spec files per batch. Default: 5
|
||||
-h, --help Show help
|
||||
|
||||
Examples:
|
||||
npm run test:unit
|
||||
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
|
||||
`);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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 chunkFiles(files, size) {
|
||||
const chunks = [];
|
||||
|
||||
for (let index = 0; index < files.length; index += size) {
|
||||
chunks.push({
|
||||
batch: Math.floor(index / size) + 1,
|
||||
files: files.slice(index, index + size),
|
||||
});
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function formatDuration(durationMs) {
|
||||
return `${(durationMs / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function hasForwardedArg(name) {
|
||||
return args.forwardedArgs.some((value) => value === name || value.startsWith(`${name}=`));
|
||||
}
|
||||
|
||||
async function runBatch(chunk, totalBatches) {
|
||||
console.log(`[vitest-batch] Running batch ${chunk.batch}/${totalBatches} with ${chunk.files.length} spec file(s).`);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const enforcedArgs = [];
|
||||
|
||||
if (!hasForwardedArg("--maxWorkers")) {
|
||||
enforcedArgs.push("--maxWorkers=1");
|
||||
}
|
||||
|
||||
if (!hasForwardedArg("--fileParallelism") && !hasForwardedArg("--no-file-parallelism")) {
|
||||
enforcedArgs.push("--no-file-parallelism");
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
activeChild = spawn(
|
||||
process.execPath,
|
||||
[vitestCliPath, "run", ...chunk.files, ...enforcedArgs, ...args.forwardedArgs],
|
||||
{
|
||||
cwd: workingDirectory,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
}
|
||||
);
|
||||
|
||||
activeChild.on("close", (code) => {
|
||||
activeChild = null;
|
||||
resolve({
|
||||
batch: chunk.batch,
|
||||
files: chunk.files.length,
|
||||
code: code ?? 1,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function printSummary(results, totalFiles) {
|
||||
console.log("\nVitest batch summary");
|
||||
console.log(`Total spec files: ${totalFiles}`);
|
||||
console.log("batch\tstatus\tfiles\tduration");
|
||||
|
||||
for (const result of results) {
|
||||
const status = result.code === 0 ? "passed" : "failed";
|
||||
console.log(`${result.batch}\t${status}\t${result.files}\t${formatDuration(result.durationMs)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdown(signal) {
|
||||
if (isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
isShuttingDown = true;
|
||||
console.error(`Received ${signal}. Stopping active Vitest batch...`);
|
||||
|
||||
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 discoveredFiles = await collectSpecFiles(unitTestsRoot);
|
||||
const selectedFiles = applyFilters(discoveredFiles, args.specFilters);
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
throw new Error("No unit spec files matched the provided filters.");
|
||||
}
|
||||
|
||||
const chunks = chunkFiles(selectedFiles, batchSize);
|
||||
console.log(
|
||||
`Running ${selectedFiles.length} unit spec file(s) in ${chunks.length} batch(es) at ${batchSize} file(s) per batch.`
|
||||
);
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
results.push(await runBatch(chunk, chunks.length));
|
||||
}
|
||||
|
||||
printSummary(results, selectedFiles.length);
|
||||
|
||||
const failedBatches = results.filter((result) => result.code !== 0);
|
||||
if (failedBatches.length > 0) {
|
||||
throw new Error(`Vitest batches failed: ${failedBatches.map((result) => result.batch).join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
param(
|
||||
[int]$Port = 5173
|
||||
)
|
||||
|
||||
function Get-ListeningProcess {
|
||||
param([int]$TargetPort)
|
||||
|
||||
$listener = Get-NetTCPConnection -LocalPort $TargetPort -State Listen -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $listener) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $($listener.OwningProcess)" -ErrorAction SilentlyContinue
|
||||
if (-not $process) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
Id = $listener.OwningProcess
|
||||
CommandLine = [string]($process.CommandLine ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
$existing = Get-ListeningProcess -TargetPort $Port
|
||||
if ($existing) {
|
||||
if ($existing.CommandLine -notmatch "vite(?:\.js)?") {
|
||||
throw "Port $Port is already in use by a non-Vite process: $($existing.CommandLine)"
|
||||
}
|
||||
|
||||
Stop-Process -Id $existing.Id -Force
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
|
||||
$npm = if (Get-Command npm.cmd -ErrorAction SilentlyContinue) {
|
||||
"npm.cmd"
|
||||
} else {
|
||||
"npm"
|
||||
}
|
||||
|
||||
& $npm run dev -- --host localhost --port $Port --strictPort
|
||||
exit $LASTEXITCODE
|
||||
+1035
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -6,6 +6,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import favicon from '@/assets/favicon.ico';
|
||||
import VersionCheck from "@/components/global/VersionCheck.vue";
|
||||
import RequestQueueProgress from "@/components/global/RequestQueueProgress.vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import LayoutV2 from "@/components/page/wrappers/LayoutV2.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -23,7 +24,8 @@ const routeTitleI18nKeys = {
|
||||
"adminlogin": ["header.operator_login"],
|
||||
"guestHome": ["guest.home.title"],
|
||||
"PwaDownload": ["guest.pwa.title"],
|
||||
"connectivity-issue": ["connectivity.title"]
|
||||
"connectivity-issue": ["connectivity.title"],
|
||||
"selfservestudio": ["admin.self_serve.title"]
|
||||
};
|
||||
|
||||
const toReadableTitle = (value) => {
|
||||
@@ -90,6 +92,7 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
|
||||
</DefaultPageWrapper>
|
||||
</main>
|
||||
</template>
|
||||
<RequestQueueProgress />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+28
-1
@@ -98,4 +98,31 @@ textarea.has-sharp-edges {
|
||||
/*}*/
|
||||
/*button.is-link {*/
|
||||
/* background-color: #1584BC;*/
|
||||
/*}*/
|
||||
/*}*/
|
||||
|
||||
.mt-30 {
|
||||
margin-top: 30px !important;
|
||||
}
|
||||
|
||||
.cell .card-content .field{
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cell .card-content .field .label{
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.mb-8-mobile{
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 769px) {
|
||||
.ml-16-tablet {
|
||||
margin-left: 16px !important;
|
||||
}
|
||||
.mt-30-tablet {
|
||||
margin-top: 30px !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
:root {
|
||||
--pos-shell-gap: 1.25rem;
|
||||
--pos-card-padding: 1.25rem;
|
||||
--pos-card-radius: 1rem;
|
||||
--pos-card-border: #dce5f1;
|
||||
--pos-card-shadow: 0 14px 36px rgba(6, 54, 81, 0.08);
|
||||
--pos-muted: #6b7a90;
|
||||
}
|
||||
|
||||
.pos-shell {
|
||||
display: grid;
|
||||
gap: var(--pos-shell-gap);
|
||||
align-items: start;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
|
||||
}
|
||||
|
||||
.pos-main,
|
||||
.pos-rail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--pos-shell-gap);
|
||||
}
|
||||
|
||||
.pos-shell-actions {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
gap: var(--pos-shell-gap);
|
||||
align-items: start;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
|
||||
}
|
||||
|
||||
.pos-shell-actions__rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.pos-duplicate-warning {
|
||||
margin: 0;
|
||||
border: 1px solid #f2d38a;
|
||||
border-radius: 0.95rem;
|
||||
box-shadow: 0 10px 24px rgba(142, 108, 24, 0.08);
|
||||
}
|
||||
|
||||
.pos-duplicate-warning__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.pos-duplicate-warning__header-copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-duplicate-warning__icon {
|
||||
color: #8a6300;
|
||||
}
|
||||
|
||||
.pos-duplicate-warning__title {
|
||||
margin: 0;
|
||||
color: #604700;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pos-duplicate-warning__message {
|
||||
margin: 0.75rem 0 0;
|
||||
color: #5e5230;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.pos-duplicate-warning__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.pos-duplicate-warning__detail-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid rgba(142, 108, 24, 0.16);
|
||||
border-radius: 0.8rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.pos-duplicate-warning__detail-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.pos-duplicate-warning__detail-title {
|
||||
margin: 0;
|
||||
color: #2b3442;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pos-duplicate-warning__detail-content {
|
||||
margin: 0;
|
||||
color: #5c6a7c;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.pos-duplicate-warning__detail-actions {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pos-duplicate-warning__detail-actions .button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
.pos-rail--sticky {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1023px) {
|
||||
.pos-shell {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pos-shell-actions {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pos-duplicate-warning__header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.box.has-sharp-edges.pos-card {
|
||||
padding: var(--pos-card-padding);
|
||||
border-radius: var(--pos-card-radius);
|
||||
border: 1px solid var(--pos-card-border);
|
||||
box-shadow: var(--pos-card-shadow);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.box.has-sharp-edges.pos-card.pos-card--flush {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.box.has-sharp-edges.pos-card.pos-card--compact {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.pos-card-tabs .tabs {
|
||||
margin: 0;
|
||||
padding: 0.85rem 1rem 0.35rem;
|
||||
border-bottom: 1px solid #e6edf7;
|
||||
}
|
||||
|
||||
.pos-card-tabs .tabs ul {
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.pos-card-tabs .tabs li a {
|
||||
min-height: 2.4rem;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
color: #56657a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pos-card-tabs .tabs li.is-active a {
|
||||
background: #eef4ff;
|
||||
color: #21446d;
|
||||
}
|
||||
|
||||
.tabs.pos-sidebar-panel-tabs {
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.tabs.pos-sidebar-panel-tabs ul {
|
||||
justify-content: flex-start;
|
||||
margin: 0;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tabs.pos-sidebar-panel-tabs li a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
min-height: 2.1rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
color: #5a6980;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabs.pos-sidebar-panel-tabs li.is-active a {
|
||||
background: #eef4ff;
|
||||
color: #21446d;
|
||||
}
|
||||
|
||||
.buttons.pos-actions {
|
||||
margin: 0;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.buttons.pos-actions > * {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.buttons.pos-actions .button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.buttons.pos-actions.pos-actions--desktop {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.buttons.pos-actions.pos-actions--stacked,
|
||||
.buttons.pos-actions.pos-actions--rail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.auto-excel-export-inline,
|
||||
[data-auto-excel-export-button="1"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auto-excel-export-inline .button,
|
||||
[data-auto-excel-export-button="1"] .button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pos-inline-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.pos-inline-tag {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #5c6d83;
|
||||
font-size: var(--pos-order-subheading-font-size, 0.75rem);
|
||||
font-weight: 500;
|
||||
line-height: var(--pos-order-subheading-line-height, 1.5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pos-inline-tag__label {
|
||||
color: #7f8ea3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pos-inline-tag__value {
|
||||
color: #243347;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pos-desktop-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pos-order-metadata-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.pos-order-metadata-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
padding: 1rem;
|
||||
border: 1px solid #dce5f1;
|
||||
border-radius: 0.95rem;
|
||||
background: linear-gradient(180deg, #fbfdff 0%, #f6faff 100%);
|
||||
}
|
||||
|
||||
.pos-order-metadata-card--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.pos-order-metadata-label {
|
||||
margin: 0;
|
||||
color: #607086;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pos-order-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-order-field__control {
|
||||
width: 100%;
|
||||
min-height: 3.25rem;
|
||||
padding: 0.8rem 0.95rem;
|
||||
border: 1px solid #d7e2ef;
|
||||
border-radius: 0.85rem;
|
||||
background: #ffffff;
|
||||
color: #223347;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.pos-order-field__control--interactive {
|
||||
cursor: text;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.pos-order-field__control--interactive:hover,
|
||||
.pos-order-field__control--interactive:focus-visible {
|
||||
border-color: #93b3d8;
|
||||
box-shadow: 0 0 0 3px rgba(93, 142, 191, 0.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.pos-order-field__control--empty {
|
||||
color: #8ea0b6;
|
||||
}
|
||||
|
||||
.pos-order-field__control--editing {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pos-order-field__preview {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.pos-order-field__empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-order-field__empty-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 0.32rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: #f3f6fa;
|
||||
color: #4c5f76;
|
||||
font-size: 0.79rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pos-order-note-editor .pos-order-field__control {
|
||||
min-height: 7.25rem;
|
||||
}
|
||||
|
||||
.pos-order-note-editor .pos-order-field__control--interactive {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.pos-order-note-editor .pos-order-field__preview {
|
||||
-webkit-line-clamp: 5;
|
||||
}
|
||||
|
||||
.pos-order-note-editor__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.pos-order-note-editor__empty-copy {
|
||||
color: #7a8ba1;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.pos-order-field__input {
|
||||
width: 100%;
|
||||
min-height: 3.25rem;
|
||||
resize: vertical;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0.8rem 0.95rem;
|
||||
}
|
||||
|
||||
.pos-order-note-editor .pos-order-field__input {
|
||||
min-height: 7.25rem;
|
||||
}
|
||||
|
||||
.pos-order-field__input:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pos-registration-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.pos-registration-grid--customer-wishes {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.pos-registration-slot {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-registration-control {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pos-registration-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
min-height: 4.25rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px solid #d7e2ef;
|
||||
border-radius: 0.85rem;
|
||||
background: #ffffff;
|
||||
color: #223347;
|
||||
text-align: left;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.pos-registration-field--editing {
|
||||
justify-content: center;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.pos-registration-field:hover,
|
||||
.pos-registration-field:focus-visible {
|
||||
border-color: #93b3d8;
|
||||
box-shadow: 0 0 0 3px rgba(93, 142, 191, 0.12);
|
||||
outline: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pos-registration-field--empty {
|
||||
color: #8ea0b6;
|
||||
}
|
||||
|
||||
.pos-registration-field__label {
|
||||
color: #607086;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pos-registration-field__label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-registration-field__status-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pos-registration-field__status-icon--warning {
|
||||
color: #a46d00;
|
||||
}
|
||||
|
||||
.pos-registration-field__status-icon--danger {
|
||||
color: #b53e3e;
|
||||
}
|
||||
|
||||
.pos-registration-field__value {
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.pos-registration-field--add {
|
||||
border-style: dashed;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.pos-registration-field--warning {
|
||||
border-style: solid;
|
||||
border-color: #ddc15b;
|
||||
background: #fff4c4;
|
||||
}
|
||||
|
||||
.pos-registration-field--danger {
|
||||
border-style: solid;
|
||||
border-color: #e0a7a7;
|
||||
background: #fff1f1;
|
||||
}
|
||||
|
||||
.pos-registration-field--add:hover,
|
||||
.pos-registration-field--add:focus-visible {
|
||||
border-color: #93b3d8;
|
||||
box-shadow: 0 0 0 3px rgba(93, 142, 191, 0.12);
|
||||
background: #fbfdff;
|
||||
outline: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.pos-registration-field--warning:hover,
|
||||
.pos-registration-field--warning:focus-visible {
|
||||
border-color: #cfaf3f;
|
||||
background: #ffefb2;
|
||||
box-shadow: 0 0 0 3px rgba(207, 175, 63, 0.16);
|
||||
}
|
||||
|
||||
.pos-registration-field--danger:hover,
|
||||
.pos-registration-field--danger:focus-visible {
|
||||
border-color: #d38c8c;
|
||||
background: #ffe7e7;
|
||||
box-shadow: 0 0 0 3px rgba(211, 140, 140, 0.16);
|
||||
}
|
||||
|
||||
.pos-registration-field__value--muted {
|
||||
color: #63768f;
|
||||
}
|
||||
|
||||
.pos-registration-field__input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pos-registration-field__input--customer-wishes {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.pos-registration-field__input:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-metadata-grid {
|
||||
gap: 0.9rem;
|
||||
grid-template-columns: minmax(20rem, 1.22fr) minmax(16rem, 1fr);
|
||||
grid-template-areas:
|
||||
"license-plates customer-wishes"
|
||||
"note note";
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-metadata-card {
|
||||
gap: 0.7rem;
|
||||
padding: 0.95rem 1rem;
|
||||
border-color: #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fafbfd 100%);
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-metadata-card--license-plates {
|
||||
grid-area: license-plates;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-metadata-card--customer-wishes {
|
||||
grid-area: customer-wishes;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-metadata-card--note {
|
||||
grid-area: note;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-metadata-label {
|
||||
color: #738397;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-field__control {
|
||||
min-height: 3rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-color: #dbe3eb;
|
||||
border-radius: 0.8rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-field__empty-pill {
|
||||
background: #f4f6f9;
|
||||
color: #445466;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-field__input {
|
||||
min-height: 3rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-customer-wishes .pos-registration-field__value {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-note-editor .pos-order-field__control {
|
||||
min-height: 4.6rem;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-note-editor .pos-order-field__preview {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-note-editor .pos-order-field__input {
|
||||
min-height: 4.6rem;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-note-editor__empty {
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-note-editor__empty-copy {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-registration-grid {
|
||||
gap: 0.55rem;
|
||||
grid-auto-rows: 1fr;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-registration-slot {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-registration-control {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-registration-field {
|
||||
flex: 1 1 0;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-registration-field {
|
||||
min-height: 3.35rem;
|
||||
padding: 0.62rem 0.78rem;
|
||||
border-color: #dbe3eb;
|
||||
border-radius: 0.8rem;
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-registration-field--editing {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-registration-field:hover,
|
||||
.pos-order-items--order-detail .pos-registration-field:focus-visible {
|
||||
transform: none;
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-registration-field--warning:hover,
|
||||
.pos-order-items--order-detail .pos-registration-field--warning:focus-visible {
|
||||
background: #ffefb2;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-registration-field--danger:hover,
|
||||
.pos-order-items--order-detail .pos-registration-field--danger:focus-visible {
|
||||
background: #ffe7e7;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-registration-field__label {
|
||||
color: #738397;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-registration-field__value {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-registration-field--add {
|
||||
border-color: #d2dbe5;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-registration-field--warning {
|
||||
border-color: #d8b748;
|
||||
background: #fff3bf;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-registration-field--danger {
|
||||
border-color: #dc9f9f;
|
||||
background: #fff0f0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1080px) {
|
||||
.pos-order-items--order-detail .pos-order-metadata-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"license-plates"
|
||||
"customer-wishes"
|
||||
"note";
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-note-editor .pos-order-field__control,
|
||||
.pos-order-items--order-detail .pos-order-note-editor .pos-order-field__input {
|
||||
min-height: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.box.has-sharp-edges.pos-card {
|
||||
padding: 1rem;
|
||||
border-radius: 0.9rem;
|
||||
}
|
||||
|
||||
.pos-card-tabs .tabs {
|
||||
padding: 0.75rem 0.75rem 0.35rem;
|
||||
}
|
||||
|
||||
.pos-order-metadata-grid,
|
||||
.pos-registration-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div data-testid="superuser-complaints-page">
|
||||
<PageTitle
|
||||
:title="t('superuser.pages.complaints.title')"
|
||||
:subtitle="t('superuser.pages.complaints.subtitle')"
|
||||
/>
|
||||
<CustomerComplaintsPagination auto-load="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -8,8 +8,7 @@ const { t } = useI18n();
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageTitle :title="t('objects.subuser_grants.title')" :subtitle="t('objects.subuser_grants.subtitle')">
|
||||
</PageTitle>
|
||||
<PageTitle :title="t('objects.subuser_grants.title')" :subtitle="t('objects.subuser_grants.subtitle')"></PageTitle>
|
||||
<SubuserGrantsPagination auto-load="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,19 +1,61 @@
|
||||
<script setup>
|
||||
import SubusersPagination from "@/components/displays/pagination/models/SuperUserDashboard/SubusersPagination.vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import SubusersPagination from "@/components/displays/pagination/models/SuperUserDashboard/SubusersPagination.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const canInvite = computed(() => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_ADD"));
|
||||
const requiresGrantSelection = computed(
|
||||
() => SessionUser.isSubuser.value && !SessionUser.subuser.selectedGrantCustomerNumber.value
|
||||
);
|
||||
const paginationVersion = ref(0);
|
||||
const paginationKey = computed(() =>
|
||||
SessionUser.isSubuser.value
|
||||
? `subusers-${SessionUser.subuser.selectedGrantCustomerNumber.value || "none"}-${paginationVersion.value}`
|
||||
: `subusers-user-${paginationVersion.value}`
|
||||
);
|
||||
|
||||
const onInviteClick = async () => {
|
||||
await SessionUser.objects.subusers.functions.showInviteForm(() => {
|
||||
paginationVersion.value += 1;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageTitle :title="t('superuser.pages.subusers.title')" :subtitle="t('superuser.pages.subusers.subtitle')">
|
||||
<template #buttons>
|
||||
<button v-if="canInvite" class="button is-dark" type="button" @click="onInviteClick">
|
||||
<span class="icon">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
</span>
|
||||
<span>Invitér chauffør</span>
|
||||
</button>
|
||||
</template>
|
||||
</PageTitle>
|
||||
<SubusersPagination auto-load="true" />
|
||||
|
||||
<div v-if="SessionUser.isSubuser.value" class="mb-5">
|
||||
<SubuserGrantSelector />
|
||||
<p class="help">
|
||||
Vælg den kunde, du vil administrere chauffører for. Listen og rettighederne følger det valgte kundenummer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="requiresGrantSelection" class="notification is-info is-light">
|
||||
Vælg først en kunde for at se og administrere chauffører.
|
||||
</div>
|
||||
|
||||
<SubusersPagination v-else :key="paginationKey" auto-load="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.help {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -51,33 +51,22 @@ const visibleTabs = ref([]);
|
||||
* The is by default the prop defaultActiveTab, but can be changed by the user and won't be emitted, as it is not a prop.
|
||||
*/
|
||||
const activeTab = ref(props.defaultActiveTab);
|
||||
|
||||
const isHidden = (tab) => {
|
||||
if (typeof tab.hidden === 'function') {
|
||||
return tab.hidden();
|
||||
}
|
||||
return !!tab.hidden;
|
||||
};
|
||||
/**
|
||||
* Update the visible tabs
|
||||
* This checks if the tab is hidden or not, and returns the visible tabs
|
||||
*/
|
||||
const updateVisibleTabs = () => {
|
||||
// Loop through the tabs and set the visible tabs
|
||||
console.log('Updating visible tabs');
|
||||
const tmp_visibleTabs = [];
|
||||
props.tabs.forEach((tab) => {
|
||||
if (typeof tab.hidden === 'function') {
|
||||
if (!tab.hidden()) {
|
||||
tmp_visibleTabs.push(tab);
|
||||
}
|
||||
} else if (typeof tab.hidden === 'boolean') {
|
||||
if (!tab.hidden) {
|
||||
tmp_visibleTabs.push(tab);
|
||||
}
|
||||
} else {
|
||||
if (!tab.hidden) {
|
||||
tmp_visibleTabs.push(tab);
|
||||
}
|
||||
}
|
||||
});
|
||||
visibleTabs.value = tmp_visibleTabs;
|
||||
visibleTabs.value = props.tabs.filter((tab) => !isHidden(tab));
|
||||
// If the active tab is not visible, set the active tab to the first visible tab
|
||||
if (!visibleTabs.value.find((tab) => tab.slot === activeTab.value)) {
|
||||
activeTab.value = visibleTabs.value[0].slot;
|
||||
activeTab.value = visibleTabs.value[0]?.slot ?? null;
|
||||
}
|
||||
};
|
||||
// Update the visible tabs every time the slot changes (If the tabs is a reactive object)
|
||||
@@ -98,26 +87,10 @@ watch(activeTab, () => {
|
||||
});
|
||||
|
||||
const isTabVisible = (tab) => {
|
||||
let tmp_visible = null
|
||||
console.log('Checking if tab is hidden', tab);
|
||||
switch (typeof tab.hidden) {
|
||||
case 'function':
|
||||
console.log('Tab is hidden function');
|
||||
tmp_visible = !tab.hidden();
|
||||
break;
|
||||
case 'boolean':
|
||||
console.log('Tab is hidden boolean');
|
||||
tmp_visible = !tab.hidden;
|
||||
break;
|
||||
default:
|
||||
console.log('Tab is hidden default');
|
||||
tmp_visible = !tab.hidden;
|
||||
break;
|
||||
}
|
||||
console.log('Tab is visible', tmp_visible);
|
||||
const tmp_visible = !isHidden(tab);
|
||||
// If the tab is the current active tab, and the tab is not visible, set the active tab to the first visible tab
|
||||
if (tab.slot === activeTab.value && !tmp_visible) {
|
||||
activeTab.value = visibleTabs.value[0].slot;
|
||||
activeTab.value = visibleTabs.value[0]?.slot ?? null;
|
||||
}
|
||||
return tmp_visible;
|
||||
};
|
||||
@@ -191,4 +164,4 @@ if (props.forceActiveTab !== null && props.forceActiveTab !== undefined) {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -15,6 +15,7 @@ const toggleIsSelected = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="expandable-content-box">
|
||||
<div v-if="props.expanded">
|
||||
<slot name="expandedContent"></slot>
|
||||
</div>
|
||||
@@ -36,8 +37,9 @@ const toggleIsSelected = () => {
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, defineProps, defineEmits } from 'vue';
|
||||
import { ref, computed, defineProps, defineEmits } from 'vue';
|
||||
import { Colors } from "@/ThemeConfig.vue";
|
||||
import { showPopper, popperBoxProductAddonHtml, removePopperIfOpen } from "@/components/displays/PopperDefault.vue";
|
||||
import Swal from "sweetalert2";
|
||||
@@ -37,21 +37,29 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const addonsSafe = computed(() => (Array.isArray(props.addons) ? props.addons : []));
|
||||
const selectedAddonsSafe = computed(() => (Array.isArray(props.selectedAddons) ? props.selectedAddons : []));
|
||||
const customerAttributesSafe = computed(() => (Array.isArray(props.customerAttributes) ? props.customerAttributes : []));
|
||||
|
||||
const getAddonProduct = (addon) => addon?.product || {};
|
||||
const getAddonName = (addon) => addon?.name || '';
|
||||
|
||||
const isAddonSelected = (productId, addonId) => {
|
||||
return props.selectedAddons.some((addon) => addon.product_id === productId && addon.addon_id === addonId && addon.status === true);
|
||||
return selectedAddonsSafe.value.some((addon) => addon.product_id === productId && addon.addon_id === addonId && addon.status === true);
|
||||
};
|
||||
|
||||
const getAddonPrice = (addonId) => {
|
||||
const addon = props.addons.find((addon) => addon.id === addonId);
|
||||
return addon.product.price;
|
||||
const addon = addonsSafe.value.find((entry) => entry.id === addonId);
|
||||
return Number(getAddonProduct(addon).price ?? 0);
|
||||
};
|
||||
|
||||
const showAddonPopper = (addon, element) => {
|
||||
const addonProduct = getAddonProduct(addon);
|
||||
showPopper(popperBoxProductAddonHtml(
|
||||
addon.id,
|
||||
addon.name,
|
||||
addon.product.price,
|
||||
addon.product.description,
|
||||
addonProduct.price ?? 0,
|
||||
addonProduct.description ?? '',
|
||||
'pos-addon-popper'
|
||||
), element);
|
||||
};
|
||||
@@ -73,23 +81,23 @@ const toggleIsSelected = () => {
|
||||
};
|
||||
|
||||
const hasRestrictAdditionalServices = () => {
|
||||
return props.customerAttributes.some((attribute) => attribute.attribute === 'restrictAdditionalServices');
|
||||
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictAdditionalServices');
|
||||
};
|
||||
|
||||
const hasRestrictSpotFree = () => {
|
||||
return props.customerAttributes.some((attribute) => attribute.attribute === 'restrictSpotFree');
|
||||
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictSpotFree');
|
||||
};
|
||||
|
||||
const hasRestrictInteriorCleaning = () => {
|
||||
return props.customerAttributes.some((attribute) => attribute.attribute === 'restrictInteriorCleaning');
|
||||
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictInteriorCleaning');
|
||||
};
|
||||
|
||||
const hasRestrictTankCleaning = () => {
|
||||
return props.customerAttributes.some((attribute) => attribute.attribute === 'restrictTankCleaning');
|
||||
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictTankCleaning');
|
||||
};
|
||||
|
||||
const hasOnlyTankCleaning = () => {
|
||||
return props.customerAttributes.some((attribute) => attribute.attribute === 'onlyTankCleaning');
|
||||
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'onlyTankCleaning');
|
||||
};
|
||||
|
||||
|
||||
@@ -151,31 +159,33 @@ const onBeforeToggleProductAddon = (addon, isAddonCurrentlySelected, onAfterPreC
|
||||
};
|
||||
|
||||
const doesAddonHaveNoteRequirement = (addon) => {
|
||||
return addon.product.requires_note;
|
||||
return getAddonProduct(addon).requires_note === true;
|
||||
};
|
||||
|
||||
const isAddonRestricted = (addon) => {
|
||||
const addonName = getAddonName(addon).toLowerCase();
|
||||
// Check if the addon has "Spot Free" in the name
|
||||
if (hasRestrictSpotFree() && addon.name.toLowerCase().includes('spot free')) {
|
||||
if (hasRestrictSpotFree() && addonName.includes('spot free')) {
|
||||
return true;
|
||||
}
|
||||
// Check if the addon has "Indvendig vask" in the name
|
||||
if (hasRestrictInteriorCleaning() && addon.name.toLowerCase().includes('indvendig vask')) {
|
||||
if (hasRestrictInteriorCleaning() && addonName.includes('indvendig vask')) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const isProductRestricted = () => {
|
||||
const productName = String(props.name || '').toLowerCase();
|
||||
// Check if the product has "Spot Free" in the name
|
||||
if (hasRestrictSpotFree() && props.name.toLowerCase().includes('spot free')) {
|
||||
if (hasRestrictSpotFree() && productName.includes('spot free')) {
|
||||
return true;
|
||||
}
|
||||
// Check if the product has "Indvendig vask" in the name
|
||||
if (hasRestrictInteriorCleaning() && props.name.toLowerCase().includes('indvendig vask')) {
|
||||
if (hasRestrictInteriorCleaning() && productName.includes('indvendig vask')) {
|
||||
return true;
|
||||
}
|
||||
// Check if the product category is 5 (Tank Cleaning)
|
||||
if (props.product.category === 5) {
|
||||
if (props.product?.category === 5) {
|
||||
if (hasRestrictTankCleaning()) {
|
||||
return true;
|
||||
}
|
||||
@@ -187,7 +197,11 @@ const isProductRestricted = () => {
|
||||
};
|
||||
|
||||
const orderByOrderPriority = (addons) => {
|
||||
return addons.sort((a, b) => a.product.order_priority - b.product.order_priority);
|
||||
return [...addons].sort((a, b) => {
|
||||
const aPriority = Number(getAddonProduct(a).order_priority ?? 0);
|
||||
const bPriority = Number(getAddonProduct(b).order_priority ?? 0);
|
||||
return aPriority - bPriority;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -247,7 +261,7 @@ const orderByOrderPriority = (addons) => {
|
||||
<!-- Expandable product details -->
|
||||
<div v-if="isSelected" class="column is-12-desktop">
|
||||
<!-- Addons -->
|
||||
<div class="is-centered mt-3 mb-0 pl-6" v-if="addons.length > 0">
|
||||
<div class="is-centered mt-3 mb-0 pl-6" v-if="addonsSafe.length > 0">
|
||||
<template v-if="hasRestrictAdditionalServices()">
|
||||
<div class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap">
|
||||
<button
|
||||
@@ -261,7 +275,7 @@ const orderByOrderPriority = (addons) => {
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-for="addon in orderByOrderPriority(addons)" :key="addon.id" v-else>
|
||||
<template v-for="addon in orderByOrderPriority(addonsSafe)" :key="addon.id" v-else>
|
||||
<template v-if="!isAddonRestricted(addon)">
|
||||
<div
|
||||
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"
|
||||
@@ -329,6 +343,7 @@ const orderByOrderPriority = (addons) => {
|
||||
<div class="buttons is-centered mt-2 mb-3 pl-6">
|
||||
<button
|
||||
v-bind:disabled="isProductRestricted()"
|
||||
:data-testid="`pos-add-to-cart-${id}`"
|
||||
class="button is-link is-small is-fullwidth"
|
||||
@click="emitAddToCart(id);"
|
||||
>
|
||||
@@ -369,4 +384,4 @@ const orderByOrderPriority = (addons) => {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
import { defineEmits, defineProps, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
clickAction: Function,
|
||||
@@ -8,6 +8,7 @@ const props = defineProps({
|
||||
disabled: Boolean,
|
||||
template: String // The style of the button (default, danger, success, warning, info, light)
|
||||
});
|
||||
const emit = defineEmits(['selected']);
|
||||
|
||||
const styles = {
|
||||
default: {
|
||||
@@ -72,9 +73,22 @@ const styles = {
|
||||
},
|
||||
};
|
||||
|
||||
const click = () => {
|
||||
if (props.clickAction && !props.disabled)
|
||||
props.clickAction();
|
||||
const isProcessing = ref(false);
|
||||
const click = async (event) => {
|
||||
if (isDisabled() || isProcessing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing.value = true;
|
||||
try {
|
||||
if (props.clickAction) {
|
||||
await props.clickAction();
|
||||
}
|
||||
} finally {
|
||||
isProcessing.value = false;
|
||||
emit('selected');
|
||||
event?.currentTarget?.dispatchEvent(new CustomEvent('dropdown-action-selected', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = () => {
|
||||
@@ -111,19 +125,31 @@ const getLabelColor = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a class="dropdown-item" @click="click" :class="{'is-disabled': isDisabled()}">
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item dropdown-item-action"
|
||||
@click.stop.prevent="click"
|
||||
:class="{'is-disabled': isDisabled()}"
|
||||
:disabled="isDisabled()"
|
||||
>
|
||||
<span class="icon">
|
||||
<i :class="getIcon() + ' ' + getIconColor()"></i>
|
||||
</span>
|
||||
<span class="ml-1"
|
||||
:class="getLabelColor()"
|
||||
>{{ getLabel() }}</span>
|
||||
</a>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-item-action {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.is-disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -134,4 +160,4 @@ const getLabelColor = () => {
|
||||
.is-disabled .icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import {defineProps, ref} from 'vue';
|
||||
import {computed, defineProps, ref, useSlots} from 'vue';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {BTooltip} from "buefy";
|
||||
|
||||
const slots = useSlots();
|
||||
const props = defineProps({
|
||||
object: Object,
|
||||
loadList: Function,
|
||||
@@ -44,10 +46,28 @@ const props = defineProps({
|
||||
default: () => {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
cellTestId: {
|
||||
type: String,
|
||||
default: () => {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
editButtonTestId: {
|
||||
type: String,
|
||||
default: () => {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const canEdit = computed(() => Boolean(props.editFunction) && props.permissionCheckFunction());
|
||||
|
||||
const clickEdit = () => {
|
||||
if (!canEdit.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the virtual column is not set, then show the edit form
|
||||
if (!props.virtualColumn) {
|
||||
isLoading.value = true;
|
||||
@@ -71,6 +91,8 @@ const clickEdit = () => {
|
||||
}
|
||||
}
|
||||
const isLoading = ref(false);
|
||||
const hasHoverHtmlSlot = computed(() => Boolean(slots.hoverHtml));
|
||||
const isTooltipMultiline = computed(() => Boolean(props.hoverHtml || hasHoverHtmlSlot.value));
|
||||
|
||||
//console.log(props);
|
||||
</script>
|
||||
@@ -79,7 +101,8 @@ const isLoading = ref(false);
|
||||
<component :is="props.componentWrapper ? props.componentWrapper : 'td'"
|
||||
@click="clickEdit"
|
||||
class="hover-illustration"
|
||||
:class="{'is-clickable': props.permissionCheckFunction() && props.editFunction}">
|
||||
:class="{'is-clickable': canEdit}"
|
||||
:data-testid="props.cellTestId || null">
|
||||
<div>
|
||||
|
||||
<!-- Divided display ( value | edit button ) -->
|
||||
@@ -93,8 +116,14 @@ const isLoading = ref(false);
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<span class="ml-2 is-float-right" v-if="props.permissionCheckFunction()">
|
||||
<button class="button is-small is-transparent is-text" style="text-decoration-line: none;" @click="clickEdit" v-if="props.editFunction" :class="{'is-loading': isLoading}" :disabled="isLoading">
|
||||
<span class="ml-2 is-float-right" v-if="canEdit">
|
||||
<button class="button is-small is-transparent is-text"
|
||||
style="text-decoration-line: none;"
|
||||
@click.stop="clickEdit"
|
||||
v-if="props.editFunction"
|
||||
:class="{'is-loading': isLoading}"
|
||||
:disabled="isLoading"
|
||||
:data-testid="props.editButtonTestId || null">
|
||||
<span class="icon">
|
||||
<i class="fas fa-edit"></i>
|
||||
</span>
|
||||
@@ -110,7 +139,7 @@ const isLoading = ref(false);
|
||||
<b-tooltip
|
||||
type="is-light"
|
||||
position="is-bottom"
|
||||
:multilined="$slots.hoverHtml"
|
||||
:multilined="isTooltipMultiline"
|
||||
square>
|
||||
<!-- Value Display -->
|
||||
<span class="" v-if="!props.virtualColumn">{{ parseFunction ? parseFunction(props.object[props.column]) : props.object[props.column] }}</span>
|
||||
@@ -134,4 +163,4 @@ const isLoading = ref(false);
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -5,14 +5,29 @@ import { authenticatedRequest } from "@/components/session/authenticatedRequest.
|
||||
import {BSwitch} from "buefy";
|
||||
import {useToast} from 'vue-toast-notification';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import {
|
||||
getDepartmentSelfServeEnabled,
|
||||
setDepartmentSelfServeEnabled,
|
||||
} from "@/composables/departmentSelfServeEnabled.js";
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const selfServeEnabled = ref(false);
|
||||
const isLoadingSelfServeEnabled = ref(false);
|
||||
const isSavingSelfServeEnabled = ref(false);
|
||||
const bookingsCount = ref(0);
|
||||
|
||||
const getDepartmentId = () => {
|
||||
return parseInt(router.currentRoute.value.params.departmentId);
|
||||
};
|
||||
|
||||
const buildDepartmentModulePath = (modulePath = "") => {
|
||||
const departmentId = getDepartmentId();
|
||||
return `/admin/${departmentId}${modulePath}`;
|
||||
};
|
||||
|
||||
const modules = computed(() => [
|
||||
{
|
||||
id: 1,
|
||||
@@ -20,13 +35,10 @@ const modules = computed(() => [
|
||||
description: t('admin.department_modules.pos.description'),
|
||||
icon: "fas fa-book",
|
||||
onClick: () => {
|
||||
// Get the departmentId from the URL
|
||||
const departmentId = router.currentRoute.value.params.departmentId;
|
||||
// Redirect to the POS system
|
||||
router.push(`/admin/${departmentId}/modules/pos`);
|
||||
router.push(buildDepartmentModulePath('/modules/pos'));
|
||||
},
|
||||
onClickCount: () => {
|
||||
this.onClick();
|
||||
router.push(buildDepartmentModulePath('/modules/pos'));
|
||||
},
|
||||
count: 0
|
||||
},
|
||||
@@ -36,16 +48,10 @@ const modules = computed(() => [
|
||||
description: t('admin.department_modules.bookings.description'),
|
||||
icon: "fas fa-calendar-alt",
|
||||
onClick: () => {
|
||||
// Get the departmentId from the URL
|
||||
const departmentId = router.currentRoute.value.params.departmentId;
|
||||
// Redirect to the bookings
|
||||
router.push(`/admin/${departmentId}/modules/bookings`);
|
||||
router.push(buildDepartmentModulePath('/modules/bookings'));
|
||||
},
|
||||
onClickCount: () => {
|
||||
// Get the departmentId from the URL
|
||||
const departmentId = router.currentRoute.value.params.departmentId;
|
||||
// Redirect to the bookings
|
||||
router.push(`/admin/${departmentId}/modules/bookings?search=pending`);
|
||||
router.push(buildDepartmentModulePath('/modules/bookings?search=pending'));
|
||||
},
|
||||
count: bookingsCount.value
|
||||
},
|
||||
@@ -55,13 +61,10 @@ const modules = computed(() => [
|
||||
description: t('admin.department_modules.daily_report.description'),
|
||||
icon: "fas fa-book",
|
||||
onClick: () => {
|
||||
// Get the departmentId from the URL
|
||||
const departmentId = router.currentRoute.value.params.departmentId;
|
||||
// Redirect to the POS system
|
||||
router.push(`/admin/${departmentId}/modules/daily-report`);
|
||||
router.push(buildDepartmentModulePath('/modules/daily-report'));
|
||||
},
|
||||
onClickCount: () => {
|
||||
this.onClick();
|
||||
router.push(buildDepartmentModulePath('/modules/daily-report'));
|
||||
},
|
||||
count: 0
|
||||
},
|
||||
@@ -71,13 +74,10 @@ const modules = computed(() => [
|
||||
description: t('admin.department_modules.goals.description'),
|
||||
icon: "fas fa-bullseye",
|
||||
onClick: () => {
|
||||
// Get the departmentId from the URL
|
||||
const departmentId = router.currentRoute.value.params.departmentId;
|
||||
// Redirect to the goals
|
||||
router.push(`/admin/${departmentId}/modules/goals`);
|
||||
router.push(buildDepartmentModulePath('/modules/goals'));
|
||||
},
|
||||
onClickCount: () => {
|
||||
this.onClick();
|
||||
router.push(buildDepartmentModulePath('/modules/goals'));
|
||||
},
|
||||
count: 0
|
||||
},
|
||||
@@ -87,32 +87,47 @@ const modules = computed(() => [
|
||||
description: t('admin.department_modules.self_wash.description'),
|
||||
icon: "fas fa-tint",
|
||||
onClick: () => {
|
||||
// Get the departmentId from the URL
|
||||
const departmentId = router.currentRoute.value.params.departmentId;
|
||||
// Redirect to the wash lanes
|
||||
router.push(`/admin/${departmentId}/modules/wash-lanes`);
|
||||
router.push(buildDepartmentModulePath('/modules/wash-lanes'));
|
||||
},
|
||||
onClickCount: () => {
|
||||
this.onClick();
|
||||
router.push(buildDepartmentModulePath('/modules/wash-lanes'));
|
||||
},
|
||||
count: 0,
|
||||
hasSwitch: true,
|
||||
switchValue: selfServeEnabled,
|
||||
switchDisabled: isLoadingSelfServeEnabled.value || isSavingSelfServeEnabled.value,
|
||||
onToggle: async (newValue) => {
|
||||
const departmentId = getDepartmentId();
|
||||
if (!departmentId) return;
|
||||
// Get the current self-serve status, and set it to the opposite of the new value
|
||||
|
||||
const currentStatus = selfServeEnabled.value;
|
||||
selfServeEnabled.value = newValue;
|
||||
isSavingSelfServeEnabled.value = true;
|
||||
|
||||
try {
|
||||
await authenticatedRequest(`/departments/self-serve/enabled?id=${departmentId}&enabled=${newValue}`, 'PUT');
|
||||
selfServeEnabled.value = await setDepartmentSelfServeEnabled(departmentId, newValue);
|
||||
toast.success(newValue ? t('admin.department_modules.self_wash.enabled') : t('admin.department_modules.self_wash.disabled'));
|
||||
} catch (error) {
|
||||
console.error("Failed to update self-serve status", error);
|
||||
// Revert switch value on error
|
||||
selfServeEnabled.value = currentStatus;
|
||||
toast.error(t('admin.department_modules.self_wash.update_error'));
|
||||
} finally {
|
||||
isSavingSelfServeEnabled.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Self-Serve Studio',
|
||||
description: 'Configure questions, conditions, rules and tasks',
|
||||
icon: "fas fa-project-diagram",
|
||||
onClick: () => {
|
||||
router.push(buildDepartmentModulePath('/modules/self-serve/studio'));
|
||||
},
|
||||
onClickCount: () => {
|
||||
router.push(buildDepartmentModulePath('/modules/self-serve/studio'));
|
||||
},
|
||||
count: 0
|
||||
}
|
||||
/**
|
||||
* {
|
||||
@@ -152,11 +167,6 @@ const modules = computed(() => [
|
||||
*/
|
||||
]);
|
||||
|
||||
// Get the departmentId from the URL
|
||||
const getDepartmentId = () => {
|
||||
return parseInt(router.currentRoute.value.params.departmentId);
|
||||
};
|
||||
|
||||
// Get today's bookings
|
||||
const getTodaysBookings = async () => {
|
||||
// Return, if the departmentId is not set
|
||||
@@ -179,11 +189,13 @@ const getSelfServeStatus = async () => {
|
||||
const departmentId = getDepartmentId();
|
||||
if (!departmentId) return;
|
||||
|
||||
isLoadingSelfServeEnabled.value = true;
|
||||
try {
|
||||
const response = await authenticatedRequest(`/departments/self-serve/enabled?id=${departmentId}`, 'GET');
|
||||
selfServeEnabled.value = response.data.data.enabled;
|
||||
selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch self-serve status", error);
|
||||
} finally {
|
||||
isLoadingSelfServeEnabled.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -204,9 +216,9 @@ setInterval(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-3" v-for="module in modules" :key="module.id">
|
||||
<div class="card is-clickable" @click="module.onClick">
|
||||
<div class="columns is-multiline department-modules" data-testid="admin-mobile-modules">
|
||||
<div class="column is-12-mobile is-6-tablet is-4-desktop is-3-widescreen" v-for="module in modules" :key="module.id">
|
||||
<div class="card is-clickable department-module-card" @click="module.onClick">
|
||||
<header class="card-header">
|
||||
<div class="card-header-icon">
|
||||
<span class="icon">
|
||||
@@ -215,14 +227,20 @@ setInterval(() => {
|
||||
</div>
|
||||
<p class="card-header-title">{{ module.name }}</p>
|
||||
<div class="is-pulled-right card-header-title pr-0">
|
||||
<span class="button is-small is-dark" @click="module.onClickCount" v-if="module.count > 0">{{ module.count }}</span>
|
||||
<span class="button is-small is-outlined" v-else-if="module?.hasSwitch" @click.stop>
|
||||
<b-switch size="is-small" type="is-link" :model-value="module.switchValue" @update:model-value="module.onToggle"/>
|
||||
<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-outlined department-module-card__switch" v-else-if="module?.hasSwitch" @click.stop>
|
||||
<b-switch
|
||||
size="is-small"
|
||||
type="is-link"
|
||||
:model-value="Boolean(module?.switchValue?.value ?? module?.switchValue)"
|
||||
:disabled="module?.switchDisabled"
|
||||
@update:model-value="module.onToggle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<p class="title is-4">{{ module.name }}</p>
|
||||
<p class="title is-4 department-module-card__title">{{ module.name }}</p>
|
||||
<p class="subtitle">{{ module.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,5 +249,26 @@ setInterval(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.department-module-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
</style>
|
||||
.department-module-card .card-header-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.department-module-card__count,
|
||||
.department-module-card__switch {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.department-module-card__title {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,12 +8,14 @@ import PosDepartmentStep4 from "@/components/displays/department/pos/steps/PosDe
|
||||
import ViewportResponsiveWrapper from "@/components/viewport/conditions/elements/ViewportResponsiveWrapper.vue";
|
||||
import PosDepartmentStepMobile1 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue";
|
||||
import PosDepartmentStepMobile2 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue";
|
||||
import PosDepartmentStepMobile3 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile3.vue";
|
||||
import { pos } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
|
||||
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
|
||||
import BookedCustomer from "@/components/viewport/elements/icons/BookedCustomer.vue";
|
||||
import PosDepartmentStepMobilePopupRenderer
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupRenderer.vue";
|
||||
import { applyPosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
|
||||
const debugVehicles = () => {
|
||||
pos.vehicles.select(
|
||||
1,
|
||||
@@ -39,34 +41,12 @@ const debugVehicles = () => {
|
||||
};
|
||||
setDepartment();
|
||||
|
||||
// Debug:
|
||||
// If the &id= query parameter is set, then set the order_id to the value of the query parameter
|
||||
// If the &step= query parameter is set, then set the current_step to the value of the query parameter
|
||||
// If the &customer_id= query parameter is set, then set the customer_id to the value of the query parameter
|
||||
if (window.location.search.includes("id=")) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const orderId = urlParams.get("id");
|
||||
if (orderId !== null) {
|
||||
setOrderId(parseInt(orderId));
|
||||
}
|
||||
loadOrderItems();
|
||||
}
|
||||
|
||||
if (window.location.search.includes("step=")) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const step = urlParams.get("step");
|
||||
if (step !== null) {
|
||||
setStep(parseInt(step));
|
||||
}
|
||||
}
|
||||
|
||||
if (window.location.search.includes("customer_id=")) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const customerId = urlParams.get("customer_id");
|
||||
if (customerId !== null) {
|
||||
searchAndSelectCustomer(parseInt(customerId));
|
||||
}
|
||||
}
|
||||
applyPosRouteSearch(window.location.search, {
|
||||
setOrderId,
|
||||
loadOrderItems,
|
||||
setStep,
|
||||
searchAndSelectCustomer,
|
||||
});
|
||||
|
||||
/** Define the createOrder function */
|
||||
</script>
|
||||
@@ -86,6 +66,7 @@ if (window.location.search.includes("customer_id=")) {
|
||||
<PosDepartmentStepMobilePopupRenderer/>
|
||||
<PosDepartmentStepMobile1 v-if="getCurrentStep() === 1" />
|
||||
<PosDepartmentStepMobile2 v-else-if="getCurrentStep() === 2 && (getOrderId() !== null)" />
|
||||
<PosDepartmentStepMobile3 v-else-if="getCurrentStep() === 3 && (getOrderId() !== null)" />
|
||||
</template>
|
||||
</ViewportResponsiveWrapper>
|
||||
</div>
|
||||
@@ -93,4 +74,4 @@ if (window.location.search.includes("customer_id=")) {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,8 +18,8 @@ showLabel.value = props.isLabelVisible || false;
|
||||
<RequiresPermission permission="list_customer_notes">
|
||||
<div class="field">
|
||||
<label class="label" v-if="(props.notes.length > 0 || props.isAddFormVisible !== undefined ? props.isAddFormVisible : true) && showLabel"> Kundebemærkninger</label>
|
||||
<div class="control mt-3" v-if="isOldNotesVisible">
|
||||
<div class="message" v-for="note in notes" :key="note.id">
|
||||
<div class="control mt-3" v-if="isOldNotesVisible" data-testid="pos-customer-notes">
|
||||
<div class="message" v-for="note in notes" :key="note.id" :data-testid="`pos-customer-note-${note.id}`">
|
||||
<div class="message-header">
|
||||
<p>{{ note.created_at }}</p>
|
||||
<RequiresPermission permission="delete_customer_note">
|
||||
@@ -50,4 +50,4 @@ showLabel.value = props.isLabelVisible || false;
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,267 +1,363 @@
|
||||
<script setup>
|
||||
import { customer_name, customer_id, customer_data, customer_attributes, loadCustomerAttributes, hideDiscountsCatalog, hidePricesCatalog, setHidePricesCatalog, setHideDiscountsCatalog, getHideDiscountsCatalog, getHidePricesCatalog, getUserDiscounts, user_discounts } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import {
|
||||
customer_name,
|
||||
customer_id,
|
||||
customer_data,
|
||||
customer_attributes,
|
||||
loadCustomerAttributes,
|
||||
hideDiscountsCatalog,
|
||||
hidePricesCatalog,
|
||||
setHidePricesCatalog,
|
||||
setHideDiscountsCatalog,
|
||||
getHideDiscountsCatalog,
|
||||
getHidePricesCatalog,
|
||||
getUserDiscounts,
|
||||
user_discounts,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { createAttribute, deleteAttribute } from "@/components/shop/CustomerAttributes.vue";
|
||||
import {ref, watch} from 'vue';
|
||||
import 'bulma-switch/dist/css/bulma-switch.min.css';
|
||||
import 'bulma-block-list/src/block-list.scss';
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import {popperBox, popper, removePopperIfOpen, showPopperWithContent, showPopper} from "@/components/displays/PopperDefault.vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import "bulma-switch/dist/css/bulma-switch.min.css";
|
||||
import "bulma-block-list/src/block-list.scss";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {
|
||||
popperBox,
|
||||
popper,
|
||||
removePopperIfOpen,
|
||||
showPopperWithContent,
|
||||
showPopper,
|
||||
} from "@/components/displays/PopperDefault.vue";
|
||||
import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue";
|
||||
import CustomerDiscountsDepartmentDisplay
|
||||
from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
|
||||
import CustomerDiscountsDepartmentDisplay from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
|
||||
import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentBox.vue";
|
||||
|
||||
const panel_tabs = ref([
|
||||
{
|
||||
name: 'Detaljer',
|
||||
key: "details",
|
||||
name: "Detaljer",
|
||||
active: true,
|
||||
permission: 'get_user'
|
||||
permission: "get_user",
|
||||
},
|
||||
{
|
||||
name: 'Regler',
|
||||
key: "rules",
|
||||
name: "Regler",
|
||||
active: false,
|
||||
permission: 'list_customer_attributes'
|
||||
permission: "list_customer_attributes",
|
||||
},
|
||||
{
|
||||
name: 'Genveje',
|
||||
key: "shortcuts",
|
||||
name: "Genveje",
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
name: 'Rabatter',
|
||||
key: "discounts",
|
||||
name: "Rabatter",
|
||||
active: false,
|
||||
permission: 'get_custom_prices_other'
|
||||
}
|
||||
permission: "get_custom_prices_other",
|
||||
},
|
||||
]);
|
||||
|
||||
const details = ref([
|
||||
{
|
||||
name: 'Kunde nummer',
|
||||
prop: 'customerNumber',
|
||||
icon: 'fas fa-user'
|
||||
name: "Kunde nummer",
|
||||
prop: "customerNumber",
|
||||
icon: "fas fa-user",
|
||||
},
|
||||
{
|
||||
name: 'Navn',
|
||||
prop: 'name',
|
||||
icon: 'fas fa-user'
|
||||
name: "Navn",
|
||||
prop: "name",
|
||||
icon: "fas fa-user",
|
||||
},
|
||||
{
|
||||
name: 'Adresse',
|
||||
prop: 'address',
|
||||
icon: 'fas fa-map-marker-alt'
|
||||
name: "Adresse",
|
||||
prop: "address",
|
||||
icon: "fas fa-map-marker-alt",
|
||||
},
|
||||
{
|
||||
name: 'Postnummer',
|
||||
prop: 'zip',
|
||||
icon: 'fas fa-map-marker-alt'
|
||||
name: "Postnummer",
|
||||
prop: "zip",
|
||||
icon: "fas fa-map-marker-alt",
|
||||
},
|
||||
{
|
||||
name: 'Kunde by',
|
||||
prop: 'city',
|
||||
icon: 'fas fa-map-marker-alt'
|
||||
name: "Kunde by",
|
||||
prop: "city",
|
||||
icon: "fas fa-map-marker-alt",
|
||||
},
|
||||
{
|
||||
name: 'Telefon',
|
||||
prop: 'mobilePhone',
|
||||
icon: 'fas fa-phone'
|
||||
name: "Telefon",
|
||||
prop: "mobilePhone",
|
||||
icon: "fas fa-phone",
|
||||
},
|
||||
{
|
||||
name: 'Email',
|
||||
prop: 'email',
|
||||
icon: 'fas fa-envelope'
|
||||
name: "Email",
|
||||
prop: "email",
|
||||
icon: "fas fa-envelope",
|
||||
},
|
||||
{
|
||||
name: 'CVR',
|
||||
prop: 'corporateIdentificationNumber',
|
||||
icon: 'fas fa-id-card'
|
||||
}
|
||||
name: "CVR",
|
||||
prop: "corporateIdentificationNumber",
|
||||
icon: "fas fa-id-card",
|
||||
},
|
||||
]);
|
||||
|
||||
const attributes = ref([
|
||||
{
|
||||
name: 'Kræver reference nr.',
|
||||
prop: 'requiresReferenceNumber',
|
||||
description: 'Når denne er sat, kræves der et reference nr. på ordren før den kan oprettes',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Kræver reference nr.",
|
||||
prop: "requiresReferenceNumber",
|
||||
description: "Når denne er sat, kræves der et reference nr. på ordren før den kan oprettes",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Må ikke ydes tillægsydelser',
|
||||
prop: 'restrictAdditionalServices',
|
||||
name: "Må ikke ydes tillægsydelser",
|
||||
prop: "restrictAdditionalServices",
|
||||
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "addons" til ordren',
|
||||
icon: 'fas fa-cogs'
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Registreringsnumre på faktura linjer',
|
||||
prop: 'requiresRegistrationNumbersInvoice',
|
||||
description: 'Når denne er sat, bliver der sendt registreringsnumre med på alle faktura linjer',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Registreringsnumre på faktura linjer",
|
||||
prop: "requiresRegistrationNumbersInvoice",
|
||||
description: "Når denne er sat, bliver der sendt registreringsnumre med på alle faktura linjer",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Fakturer alle ordrer individuelt',
|
||||
prop: 'invoiceAllOrdersIndividually',
|
||||
description: 'Når denne er sat, faktureres alle ordrer individuelt og ikke samlet',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Fakturer alle ordrer individuelt",
|
||||
prop: "invoiceAllOrdersIndividually",
|
||||
description: "Når denne er sat, faktureres alle ordrer individuelt og ikke samlet",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Må ikke ydes tank cleaning',
|
||||
prop: 'restrictTankCleaning',
|
||||
name: "Må ikke ydes tank cleaning",
|
||||
prop: "restrictTankCleaning",
|
||||
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "tank cleaning" til ordren',
|
||||
icon: 'fas fa-cogs'
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Må ikke ydes spot free',
|
||||
prop: 'restrictSpotFree',
|
||||
name: "Må ikke ydes spot free",
|
||||
prop: "restrictSpotFree",
|
||||
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "spot free" til ordren',
|
||||
icon: 'fas fa-cogs'
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Må ikke ydes indvendig vask',
|
||||
prop: 'restrictInteriorCleaning',
|
||||
name: "Må ikke ydes indvendig vask",
|
||||
prop: "restrictInteriorCleaning",
|
||||
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "interior cleaning" til ordren',
|
||||
icon: 'fas fa-cogs'
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Faktureres med Stripe',
|
||||
prop: 'invoiceWithStripe',
|
||||
description: 'Når denne er sat, faktureres ordren med Stripe',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Faktureres med Stripe",
|
||||
prop: "invoiceWithStripe",
|
||||
description: "Når denne er sat, faktureres ordren med Stripe",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Only tank cleaning',
|
||||
prop: 'onlyTankCleaning',
|
||||
name: "Only tank cleaning",
|
||||
prop: "onlyTankCleaning",
|
||||
description: 'Når denne er sat, bliver kunden kategoriseret som "Tank cleaning" kunde.',
|
||||
icon: 'fas fa-cogs'
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Vises priser på kunde og bookingside',
|
||||
prop: 'showPricesOnBookingPage',
|
||||
description: 'Når denne er sat, bliver kunden vist priser på kunde og bookingsiden.',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Vises priser på kunde og bookingside",
|
||||
prop: "showPricesOnBookingPage",
|
||||
description: "Når denne er sat, bliver kunden vist priser på kunde og bookingsiden.",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Bruger PO nummer',
|
||||
prop: 'usePONumbers',
|
||||
description: 'Når denne er sat, kan kunden angive et PO nummer på ordrer.',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Bruger PO nummer",
|
||||
prop: "usePONumbers",
|
||||
description: "Når denne er sat, kan kunden angive et PO nummer på ordrer.",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Undtaget fra månedligt administrations- og miljøgebyr',
|
||||
prop: 'exemptFromAdministrationFee',
|
||||
description: 'Når denne er sat, bliver kunden undtaget fra månedligt administrations- og miljøgebyr.',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Undtaget fra månedligt administrations- og miljøgebyr",
|
||||
prop: "exemptFromAdministrationFee",
|
||||
description: "Når denne er sat, bliver kunden undtaget fra månedligt administrations- og miljøgebyr.",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
]);
|
||||
|
||||
const shortcuts = ref([
|
||||
{
|
||||
name: 'Administrer kunde',
|
||||
icon: 'fas fa-user-edit',
|
||||
name: "Administrer kunde",
|
||||
icon: "fas fa-user-edit",
|
||||
// Open link in new tab
|
||||
action: () => {
|
||||
SessionUser.adminUser.customers.fromCustomerNumber.getUserId(customer_id.value).then(response => window.open('/superuser/users/' + response.data.data.user_id, '_blank'));
|
||||
SessionUser.adminUser.customers.fromCustomerNumber
|
||||
.getUserId(customer_id.value)
|
||||
.then((response) => window.open("/superuser/users/" + response.data.data.user_id, "_blank"));
|
||||
},
|
||||
visible: SessionUser.canAccessSuperUser()
|
||||
visible: SessionUser.canAccessSuperUser(),
|
||||
},
|
||||
{
|
||||
name: 'Vaskeabonnement & Specialaftaler',
|
||||
icon: 'fas fa-user-edit',
|
||||
name: "Vaskeabonnement & Specialaftaler",
|
||||
icon: "fas fa-user-edit",
|
||||
// Open link in new tab
|
||||
action: () => {
|
||||
SessionUser.adminUser.customers.fromCustomerNumber.getUserId(customer_id.value).then(response => window.open('/superuser/users/' + response.data.data.user_id + '/other', '_blank'));
|
||||
SessionUser.adminUser.customers.fromCustomerNumber
|
||||
.getUserId(customer_id.value)
|
||||
.then((response) => window.open("/superuser/users/" + response.data.data.user_id + "/other", "_blank"));
|
||||
},
|
||||
visible: SessionUser.canAccessSuperUser()
|
||||
visible: SessionUser.canAccessSuperUser(),
|
||||
},
|
||||
]);
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: "default",
|
||||
validator: (value) => ["default", "order-detail", "sidebar"].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const rootClasses = computed(() => ({
|
||||
"pos-selected-customer": true,
|
||||
[`pos-selected-customer--${props.variant}`]: true,
|
||||
}));
|
||||
const isSidebarVariant = computed(() => props.variant === "sidebar");
|
||||
const isRulesTabActive = computed(() => panel_tabs.value[1]?.active === true);
|
||||
|
||||
const setActiveTab = (selectedIndex) => {
|
||||
panel_tabs.value.forEach((tab, index) => {
|
||||
tab.active = index === selectedIndex;
|
||||
});
|
||||
};
|
||||
|
||||
const refreshCustomerAttributesForRules = async (selectedCustomerNumber = customer_id.value) => {
|
||||
if (!selectedCustomerNumber) {
|
||||
return customer_attributes.value;
|
||||
}
|
||||
|
||||
return loadCustomerAttributes(selectedCustomerNumber);
|
||||
};
|
||||
|
||||
/** Check if an attribute is set in the customer attributes */
|
||||
const hasAttribute = (prop) => {
|
||||
return customer_attributes.value.some(attribute => attribute.attribute === prop);
|
||||
return customer_attributes.value.some((attribute) => attribute.attribute === prop);
|
||||
};
|
||||
/** Fetch the customer attributes when the component is mounted */
|
||||
loadCustomerAttributes();
|
||||
|
||||
watch(
|
||||
isRulesTabActive,
|
||||
(isActive) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
void refreshCustomerAttributesForRules();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => customer_id.value,
|
||||
(nextCustomerId, previousCustomerId) => {
|
||||
if (!nextCustomerId || nextCustomerId === previousCustomerId) {
|
||||
return;
|
||||
}
|
||||
void refreshCustomerAttributesForRules(nextCustomerId);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const isCustomerDetailsExpanded = ref(false);
|
||||
|
||||
const customer_data_has_empty_details = () => {
|
||||
return details.value.some(detail => customer_data.value[detail.prop] === null || customer_data.value[detail.prop] === '');
|
||||
return details.value.some(
|
||||
(detail) => customer_data.value[detail.prop] === null || customer_data.value[detail.prop] === ""
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="customer_name">
|
||||
<article class="">
|
||||
<div class="panel-block">
|
||||
<div class="columns is-vcentered is-multiline">
|
||||
<div class="column is-12">
|
||||
<span class="is-size-6 title"
|
||||
>{{ customer_name }}</span>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="tabs is-small is-centered">
|
||||
<ul>
|
||||
<li v-for="(tab, index) in panel_tabs" :key="index" :class="{'is-active': tab.active}" @click="panel_tabs.forEach(tab => tab.active = false); tab.active = true">
|
||||
<RequiresPermission :permission="tab.permission" v-if="tab.permission">
|
||||
<a>
|
||||
{{ tab.name }}
|
||||
</a>
|
||||
</RequiresPermission>
|
||||
<a v-else>
|
||||
{{ tab.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="customer_name" :class="rootClasses">
|
||||
<article class="pos-selected-customer__panel">
|
||||
<div class="panel-block pos-selected-customer__header">
|
||||
<div class="pos-selected-customer__title-wrap">
|
||||
<span class="title is-6 pos-selected-customer__title">{{ customer_name }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="tabs is-small is-centered pos-selected-customer__tabs"
|
||||
:class="{ 'pos-sidebar-panel-tabs': isSidebarVariant }"
|
||||
:data-testid="isSidebarVariant ? 'pos-customer-sidebar-tabs' : undefined"
|
||||
>
|
||||
<ul>
|
||||
<li
|
||||
v-for="(tab, index) in panel_tabs"
|
||||
:key="tab.key"
|
||||
:class="{ 'is-active': tab.active }"
|
||||
:data-testid="`pos-customer-tab-${tab.key}`"
|
||||
@click="setActiveTab(index)"
|
||||
>
|
||||
<RequiresPermission :permission="tab.permission" v-if="tab.permission">
|
||||
<a>
|
||||
{{ tab.name }}
|
||||
</a>
|
||||
</RequiresPermission>
|
||||
<a v-else>
|
||||
{{ tab.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="panel_tabs[0].active" v-for="detail in details" :key="detail.name">
|
||||
<!-- Details that's not empty (unless isCustomerDetailsExpanded is true) -->
|
||||
<a class="panel-block" v-if="customer_data[detail.prop] || isCustomerDetailsExpanded">
|
||||
<span class="panel-icon">
|
||||
<a
|
||||
class="panel-block pos-selected-customer__row"
|
||||
v-if="customer_data[detail.prop] || isCustomerDetailsExpanded"
|
||||
>
|
||||
<span class="panel-icon pos-selected-customer__icon">
|
||||
<i :class="detail.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span style="width: 50%;">{{ detail.name }}</span><span class="has-text-right" style="width: 50%;">{{ customer_data[detail.prop] }}</span>
|
||||
<span class="pos-selected-customer__label">{{ detail.name }}</span>
|
||||
<span class="pos-selected-customer__value">{{ customer_data[detail.prop] }}</span>
|
||||
</a>
|
||||
</template>
|
||||
<!-- Details that's empty, (If there are any) -->
|
||||
<ExpandableContentBox
|
||||
v-if="panel_tabs[0].active && customer_data_has_empty_details"
|
||||
@update:expanded="isCustomerDetailsExpanded = !isCustomerDetailsExpanded"
|
||||
v-bind:expanded="isCustomerDetailsExpanded"
|
||||
v-if="panel_tabs[0].active && customer_data_has_empty_details()"
|
||||
@update:expanded="isCustomerDetailsExpanded = !isCustomerDetailsExpanded"
|
||||
v-bind:expanded="isCustomerDetailsExpanded"
|
||||
/>
|
||||
<!-- Attributes -->
|
||||
<RequiresPermission permission="list_customer_attributes">
|
||||
<div class="panel-block" v-if="panel_tabs[1].active" v-for="attribute in attributes" :key="attribute.name">
|
||||
<span class="panel-icon">
|
||||
<div
|
||||
class="panel-block pos-selected-customer__row"
|
||||
v-if="panel_tabs[1].active"
|
||||
v-for="attribute in attributes"
|
||||
:key="attribute.name"
|
||||
>
|
||||
<span class="panel-icon pos-selected-customer__icon">
|
||||
<i :class="attribute.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span style="width: 50%;"
|
||||
@mouseover="showPopper(
|
||||
popperBox(
|
||||
attribute.name,
|
||||
attribute.description
|
||||
),
|
||||
$event.target
|
||||
)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
>{{ attribute.name }}</span>
|
||||
<span class="has-text-right" style="width: 50%;">
|
||||
<span v-if="hasAttribute(attribute.prop)" @click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
|
||||
<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
|
||||
v-if="hasAttribute(attribute.prop)"
|
||||
@click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())"
|
||||
>
|
||||
<span class="field">
|
||||
<input :id="attribute.prop"
|
||||
type="checkbox" :name="attribute.prop"
|
||||
class="switch" checked="checked"
|
||||
:disabled="!SessionUser.hasPermission('delete_customer_attribute')"
|
||||
<input
|
||||
:id="attribute.prop"
|
||||
type="checkbox"
|
||||
: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')"/>
|
||||
<input
|
||||
:id="attribute.prop"
|
||||
type="checkbox"
|
||||
:name="attribute.prop"
|
||||
class="switch"
|
||||
:disabled="!SessionUser.hasPermission('add_customer_attribute')"
|
||||
/>
|
||||
<label :for="attribute.prop"></label>
|
||||
</span>
|
||||
</span>
|
||||
@@ -271,26 +367,158 @@ const customer_data_has_empty_details = () => {
|
||||
<!-- Shortcuts -->
|
||||
<template v-if="panel_tabs[2].active" v-for="shortcut in shortcuts" :key="shortcut.name">
|
||||
<RequiresPermission permission="access_super_user">
|
||||
<a class="panel-block">
|
||||
<span class="panel-icon">
|
||||
<a class="panel-block pos-selected-customer__row">
|
||||
<span class="panel-icon pos-selected-customer__icon">
|
||||
<i :class="shortcut.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span @click="shortcut.action">{{ shortcut.name }}</span>
|
||||
<span class="pos-selected-customer__shortcut" @click="shortcut.action">{{ shortcut.name }}</span>
|
||||
</a>
|
||||
</RequiresPermission>
|
||||
</template>
|
||||
<!-- Discounts -->
|
||||
<RequiresPermission permission="get_custom_prices_other">
|
||||
<div class="panel-block" v-if="panel_tabs[3].active">
|
||||
<CustomerDiscountsDepartmentDisplay/>
|
||||
<div class="panel-block pos-selected-customer__discounts" v-if="panel_tabs[3].active">
|
||||
<CustomerDiscountsDepartmentDisplay />
|
||||
</div>
|
||||
</RequiresPermission>
|
||||
</article>
|
||||
<div>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pos-selected-customer {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
.pos-selected-customer__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-selected-customer__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1rem 0.8rem;
|
||||
border-bottom: 1px solid #e6edf7;
|
||||
}
|
||||
|
||||
.pos-selected-customer__title-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-selected-customer__title {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.2;
|
||||
color: #26384c;
|
||||
}
|
||||
|
||||
.pos-selected-customer:not(.pos-selected-customer--sidebar) .pos-selected-customer__tabs {
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.pos-selected-customer:not(.pos-selected-customer--sidebar) .pos-selected-customer__tabs ul {
|
||||
justify-content: flex-start;
|
||||
margin: 0;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.pos-selected-customer:not(.pos-selected-customer--sidebar) .pos-selected-customer__tabs li a {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
min-height: 2.1rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
color: #5a6980;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pos-selected-customer:not(.pos-selected-customer--sidebar) .pos-selected-customer__tabs li.is-active a {
|
||||
background: #eef4ff;
|
||||
color: #21446d;
|
||||
}
|
||||
|
||||
.pos-selected-customer__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1rem minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid #eef3f8;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pos-selected-customer__icon {
|
||||
margin-right: 0;
|
||||
color: #62748a;
|
||||
width: 1rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.pos-selected-customer__label {
|
||||
min-width: 0;
|
||||
color: #4b5a6d;
|
||||
font-weight: 600;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.pos-selected-customer__value {
|
||||
min-width: 0;
|
||||
max-width: 14rem;
|
||||
justify-self: end;
|
||||
text-align: right;
|
||||
color: #243446;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.pos-selected-customer__shortcut {
|
||||
color: #21446d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pos-selected-customer__discounts {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.pos-selected-customer--sidebar .pos-selected-customer__title,
|
||||
.pos-selected-customer--order-detail .pos-selected-customer__title {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.pos-selected-customer--sidebar .pos-selected-customer__header,
|
||||
.pos-selected-customer--order-detail .pos-selected-customer__header {
|
||||
padding: 0.95rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.pos-selected-customer :deep(.column.is-12-desktop) {
|
||||
padding: 0.35rem 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.pos-selected-customer :deep(.has-text-centered) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pos-selected-customer :deep(.field) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.pos-selected-customer__row {
|
||||
grid-template-columns: 1rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pos-selected-customer__value {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
text-align: left;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
||||
<script setup>
|
||||
import { computed, defineProps, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||
import { isBlankPosMetadataValue } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
|
||||
const props = defineProps({
|
||||
order_id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
reference: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
po: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
safetySeal: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
showSafetySeal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
referenceRequired: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
poRequired: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const createCustomerWishField = ({
|
||||
key,
|
||||
label,
|
||||
source,
|
||||
saveValue,
|
||||
testIdBase,
|
||||
isRequired,
|
||||
warningStateWhenEmpty,
|
||||
warningIconClass,
|
||||
fillRow = false,
|
||||
}) => {
|
||||
const inputId = `${testIdBase}-input`;
|
||||
const isEditing = ref(false);
|
||||
const autosave = useOrderMetadataAutosave({
|
||||
source,
|
||||
saveValue: async (value) => {
|
||||
await saveValue(value);
|
||||
return value;
|
||||
},
|
||||
});
|
||||
const isEmpty = computed(() => isBlankPosMetadataValue(autosave.draft.value));
|
||||
const hasValue = computed(() => !isEmpty.value);
|
||||
const warningState = computed(() => {
|
||||
return isRequired() && isEmpty.value ? warningStateWhenEmpty : null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => autosave.draft.value,
|
||||
() => {
|
||||
if (isEditing.value) {
|
||||
autosave.scheduleSave();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const focusInput = () => {
|
||||
document.getElementById(inputId)?.focus();
|
||||
};
|
||||
|
||||
const openEditor = () => {
|
||||
if (isEditing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isEditing.value = true;
|
||||
nextTick(() => {
|
||||
focusInput();
|
||||
});
|
||||
};
|
||||
|
||||
const closeEditor = async () => {
|
||||
isEditing.value = false;
|
||||
await autosave.flush();
|
||||
};
|
||||
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
inputId,
|
||||
isEditing,
|
||||
autosave,
|
||||
hasValue,
|
||||
warningState,
|
||||
openEditor,
|
||||
closeEditor,
|
||||
testIdBase,
|
||||
warningIconClass,
|
||||
fillRow,
|
||||
};
|
||||
};
|
||||
|
||||
const referenceField = createCustomerWishField({
|
||||
key: "reference",
|
||||
label: computed(() => t("pos.order.reference")),
|
||||
source: () => props.reference,
|
||||
saveValue: (value) => SessionUser.objects.orders.set.reference(props.order_id, value),
|
||||
testIdBase: "pos-order-customer-wishes-reference",
|
||||
isRequired: () => props.referenceRequired,
|
||||
warningStateWhenEmpty: "danger",
|
||||
warningIconClass: "fas fa-exclamation-circle",
|
||||
});
|
||||
|
||||
const poField = createCustomerWishField({
|
||||
key: "po",
|
||||
label: computed(() => t("admin.pos.po_number")),
|
||||
source: () => props.po,
|
||||
saveValue: (value) => SessionUser.objects.orders.set.po(props.order_id, value),
|
||||
testIdBase: "pos-order-customer-wishes-po",
|
||||
isRequired: () => props.poRequired,
|
||||
warningStateWhenEmpty: "warning",
|
||||
warningIconClass: "fas fa-exclamation-triangle",
|
||||
});
|
||||
|
||||
const safetySealField = createCustomerWishField({
|
||||
key: "safety_seal",
|
||||
label: computed(() => t("admin.pos.safety_seal")),
|
||||
source: () => props.safetySeal,
|
||||
saveValue: (value) => SessionUser.objects.orders.set.safety_seal(props.order_id, value),
|
||||
testIdBase: "pos-order-customer-wishes-safety-seal",
|
||||
isRequired: () => false,
|
||||
warningStateWhenEmpty: null,
|
||||
warningIconClass: "fas fa-shield-alt",
|
||||
fillRow: true,
|
||||
});
|
||||
|
||||
const fields = computed(() => {
|
||||
return props.showSafetySeal ? [referenceField, poField, safetySealField] : [referenceField, poField];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pos-order-customer-wishes">
|
||||
<div class="pos-registration-grid pos-registration-grid--customer-wishes">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
class="pos-registration-slot"
|
||||
:class="{ 'pos-registration-slot--fill-row': field.fillRow }"
|
||||
>
|
||||
<div
|
||||
class="control pos-registration-control"
|
||||
:class="{ 'is-loading': field.autosave.isSaving.value }"
|
||||
:data-testid="`${field.testIdBase}-control`"
|
||||
:data-warning-state="field.warningState.value || undefined"
|
||||
>
|
||||
<div
|
||||
v-if="field.isEditing.value"
|
||||
class="pos-registration-field pos-registration-field--editing"
|
||||
:class="{
|
||||
'pos-registration-field--warning': field.warningState.value === 'warning',
|
||||
'pos-registration-field--danger': field.warningState.value === 'danger',
|
||||
}"
|
||||
>
|
||||
<div class="pos-registration-field__label-row">
|
||||
<label class="pos-registration-field__label" :for="field.inputId">{{ field.label.value }}</label>
|
||||
<span
|
||||
v-if="field.warningState.value"
|
||||
class="pos-registration-field__status-icon"
|
||||
:class="{
|
||||
'pos-registration-field__status-icon--warning': field.warningState.value === 'warning',
|
||||
'pos-registration-field__status-icon--danger': field.warningState.value === 'danger',
|
||||
}"
|
||||
:data-testid="`${field.testIdBase}-warning-icon`"
|
||||
>
|
||||
<i :class="field.warningIconClass" aria-hidden="true"></i>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
:id="field.inputId"
|
||||
v-model="field.autosave.draft.value"
|
||||
:data-testid="`${field.testIdBase}-input`"
|
||||
class="pos-registration-field__input pos-registration-field__input--customer-wishes"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@blur="field.closeEditor()"
|
||||
@keydown.enter.prevent="field.closeEditor()"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="pos-registration-field"
|
||||
:class="{
|
||||
'pos-registration-field--add': !field.hasValue.value,
|
||||
'pos-registration-field--warning': field.warningState.value === 'warning',
|
||||
'pos-registration-field--danger': field.warningState.value === 'danger',
|
||||
}"
|
||||
type="button"
|
||||
:data-testid="field.testIdBase"
|
||||
:aria-label="!field.hasValue.value ? `${t('common.add')} ${field.label.value}` : undefined"
|
||||
@click="field.openEditor()"
|
||||
>
|
||||
<span class="pos-registration-field__label-row">
|
||||
<span class="pos-registration-field__label">{{ field.label.value }}</span>
|
||||
<span
|
||||
v-if="field.warningState.value"
|
||||
class="pos-registration-field__status-icon"
|
||||
:class="{
|
||||
'pos-registration-field__status-icon--warning': field.warningState.value === 'warning',
|
||||
'pos-registration-field__status-icon--danger': field.warningState.value === 'danger',
|
||||
}"
|
||||
:data-testid="`${field.testIdBase}-warning-icon`"
|
||||
>
|
||||
<i :class="field.warningIconClass" aria-hidden="true"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="pos-registration-field__value"
|
||||
:class="{ 'pos-registration-field__value--muted': !field.hasValue.value }"
|
||||
>
|
||||
{{ field.hasValue.value ? field.autosave.draft.value : `+ ${t("common.add")}` }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pos-registration-slot--fill-row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import {ref, defineProps, watch, nextTick} from 'vue';
|
||||
import { computed, defineProps, nextTick, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||
|
||||
const props = defineProps({
|
||||
order_id: {
|
||||
@@ -17,150 +19,82 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
// Check if the required props are set
|
||||
if (props.order_id === null || props.note === null || props.loadOrder === null) {
|
||||
const missingProps = [];
|
||||
if (props.order_id === null) {
|
||||
missingProps.push("order_id");
|
||||
}
|
||||
if (props.note === null) {
|
||||
missingProps.push("note");
|
||||
}
|
||||
if (props.loadOrder === null) {
|
||||
missingProps.push("loadOrder");
|
||||
}
|
||||
throw new Error("The required props are not set: " + missingProps.join(", "));
|
||||
}
|
||||
// Random unique element id
|
||||
const uniqueId = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
const { t } = useI18n();
|
||||
|
||||
// Define the input note, this is separate from the props note, because otherwise we risk loosing changes.
|
||||
const input = ref(props.note);
|
||||
const uniqueId = Math.random().toString(36).slice(2);
|
||||
const isEditing = ref(false);
|
||||
const autosave = useOrderMetadataAutosave({
|
||||
source: () => props.note,
|
||||
saveValue: async (value) => {
|
||||
await SessionUser.objects.orders.set.notes(props.order_id, value);
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
// When the props note changes, then update the input note (If the input note is empty)
|
||||
watch(() => props.note, (value) => {
|
||||
if (input.value === "") {
|
||||
input.value = value;
|
||||
const previewValue = computed(() => autosave.draft.value);
|
||||
const rowCount = computed(() => Math.max((autosave.draft.value || '').split('\n').length, 5));
|
||||
|
||||
watch(() => autosave.draft.value, () => {
|
||||
if (isEditing.value) {
|
||||
autosave.scheduleSave();
|
||||
}
|
||||
});
|
||||
|
||||
const isSaved = () => {
|
||||
return input.value === props.note;
|
||||
}
|
||||
|
||||
// Define the input changed function
|
||||
const inputChanged = () => {}
|
||||
|
||||
// Define the lost focus function
|
||||
const lostFocus = () => {
|
||||
isEditing.value = false;
|
||||
saveChanges({force: true});
|
||||
}
|
||||
|
||||
// Save the last time the note was synced, this is used to prevent spamming the server with requests.
|
||||
const lastSync = ref(new Date().getTime());
|
||||
|
||||
// Define the save changes function
|
||||
const saveChanges = (options = {}) => {
|
||||
// If the input value is the same as the note, then return
|
||||
if (input.value === props.note && !options.force) {
|
||||
return;
|
||||
}
|
||||
// Set the note to the input value
|
||||
props.note = input.value;
|
||||
// Save the order
|
||||
SessionUser.request('/order', 'PUT', {
|
||||
id: props.order_id,
|
||||
field: 'notes',
|
||||
value: input.value
|
||||
}).then((response) => {
|
||||
console.log(response);
|
||||
// Load the order
|
||||
props.loadOrder();
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
// Sync the note with the server once every 2 seconds, if the note has changed.
|
||||
setInterval(() => {
|
||||
if (input.value !== props.note && new Date().getTime() - lastSync.value > 2000) {
|
||||
lastSync.value = new Date().getTime();
|
||||
saveChanges();
|
||||
} else {
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const isEditing = ref(false);
|
||||
const focusOnTextarea = () => {
|
||||
document.getElementById(`${uniqueId}-textarea`)?.focus();
|
||||
};
|
||||
|
||||
const onFieldClicked = () => {
|
||||
if (isEditing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isEditing.value = true;
|
||||
// Wait for the next tick, then focus on the textarea
|
||||
nextTick(() => {
|
||||
focusOnTextarea();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const focusOnTextarea = () => {
|
||||
const textarea = document.getElementById(uniqueId + '-textarea');
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const getRows = () => {
|
||||
return Math.max(input.value.split("\n").length, 1);
|
||||
}
|
||||
|
||||
const shortNote = ref('');
|
||||
|
||||
watch(() => props.note, (value) => {
|
||||
shortNote.value = generateShortNote();
|
||||
});
|
||||
|
||||
const generateShortNote = () => {
|
||||
let shownNote = '';
|
||||
if (input.value.length > 20 || input.value.split("\n").length > 1) {
|
||||
// Get the first line, and add ...
|
||||
shownNote = input.value.split("\n")[0].substring(0, 20) + '...';
|
||||
} else {
|
||||
shownNote = input.value;
|
||||
}
|
||||
shortNote.value = shownNote;
|
||||
return shownNote;
|
||||
}
|
||||
|
||||
generateShortNote();
|
||||
const lostFocus = async () => {
|
||||
isEditing.value = false;
|
||||
await autosave.flush();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="field" @click="onFieldClicked()">
|
||||
<div class="control" :class="{'is-loading': !isSaved()}">
|
||||
<textarea
|
||||
v-if="isEditing"
|
||||
class="textarea"
|
||||
:style="{'height': isEditing ? 'auto' : '2.25em', 'resize': isEditing ? 'vertical' : 'none', 'overflow': 'hidden', 'padding': '0.5em'}"
|
||||
:readonly="!isEditing"
|
||||
:rows="isEditing ? getRows() : 1"
|
||||
placeholder="Ordre note, denne bliver inkluderet i fakturaen"
|
||||
v-model="input"
|
||||
@input="inputChanged"
|
||||
@blur="lostFocus()"
|
||||
:id="uniqueId + '-textarea'"
|
||||
></textarea>
|
||||
<textarea
|
||||
class="textarea"
|
||||
:style="{'height': '2.25em', 'resize': 'none', 'overflow': 'hidden', 'padding': '0.5em'}"
|
||||
:readonly="true"
|
||||
:rows="1"
|
||||
v-if="!isEditing"
|
||||
v-model="shortNote"
|
||||
placeholder="Ordre note, denne bliver inkluderet i fakturaen"
|
||||
></textarea>
|
||||
<div class="pos-order-field pos-order-note-editor">
|
||||
<div class="control" :class="{ 'is-loading': autosave.isSaving.value }">
|
||||
<div v-if="isEditing" class="pos-order-field__control pos-order-field__control--editing">
|
||||
<textarea
|
||||
:id="`${uniqueId}-textarea`"
|
||||
v-model="autosave.draft.value"
|
||||
data-testid="pos-order-note-textarea"
|
||||
class="textarea pos-order-field__input"
|
||||
:rows="rowCount"
|
||||
:placeholder="t('pos.order.order_note')"
|
||||
@blur="lostFocus()"
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="pos-order-field__control pos-order-field__control--interactive"
|
||||
:class="{ 'pos-order-field__control--empty': !autosave.draft.value }"
|
||||
type="button"
|
||||
@click="onFieldClicked()"
|
||||
>
|
||||
<span v-if="previewValue" class="pos-order-field__preview">{{ previewValue }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="pos-order-field__empty-state pos-order-note-editor__empty"
|
||||
data-testid="pos-order-note-empty-state"
|
||||
>
|
||||
<span class="pos-order-field__empty-pill">+ {{ t('pos.add_note') }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
<script setup>
|
||||
import {ref, defineProps, watch, nextTick} from 'vue';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const props = defineProps({
|
||||
order_id: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
reference: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
loadOrder: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// Check if the required props are set
|
||||
if (props.order_id === null || props.reference === null || props.loadOrder === null) {
|
||||
const missingProps = [];
|
||||
if (props.order_id === null) {
|
||||
missingProps.push("order_id");
|
||||
}
|
||||
if (props.reference === null) {
|
||||
missingProps.push("reference");
|
||||
}
|
||||
if (props.loadOrder === null) {
|
||||
missingProps.push("loadOrder");
|
||||
}
|
||||
throw new Error("The required props are not set: " + missingProps.join(", "));
|
||||
}
|
||||
// Random unique element id
|
||||
const uniqueId = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// Define the input reference, this is separate from the props reference, because otherwise we risk loosing changes.
|
||||
const input = ref(props.reference);
|
||||
|
||||
// When the props reference changes, then update the input reference (If the input reference is empty)
|
||||
watch(() => props.reference, (value) => {
|
||||
if (input.value === "") {
|
||||
input.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
const isSaved = () => {
|
||||
return input.value === props.reference;
|
||||
}
|
||||
|
||||
// Define the input changed function
|
||||
const inputChanged = () => {}
|
||||
|
||||
// Define the lost focus function
|
||||
const lostFocus = () => {
|
||||
isEditing.value = false;
|
||||
saveChanges({force: true});
|
||||
}
|
||||
|
||||
// Save the last time the reference was synced, this is used to prevent spamming the server with requests.
|
||||
const lastSync = ref(new Date().getTime());
|
||||
|
||||
// Define the save changes function
|
||||
const saveChanges = (options = {}) => {
|
||||
// If the input value is the same as the reference, then return
|
||||
if (input.value === props.reference && !options.force) {
|
||||
return;
|
||||
}
|
||||
// Set the reference to the input value
|
||||
props.reference = input.value;
|
||||
// Save the order
|
||||
SessionUser.request('/order', 'PUT', {
|
||||
id: props.order_id,
|
||||
field: 'reference',
|
||||
value: input.value
|
||||
}).then((response) => {
|
||||
console.log(response);
|
||||
// Load the order
|
||||
props.loadOrder();
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
// Sync the reference with the server once every 2 seconds, if the reference has changed.
|
||||
setInterval(() => {
|
||||
if (input.value !== props.reference && new Date().getTime() - lastSync.value > 2000) {
|
||||
lastSync.value = new Date().getTime();
|
||||
saveChanges();
|
||||
} else {
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const isEditing = ref(false);
|
||||
|
||||
const onFieldClicked = () => {
|
||||
isEditing.value = true;
|
||||
// Wait for the next tick, then focus on the textarea
|
||||
nextTick(() => {
|
||||
focusOnTextarea();
|
||||
});
|
||||
}
|
||||
|
||||
const focusOnTextarea = () => {
|
||||
const textarea = document.getElementById(uniqueId + '-textarea');
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const getRows = () => {
|
||||
return Math.max(input.value.split("\n").length, 1);
|
||||
}
|
||||
|
||||
const shortReference = ref('');
|
||||
|
||||
watch(() => props.reference, (value) => {
|
||||
generateShortReference();
|
||||
});
|
||||
|
||||
const generateShortReference = () => {
|
||||
let shownReference = '';
|
||||
if (input.value.length > 20 || input.value.split("\n").length > 1) {
|
||||
// Get the first line, and add ...
|
||||
shownReference = input.value.split("\n")[0].substring(0, 20) + '...';
|
||||
} else {
|
||||
shownReference = input.value;
|
||||
}
|
||||
shortReference.value = shownReference;
|
||||
}
|
||||
|
||||
generateShortReference();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="field" @click="onFieldClicked()">
|
||||
<div class="control" :class="{'is-loading': !isSaved()}">
|
||||
<textarea
|
||||
v-if="isEditing"
|
||||
class="textarea"
|
||||
:style="{'height': isEditing ? 'auto' : '2.25em', 'resize': isEditing ? 'vertical' : 'none', 'overflow': 'hidden', 'padding': '0.5em'}"
|
||||
:readonly="!isEditing"
|
||||
:rows="isEditing ? getRows() : 1"
|
||||
placeholder="Ordre reference nummer, dette inkluderes i fakturaen og kan bruges til at finde ordren"
|
||||
v-model="input"
|
||||
@input="inputChanged"
|
||||
@blur="lostFocus()"
|
||||
:id="uniqueId + '-textarea'"
|
||||
></textarea>
|
||||
<textarea
|
||||
class="textarea"
|
||||
:style="{'height': '2.25em', 'resize': 'none', 'overflow': 'hidden', 'padding': '0.5em'}"
|
||||
:readonly="true"
|
||||
:rows="1"
|
||||
v-if="!isEditing"
|
||||
v-text="shortReference"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,308 @@
|
||||
<script setup>
|
||||
import { computed, defineProps, onMounted, ref, useSlots, watch } from "vue";
|
||||
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
|
||||
import PosOrderItemsCurrent from "@/components/displays/department/pos/PosOrderItemsCurrent.vue";
|
||||
import PosSelectedCustomer from "@/components/displays/department/pos/PosSelectedCustomer.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { getDepartmentName, getDepartments } from "@/components/pagination/departmentTabs.vue";
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: "summary",
|
||||
validator: (value) => ["summary", "catalog"].includes(value),
|
||||
},
|
||||
rootTestId: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
orderId: {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
orderItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loadOrderItems: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
editPossible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showCatalogPane: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showCustomerRail: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
summaryOrder: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
const fetchedSummaryOrder = ref(null);
|
||||
const departmentTabsLoaded = ref(false);
|
||||
|
||||
const parsePositiveInteger = (value) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const normalizedOrderId = computed(() => parsePositiveInteger(props.orderId));
|
||||
const resolvedSummaryOrder = computed(() => props.summaryOrder || fetchedSummaryOrder.value || null);
|
||||
const shouldShowRail = computed(() => props.showCustomerRail && !props.showCatalogPane);
|
||||
const shouldShowInlineSummary = computed(() => !props.showCatalogPane);
|
||||
|
||||
const resolvedDepartmentLabel = computed(() => {
|
||||
const departmentId = parsePositiveInteger(resolvedSummaryOrder.value?.department_id);
|
||||
if (!departmentId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return getDepartmentName(departmentId) || String(departmentId);
|
||||
});
|
||||
|
||||
const resolvedCreatedAt = computed(() => {
|
||||
return resolvedSummaryOrder.value?.created_at || "";
|
||||
});
|
||||
|
||||
const hasInlineSummaryTags = computed(() => {
|
||||
return shouldShowInlineSummary.value && Boolean(resolvedDepartmentLabel.value || resolvedCreatedAt.value);
|
||||
});
|
||||
|
||||
const loadDepartmentTabs = async () => {
|
||||
if (departmentTabsLoaded.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await getDepartments();
|
||||
} catch (error) {
|
||||
console.error("Unable to load POS department labels", error);
|
||||
} finally {
|
||||
departmentTabsLoaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const loadSummaryOrder = async (candidateOrderId = normalizedOrderId.value) => {
|
||||
if (props.summaryOrder) {
|
||||
fetchedSummaryOrder.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!candidateOrderId) {
|
||||
fetchedSummaryOrder.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
fetchedSummaryOrder.value = await SessionUser.objects.orders.get.single(candidateOrderId);
|
||||
} catch (error) {
|
||||
console.error("Unable to load POS order summary", error);
|
||||
fetchedSummaryOrder.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDepartmentTabs();
|
||||
await loadSummaryOrder();
|
||||
});
|
||||
|
||||
watch(() => props.summaryOrder, async () => {
|
||||
await loadSummaryOrder();
|
||||
}, { deep: true });
|
||||
|
||||
watch(normalizedOrderId, async (nextOrderId, previousOrderId) => {
|
||||
if (nextOrderId === previousOrderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadSummaryOrder(nextOrderId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="pos-shell pos-order-detail"
|
||||
:class="{ 'pos-order-detail--editing': showCatalogPane }"
|
||||
:data-testid="rootTestId || undefined"
|
||||
>
|
||||
<div class="pos-main pos-order-detail__main" data-testid="pos-order-main">
|
||||
<div v-if="$slots.leading" class="pos-order-detail__leading">
|
||||
<slot name="leading"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pos-order-detail__workspace"
|
||||
data-testid="pos-order-workspace"
|
||||
:class="{ 'pos-order-detail__workspace--editing': showCatalogPane }"
|
||||
>
|
||||
<div
|
||||
v-if="showCatalogPane"
|
||||
class="pos-order-detail__products"
|
||||
data-testid="pos-order-add-items-panel"
|
||||
>
|
||||
<slot name="catalog"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pos-order-detail__order-column"
|
||||
:class="{ 'pos-order-detail__order-column--editing': showCatalogPane }"
|
||||
>
|
||||
<WhiteBox class="pos-card pos-card--flush pos-order-detail__order" data-testid="pos-order-card">
|
||||
<PosOrderItemsCurrent
|
||||
:orderItems="orderItems"
|
||||
:orderId="orderId"
|
||||
:loadOrderItems="loadOrderItems"
|
||||
:editPossible="editPossible"
|
||||
:stackMetadata="showCatalogPane"
|
||||
variant="order-detail"
|
||||
>
|
||||
<template #default>
|
||||
<div v-if="$slots.status" class="is-hidden-touch pos-order-detail__status-group">
|
||||
<slot name="status"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #headingcenter>
|
||||
<div class="pos-inline-tags is-hidden-touch" v-if="hasInlineSummaryTags">
|
||||
<span
|
||||
v-if="resolvedDepartmentLabel"
|
||||
class="pos-inline-tag"
|
||||
data-testid="pos-order-inline-department"
|
||||
>
|
||||
<span class="pos-inline-tag__label">{{ $t('pos.order.department') }}:</span>
|
||||
<span class="pos-inline-tag__value">{{ resolvedDepartmentLabel }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="resolvedCreatedAt"
|
||||
class="pos-inline-tag"
|
||||
data-testid="pos-order-inline-created"
|
||||
>
|
||||
<span class="pos-inline-tag__label">{{ $t('pos.order.created') }}:</span>
|
||||
<span class="pos-inline-tag__value">{{ resolvedCreatedAt }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<slot name="headingcenter"></slot>
|
||||
</template>
|
||||
|
||||
<template v-if="$slots.headerActions" #headerActions>
|
||||
<slot name="headerActions"></slot>
|
||||
</template>
|
||||
|
||||
<template v-if="$slots.beforeItems" #beforeItems>
|
||||
<slot name="beforeItems"></slot>
|
||||
</template>
|
||||
|
||||
<template v-if="$slots.attachments" #attachments>
|
||||
<slot name="attachments"></slot>
|
||||
</template>
|
||||
|
||||
<template v-if="$slots.other" #other>
|
||||
<slot name="other"></slot>
|
||||
</template>
|
||||
|
||||
<template v-if="$slots.details" #details>
|
||||
<slot name="details"></slot>
|
||||
</template>
|
||||
|
||||
<template #bottom-order-items>
|
||||
<slot name="bottom-order-items"></slot>
|
||||
</template>
|
||||
</PosOrderItemsCurrent>
|
||||
</WhiteBox>
|
||||
|
||||
<div
|
||||
v-if="$slots.orderFooter"
|
||||
class="pos-order-detail__order-footer"
|
||||
>
|
||||
<slot name="orderFooter"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="shouldShowRail" class="pos-rail pos-rail--sticky" data-testid="pos-order-rail">
|
||||
<WhiteBox class="pos-card pos-card--flush is-hidden-touch" data-testid="pos-customer-card">
|
||||
<PosSelectedCustomer variant="order-detail" />
|
||||
</WhiteBox>
|
||||
|
||||
<div v-if="$slots['rail-actions']" class="pos-order-detail__rail-actions">
|
||||
<slot name="rail-actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pos-order-detail__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pos-order-detail__leading,
|
||||
.pos-order-detail__rail-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.pos-order-detail__workspace {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.pos-order-detail__order {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pos-order-detail__order-column {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.pos-order-detail__products {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pos-order-detail__order-footer {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-order-detail--editing {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pos-order-detail__workspace--editing {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(20rem, 23.75rem);
|
||||
}
|
||||
|
||||
.pos-order-detail__order-column--editing {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1023px) {
|
||||
.pos-order-detail__workspace--editing {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pos-order-detail__order-column--editing {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,392 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
totalPrice: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
canEditOrderItems: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['edit', 'delete']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const getOrderItemProductName = (orderItem) => {
|
||||
return orderItem?.product?.name || t('global.no_data');
|
||||
};
|
||||
|
||||
const getOrderItemBasePrice = (orderItem) => {
|
||||
return Number(orderItem?.product?.price ?? orderItem?.price ?? 0);
|
||||
};
|
||||
|
||||
const getRowTotal = (orderItem) => {
|
||||
return Number(orderItem?.price ?? 0) * Number(orderItem?.quantity ?? 0);
|
||||
};
|
||||
|
||||
const getRowClass = (row) => {
|
||||
return {
|
||||
'pos-order-items__row--related': row.isRelated,
|
||||
'pos-order-items__row--group-parent': row.hasRelatedItems,
|
||||
'pos-order-items__row--group-middle': row.isRelated && !row.isLastRelatedItem,
|
||||
};
|
||||
};
|
||||
|
||||
const showValuePopper = (title, value, target) => {
|
||||
showPopper(popperBox(title, value), target);
|
||||
};
|
||||
|
||||
const showPricePopper = (orderItem, target) => {
|
||||
const basePrice = getOrderItemBasePrice(orderItem);
|
||||
const total = getRowTotal(orderItem);
|
||||
const body = Number(orderItem?.price ?? 0) !== basePrice
|
||||
? `Pris changed from ${basePrice} DKK to ${orderItem.price} DKK / unit (${total} DKK total)`
|
||||
: `${orderItem.price} DKK / unit (${total} DKK total)`;
|
||||
|
||||
showValuePopper('Pris', body, target);
|
||||
};
|
||||
|
||||
const getPriceColumnWidth = () => (props.showActions ? '10.5rem' : '11.5rem');
|
||||
const getActionsColumnWidth = () => '7.25rem';
|
||||
|
||||
const getPriceHeaderAttrs = () => ({
|
||||
style: {
|
||||
width: getPriceColumnWidth(),
|
||||
},
|
||||
'data-testid': 'pos-order-price-header',
|
||||
});
|
||||
|
||||
const getPriceCellAttrs = () => ({
|
||||
class: 'pos-order-items__price-column',
|
||||
style: {
|
||||
width: getPriceColumnWidth(),
|
||||
},
|
||||
});
|
||||
|
||||
const getActionsHeaderAttrs = () => ({
|
||||
style: {
|
||||
width: getActionsColumnWidth(),
|
||||
},
|
||||
'data-testid': 'pos-order-actions-header',
|
||||
});
|
||||
|
||||
const getActionsCellAttrs = () => ({
|
||||
class: 'pos-order-items__actions-column',
|
||||
style: {
|
||||
width: getActionsColumnWidth(),
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pos-order-items__detail-table" data-testid="pos-order-detail-items-table">
|
||||
<b-table
|
||||
:data="rows"
|
||||
:mobile-cards="false"
|
||||
:hoverable="true"
|
||||
:row-class="getRowClass"
|
||||
custom-row-key="rowKey"
|
||||
>
|
||||
<b-table-column
|
||||
field="product"
|
||||
:label="$t('admin.pos.product')"
|
||||
v-slot="tableProps"
|
||||
>
|
||||
<div class="pos-order-items__product-cell" :data-testid="`pos-order-item-name-${tableProps.row.orderItem.id}`">
|
||||
<span
|
||||
class="pos-order-items__product-name"
|
||||
:class="{ 'related-item-text': tableProps.row.isRelated }"
|
||||
>
|
||||
<template v-if="tableProps.row.isRelated">
|
||||
+ <template v-if="tableProps.row.orderItem.quantity > 1">{{ tableProps.row.orderItem.quantity }} x </template>{{ getOrderItemProductName(tableProps.row.orderItem) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ getOrderItemProductName(tableProps.row.orderItem) }}
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="tableProps.row.orderItem.notes"
|
||||
:data-testid="`pos-order-item-note-trigger-${tableProps.row.orderItem.id}`"
|
||||
class="ml-2 is-size-7 has-text-grey is-clickable"
|
||||
@mouseover="showValuePopper('Note', tableProps.row.orderItem.notes, $event.target)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
@click="emits('edit', tableProps.row.orderItem)"
|
||||
>
|
||||
<i class="fas fa-comment"></i>
|
||||
{{ $t('admin.pos.see_note') }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="tableProps.row.orderItem.reference"
|
||||
:data-testid="`pos-order-item-reference-trigger-${tableProps.row.orderItem.id}`"
|
||||
class="ml-2 is-size-7 has-text-grey is-clickable"
|
||||
@mouseover="showValuePopper('Reference', tableProps.row.orderItem.reference, $event.target)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
@click="emits('edit', tableProps.row.orderItem)"
|
||||
>
|
||||
<i class="fas fa-link"></i>
|
||||
{{ $t('admin.pos.see_reference') }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="!tableProps.row.isRelated && tableProps.row.orderItem.quantity > 1"
|
||||
:data-testid="`pos-order-item-quantity-${tableProps.row.orderItem.id}`"
|
||||
class="ml-2 is-size-7 has-text-grey is-clickable"
|
||||
@mouseover="showValuePopper('Quantity', tableProps.row.orderItem.quantity, $event.target)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
@click="emits('edit', tableProps.row.orderItem)"
|
||||
>
|
||||
<i class="fas fa-box"></i>
|
||||
{{ $t('admin.pos.quantity') }}: {{ tableProps.row.orderItem.quantity }}
|
||||
</span>
|
||||
</div>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column
|
||||
field="price"
|
||||
:label="$t('admin.pos.price_dkk')"
|
||||
custom-key="price"
|
||||
header-class="pos-order-items__price-header"
|
||||
:th-attrs="getPriceHeaderAttrs"
|
||||
:td-attrs="getPriceCellAttrs"
|
||||
v-slot="tableProps"
|
||||
>
|
||||
<div
|
||||
class="pos-order-items__price-cell"
|
||||
:data-testid="`pos-order-item-price-${tableProps.row.orderItem.id}`"
|
||||
>
|
||||
<span
|
||||
@mouseover="showPricePopper(tableProps.row.orderItem, $event.target)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
>
|
||||
<template v-if="Number(tableProps.row.orderItem.price ?? 0) !== getOrderItemBasePrice(tableProps.row.orderItem)">
|
||||
<span class="pos-order-items__base-price">{{ getOrderItemBasePrice(tableProps.row.orderItem) * tableProps.row.orderItem.quantity }}</span>
|
||||
<span>{{ getRowTotal(tableProps.row.orderItem) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ getRowTotal(tableProps.row.orderItem) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column
|
||||
v-if="showActions"
|
||||
field="actions"
|
||||
:label="$t('global.actions')"
|
||||
custom-key="actions"
|
||||
header-class="pos-order-items__actions-header"
|
||||
:th-attrs="getActionsHeaderAttrs"
|
||||
:td-attrs="getActionsCellAttrs"
|
||||
v-slot="tableProps"
|
||||
>
|
||||
<div class="pos-order-items__action-cell">
|
||||
<div class="pos-order-items__item-actions">
|
||||
<button
|
||||
class="button is-small pos-order-items__item-action pos-order-items__item-action--edit"
|
||||
type="button"
|
||||
:data-testid="`pos-order-item-edit-${tableProps.row.orderItem.id}`"
|
||||
:disabled="!canEditOrderItems"
|
||||
:aria-label="$t('admin.pos.edit')"
|
||||
:title="$t('admin.pos.edit')"
|
||||
@click="emits('edit', tableProps.row.orderItem)"
|
||||
>
|
||||
<span class="icon is-small"><i class="fas fa-pen"></i></span>
|
||||
</button>
|
||||
<button
|
||||
class="button is-small pos-order-items__item-action pos-order-items__item-action--delete"
|
||||
type="button"
|
||||
:data-testid="`pos-order-item-delete-${tableProps.row.orderItem.id}`"
|
||||
:aria-label="$t('admin.pos.delete')"
|
||||
:title="$t('admin.pos.delete')"
|
||||
@click="emits('delete', tableProps.row.orderItem.id)"
|
||||
>
|
||||
<span class="icon is-small"><i class="fas fa-trash"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</b-table-column>
|
||||
|
||||
<template #empty>
|
||||
<div class="pos-order-items__empty" data-testid="pos-order-empty-state">
|
||||
{{ $t('global.no_data') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<td class="pos-order-items__footer-label">{{ $t('admin.pos.total') }}</td>
|
||||
<td class="pos-order-items__footer-value">
|
||||
<span
|
||||
data-testid="pos-order-total"
|
||||
@mouseover="showValuePopper('Total', `Order total: ${totalPrice} DKK`, $event.target)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
>
|
||||
{{ totalPrice }} DKK
|
||||
</span>
|
||||
</td>
|
||||
<td v-if="showActions"></td>
|
||||
</template>
|
||||
</b-table>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pos-order-items__detail-table :deep(.b-table) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pos-order-items__detail-table :deep(.table) {
|
||||
table-layout: fixed;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pos-order-items__detail-table :deep(.table th) {
|
||||
color: #20344f;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pos-order-items__detail-table :deep(.table th.pos-order-items__price-header),
|
||||
.pos-order-items__detail-table :deep(.table th.pos-order-items__actions-header) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pos-order-items__detail-table :deep(.table th.pos-order-items__price-header .th-wrap),
|
||||
.pos-order-items__detail-table :deep(.table th.pos-order-items__actions-header .th-wrap) {
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pos-order-items__detail-table :deep(.table td) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.pos-order-items__detail-table :deep(.table td.pos-order-items__price-column),
|
||||
.pos-order-items__detail-table :deep(.table td.pos-order-items__actions-column) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pos-order-items__detail-table :deep(.table tbody tr:first-child td) {
|
||||
border-top: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.pos-order-items__detail-table :deep(.table tbody tr.pos-order-items__row--group-parent td) {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.pos-order-items__detail-table :deep(.table tbody tr.pos-order-items__row--group-middle td) {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.pos-order-items__detail-table :deep(.table tfoot td) {
|
||||
font-weight: 600;
|
||||
border-top: 1px solid #dfe7f1;
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.pos-order-items__product-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-order-items__product-name {
|
||||
color: #243347;
|
||||
}
|
||||
|
||||
.pos-order-items__price-cell,
|
||||
.pos-order-items__footer-value {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pos-order-items__footer-label {
|
||||
color: #243347;
|
||||
}
|
||||
|
||||
.pos-order-items__base-price {
|
||||
margin-right: 0.45rem;
|
||||
color: #303030;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.pos-order-items__action-cell {
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pos-order-items__item-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.pos-order-items__item-action {
|
||||
min-width: 2.2rem;
|
||||
min-height: 2.2rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.pos-order-items__item-action--edit {
|
||||
border-color: #d8e2ee;
|
||||
background: #f7f9fc;
|
||||
color: #22446d;
|
||||
}
|
||||
|
||||
.pos-order-items__item-action--edit:hover,
|
||||
.pos-order-items__item-action--edit:focus-visible {
|
||||
border-color: #9bb3cf;
|
||||
background: #eef4fb;
|
||||
color: #173558;
|
||||
}
|
||||
|
||||
.pos-order-items__item-action--delete {
|
||||
border-color: #f0d2d2;
|
||||
background: #fff6f6;
|
||||
color: #b24141;
|
||||
}
|
||||
|
||||
.pos-order-items__item-action--delete:hover,
|
||||
.pos-order-items__item-action--delete:focus-visible {
|
||||
border-color: #e4b5b5;
|
||||
background: #ffecec;
|
||||
color: #9a2d2d;
|
||||
}
|
||||
|
||||
.related-item-text {
|
||||
color: #4a4a4a;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pos-order-items__empty {
|
||||
padding: 1rem 0;
|
||||
color: #65758b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.pos-order-items__action-cell {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pos-order-items__item-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,244 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { editOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
orderItem: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
canEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['update:modelValue', 'saved']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
price: '',
|
||||
notes: '',
|
||||
reference: '',
|
||||
quantity: '1',
|
||||
});
|
||||
|
||||
const isSubmitting = ref(false);
|
||||
const errorMessage = ref('');
|
||||
|
||||
const isActive = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emits('update:modelValue', value),
|
||||
});
|
||||
|
||||
const syncForm = () => {
|
||||
form.name = props.orderItem?.product?.name || t('global.no_data');
|
||||
form.price = String(props.orderItem?.price ?? '');
|
||||
form.notes = String(props.orderItem?.notes ?? '');
|
||||
form.reference = String(props.orderItem?.reference ?? '');
|
||||
form.quantity = String(props.orderItem?.quantity ?? 1);
|
||||
errorMessage.value = '';
|
||||
};
|
||||
|
||||
watch(() => [props.modelValue, props.orderItem?.id], syncForm, { immediate: true });
|
||||
|
||||
const isPriceValid = computed(() => {
|
||||
const value = Number(form.price);
|
||||
return Number.isFinite(value) && value >= 0;
|
||||
});
|
||||
|
||||
const isQuantityValid = computed(() => {
|
||||
const value = Number(form.quantity);
|
||||
return Number.isInteger(value) && value > 0;
|
||||
});
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return Boolean(props.orderItem) && props.canEdit && !isSubmitting.value && isPriceValid.value && isQuantityValid.value;
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
if (isSubmitting.value) {
|
||||
return;
|
||||
}
|
||||
isActive.value = false;
|
||||
};
|
||||
|
||||
const saveChanges = async () => {
|
||||
if (!props.orderItem || !canSubmit.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
await editOrderItem(
|
||||
props.orderItem.id,
|
||||
Number(form.price),
|
||||
form.notes,
|
||||
form.reference,
|
||||
Number(form.quantity)
|
||||
);
|
||||
emits('saved');
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.response?.data?.error
|
||||
|| error?.response?.data?.data?.message
|
||||
|| error?.message
|
||||
|| 'Unable to save order item';
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<b-modal
|
||||
v-model="isActive"
|
||||
:width="720"
|
||||
:can-cancel="['escape', 'x', 'outside']"
|
||||
:destroy-on-hide="false"
|
||||
trap-focus
|
||||
aria-role="dialog"
|
||||
aria-modal
|
||||
>
|
||||
<div
|
||||
v-if="orderItem"
|
||||
class="pos-order-item-edit-modal"
|
||||
data-testid="pos-order-item-edit-modal"
|
||||
>
|
||||
<div class="pos-order-item-edit-modal__header">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12">
|
||||
<label class="label" for="pos-order-item-edit-name">{{ t('admin.pos.product') }}</label>
|
||||
<input
|
||||
id="pos-order-item-edit-name"
|
||||
class="input"
|
||||
type="text"
|
||||
:value="form.name"
|
||||
data-testid="pos-order-item-edit-name"
|
||||
readonly
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="column is-12-mobile is-6-tablet">
|
||||
<label class="label" for="pos-order-item-edit-price">{{ t('global.price') }}</label>
|
||||
<input
|
||||
id="pos-order-item-edit-price"
|
||||
v-model="form.price"
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
:disabled="!canEdit"
|
||||
data-testid="pos-order-item-edit-price"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="column is-12-mobile is-6-tablet">
|
||||
<label class="label" for="pos-order-item-edit-quantity">{{ t('global.quantity') }}</label>
|
||||
<input
|
||||
id="pos-order-item-edit-quantity"
|
||||
v-model="form.quantity"
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
:disabled="!canEdit"
|
||||
data-testid="pos-order-item-edit-quantity"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<label class="label" for="pos-order-item-edit-notes">{{ t('global.note') }}</label>
|
||||
<textarea
|
||||
id="pos-order-item-edit-notes"
|
||||
v-model="form.notes"
|
||||
class="textarea"
|
||||
rows="4"
|
||||
:disabled="!canEdit"
|
||||
data-testid="pos-order-item-edit-notes"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<label class="label" for="pos-order-item-edit-reference">{{ t('objects.columns.reference') }}</label>
|
||||
<input
|
||||
id="pos-order-item-edit-reference"
|
||||
v-model="form.reference"
|
||||
class="input"
|
||||
type="text"
|
||||
:disabled="!canEdit"
|
||||
data-testid="pos-order-item-edit-reference"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="errorMessage"
|
||||
class="help is-danger mb-3"
|
||||
data-testid="pos-order-item-edit-error"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="buttons is-right pos-order-item-edit-modal__actions">
|
||||
<button
|
||||
class="button is-light"
|
||||
type="button"
|
||||
data-testid="pos-order-item-edit-cancel"
|
||||
:disabled="isSubmitting"
|
||||
@click="closeModal"
|
||||
>
|
||||
{{ SessionUser.objects.global.language.cancel || 'Cancel' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="button is-link"
|
||||
type="button"
|
||||
data-testid="pos-order-item-edit-save"
|
||||
:class="{ 'is-loading': isSubmitting }"
|
||||
:disabled="!canSubmit"
|
||||
@click="saveChanges"
|
||||
>
|
||||
{{ SessionUser.objects.global.language.save || 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pos-order-item-edit-modal {
|
||||
background: #ffffff;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1.2rem 2.8rem rgba(18, 32, 52, 0.18);
|
||||
}
|
||||
|
||||
.pos-order-item-edit-modal__header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pos-order-item-edit-modal__actions {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.pos-order-item-edit-modal {
|
||||
padding: 1rem;
|
||||
border-radius: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,266 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
totalPrice: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
canEditOrderItems: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['edit', 'delete']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const getOrderItemProductName = (orderItem) => {
|
||||
return orderItem?.product?.name || t('global.no_data');
|
||||
};
|
||||
|
||||
const getOrderItemBasePrice = (orderItem) => {
|
||||
return Number(orderItem?.product?.price ?? orderItem?.price ?? 0);
|
||||
};
|
||||
|
||||
const getRowTotal = (orderItem) => {
|
||||
return Number(orderItem?.price ?? 0) * Number(orderItem?.quantity ?? 0);
|
||||
};
|
||||
|
||||
const getRowClass = (row) => {
|
||||
return {
|
||||
'pos-order-items__row--related': row.isRelated,
|
||||
'pos-order-items__row--group-parent': row.hasRelatedItems,
|
||||
'pos-order-items__row--group-middle': row.isRelated && !row.isLastRelatedItem,
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pos-order-items__default-table" data-testid="pos-order-items-table">
|
||||
<b-table
|
||||
:data="rows"
|
||||
:mobile-cards="false"
|
||||
:hoverable="true"
|
||||
:row-class="getRowClass"
|
||||
custom-row-key="rowKey"
|
||||
>
|
||||
<b-table-column
|
||||
field="product"
|
||||
:label="$t('admin.pos.product')"
|
||||
v-slot="tableProps"
|
||||
>
|
||||
<div class="pos-order-items__product-cell" :data-testid="`pos-order-item-name-${tableProps.row.orderItem.id}`">
|
||||
<span
|
||||
class="pos-order-items__product-name"
|
||||
:class="{ 'related-item-text': tableProps.row.isRelated }"
|
||||
>
|
||||
<template v-if="tableProps.row.isRelated">
|
||||
+ <template v-if="tableProps.row.orderItem.quantity > 1">{{ tableProps.row.orderItem.quantity }} x </template>{{ getOrderItemProductName(tableProps.row.orderItem) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ getOrderItemProductName(tableProps.row.orderItem) }}
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="tableProps.row.orderItem.notes"
|
||||
:data-testid="`pos-order-item-note-trigger-${tableProps.row.orderItem.id}`"
|
||||
class="ml-2 is-size-7 has-text-grey is-clickable"
|
||||
@mouseover="showPopper(popperBox('Note', tableProps.row.orderItem.notes), $event.target)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
@click="emits('edit', tableProps.row.orderItem)"
|
||||
>
|
||||
<i class="fas fa-comment"></i>
|
||||
{{ $t('admin.pos.see_note') }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="tableProps.row.orderItem.reference"
|
||||
:data-testid="`pos-order-item-reference-trigger-${tableProps.row.orderItem.id}`"
|
||||
class="ml-2 is-size-7 has-text-grey is-clickable"
|
||||
@mouseover="showPopper(popperBox('Reference', tableProps.row.orderItem.reference), $event.target)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
@click="emits('edit', tableProps.row.orderItem)"
|
||||
>
|
||||
<i class="fas fa-link"></i>
|
||||
{{ $t('admin.pos.see_reference') }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="!tableProps.row.isRelated && tableProps.row.orderItem.quantity > 1"
|
||||
:data-testid="`pos-order-item-quantity-${tableProps.row.orderItem.id}`"
|
||||
class="ml-2 is-size-7 has-text-grey is-clickable"
|
||||
@mouseover="showPopper(popperBox('Antal', tableProps.row.orderItem.quantity), $event.target)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
@click="emits('edit', tableProps.row.orderItem)"
|
||||
>
|
||||
<i class="fas fa-box"></i>
|
||||
{{ $t('admin.pos.quantity') }}: {{ tableProps.row.orderItem.quantity }}
|
||||
</span>
|
||||
</div>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column
|
||||
field="price"
|
||||
:label="$t('admin.pos.price_dkk')"
|
||||
custom-key="price"
|
||||
v-slot="tableProps"
|
||||
>
|
||||
<div
|
||||
class="pos-order-items__price-cell"
|
||||
:data-testid="`pos-order-item-price-${tableProps.row.orderItem.id}`"
|
||||
>
|
||||
<span
|
||||
:class="{ 'related-item-text': tableProps.row.isRelated }"
|
||||
@mouseover="showPopper(
|
||||
popperBox(
|
||||
'Pris',
|
||||
Number(tableProps.row.orderItem.price ?? 0) !== getOrderItemBasePrice(tableProps.row.orderItem)
|
||||
? 'Pris ændret fra ' + getOrderItemBasePrice(tableProps.row.orderItem) + ' DKK til ' + tableProps.row.orderItem.price + ' DKK / Enhed (' + getRowTotal(tableProps.row.orderItem) + ' DKK i alt)'
|
||||
: tableProps.row.orderItem.price + ' DKK / Enhed (' + getRowTotal(tableProps.row.orderItem) + ' DKK i alt)'
|
||||
),
|
||||
$event.target
|
||||
)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
>
|
||||
<template v-if="Number(tableProps.row.orderItem.price ?? 0) !== getOrderItemBasePrice(tableProps.row.orderItem)">
|
||||
<span class="pos-order-items__base-price" :class="{ 'related-item-text': tableProps.row.isRelated }">{{ getOrderItemBasePrice(tableProps.row.orderItem) * tableProps.row.orderItem.quantity }}</span>
|
||||
<span>{{ getRowTotal(tableProps.row.orderItem) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ getRowTotal(tableProps.row.orderItem) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column
|
||||
v-if="showActions"
|
||||
field="actions"
|
||||
label=""
|
||||
custom-key="actions"
|
||||
v-slot="tableProps"
|
||||
>
|
||||
<div class="pos-order-items__action-cell">
|
||||
<div class="buttons is-narrow">
|
||||
<ActionSettingsWheelButton>
|
||||
<template #actions>
|
||||
<ActionSettingsWheelItem
|
||||
:label="$t('admin.pos.delete')"
|
||||
icon="fas fa-trash"
|
||||
template="danger"
|
||||
@click="emits('delete', tableProps.row.orderItem.id)"
|
||||
/>
|
||||
<ActionSettingsWheelItem
|
||||
:label="$t('admin.pos.edit')"
|
||||
icon="fas fa-edit"
|
||||
template="warning"
|
||||
:disabled="!canEditOrderItems"
|
||||
@click="emits('edit', tableProps.row.orderItem)"
|
||||
/>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</div>
|
||||
</div>
|
||||
</b-table-column>
|
||||
|
||||
<template #empty>
|
||||
<div class="pos-order-items__empty" data-testid="pos-order-empty-state">
|
||||
{{ $t('global.no_data') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<td class="pos-order-items__footer-label">{{ $t('admin.pos.total') }}</td>
|
||||
<td class="pos-order-items__footer-value">
|
||||
<span
|
||||
data-testid="pos-order-total"
|
||||
@mouseover="showPopper(popperBox('Total', 'Total pris for ordren: ' + totalPrice + ' DKK'), $event.target)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
>
|
||||
{{ totalPrice }} DKK
|
||||
</span>
|
||||
</td>
|
||||
<td v-if="showActions"></td>
|
||||
</template>
|
||||
</b-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pos-order-items__default-table :deep(.b-table) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pos-order-items__default-table :deep(.table) {
|
||||
table-layout: fixed;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pos-order-items__default-table :deep(.table th) {
|
||||
color: #20344f;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pos-order-items__default-table :deep(.table td) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.pos-order-items__default-table :deep(.table tbody tr:first-child td) {
|
||||
border-top: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.pos-order-items__default-table :deep(.table tbody tr.pos-order-items__row--group-parent td) {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.pos-order-items__default-table :deep(.table tbody tr.pos-order-items__row--group-middle td) {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.pos-order-items__default-table :deep(.table tfoot td) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pos-order-items__product-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-order-items__product-name {
|
||||
color: #243347;
|
||||
}
|
||||
|
||||
.pos-order-items__price-cell,
|
||||
.pos-order-items__footer-value,
|
||||
.pos-order-items__action-cell {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pos-order-items__base-price {
|
||||
text-decoration: line-through;
|
||||
color: #1f2f3f;
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
.related-item-text {
|
||||
color: #4a4a4a;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,38 @@
|
||||
<script setup>
|
||||
import SpanSkeleton from "@/components/displays/skeletons/SpanSkeleton.vue";
|
||||
import { ref, defineProps } from "vue";
|
||||
const props = defineProps(["registration_numbers", "order_id", "loadOrder"]);
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { Colors } from "@/ThemeConfig.vue";
|
||||
import { computed, defineProps } from "vue";
|
||||
import PosOrderRegistrationField from "@/components/displays/department/pos/order/PosOrderRegistrationField.vue";
|
||||
|
||||
const props = defineProps({
|
||||
registration_numbers: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
order_id: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
loadOrder: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const registrationEntries = computed(() => {
|
||||
return [1, 2, 3].map((index) => ({
|
||||
index,
|
||||
value: props.registration_numbers?.[index] ?? '',
|
||||
}));
|
||||
});
|
||||
|
||||
// Check if the required props are passed
|
||||
if (!props.registration_numbers) {
|
||||
throw new Error("Missing required prop: registration_numbers");
|
||||
}
|
||||
if (!props.order_id) {
|
||||
if (props.order_id === null || props.order_id === undefined) {
|
||||
throw new Error("Missing required prop: order_id");
|
||||
}
|
||||
if (!props.loadOrder) {
|
||||
@@ -18,44 +41,23 @@ if (!props.loadOrder) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Registration numbers shown with space between them -->
|
||||
<div class="columns is-multiline is-mobile mb-0">
|
||||
<template v-for="(item, index ) in props.registration_numbers" :key="index">
|
||||
<div class="column is-4">
|
||||
<div class="pos-registration-grid">
|
||||
<template v-for="entry in registrationEntries" :key="entry.index">
|
||||
<div class="pos-registration-slot">
|
||||
<SpanSkeleton
|
||||
:isSkeleton="props.registration_numbers[index] === null"
|
||||
v-if="props.isLoading"
|
||||
:isSkeleton="true"
|
||||
width="100%"
|
||||
height="1.5rem"
|
||||
skeleton-class="is-size-4"
|
||||
>
|
||||
<!-- Field -->
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<div
|
||||
class="tags has-addons is-fullwidth is-flex-wrap-nowrap is-clickable"
|
||||
@click="SessionUser.editField.showEditFieldForm(
|
||||
'/order',
|
||||
props.order_id,
|
||||
'reg_' + index,
|
||||
item,
|
||||
'Reg ' + index,
|
||||
props.loadOrder
|
||||
).then(() => props.loadOrder())">
|
||||
<span class="tag is-dark">Reg. {{ index }}</span>
|
||||
<span
|
||||
class="tag is-info"
|
||||
style="width: 100%;"
|
||||
:style="{ 'background-color': (props.registration_numbers[index])
|
||||
? Colors.global.activeColor
|
||||
: Colors.global.disabledTextColor,
|
||||
}"
|
||||
>
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SpanSkeleton>
|
||||
height="3rem"
|
||||
skeleton-class="is-size-6"
|
||||
/>
|
||||
<PosOrderRegistrationField
|
||||
v-else
|
||||
:index="entry.index"
|
||||
:order_id="props.order_id"
|
||||
:modelValue="entry.value"
|
||||
:loadOrder="props.loadOrder"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -63,4 +65,4 @@ if (!props.loadOrder) {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { computed, defineProps, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||
|
||||
const props = defineProps({
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
order_id: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
loadOrder: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const normalizeRegistrationDraft = (value) => String(value ?? "").toUpperCase();
|
||||
|
||||
const inputId = `pos-order-registration-input-${props.index}`;
|
||||
const isEditing = ref(false);
|
||||
const fieldKey = computed(() => `reg_${props.index}`);
|
||||
let autosave;
|
||||
|
||||
autosave = useOrderMetadataAutosave({
|
||||
source: () => props.modelValue,
|
||||
normalizeValue: normalizeRegistrationDraft,
|
||||
saveValue: async (value) => {
|
||||
await SessionUser.objects.orders.set[fieldKey.value](props.order_id, value);
|
||||
return value;
|
||||
},
|
||||
onSaved: async () => {
|
||||
await props.loadOrder();
|
||||
await nextTick();
|
||||
autosave.syncFromSource(props.modelValue);
|
||||
},
|
||||
});
|
||||
|
||||
const hasValue = computed(() => autosave.draft.value.length > 0);
|
||||
const buttonTestId = computed(() => (hasValue.value ? `pos-order-registration-${props.index}` : `pos-order-registration-add-${props.index}`));
|
||||
|
||||
watch(() => autosave.draft.value, () => {
|
||||
if (isEditing.value) {
|
||||
autosave.scheduleSave();
|
||||
}
|
||||
});
|
||||
|
||||
const focusInput = () => {
|
||||
document.getElementById(inputId)?.focus();
|
||||
};
|
||||
|
||||
const openEditor = () => {
|
||||
if (isEditing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isEditing.value = true;
|
||||
nextTick(() => {
|
||||
focusInput();
|
||||
});
|
||||
};
|
||||
|
||||
const closeEditor = async () => {
|
||||
isEditing.value = false;
|
||||
await autosave.flush();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="control pos-registration-control" :class="{ 'is-loading': autosave.isSaving.value }">
|
||||
<div v-if="isEditing" class="pos-registration-field pos-registration-field--editing">
|
||||
<label class="pos-registration-field__label" :for="inputId">{{ t('pos.order.reg') }} {{ props.index }}</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-model="autosave.draft.value"
|
||||
:data-testid="`pos-order-registration-input-${props.index}`"
|
||||
class="pos-registration-field__input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@blur="closeEditor()"
|
||||
@keydown.enter.prevent="closeEditor()"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="pos-registration-field"
|
||||
:class="{ 'pos-registration-field--add': !hasValue }"
|
||||
type="button"
|
||||
:data-testid="buttonTestId"
|
||||
:aria-label="!hasValue ? `${t('common.add')} ${t('pos.order.reg')} ${props.index}` : undefined"
|
||||
@click="openEditor()"
|
||||
>
|
||||
<span class="pos-registration-field__label">{{ t('pos.order.reg') }} {{ props.index }}</span>
|
||||
<span
|
||||
class="pos-registration-field__value"
|
||||
:class="{ 'pos-registration-field__value--muted': !hasValue }"
|
||||
>
|
||||
{{ hasValue ? autosave.draft.value : `+ ${t('common.add')}` }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -60,6 +60,14 @@ const calculateTotalPrice = (price, quantity) => {
|
||||
const tax = calculateTax(totalPrice, props.taxPercentage);
|
||||
return totalPrice + tax;
|
||||
};
|
||||
|
||||
const getOrderItemProductName = (orderItem) => {
|
||||
return orderItem?.product?.name || t('global.no_data');
|
||||
};
|
||||
|
||||
const getOrderItemBasePrice = (orderItem) => {
|
||||
return Number(orderItem?.product?.price ?? orderItem?.price ?? 0);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -81,7 +89,7 @@ const calculateTotalPrice = (price, quantity) => {
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="orderItem in props.orderItems" :key="orderItem.id" v-else>
|
||||
<td>{{ orderItem.product.name }}</td>
|
||||
<td>{{ getOrderItemProductName(orderItem) }}</td>
|
||||
<td>
|
||||
<input type="text" v-if="props.allowEdit" v-model="orderItem.notes" @input="props.setItemValue(orderItem, 'notes', orderItem.notes)" class="input is-small" />
|
||||
<span v-else>{{ orderItem.notes }}</span>
|
||||
@@ -94,8 +102,8 @@ const calculateTotalPrice = (price, quantity) => {
|
||||
<input type="number" v-if="props.allowEdit" v-model.number="orderItem.quantity" @input="props.setItemValue(orderItem, 'quantity', orderItem.quantity)" class="input is-small" min="1" />
|
||||
<span v-else>{{ orderItem.quantity }}</span>
|
||||
</td>
|
||||
<td style="text-align: right;" v-if="orderItem.price !== orderItem.product.price">
|
||||
<span style="text-decoration: line-through;" class="has-text-dark">{{ orderItem.product.price * orderItem.quantity}}</span> <span>{{ SessionUser.functions.currency.toLocal(orderItem.price * orderItem.quantity) }}</span>
|
||||
<td style="text-align: right;" v-if="orderItem.price !== getOrderItemBasePrice(orderItem)">
|
||||
<span style="text-decoration: line-through;" class="has-text-dark">{{ getOrderItemBasePrice(orderItem) * orderItem.quantity}}</span> <span>{{ SessionUser.functions.currency.toLocal(orderItem.price * orderItem.quantity) }}</span>
|
||||
</td>
|
||||
<td style="text-align: right;" v-else>{{ SessionUser.functions.currency.toLocal(orderItem.price * orderItem.quantity) }}</td>
|
||||
</tr>
|
||||
@@ -140,4 +148,4 @@ const calculateTotalPrice = (price, quantity) => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
|
||||
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
|
||||
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
|
||||
|
||||
const props = defineProps({
|
||||
order: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
refreshFunction: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
dataTestId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dropdownRoot = ref(null);
|
||||
const isDropdownOpen = ref(false);
|
||||
const attachments = ref([]);
|
||||
const attachmentsLoaded = ref(false);
|
||||
const isLoadingAttachments = ref(false);
|
||||
const activePreviewAttachmentId = ref(null);
|
||||
const previewLoadingAttachmentId = ref(null);
|
||||
const previewSourcesById = ref({});
|
||||
const previewRequestsInFlight = new Set();
|
||||
const generatedObjectUrls = new Set();
|
||||
|
||||
const normalizeAttachments = (order) => (Array.isArray(order?.attachments) ? order.attachments : []);
|
||||
|
||||
const isObjectUrl = (value) => typeof value === "string" && value.startsWith("blob:");
|
||||
|
||||
const releaseObjectUrl = (value) => {
|
||||
if (!isObjectUrl(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(value);
|
||||
generatedObjectUrls.delete(value);
|
||||
};
|
||||
|
||||
const clearPreviewState = () => {
|
||||
Object.values(previewSourcesById.value).forEach((value) => {
|
||||
releaseObjectUrl(value);
|
||||
});
|
||||
previewSourcesById.value = {};
|
||||
activePreviewAttachmentId.value = null;
|
||||
previewLoadingAttachmentId.value = null;
|
||||
};
|
||||
|
||||
const resetAttachmentsFromOrder = () => {
|
||||
const normalizedAttachments = normalizeAttachments(props.order);
|
||||
attachments.value = normalizedAttachments;
|
||||
attachmentsLoaded.value = normalizedAttachments.length > 0;
|
||||
};
|
||||
|
||||
resetAttachmentsFromOrder();
|
||||
|
||||
watch(
|
||||
() => [props.order?.id, props.order?.attachments],
|
||||
() => {
|
||||
resetAttachmentsFromOrder();
|
||||
clearPreviewState();
|
||||
isDropdownOpen.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
const canAddOrderAttachments = computed(() => {
|
||||
return !!props.order?.id && (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser());
|
||||
});
|
||||
|
||||
const canAccessOrderAttachments = computed(() => {
|
||||
return (
|
||||
SessionUser.canAccessAdmin() ||
|
||||
SessionUser.canAccessSuperUser() ||
|
||||
SessionUser.hasPermission("download_order_attachments_own") ||
|
||||
SessionUser.hasPermission("download_order_attachments")
|
||||
);
|
||||
});
|
||||
|
||||
const hasAttachmentIndicator = computed(() => {
|
||||
if (attachments.value.length > 0 || props.order?.has_attachments === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [props.order?.attachments_count, props.order?.attachment_count, props.order?.attachmentsCount].some(
|
||||
(value) => {
|
||||
const normalizedValue = Number.parseInt(value, 10);
|
||||
return Number.isInteger(normalizedValue) && normalizedValue > 0;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const shouldShowButton = computed(() => {
|
||||
if (hasAttachmentIndicator.value) {
|
||||
return canAccessOrderAttachments.value || canAddOrderAttachments.value;
|
||||
}
|
||||
|
||||
return canAddOrderAttachments.value;
|
||||
});
|
||||
|
||||
const triggerIcon = computed(() => (hasAttachmentIndicator.value ? "fas fa-paperclip" : "fas fa-plus"));
|
||||
const isTextTriggerButton = computed(() => !hasAttachmentIndicator.value);
|
||||
const triggerButtonClass = computed(() => ({
|
||||
button: true,
|
||||
"is-small": true,
|
||||
"is-dark": hasAttachmentIndicator.value,
|
||||
"action-settings-wheel-trigger": true,
|
||||
"action-settings-wheel-trigger--text": isTextTriggerButton.value,
|
||||
"action-settings-wheel-trigger--icon-only": true,
|
||||
}));
|
||||
const activePreviewAttachment = computed(() => {
|
||||
return attachments.value.find((attachment) => attachment.id === activePreviewAttachmentId.value) || null;
|
||||
});
|
||||
|
||||
const getAttachmentLabel = (attachment) => {
|
||||
return (
|
||||
attachment?.content?.document ||
|
||||
attachment?.content?.image ||
|
||||
attachment?.content?.other ||
|
||||
`Attachment ${attachment?.id ?? ""}`.trim()
|
||||
);
|
||||
};
|
||||
|
||||
const getAttachmentExtension = (attachment) => {
|
||||
const match = getAttachmentLabel(attachment)
|
||||
.toLowerCase()
|
||||
.match(/(\.[a-z0-9]+)$/);
|
||||
|
||||
return match?.[1] || "";
|
||||
};
|
||||
|
||||
const getAttachmentPreviewKind = (attachment) => {
|
||||
const extension = getAttachmentExtension(attachment);
|
||||
|
||||
if (attachment?.content?.image || imageExtensions.includes(extension)) {
|
||||
return "image";
|
||||
}
|
||||
|
||||
if (attachment?.content?.document || extension === ".pdf") {
|
||||
return officeExtensions.includes(extension) ? "office" : "document";
|
||||
}
|
||||
|
||||
if (officeExtensions.includes(extension)) {
|
||||
return "office";
|
||||
}
|
||||
|
||||
if (String(attachment?.content?.other || "").startsWith("http")) {
|
||||
return "link";
|
||||
}
|
||||
|
||||
if (attachment?.content?.other) {
|
||||
return "text";
|
||||
}
|
||||
|
||||
return "none";
|
||||
};
|
||||
|
||||
const activePreviewKind = computed(() => {
|
||||
if (!activePreviewAttachment.value) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
return getAttachmentPreviewKind(activePreviewAttachment.value);
|
||||
});
|
||||
|
||||
const activePreviewSource = computed(() => {
|
||||
if (!activePreviewAttachment.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return previewSourcesById.value[activePreviewAttachment.value.id] ?? null;
|
||||
});
|
||||
|
||||
const closeDropdown = () => {
|
||||
isDropdownOpen.value = false;
|
||||
activePreviewAttachmentId.value = null;
|
||||
previewLoadingAttachmentId.value = null;
|
||||
};
|
||||
|
||||
const onActionSelected = () => {
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const onDocumentClick = (event) => {
|
||||
if (!isDropdownOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dropdownRoot.value && !dropdownRoot.value.contains(event.target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentKeydown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", onDocumentClick);
|
||||
document.addEventListener("keydown", onDocumentKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("click", onDocumentClick);
|
||||
document.removeEventListener("keydown", onDocumentKeydown);
|
||||
clearPreviewState();
|
||||
});
|
||||
|
||||
const ensureAttachmentsLoaded = async (force = false) => {
|
||||
const shouldRefetchAttachmentList = attachments.value.length === 0;
|
||||
|
||||
if (
|
||||
!props.order?.id ||
|
||||
(attachmentsLoaded.value && !force && !shouldRefetchAttachmentList) ||
|
||||
isLoadingAttachments.value
|
||||
) {
|
||||
return attachments.value;
|
||||
}
|
||||
|
||||
isLoadingAttachments.value = true;
|
||||
|
||||
try {
|
||||
const response = await SessionUser.objects.orders.functions.fetchAttachments(props.order.id);
|
||||
attachments.value = Array.isArray(response) ? response : [];
|
||||
attachmentsLoaded.value = true;
|
||||
} catch (error) {
|
||||
console.error("Error loading order attachments:", error);
|
||||
} finally {
|
||||
isLoadingAttachments.value = false;
|
||||
}
|
||||
|
||||
return attachments.value;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.order?.id, props.order?.attachments, canAccessOrderAttachments.value, canAddOrderAttachments.value],
|
||||
() => {
|
||||
if (!props.order?.id || (!canAccessOrderAttachments.value && !canAddOrderAttachments.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachments.value.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void ensureAttachmentsLoaded();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const readAttachmentFileAsBase64 = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
resolve(event.target?.result ?? null);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
reject(new Error("Failed to read attachment file"));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const refreshAfterMutation = async () => {
|
||||
await ensureAttachmentsLoaded(true);
|
||||
await props.refreshFunction();
|
||||
};
|
||||
|
||||
const uploadOrderAttachmentFile = async (file) => {
|
||||
try {
|
||||
const base64File = await readAttachmentFileAsBase64(file);
|
||||
const response = await SessionUser.objects.orders.functions.uploadAttachment(props.order.id, file.name, base64File);
|
||||
|
||||
if (!response) {
|
||||
throw new Error("Attachment upload failed");
|
||||
}
|
||||
|
||||
await refreshAfterMutation();
|
||||
} catch (error) {
|
||||
console.error("Error uploading order attachment:", error);
|
||||
Swal.fire({
|
||||
title: t("admin.pos.settings_wheel.error"),
|
||||
text: t("admin.pos.attachments_upload_error"),
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openOrderAttachmentFilePicker = () => {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = acceptedOrderAttachmentFileTypes;
|
||||
fileInput.onchange = async (event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLInputElement && target.files && target.files[0]) {
|
||||
await uploadOrderAttachmentFile(target.files[0]);
|
||||
}
|
||||
};
|
||||
fileInput.click();
|
||||
};
|
||||
|
||||
const showAddOrderAttachmentsForm = () => {
|
||||
return SessionUser.objects.orders.functions.showAttachWashCertificateForm(props.order.id, async () => {
|
||||
await refreshAfterMutation();
|
||||
});
|
||||
};
|
||||
|
||||
const hasCachedPreviewSource = (attachmentId) => {
|
||||
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
|
||||
};
|
||||
|
||||
const createEmbeddablePreviewUrl = async (downloadLink) => {
|
||||
if (!downloadLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(downloadLink, { method: "GET" });
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileBlob = await response.blob();
|
||||
if (!fileBlob || fileBlob.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(fileBlob);
|
||||
generatedObjectUrls.add(objectUrl);
|
||||
return objectUrl;
|
||||
} catch (error) {
|
||||
console.warn("Unable to create embeddable preview blob", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const ensurePreviewSource = async (attachment) => {
|
||||
if (!props.order?.id || !attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previewKind = getAttachmentPreviewKind(attachment);
|
||||
if (!["image", "document"].includes(previewKind)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasCachedPreviewSource(attachment.id) || previewRequestsInFlight.has(attachment.id)) {
|
||||
return previewSourcesById.value[attachment.id] ?? null;
|
||||
}
|
||||
|
||||
previewRequestsInFlight.add(attachment.id);
|
||||
previewLoadingAttachmentId.value = attachment.id;
|
||||
|
||||
try {
|
||||
const downloadLink = await SessionUser.objects.orders.functions.downloadAttachment(
|
||||
props.order.id,
|
||||
attachment.id,
|
||||
false
|
||||
);
|
||||
const previewSource = await createEmbeddablePreviewUrl(downloadLink);
|
||||
previewSourcesById.value = {
|
||||
...previewSourcesById.value,
|
||||
[attachment.id]: previewSource,
|
||||
};
|
||||
return previewSource;
|
||||
} catch (error) {
|
||||
console.warn("Unable to load attachment preview", attachment.id, error);
|
||||
previewSourcesById.value = {
|
||||
...previewSourcesById.value,
|
||||
[attachment.id]: null,
|
||||
};
|
||||
return null;
|
||||
} finally {
|
||||
previewRequestsInFlight.delete(attachment.id);
|
||||
if (previewLoadingAttachmentId.value === attachment.id) {
|
||||
previewLoadingAttachmentId.value = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onAttachmentHover = async (attachment) => {
|
||||
activePreviewAttachmentId.value = attachment.id;
|
||||
await ensurePreviewSource(attachment);
|
||||
};
|
||||
|
||||
const downloadOrderAttachment = async (attachment) => {
|
||||
const downloadLink = await SessionUser.objects.orders.functions.downloadAttachment(
|
||||
props.order.id,
|
||||
attachment.id,
|
||||
false
|
||||
);
|
||||
if (!downloadLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadLink;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.download = getAttachmentLabel(attachment);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
};
|
||||
|
||||
const toggleDropdown = async () => {
|
||||
if (isDropdownOpen.value) {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
isDropdownOpen.value = true;
|
||||
|
||||
if (hasAttachmentIndicator.value) {
|
||||
await ensureAttachmentsLoaded();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="shouldShowButton"
|
||||
ref="dropdownRoot"
|
||||
class="dropdown is-right"
|
||||
:class="{ 'is-active': isDropdownOpen }"
|
||||
:data-testid="props.dataTestId"
|
||||
>
|
||||
<div class="dropdown-trigger">
|
||||
<button
|
||||
type="button"
|
||||
:class="triggerButtonClass"
|
||||
aria-haspopup="true"
|
||||
aria-controls="dropdown-menu"
|
||||
:aria-expanded="isDropdownOpen ? 'true' : 'false'"
|
||||
@click.stop="toggleDropdown"
|
||||
>
|
||||
<span class="icon">
|
||||
<i :class="triggerIcon"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu order-attachments-dropdown-menu" id="dropdown-menu" role="menu">
|
||||
<div class="dropdown-content" @dropdown-action-selected="onActionSelected">
|
||||
<ActionSettingsWheelItem
|
||||
v-if="canAddOrderAttachments"
|
||||
:label="t('admin.pos.settings_wheel.attach_wash_certificate')"
|
||||
icon="fas fa-plus"
|
||||
:click-action="showAddOrderAttachmentsForm"
|
||||
:disabled="false"
|
||||
/>
|
||||
<ActionSettingsWheelItem
|
||||
v-if="canAddOrderAttachments"
|
||||
:label="t('admin.pos.attachments_upload_action')"
|
||||
icon="fas fa-upload"
|
||||
:click-action="openOrderAttachmentFilePicker"
|
||||
:disabled="false"
|
||||
/>
|
||||
|
||||
<template v-if="attachments.length > 0">
|
||||
<ActionSettingsWheelItemLabel :label="t('admin.pos.settings_wheel.attached_files')" />
|
||||
<div
|
||||
v-for="attachment in attachments"
|
||||
:key="attachment.id"
|
||||
class="order-attachments-previewable-item"
|
||||
:data-testid="`pos-order-list-attachment-item-${props.order.id}-${attachment.id}`"
|
||||
@mouseenter="onAttachmentHover(attachment)"
|
||||
@focusin="onAttachmentHover(attachment)"
|
||||
>
|
||||
<ActionSettingsWheelItem
|
||||
:label="getAttachmentLabel(attachment)"
|
||||
icon="fas fa-paperclip"
|
||||
:click-action="() => downloadOrderAttachment(attachment)"
|
||||
:disabled="false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activePreviewAttachment"
|
||||
class="order-attachments-preview-panel"
|
||||
:data-testid="`pos-order-list-attachment-preview-${props.order.id}`"
|
||||
>
|
||||
<div class="order-attachments-preview-panel__header">
|
||||
{{ getAttachmentLabel(activePreviewAttachment) }}
|
||||
</div>
|
||||
<div class="order-attachments-preview-panel__content">
|
||||
<span
|
||||
v-if="previewLoadingAttachmentId === activePreviewAttachment.id"
|
||||
class="order-attachments-preview-panel__text"
|
||||
>
|
||||
{{ t("global.loading") }}
|
||||
</span>
|
||||
<img
|
||||
v-else-if="activePreviewKind === 'image' && activePreviewSource"
|
||||
:src="activePreviewSource"
|
||||
alt=""
|
||||
class="order-attachments-preview-panel__image"
|
||||
/>
|
||||
<iframe
|
||||
v-else-if="activePreviewKind === 'document' && activePreviewSource"
|
||||
:src="activePreviewSource"
|
||||
class="order-attachments-preview-panel__document"
|
||||
title="Attachment preview"
|
||||
></iframe>
|
||||
<a
|
||||
v-else-if="activePreviewKind === 'link'"
|
||||
:href="activePreviewAttachment.content?.other"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="order-attachments-preview-panel__link"
|
||||
>
|
||||
{{ activePreviewAttachment.content?.other }}
|
||||
</a>
|
||||
<span v-else-if="activePreviewKind === 'office'" class="order-attachments-preview-panel__text">
|
||||
{{ t("admin.pos.attachments_office_preview_unavailable") }}
|
||||
</span>
|
||||
<span v-else-if="activePreviewKind === 'text'" class="order-attachments-preview-panel__text">
|
||||
{{ activePreviewAttachment.content?.other }}
|
||||
</span>
|
||||
<span v-else class="order-attachments-preview-panel__text">
|
||||
{{ t("admin.pos.attachments_no_preview") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.action-settings-wheel-trigger {
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.action-settings-wheel-trigger--text {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
color: #25344d;
|
||||
padding-inline: 0.35rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.action-settings-wheel-trigger--text:hover,
|
||||
.action-settings-wheel-trigger--text:focus,
|
||||
.action-settings-wheel-trigger--text[aria-expanded="true"] {
|
||||
background: rgba(37, 52, 77, 0.1);
|
||||
color: #132339;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.action-settings-wheel-trigger--text .icon,
|
||||
.action-settings-wheel-trigger--text .icon:hover,
|
||||
.action-settings-wheel-trigger--text .icon:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.action-settings-wheel-trigger--icon-only {
|
||||
width: 2rem;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.action-settings-wheel-trigger--icon-only .icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-settings-wheel-trigger--icon-only .icon i {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.order-attachments-dropdown-menu {
|
||||
min-width: 15rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
background: #ffffff;
|
||||
border: 1px solid #cfd8e3;
|
||||
border-radius: 0.8rem;
|
||||
box-shadow: 0 18px 36px rgba(19, 35, 57, 0.14);
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.dropdown-content :deep(.dropdown-item-action),
|
||||
.dropdown-content :deep(.dropdown-item-label) {
|
||||
border-radius: 0.55rem;
|
||||
}
|
||||
|
||||
.order-attachments-previewable-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.order-attachments-preview-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: calc(100% + 0.75rem);
|
||||
width: min(22rem, 55vw);
|
||||
min-height: 20rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #cfd8e3;
|
||||
border-radius: 0.9rem;
|
||||
box-shadow: 0 18px 36px rgba(19, 35, 57, 0.14);
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.order-attachments-preview-panel__header {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #132339;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.order-attachments-preview-panel__content {
|
||||
min-height: 17rem;
|
||||
border: 1px solid #e7edf5;
|
||||
border-radius: 0.75rem;
|
||||
background: linear-gradient(180deg, #fbfcfe 0%, #f3f6fa 100%);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.order-attachments-preview-panel__image,
|
||||
.order-attachments-preview-panel__document {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.order-attachments-preview-panel__image {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.order-attachments-preview-panel__text,
|
||||
.order-attachments-preview-panel__link {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: #4a5568;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.order-attachments-preview-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,77 +1,56 @@
|
||||
<script setup>
|
||||
|
||||
import SelectCustomerFormPOS from "@/components/forms/department/pos/SelectCustomerFormPOS.vue";
|
||||
import PosLastScannedLicensePlates from "@/components/displays/department/pos/PosLastScannedLicensePlates.vue";
|
||||
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
|
||||
import SelectProductsFormPOS from "@/components/forms/department/pos/SelectProductsFormPOS.vue";
|
||||
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
|
||||
import NextStepError from "@/components/forms/department/pos/error/NextStepError.vue";
|
||||
import PosSelectedCustomer from "@/components/displays/department/pos/PosSelectedCustomer.vue";
|
||||
import PosOrderItemsCurrent from "@/components/displays/department/pos/PosOrderItemsCurrent.vue";
|
||||
import { order_items, order_id, loadOrderItems, setProductsCategory, notes, user_discounts, getUserDiscounts, customer_id } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import {
|
||||
order_items,
|
||||
order_id,
|
||||
loadOrderItems,
|
||||
setProductsCategory,
|
||||
notes,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import Cancel from "@/components/forms/department/pos/buttons/Cancel.vue";
|
||||
import PosNotes from "@/components/displays/department/pos/PosNotes.vue";
|
||||
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
|
||||
import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue";
|
||||
import { ref, watch } from "vue";
|
||||
// Set the category to products
|
||||
import ButtonsBox from "@/components/displays/boxes/ButtonsBox.vue";
|
||||
import PosDesktopOrderWorkspace from "@/components/displays/department/pos/order/PosDesktopOrderWorkspace.vue";
|
||||
|
||||
setProductsCategory(null);
|
||||
|
||||
// Define the tabs
|
||||
const right_tabs = [
|
||||
{
|
||||
name: "Kurv",
|
||||
slot: "basket",
|
||||
icon: "fas fa-shopping-basket",
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
name: "Kunde",
|
||||
slot: "customer",
|
||||
icon: "fas fa-user",
|
||||
hidden: false,
|
||||
}
|
||||
];
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="columns">
|
||||
<!-- Choose products -->
|
||||
<div class="column is-8-desktop pl-0 pr-3">
|
||||
<SelectProductsFormPOS class="mt-3" category="null" :isCustomerBooking="false"/>
|
||||
<PosNotes :notes="notes" class="mt-3" :isAddFormVisible="false" :isOldNotesVisible="true"/>
|
||||
</div>
|
||||
<!-- Current order items -->
|
||||
<div class="column is-4-desktop keep-in-viewport pl-3 pr-0">
|
||||
<WhiteBox style="min-height: 40%;">
|
||||
<ElementTabsBox
|
||||
v-bind:tabs="right_tabs"
|
||||
default-active-tab="basket"
|
||||
class="is-boxed"
|
||||
:allowCompactWhenOneTab="true"
|
||||
>
|
||||
<template #customer>
|
||||
<PosSelectedCustomer />
|
||||
</template>
|
||||
<template #basket>
|
||||
<PosOrderItemsCurrent :orderItems="order_items" :orderId="order_id" :loadOrderItems="loadOrderItems" :editPossible="true" />
|
||||
</template>
|
||||
</ElementTabsBox>
|
||||
</WhiteBox>
|
||||
<!--<PosSelectedCustomer class="mt-3"/> -->
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<div class="buttons">
|
||||
<NextStep
|
||||
class="is-fullwidth"
|
||||
label="Næste"
|
||||
/>
|
||||
<Cancel class="is-fullwidth" />
|
||||
<div data-testid="pos-step-2">
|
||||
<PosDesktopOrderWorkspace
|
||||
v-if="order_id"
|
||||
mode="catalog"
|
||||
:orderId="order_id"
|
||||
:orderItems="order_items"
|
||||
:loadOrderItems="loadOrderItems"
|
||||
:editPossible="true"
|
||||
:showCatalogPane="true"
|
||||
:showCustomerRail="false"
|
||||
>
|
||||
<template #orderFooter>
|
||||
<div class="pos-step-2__actions" data-testid="pos-step-2-actions">
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<ButtonsBox class="pos-actions pos-actions--desktop">
|
||||
<NextStep class="is-fullwidth" />
|
||||
<Cancel class="is-fullwidth" />
|
||||
</ButtonsBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #catalog>
|
||||
<SelectProductsFormPOS class="mt-3" :isCustomerBooking="false" />
|
||||
<PosNotes :notes="notes" class="mt-3" :isAddFormVisible="false" :isOldNotesVisible="true" />
|
||||
</template>
|
||||
</PosDesktopOrderWorkspace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
.pos-step-2__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,83 +1,72 @@
|
||||
<script setup>
|
||||
|
||||
import SelectCustomerFormPOS from "@/components/forms/department/pos/SelectCustomerFormPOS.vue";
|
||||
import PosLastScannedLicensePlates from "@/components/displays/department/pos/PosLastScannedLicensePlates.vue";
|
||||
import { computed } from "vue";
|
||||
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
|
||||
import SelectProductsFormPOS from "@/components/forms/department/pos/SelectProductsFormPOS.vue";
|
||||
import NextStepError from "@/components/forms/department/pos/error/NextStepError.vue";
|
||||
import { order_items, order_id, loadOrderItems, isCustomerSelected, doesOrderContainMaterial, customer_id, department_id } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import PosOrderItemsCurrent from "@/components/displays/department/pos/PosOrderItemsCurrent.vue";
|
||||
import PosSelectedCustomer from "@/components/displays/department/pos/PosSelectedCustomer.vue";
|
||||
import {
|
||||
order_items,
|
||||
order_id,
|
||||
loadOrderItems,
|
||||
customer_id,
|
||||
department_id,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import Cancel from "@/components/forms/department/pos/buttons/Cancel.vue";
|
||||
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
|
||||
import ButtonsBox from "@/components/displays/boxes/ButtonsBox.vue";
|
||||
import PreviousStep from "@/components/forms/department/pos/buttons/PreviousStep.vue";
|
||||
import PosOrderLicensePlates from "@/components/displays/department/pos/order/PosOrderLicensePlates.vue";
|
||||
import PayWithStripeButton from "@/components/displays/department/pos/displays/PayWithStripeButton.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useI18n } from "vue-i18n";
|
||||
import PosDesktopOrderWorkspace from "@/components/displays/department/pos/order/PosDesktopOrderWorkspace.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const isGuestStripeCustomer = computed(() => Number(customer_id.value) === 999);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="columns">
|
||||
<!-- Choose add-ons -->
|
||||
<div class="column is-8 pl-0 pr-3">
|
||||
<WhiteBox>
|
||||
<template #default>
|
||||
<div>
|
||||
<PosOrderItemsCurrent :orderItems="order_items" :orderId="order_id" :loadOrderItems="loadOrderItems" :editPossible="true" :is-large="true">
|
||||
<template #bottom>
|
||||
<!-- Back button -->
|
||||
<ButtonsBox>
|
||||
<PreviousStep :is-text="true" :label="t('pos.add_more_items')" />
|
||||
</ButtonsBox>
|
||||
</template>
|
||||
</PosOrderItemsCurrent>
|
||||
</div>
|
||||
</template>
|
||||
</WhiteBox>
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<div data-testid="pos-step-3">
|
||||
<PosDesktopOrderWorkspace
|
||||
v-if="order_id"
|
||||
mode="summary"
|
||||
:orderId="order_id"
|
||||
:orderItems="order_items"
|
||||
:loadOrderItems="loadOrderItems"
|
||||
:editPossible="true"
|
||||
:showCatalogPane="false"
|
||||
:showCustomerRail="true"
|
||||
>
|
||||
<template #bottom-order-items>
|
||||
<ButtonsBox>
|
||||
<Cancel
|
||||
style="width: 25%;"
|
||||
tabindex="2"
|
||||
class="is-fullwidth"
|
||||
:label="t('common.cancel')"
|
||||
/>
|
||||
<template v-if="customer_id !== 999">
|
||||
<NextStep
|
||||
style="width: 25%;"
|
||||
class="is-fullwidth"
|
||||
:label="t('pos.complete')"
|
||||
/>
|
||||
</template>
|
||||
<!-- Stripe customer -->
|
||||
<template v-else>
|
||||
<PayWithStripeButton
|
||||
style="width: 25%;"
|
||||
class="is-fullwidth"
|
||||
:label="t('pos.pay_with_stripe')"
|
||||
:departmentId="department_id"
|
||||
:order_id="order_id"
|
||||
v-bind:order_items="order_items"
|
||||
/>
|
||||
</template>
|
||||
<PreviousStep :is-text="true" :label="t('admin.pos.add_more_items')" />
|
||||
</ButtonsBox>
|
||||
</div>
|
||||
<!-- Last provided orders -->
|
||||
<div class="column is-4 pl-3 pr-0">
|
||||
<WhiteBox>
|
||||
<template #default v-if="isCustomerSelected()">
|
||||
<PosSelectedCustomer />
|
||||
</template>
|
||||
|
||||
<template #rail-actions>
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<ButtonsBox class="pos-actions pos-actions--rail">
|
||||
<template v-if="!isGuestStripeCustomer">
|
||||
<NextStep
|
||||
class="is-fullwidth"
|
||||
:label="t('pos.complete')"
|
||||
/>
|
||||
</template>
|
||||
</WhiteBox>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<PayWithStripeButton
|
||||
class="is-fullwidth"
|
||||
:label="t('pos.pay_with_stripe')"
|
||||
:departmentId="department_id"
|
||||
:order_id="order_id"
|
||||
v-bind:order_items="order_items"
|
||||
/>
|
||||
</template>
|
||||
<Cancel
|
||||
tabindex="2"
|
||||
class="is-fullwidth"
|
||||
:label="t('common.cancel')"
|
||||
/>
|
||||
</ButtonsBox>
|
||||
</template>
|
||||
</PosDesktopOrderWorkspace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,71 +1,52 @@
|
||||
<script setup>
|
||||
|
||||
import SelectCustomerFormPOS from "@/components/forms/department/pos/SelectCustomerFormPOS.vue";
|
||||
import PosLastScannedLicensePlates from "@/components/displays/department/pos/PosLastScannedLicensePlates.vue";
|
||||
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
|
||||
import SelectProductsFormPOS from "@/components/forms/department/pos/SelectProductsFormPOS.vue";
|
||||
import NextStepError from "@/components/forms/department/pos/error/NextStepError.vue";
|
||||
import { order_items, order_id, loadOrderItems, isCustomerSelected, doesOrderContainMaterial } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import PosOrderItemsCurrent from "@/components/displays/department/pos/PosOrderItemsCurrent.vue";
|
||||
import PosSelectedCustomer from "@/components/displays/department/pos/PosSelectedCustomer.vue";
|
||||
import { order_items, order_id, loadOrderItems } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import Cancel from "@/components/forms/department/pos/buttons/Cancel.vue";
|
||||
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
|
||||
import ButtonsBox from "@/components/displays/boxes/ButtonsBox.vue";
|
||||
import PreviousStep from "@/components/forms/department/pos/buttons/PreviousStep.vue";
|
||||
import PosOrderLicensePlates from "@/components/displays/department/pos/order/PosOrderLicensePlates.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useI18n } from "vue-i18n";
|
||||
import PosDesktopOrderWorkspace from "@/components/displays/department/pos/order/PosDesktopOrderWorkspace.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="columns">
|
||||
<!-- Choose add-ons -->
|
||||
<div class="column is-8 pl-0 pr-3">
|
||||
<WhiteBox>
|
||||
<template #default>
|
||||
<div>
|
||||
<PosOrderItemsCurrent :orderItems="order_items" :orderId="order_id" :loadOrderItems="loadOrderItems" :editPossible="true" :is-large="true">
|
||||
<template #bottom>
|
||||
<!-- Back button -->
|
||||
<ButtonsBox>
|
||||
<PreviousStep :is-text="true" :label="t('pos.add_more_items')" />
|
||||
</ButtonsBox>
|
||||
</template>
|
||||
</PosOrderItemsCurrent>
|
||||
</div>
|
||||
</template>
|
||||
</WhiteBox>
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<div data-testid="pos-step-4">
|
||||
<PosDesktopOrderWorkspace
|
||||
v-if="order_id"
|
||||
mode="summary"
|
||||
:orderId="order_id"
|
||||
:orderItems="order_items"
|
||||
:loadOrderItems="loadOrderItems"
|
||||
:editPossible="true"
|
||||
:showCatalogPane="false"
|
||||
:showCustomerRail="true"
|
||||
>
|
||||
<template #bottom-order-items>
|
||||
<ButtonsBox>
|
||||
<div class="buttons">
|
||||
<Cancel
|
||||
style="width: 25%;"
|
||||
tabindex="2"
|
||||
class="is-fullwidth"
|
||||
:label="t('common.cancel')"
|
||||
/>
|
||||
<NextStep
|
||||
style="width: 25%;"
|
||||
class="is-fullwidth"
|
||||
:label="t('pos.complete')"
|
||||
/>
|
||||
</div>
|
||||
<PreviousStep :is-text="true" :label="t('admin.pos.add_more_items')" />
|
||||
</ButtonsBox>
|
||||
</div>
|
||||
<!-- Last provided orders -->
|
||||
<div class="column is-4 pl-3 pr-0">
|
||||
<WhiteBox>
|
||||
<template #default v-if="isCustomerSelected()">
|
||||
<PosSelectedCustomer />
|
||||
</template>
|
||||
</WhiteBox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #rail-actions>
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<ButtonsBox class="pos-actions pos-actions--rail">
|
||||
<NextStep
|
||||
class="is-fullwidth"
|
||||
:label="t('pos.complete')"
|
||||
/>
|
||||
<Cancel
|
||||
tabindex="2"
|
||||
class="is-fullwidth"
|
||||
:label="t('common.cancel')"
|
||||
/>
|
||||
</ButtonsBox>
|
||||
</template>
|
||||
</PosDesktopOrderWorkspace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const props = defineProps({
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowClose: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
conflict: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:isActive", "keep-selected-customer", "use-vehicle-customer", "cancel"]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const conflictRows = computed(() => {
|
||||
if (!props.conflict) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: t("admin.pos.customer_conflict.current_customer_label"),
|
||||
value: [props.conflict.currentCustomerName, props.conflict.currentCustomerNumber].filter(Boolean).join(" - "),
|
||||
},
|
||||
{
|
||||
label: t("admin.pos.customer_conflict.matched_customer_label"),
|
||||
value: [props.conflict.matchedCustomerName, props.conflict.matchedCustomerNumber].filter(Boolean).join(" - "),
|
||||
},
|
||||
{
|
||||
label: SessionUser.objects.orders.columns.reg_1.label,
|
||||
value: props.conflict.vehicleReg || props.conflict.plate || t("admin.pos.not_found"),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const summaryChips = computed(() => {
|
||||
const chips = [];
|
||||
if (props.conflict?.plate) {
|
||||
chips.push(`${SessionUser.objects.orders.columns.reg_1.label}: ${props.conflict.plate}`);
|
||||
}
|
||||
if (props.conflict?.matchedCustomerNumber) {
|
||||
chips.push(`${t("admin.pos.customer")}: ${props.conflict.matchedCustomerNumber}`);
|
||||
}
|
||||
return chips;
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
if (!props.allowClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("update:isActive", false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="modal pos-desktop-customer-conflict-modal"
|
||||
:class="{ 'is-active': isActive }"
|
||||
data-testid="pos-desktop-customer-conflict-modal"
|
||||
>
|
||||
<div class="modal-background" @click="closeModal" />
|
||||
<div class="modal-card pos-desktop-customer-conflict-modal__card">
|
||||
<header class="modal-card-head pos-desktop-customer-conflict-modal__head">
|
||||
<div class="pos-desktop-customer-conflict-modal__head-content">
|
||||
<div>
|
||||
<p class="modal-card-title">{{ title || t("admin.pos.customer_conflict.title") }}</p>
|
||||
<p v-if="message" class="pos-desktop-customer-conflict-modal__message">{{ message }}</p>
|
||||
</div>
|
||||
<div class="pos-desktop-customer-conflict-modal__summary">
|
||||
<span
|
||||
v-for="chip in summaryChips"
|
||||
:key="chip"
|
||||
class="pos-desktop-customer-conflict-modal__summary-chip"
|
||||
>
|
||||
{{ chip }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="allowClose" class="delete" aria-label="close" @click="closeModal" />
|
||||
</header>
|
||||
|
||||
<section class="modal-card-body pos-desktop-customer-conflict-modal__body">
|
||||
<div class="pos-desktop-customer-conflict-modal__callout">
|
||||
<span class="icon" aria-hidden="true">
|
||||
<i class="fas fa-triangle-exclamation" />
|
||||
</span>
|
||||
<p>{{ t("admin.pos.customer_conflict.warning_text") }}</p>
|
||||
</div>
|
||||
|
||||
<dl class="pos-desktop-customer-conflict-modal__grid">
|
||||
<div
|
||||
v-for="row in conflictRows"
|
||||
:key="row.label"
|
||||
class="pos-desktop-customer-conflict-modal__row"
|
||||
>
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd>{{ row.value || t("admin.pos.not_found") }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot pos-desktop-customer-conflict-modal__foot">
|
||||
<button
|
||||
class="button is-light"
|
||||
type="button"
|
||||
data-testid="pos-desktop-customer-conflict-cancel"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ t("admin.pos.customer_conflict.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
class="button is-primary is-light"
|
||||
type="button"
|
||||
data-testid="pos-desktop-customer-conflict-keep"
|
||||
@click="emit('keep-selected-customer')"
|
||||
>
|
||||
{{ t("admin.pos.customer_conflict.keep_selected_customer") }}
|
||||
</button>
|
||||
<button
|
||||
class="button is-primary"
|
||||
type="button"
|
||||
data-testid="pos-desktop-customer-conflict-use-vehicle"
|
||||
@click="emit('use-vehicle-customer')"
|
||||
>
|
||||
{{ t("admin.pos.customer_conflict.use_vehicle_customer") }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pos-desktop-customer-conflict-modal.modal {
|
||||
z-index: 125;
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__card {
|
||||
width: min(760px, calc(100vw - 2rem));
|
||||
max-width: 760px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 28px 80px rgba(10, 24, 61, 0.35);
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__head {
|
||||
align-items: flex-start;
|
||||
padding: 1.4rem 1.6rem 1.1rem;
|
||||
background: linear-gradient(135deg, rgba(78, 36, 12, 0.98), rgba(120, 60, 14, 0.95));
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__head-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__head :deep(.modal-card-title) {
|
||||
color: #fff;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__message {
|
||||
margin-top: 0.5rem;
|
||||
max-width: 34rem;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__summary {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__summary-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 2rem;
|
||||
padding: 0.15rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #fff;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.5rem 1.35rem;
|
||||
background: linear-gradient(180deg, #fffaf5 0%, #f8f1e8 100%);
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__callout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid rgba(169, 93, 29, 0.18);
|
||||
border-radius: 16px;
|
||||
background: rgba(176, 105, 28, 0.08);
|
||||
color: #4f321f;
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__callout p {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__row {
|
||||
min-width: 0;
|
||||
padding: 0.95rem 1rem;
|
||||
border: 1px solid #eadccd;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__row dt {
|
||||
margin: 0 0 0.35rem;
|
||||
color: #7f654c;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__row dd {
|
||||
margin: 0;
|
||||
color: #2b1d10;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__foot {
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem 1.25rem;
|
||||
background: rgba(255, 250, 245, 0.96);
|
||||
border-top: 1px solid #eadccd;
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__foot .button {
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.pos-desktop-customer-conflict-modal__card {
|
||||
width: calc(100vw - 1rem);
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pos-desktop-customer-conflict-modal__foot {
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+977
@@ -0,0 +1,977 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import {
|
||||
getOrderBookingItemPreviewRows,
|
||||
getOrderBookingItemPreviewTotal,
|
||||
getOrderBookingNotesValue,
|
||||
getOrderBookingPlateText,
|
||||
getOrderBookingPoValue,
|
||||
getOrderBookingReferenceValue,
|
||||
getOrderBookingServiceLabels,
|
||||
hasOrderBookingDisplayDetails,
|
||||
} from "@/components/displays/department/pos/utils/orderBookingDisplay.js";
|
||||
|
||||
const props = defineProps({
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowClose: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
bookings: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
departmentId: {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
matchedPlate: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:isActive", "select", "skip", "refresh-bookings"]);
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const detailedBookingsById = ref({});
|
||||
const loadingBookingIds = ref([]);
|
||||
const failedBookingIds = ref([]);
|
||||
|
||||
const toPositiveInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const normalizePlateValue = (value) =>
|
||||
String(value ?? "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
|
||||
const formatBookingDateTime = (booking) => {
|
||||
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
|
||||
if (!rawValue) {
|
||||
return t("admin.pos.not_found");
|
||||
}
|
||||
|
||||
const parsedValue = new Date(rawValue);
|
||||
if (Number.isNaN(parsedValue.getTime())) {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value || undefined, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
}).format(parsedValue);
|
||||
};
|
||||
|
||||
const getBookingDateValue = (booking) => {
|
||||
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
|
||||
if (!rawValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = new Date(rawValue);
|
||||
return Number.isNaN(parsedValue.getTime()) ? null : parsedValue;
|
||||
};
|
||||
|
||||
const isSameCalendarDay = (left, right) => {
|
||||
if (!(left instanceof Date) || !(right instanceof Date)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
left.getFullYear() === right.getFullYear() &&
|
||||
left.getMonth() === right.getMonth() &&
|
||||
left.getDate() === right.getDate()
|
||||
);
|
||||
};
|
||||
|
||||
const getDetailedBooking = (booking) => {
|
||||
const bookingId = toPositiveInteger(booking?.id);
|
||||
return bookingId ? detailedBookingsById.value[bookingId] || null : null;
|
||||
};
|
||||
|
||||
const getDisplayBooking = (booking) => {
|
||||
const detailedBooking = getDetailedBooking(booking);
|
||||
return detailedBooking ? { ...booking, ...detailedBooking } : booking;
|
||||
};
|
||||
|
||||
const isBookingLoading = (booking) => {
|
||||
const bookingId = toPositiveInteger(booking?.id);
|
||||
return bookingId ? loadingBookingIds.value.includes(bookingId) : false;
|
||||
};
|
||||
|
||||
const didBookingDetailFail = (booking) => {
|
||||
const bookingId = toPositiveInteger(booking?.id);
|
||||
return bookingId ? failedBookingIds.value.includes(bookingId) : false;
|
||||
};
|
||||
|
||||
const getBookingServiceLabels = (booking) => {
|
||||
return getOrderBookingServiceLabels(getDisplayBooking(booking));
|
||||
};
|
||||
|
||||
const getBookingItemPreviewRows = (booking) => {
|
||||
return getOrderBookingItemPreviewRows(getDisplayBooking(booking));
|
||||
};
|
||||
|
||||
const getBookingItemPreviewTotal = (booking) => {
|
||||
return getOrderBookingItemPreviewTotal(getDisplayBooking(booking));
|
||||
};
|
||||
|
||||
const getBookingNote = (booking) => {
|
||||
return getOrderBookingNotesValue(getDisplayBooking(booking));
|
||||
};
|
||||
|
||||
const getBookingReference = (booking) => {
|
||||
return getOrderBookingReferenceValue(getDisplayBooking(booking));
|
||||
};
|
||||
|
||||
const getBookingPo = (booking) => {
|
||||
return getOrderBookingPoValue(getDisplayBooking(booking));
|
||||
};
|
||||
|
||||
const getBookingPlates = (booking) => {
|
||||
return getOrderBookingPlateText(getDisplayBooking(booking));
|
||||
};
|
||||
|
||||
const formatCurrencyAmount = (value) => {
|
||||
const parsedValue = Number(value);
|
||||
if (!Number.isFinite(parsedValue)) {
|
||||
return "—";
|
||||
}
|
||||
|
||||
return SessionUser.functions.currency.toLocal(parsedValue);
|
||||
};
|
||||
|
||||
const totalAmountLabel = computed(() => SessionUser.objects.orders.columns.total_net_amount?.label || "Total");
|
||||
|
||||
const getLoadingCount = computed(() => loadingBookingIds.value.length);
|
||||
|
||||
const isLoadingAnyBookingDetails = computed(() => getLoadingCount.value > 0);
|
||||
|
||||
const resolvedBookingCount = computed(() => {
|
||||
return props.bookings.filter((booking) => {
|
||||
const displayBooking = getDisplayBooking(booking);
|
||||
return hasOrderBookingDisplayDetails(displayBooking) || !isBookingLoading(booking) || didBookingDetailFail(booking);
|
||||
}).length;
|
||||
});
|
||||
|
||||
const summaryChips = computed(() => {
|
||||
const chips = [];
|
||||
if (props.bookings.length > 0) {
|
||||
chips.push(`${props.bookings.length} ${SessionUser.objects.order_bookings.meta.labels.multiple}`);
|
||||
}
|
||||
if (props.matchedPlate) {
|
||||
chips.push(`${SessionUser.objects.orders.columns.reg_1.label}: ${normalizePlateValue(props.matchedPlate)}`);
|
||||
}
|
||||
return chips;
|
||||
});
|
||||
|
||||
const sortedBookings = computed(() => {
|
||||
const now = new Date();
|
||||
|
||||
return props.bookings
|
||||
.map((booking, index) => {
|
||||
const displayBooking = getDisplayBooking(booking);
|
||||
const bookingDate = getBookingDateValue(displayBooking);
|
||||
const isToday = isSameCalendarDay(bookingDate, now);
|
||||
|
||||
return {
|
||||
booking,
|
||||
index,
|
||||
bookingDate,
|
||||
isToday,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.isToday !== right.isToday) {
|
||||
return left.isToday ? -1 : 1;
|
||||
}
|
||||
|
||||
if (left.bookingDate && right.bookingDate) {
|
||||
const timeDifference = left.bookingDate.getTime() - right.bookingDate.getTime();
|
||||
if (timeDifference !== 0) {
|
||||
return timeDifference;
|
||||
}
|
||||
} else if (left.bookingDate || right.bookingDate) {
|
||||
return left.bookingDate ? -1 : 1;
|
||||
}
|
||||
|
||||
return left.index - right.index;
|
||||
})
|
||||
.map((entry) => entry.booking);
|
||||
});
|
||||
|
||||
const fetchBookingDetails = async (bookings) => {
|
||||
const pendingBookings = (Array.isArray(bookings) ? bookings : []).filter(Boolean);
|
||||
const bookingsToLoad = pendingBookings.filter((booking) => {
|
||||
const bookingId = toPositiveInteger(booking?.id);
|
||||
if (!bookingId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (detailedBookingsById.value[bookingId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loadingBookingIds.value.includes(bookingId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (bookingsToLoad.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bookingIdsToLoad = bookingsToLoad
|
||||
.map((booking) => toPositiveInteger(booking?.id))
|
||||
.filter((bookingId) => bookingId !== null);
|
||||
|
||||
loadingBookingIds.value = [...new Set([...loadingBookingIds.value, ...bookingIdsToLoad])];
|
||||
|
||||
const bookingResults = await Promise.allSettled(
|
||||
bookingsToLoad.map((booking) =>
|
||||
SessionUser.objects.order_bookings.get.single(toPositiveInteger(booking?.id), {
|
||||
department_id: props.departmentId,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const nextDetailedBookings = {
|
||||
...detailedBookingsById.value,
|
||||
};
|
||||
const nextFailedBookingIds = new Set(failedBookingIds.value);
|
||||
|
||||
bookingResults.forEach((result) => {
|
||||
if (result.status === "fulfilled" && result.value?.id) {
|
||||
nextDetailedBookings[result.value.id] = result.value;
|
||||
nextFailedBookingIds.delete(result.value.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const failedBookingId = toPositiveInteger(result?.reason?.bookingId);
|
||||
if (failedBookingId) {
|
||||
nextFailedBookingIds.add(failedBookingId);
|
||||
}
|
||||
});
|
||||
|
||||
bookingsToLoad.forEach((booking, index) => {
|
||||
const bookingId = bookingIdsToLoad[index];
|
||||
const result = bookingResults[index];
|
||||
if (result?.status !== "fulfilled" && bookingId) {
|
||||
nextFailedBookingIds.add(bookingId);
|
||||
}
|
||||
});
|
||||
|
||||
detailedBookingsById.value = nextDetailedBookings;
|
||||
failedBookingIds.value = Array.from(nextFailedBookingIds);
|
||||
loadingBookingIds.value = loadingBookingIds.value.filter((bookingId) => !bookingIdsToLoad.includes(bookingId));
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.isActive, props.bookings.map((booking) => booking?.id).join("|")],
|
||||
([isActive]) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
void fetchBookingDetails(props.bookings);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const onSelect = async (booking) => {
|
||||
emit("select", getDisplayBooking(booking));
|
||||
};
|
||||
|
||||
const onSkip = async () => {
|
||||
emit("skip");
|
||||
};
|
||||
|
||||
const emitRefreshBookings = () => {
|
||||
emit("refresh-bookings");
|
||||
};
|
||||
|
||||
const getActionBooking = (booking) => getDisplayBooking(booking);
|
||||
|
||||
const getActionCustomerNumber = (booking) => {
|
||||
const displayBooking = getActionBooking(booking);
|
||||
return toPositiveInteger(displayBooking?.customer_number ?? displayBooking?.customer_id);
|
||||
};
|
||||
|
||||
const getActionDepartmentId = (booking) => {
|
||||
const displayBooking = getActionBooking(booking);
|
||||
return toPositiveInteger(displayBooking?.department ?? props.departmentId);
|
||||
};
|
||||
|
||||
const isBookingScheduledForToday = (booking) => {
|
||||
return isSameCalendarDay(getBookingDateValue(getDisplayBooking(booking)), new Date());
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (!props.allowClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("update:isActive", false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="modal pos-desktop-order-booking-modal"
|
||||
:class="{ 'is-active': isActive }"
|
||||
data-testid="pos-desktop-order-booking-modal"
|
||||
>
|
||||
<div class="modal-background" @click="closeModal" />
|
||||
<div class="modal-card pos-desktop-order-booking-modal__card">
|
||||
<header class="modal-card-head pos-desktop-order-booking-modal__head">
|
||||
<div class="pos-desktop-order-booking-modal__head-content">
|
||||
<div>
|
||||
<p class="modal-card-title">{{ title || t("admin.pos.order_booking_selector.title") }}</p>
|
||||
<p v-if="message" class="pos-desktop-order-booking-modal__message">{{ message }}</p>
|
||||
</div>
|
||||
<div class="pos-desktop-order-booking-modal__summary">
|
||||
<span v-for="chip in summaryChips" :key="chip" class="pos-desktop-order-booking-modal__summary-chip">
|
||||
{{ chip }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="allowClose" class="delete" aria-label="close" @click="closeModal" />
|
||||
</header>
|
||||
<section class="modal-card-body pos-desktop-order-booking-modal__body">
|
||||
<div
|
||||
v-if="isLoadingAnyBookingDetails"
|
||||
class="pos-desktop-order-booking-modal__loading"
|
||||
data-testid="pos-desktop-order-booking-loading"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-spinner fa-spin" aria-hidden="true" />
|
||||
</span>
|
||||
<span>
|
||||
{{ SessionUser.objects.global.language.loading }}
|
||||
{{ resolvedBookingCount }} / {{ bookings.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pos-desktop-order-booking-modal__grid">
|
||||
<article
|
||||
v-for="booking in sortedBookings"
|
||||
:key="booking.id"
|
||||
class="pos-desktop-order-booking-card"
|
||||
:class="{ 'pos-desktop-order-booking-card--today': isBookingScheduledForToday(booking) }"
|
||||
:data-testid="`pos-desktop-order-booking-option-${booking.id}`"
|
||||
>
|
||||
<div class="pos-desktop-order-booking-card__header">
|
||||
<div class="pos-desktop-order-booking-card__header-main">
|
||||
<p class="pos-desktop-order-booking-card__eyebrow">Booking #{{ booking.id }}</p>
|
||||
<h3 class="pos-desktop-order-booking-card__title">
|
||||
{{ formatBookingDateTime(getDisplayBooking(booking)) }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="pos-desktop-order-booking-card__header-actions">
|
||||
<span
|
||||
v-if="isBookingScheduledForToday(booking)"
|
||||
class="tag pos-desktop-order-booking-card__today-tag"
|
||||
:data-testid="`pos-desktop-order-booking-today-${booking.id}`"
|
||||
>
|
||||
{{ t("global.time.today") }}
|
||||
</span>
|
||||
<span v-if="didBookingDetailFail(booking)" class="tag is-light pos-desktop-order-booking-card__tag">
|
||||
{{ t("admin.pos.not_found") }}
|
||||
</span>
|
||||
<div
|
||||
class="pos-desktop-order-booking-card__settings"
|
||||
:data-testid="`pos-desktop-order-booking-settings-${booking.id}`"
|
||||
>
|
||||
<ActionSettingsWheelButton
|
||||
:order_booking_id="toPositiveInteger(getActionBooking(booking)?.id)"
|
||||
:order_id="toPositiveInteger(getActionBooking(booking)?.order_id)"
|
||||
:customer_number="getActionCustomerNumber(booking)"
|
||||
:department_id="getActionDepartmentId(booking)"
|
||||
:reg_1="getActionBooking(booking)?.reg_1 ?? null"
|
||||
:reg_2="getActionBooking(booking)?.reg_2 ?? null"
|
||||
:refreshFunction="emitRefreshBookings"
|
||||
:icon="'fas fa-ellipsis-v'"
|
||||
trigger-button-variant="text"
|
||||
>
|
||||
<template #actions />
|
||||
</ActionSettingsWheelButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="pos-desktop-order-booking-card__customer">
|
||||
{{ getDisplayBooking(booking)?.customer_name || t("admin.pos.not_found") }}
|
||||
</p>
|
||||
|
||||
<dl class="pos-desktop-order-booking-card__meta-grid">
|
||||
<div class="pos-desktop-order-booking-card__meta-item">
|
||||
<dt>{{ t("admin.pos.order_booking_selector.plates_label") }}</dt>
|
||||
<dd>{{ getBookingPlates(booking) || t("admin.pos.not_found") }}</dd>
|
||||
</div>
|
||||
<div class="pos-desktop-order-booking-card__meta-item">
|
||||
<dt>{{ t("admin.pos.order_booking_selector.reference_label") }}</dt>
|
||||
<dd>{{ getBookingReference(booking) || "—" }}</dd>
|
||||
</div>
|
||||
<div v-if="getBookingPo(booking)" class="pos-desktop-order-booking-card__meta-item">
|
||||
<dt>{{ SessionUser.objects.orders.columns.po.label }}</dt>
|
||||
<dd>{{ getBookingPo(booking) }}</dd>
|
||||
</div>
|
||||
<div v-if="getBookingNote(booking)" class="pos-desktop-order-booking-card__meta-item">
|
||||
<dt>{{ SessionUser.objects.orders.columns.notes.label }}</dt>
|
||||
<dd>{{ getBookingNote(booking) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
getBookingItemPreviewRows(booking).length > 0 ||
|
||||
getBookingServiceLabels(booking).length > 0 ||
|
||||
isBookingLoading(booking)
|
||||
"
|
||||
class="pos-desktop-order-booking-card__services"
|
||||
:data-testid="`pos-desktop-order-booking-services-${booking.id}`"
|
||||
>
|
||||
<div class="pos-desktop-order-booking-card__section-header">
|
||||
<p class="pos-desktop-order-booking-card__section-title">
|
||||
{{ t("admin.pos.order_booking_selector.services_label") }}
|
||||
</p>
|
||||
<div
|
||||
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
|
||||
class="pos-desktop-order-booking-card__section-total"
|
||||
:data-testid="`pos-desktop-order-booking-service-total-${booking.id}`"
|
||||
>
|
||||
<span>{{ totalAmountLabel }}</span>
|
||||
<strong>{{ formatCurrencyAmount(getBookingItemPreviewTotal(booking)) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isBookingLoading(booking)" class="pos-desktop-order-booking-card__item-preview-skeleton">
|
||||
<div class="pos-desktop-order-booking-card__item-preview-row is-skeleton" />
|
||||
<div class="pos-desktop-order-booking-card__item-preview-row is-skeleton" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="getBookingItemPreviewRows(booking).length > 0"
|
||||
class="pos-desktop-order-booking-card__item-preview"
|
||||
>
|
||||
<div class="pos-desktop-order-booking-card__item-preview-head">
|
||||
<span>{{ t("global.quantity") }}</span>
|
||||
<span>{{ t("admin.pos.order_booking_selector.services_label") }}</span>
|
||||
<span>{{ t("admin.pos.price_dkk") }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, index) in getBookingItemPreviewRows(booking)"
|
||||
:key="item.key"
|
||||
class="pos-desktop-order-booking-card__item-preview-row"
|
||||
:data-testid="`pos-desktop-order-booking-service-row-${booking.id}-${index}`"
|
||||
>
|
||||
<span class="pos-desktop-order-booking-card__item-preview-qty">{{ item.quantity }}x</span>
|
||||
<div class="pos-desktop-order-booking-card__item-preview-main">
|
||||
<span
|
||||
class="pos-desktop-order-booking-card__item-preview-name"
|
||||
:class="{ 'pos-desktop-order-booking-card__item-preview-name--addon': item.depth > 0 }"
|
||||
>
|
||||
{{ item.depth > 0 ? "+ " : "" }}{{ item.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.unitPrice !== null && item.quantity > 1"
|
||||
class="pos-desktop-order-booking-card__item-preview-meta"
|
||||
>
|
||||
{{ formatCurrencyAmount(item.unitPrice) }} / stk
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="pos-desktop-order-booking-card__item-preview-amount"
|
||||
:data-testid="`pos-desktop-order-booking-service-amount-${booking.id}-${index}`"
|
||||
>
|
||||
{{ item.totalPrice !== null ? formatCurrencyAmount(item.totalPrice) : "—" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="pos-desktop-order-booking-card__service-list">
|
||||
<span
|
||||
v-for="service in getBookingServiceLabels(booking)"
|
||||
:key="`${booking.id}-${service}`"
|
||||
class="pos-desktop-order-booking-card__service-pill"
|
||||
>
|
||||
{{ service }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pos-desktop-order-booking-card__actions">
|
||||
<button
|
||||
class="button is-light"
|
||||
type="button"
|
||||
:class="{ 'is-loading': isBookingLoading(booking) }"
|
||||
:disabled="isBookingLoading(booking)"
|
||||
:data-testid="`pos-desktop-order-booking-use-${booking.id}`"
|
||||
@click="onSelect(booking)"
|
||||
>
|
||||
{{ t("admin.pos.order_booking_selector.use_booking") }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="modal-card-foot pos-desktop-order-booking-modal__foot">
|
||||
<button class="button is-light" type="button" data-testid="pos-desktop-order-booking-skip" @click="onSkip">
|
||||
{{ t("admin.pos.order_booking_selector.continue_without_booking") }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pos-desktop-order-booking-modal.modal {
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-modal__card {
|
||||
width: min(1180px, calc(100vw - 2.5rem));
|
||||
max-width: 1180px;
|
||||
max-height: calc(100vh - 2rem);
|
||||
border-radius: 20px;
|
||||
overflow: visible;
|
||||
box-shadow: 0 28px 80px rgba(10, 24, 61, 0.35);
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-modal__head {
|
||||
align-items: flex-start;
|
||||
padding: 1.5rem 1.75rem 1.1rem;
|
||||
background: linear-gradient(135deg, rgba(15, 35, 77, 0.98), rgba(10, 24, 61, 0.95)),
|
||||
linear-gradient(135deg, #0f234d, #102d62);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-modal__head-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-modal__head :deep(.modal-card-title) {
|
||||
color: #fff;
|
||||
font-size: 1.85rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-modal__message {
|
||||
margin-top: 0.55rem;
|
||||
max-width: 48rem;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-modal__summary {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-modal__summary-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 2rem;
|
||||
padding: 0.15rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-modal__body {
|
||||
padding: 1.25rem 1.5rem 1.35rem;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #f2f6fb 100%);
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-modal__loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 35, 77, 0.08);
|
||||
color: #234;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-modal__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
min-height: 100%;
|
||||
padding: 1rem;
|
||||
border: 1px solid #d7e2f0;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 24px rgba(21, 42, 76, 0.08);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card--today {
|
||||
border-color: #3d7ef0;
|
||||
background: linear-gradient(180deg, rgba(61, 126, 240, 0.08), rgba(61, 126, 240, 0.02)), #fff;
|
||||
box-shadow: 0 14px 32px rgba(34, 88, 184, 0.16);
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__header-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__header-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.45rem;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__eyebrow {
|
||||
margin: 0;
|
||||
color: #5c6d84;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__title {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #0f234d;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__customer {
|
||||
margin: 0;
|
||||
color: #1b314f;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__tag {
|
||||
color: #49576b;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__today-tag {
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(61, 126, 240, 0.2);
|
||||
background: rgba(61, 126, 240, 0.12);
|
||||
color: #18408b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__settings {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__settings :deep(.dropdown) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__settings :deep(.dropdown-menu) {
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__settings :deep(.action-settings-wheel-trigger--text) {
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__meta-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__meta-item dt,
|
||||
.pos-desktop-order-booking-card__section-title {
|
||||
margin: 0 0 0.25rem;
|
||||
color: #6b7c92;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__meta-item dd {
|
||||
margin: 0;
|
||||
color: #14243b;
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__services {
|
||||
padding-top: 0.1rem;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__section-total {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
color: #173559;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__section-total strong {
|
||||
color: #0f234d;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__item-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__item-preview-head,
|
||||
.pos-desktop-order-booking-card__item-preview-row {
|
||||
display: grid;
|
||||
grid-template-columns: 3.2rem minmax(0, 1fr) auto;
|
||||
gap: 0.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__item-preview-head {
|
||||
padding: 0 0.1rem;
|
||||
color: #6b7c92;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__item-preview-head span:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__item-preview-row {
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid #e5edf7;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #fdfefe 0%, #f7fbff 100%);
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__item-preview-qty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 1.7rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: #e9f1fb;
|
||||
color: #18408b;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__item-preview-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__item-preview-name {
|
||||
color: #14243b;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__item-preview-name--addon {
|
||||
color: #24456e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__item-preview-meta {
|
||||
color: #6a7890;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__item-preview-amount {
|
||||
color: #0f234d;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 800;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__item-preview-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__item-preview-row.is-skeleton {
|
||||
min-height: 3rem;
|
||||
border-color: transparent;
|
||||
color: transparent;
|
||||
background: linear-gradient(90deg, #ebf0f7 0%, #f7f9fc 50%, #ebf0f7 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: pos-booking-selector-pulse 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__service-list,
|
||||
.pos-desktop-order-booking-card__service-skeleton {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__service-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 2rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: #eef4fb;
|
||||
color: #173559;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__service-pill.is-skeleton {
|
||||
min-width: 6rem;
|
||||
color: transparent;
|
||||
background: linear-gradient(90deg, #ebf0f7 0%, #f7f9fc 50%, #ebf0f7 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: pos-booking-selector-pulse 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__actions {
|
||||
margin-top: auto;
|
||||
padding-top: 0.2rem;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__actions .button {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-modal__foot {
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem 1.25rem;
|
||||
background: rgba(248, 251, 255, 0.96);
|
||||
border-top: 1px solid #e1e8f2;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-modal__foot .button {
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@keyframes pos-booking-selector-pulse {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.pos-desktop-order-booking-modal__card {
|
||||
width: calc(100vw - 1rem);
|
||||
max-height: calc(100vh - 1rem);
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-modal__head,
|
||||
.pos-desktop-order-booking-modal__body,
|
||||
.pos-desktop-order-booking-modal__foot {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__meta-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__section-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__item-preview-head,
|
||||
.pos-desktop-order-booking-card__item-preview-row {
|
||||
grid-template-columns: 2.8rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pos-desktop-order-booking-card__item-preview-head span:last-child,
|
||||
.pos-desktop-order-booking-card__item-preview-amount {
|
||||
grid-column: 2;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -121,12 +121,10 @@ const handleLPRResult = () => {
|
||||
const parseImage = async (image: string) => {
|
||||
// Check if the time since the last successful parse is enough
|
||||
if (!camera.hasDelayAfterSuccessPassed()) {
|
||||
console.log("Skipping parsing due to delay after success.");
|
||||
return;
|
||||
}
|
||||
if (lastParsedImage.value === image) {
|
||||
// If the image is the same as the last parsed one, skip parsing
|
||||
console.log("Skipping parsing for the same image.");
|
||||
return;
|
||||
}
|
||||
lastParsedImage.value = image; // Update the last parsed image
|
||||
@@ -143,13 +141,11 @@ const parseImage = async (image: string) => {
|
||||
if (debug_mode.value) {
|
||||
debug_request_results.value.push(response);
|
||||
}
|
||||
// If the response is not successful, log an error and return
|
||||
// If the response is not successful, stop here.
|
||||
if (!response.data.success) {
|
||||
console.error("LPR request was not successful:", response);
|
||||
return;
|
||||
}
|
||||
latestLPRResponse.value = response.data.data as LPRResponse;
|
||||
console.log("LPR Response:", latestLPRResponse.value);
|
||||
// Set the last successful capture time
|
||||
camera.setLastSuccess();
|
||||
// Handle parsed result.
|
||||
@@ -195,10 +191,8 @@ function getSearchResultIndex(object: PosSearchResult) {
|
||||
|
||||
const onAutomaticSelection = (object: PosSearchResult | null) => {
|
||||
if (!object) {
|
||||
console.warn("No object selected for automatic selection.");
|
||||
return;
|
||||
}
|
||||
console.warn("Automatic selection for object:", object);
|
||||
// Check if the registration number already exists in the vehicles
|
||||
let targetIndex = getSearchResultIndex(object);
|
||||
|
||||
@@ -212,6 +206,7 @@ const onAutomaticSelection = (object: PosSearchResult | null) => {
|
||||
last_order_id: object?.lastOrderId || null,
|
||||
wash_subscription: object?.washSubscription,
|
||||
booking_id: object?.bookingId || null,
|
||||
booking_matches: object?.bookingMatches || [],
|
||||
} as PosVehicle;
|
||||
switch (vehicles.activeVehicleIndex.value) {
|
||||
case 1:
|
||||
@@ -226,7 +221,7 @@ const onAutomaticSelection = (object: PosSearchResult | null) => {
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.warn("Invalid active vehicle index:", targetIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,7 +247,6 @@ onMounted(() => {
|
||||
setOverflow(false); // Prevent scrolling
|
||||
});
|
||||
onUnmounted(() => {
|
||||
console.warn("Unmounting PosDepartmentStepMobile1 component.");
|
||||
// Reset the header settings when the component is unmounted
|
||||
setTransparency(false); // Reset transparency
|
||||
setBackgroundColor(backgroundColors.default); // Reset to default background color
|
||||
@@ -272,11 +266,11 @@ onUnmounted(() => {
|
||||
<!-- Default: Scanner view -->
|
||||
<template v-else>
|
||||
<div class="background-fixed">
|
||||
<ScannerCamera
|
||||
@update:frame="parseImage"
|
||||
<ScannerCamera
|
||||
@update:frame="parseImage"
|
||||
/>
|
||||
</div>
|
||||
<div class="custom-content">
|
||||
<div class="custom-content" data-testid="pos-mobile-step-1">
|
||||
<!-- Meta objects, registration number auto-lookup -->
|
||||
<RegistrationNumberSearchResult :searchQuery="getQuery" @select="onAutomaticSelection" :isHidden="true" :automaticallySelect="true" :modifyCustomerOnChange="shouldCustomerBeModified"/>
|
||||
|
||||
@@ -286,6 +280,10 @@ onUnmounted(() => {
|
||||
title="Scan nummerplader"
|
||||
subtitle="Hold kameraet op mod nummerpladerne."
|
||||
buttonText="Skriv registreringsnummer manuelt"
|
||||
rootTestId="pos-mobile-step-1-shell"
|
||||
titleTestId="pos-mobile-step-1-title"
|
||||
subtitleTestId="pos-mobile-step-1-subtitle"
|
||||
buttonTestId="pos-mobile-manual-input-toggle"
|
||||
@button-click="manualInput = true"
|
||||
/>
|
||||
<!-- Scanner outline object -->
|
||||
@@ -293,7 +291,7 @@ onUnmounted(() => {
|
||||
<ScannerOutline :loading="false" v-if="!views.attachmentView.value"/>
|
||||
</div>
|
||||
<!-- Reg. 1, Reg. 2, Reg. 3 -->
|
||||
<div class="is-flex is-align-content-center is-justify-content-center is-flex-direction-column">
|
||||
<div class="is-flex is-align-content-center is-justify-content-center is-flex-direction-column" data-testid="pos-mobile-step-1-registration-list">
|
||||
<PosDepartmentStepMobile1RegistrationNumbers v-if="!views.attachmentView.value"/>
|
||||
</div>
|
||||
<!-- Debug -->
|
||||
@@ -301,7 +299,7 @@ onUnmounted(() => {
|
||||
<!-- Location -->
|
||||
<PosDepartmentStepMobile1Location/>
|
||||
<!-- Buttons -->
|
||||
<PosDepartmentStepMobileFixedBottomControl>
|
||||
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
|
||||
<!-- Attachments -->
|
||||
<div class="is-flex is-justify-content-center">
|
||||
<PosDepartmentStepMobileAttachments :showDefaultControls="false" v-show="views.attachmentView.value"/>
|
||||
@@ -313,11 +311,12 @@ onUnmounted(() => {
|
||||
<!-- Take picture button (When attachment view is active) -->
|
||||
<div class="is-flex is-justify-content-center">
|
||||
<PosDepartmentStepMobileButtonNextStep :isWhite="false" v-if="views.attachmentView.value" style="width: 100%;" class="mb-2" :customDisabled="false" :customAction="() => { attachments.takePicture(); }" :buttonClasses="['has-background-primary-dark', 'has-text-black']">
|
||||
<span class="is-float-left has-text-white">{{ SessionUser.objects.global.language.take_picture }}</span>
|
||||
<span class="is-float-right has-text-white">
|
||||
<!-- Picture icon -->
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-camera"></i>
|
||||
<span class="pos-mobile-action-content" data-testid="pos-mobile-attachment-view-take-picture-action">
|
||||
<span class="pos-mobile-action-label has-text-white" data-testid="pos-mobile-attachment-view-take-picture-label">{{ SessionUser.objects.global.language.take_picture }}</span>
|
||||
<span class="pos-mobile-action-icon has-text-white" data-testid="pos-mobile-attachment-view-take-picture-icon">
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-camera"></i>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
@@ -327,11 +326,12 @@ onUnmounted(() => {
|
||||
<PosDepartmentStepMobileButtonNextStep style="width: 100%;" v-if="!views.attachmentView.value" :buttonClasses="['has-background-primary', 'has-text-black']" :isWhite="false"/>
|
||||
<!-- Next step button (If in attachment view) -->
|
||||
<PosDepartmentStepMobileButtonNextStep style="width: 100%;" v-if="views.attachmentView.value" :customDisabled="false" :customAction="() => { views.attachmentView.value = false; }" :buttonClasses="['has-background-primary', 'has-text-black']" :isWhite="false">
|
||||
<span class="is-float-left has-text-dark">{{ SessionUser.objects.global.language.close }}</span>
|
||||
<span class="is-float-right has-text-dark">
|
||||
<!-- x icon -->
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-times"></i>
|
||||
<span class="pos-mobile-action-content" data-testid="pos-mobile-attachment-view-close-action">
|
||||
<span class="pos-mobile-action-label has-text-dark" data-testid="pos-mobile-attachment-view-close-label">{{ SessionUser.objects.global.language.close }}</span>
|
||||
<span class="pos-mobile-action-icon has-text-dark" data-testid="pos-mobile-attachment-view-close-icon">
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-times"></i>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
@@ -356,24 +356,24 @@ onUnmounted(() => {
|
||||
/** Spread the custom content over the entire available area **/
|
||||
.custom-content {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: -webkit-fill-available;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
z-index: 1; /* Ensure it is above the background */
|
||||
display: flex;
|
||||
flex: 1 1 auto; /* Allow to grow and shrink */
|
||||
flex-direction: column;
|
||||
justify-content: space-between; /* Space out elements vertically */
|
||||
max-height: 70vh; /* Limit the maximum height to 80% of the viewport height */
|
||||
justify-content: flex-start;
|
||||
align-items: center; /* Center elements horizontally */
|
||||
color: white; /* Default text color */
|
||||
padding: 1rem; /* Optional padding for content */
|
||||
gap: 1rem;
|
||||
padding: clamp(5rem, 12vh, 6.5rem) 1rem 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/** On smaller screens, set the top position lower for custom-content **/
|
||||
@media (max-height: 768px) {
|
||||
.custom-content {
|
||||
top: 80px; /* Adjust the top position for smaller screens */
|
||||
}
|
||||
|
||||
.custom-content > * {
|
||||
width: min(100%, 48rem);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
+580
-188
@@ -19,8 +19,8 @@ import PosDepartmentStep2MobileVehicleSelection
|
||||
import PosDepartmentStepMobileButtonNextStep
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
|
||||
import { primaryItem } from "./objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { order_id, order_notes, department_id, customer_id, getCustomerEmail, customer_name, isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { getOrderItems } from "@/components/shop/OrdersItems.vue";
|
||||
import { order_id, order_notes, order_po, order_safety_seal, reference as persistedReference, reg_1, reg_2, reg_3, department_id, customer_id, getCustomerEmail, customer_name, isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
|
||||
import PosDepartmentStepMobileButtonClearAll
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
|
||||
@@ -31,22 +31,19 @@ import PosDepartmentStepMobile2AdditionalItems
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2AdditionalItems.vue";
|
||||
import PosDepartmentStepMobile2Customer
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Customer.vue";
|
||||
import { pendingBookings, getVehiclePlateBooking } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
// Set the header to be transparent
|
||||
setTransparency(false);
|
||||
setBackgroundColor(backgroundColors.default); // Set the default background color
|
||||
// Set the reference to the vehicle 1 reference if it's not already set
|
||||
if (vehicles?.vehicle_1?.value?.reference && !reference.value) {
|
||||
reference.value = vehicles.vehicle_1.value.reference;
|
||||
console.warn('Setting reference to vehicle 1 reference:', reference.value);
|
||||
}
|
||||
hydrateReferenceFromSources();
|
||||
hydrateSafetySealFromSources();
|
||||
// Set the notes to the order notes if it's not already set
|
||||
if (order_notes.value && !notes.value) {
|
||||
notes.value = order_notes.value;
|
||||
console.warn('Setting notes to order notes:', notes.value);
|
||||
}
|
||||
// Check for potential customer number changes
|
||||
const initialCustomerId = Number.parseInt(String(customer_id.value), 10);
|
||||
@@ -68,7 +65,6 @@ onMounted(() => {
|
||||
const applyPotentialCustomerNumberChange = (oldId, newId) => {
|
||||
const parsedCustomerId = Number.parseInt(String(newId), 10);
|
||||
metadata.customerId.value = Number.isNaN(parsedCustomerId) ? null : parsedCustomerId;
|
||||
console.warn('Customer ID changed from', oldId, 'to', metadata.customerId.value)
|
||||
}
|
||||
|
||||
// Watch the customer id, and if it changes, update it in the metadata
|
||||
@@ -103,14 +99,7 @@ const getSelectedPendingBooking = () => {
|
||||
if (booking) return booking;
|
||||
}
|
||||
|
||||
const reg = vehicles?.vehicle_1?.value?.reg || null;
|
||||
if (!reg) return null;
|
||||
|
||||
try {
|
||||
return getVehiclePlateBooking(reg) || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const getSelectedBooking = async () => {
|
||||
@@ -152,7 +141,7 @@ const fetchProductById = async (id: number): Promise<PosProduct> => {
|
||||
|
||||
const getFirstWashProduct = async (): Promise<PosProduct | null> => {
|
||||
try {
|
||||
const washProds = await SessionUser.objects.products.get.list({
|
||||
const washProds = await SessionUser.objects.products.get.all({
|
||||
department_id: department_id.value,
|
||||
is_wash: true,
|
||||
limit: 1
|
||||
@@ -165,6 +154,12 @@ const getFirstWashProduct = async (): Promise<PosProduct | null> => {
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeRegistrationValue = (value: string | null | undefined) =>
|
||||
String(value ?? '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]/g, '');
|
||||
|
||||
const applyPendingBookingFromSelection = async () => {
|
||||
const booking: any = await getSelectedBooking();
|
||||
if (!booking) return false;
|
||||
@@ -180,7 +175,7 @@ const applyPendingBookingFromSelection = async () => {
|
||||
if (booking.po && typeof booking.po === 'string' && booking.po.trim() !== '') {
|
||||
try {
|
||||
await SessionUser.objects.orders.set.po(order_id.value, booking.po);
|
||||
console.warn('Applied PO from booking:', booking.po);
|
||||
order_po.value = booking.po.trim();
|
||||
} catch (e) {
|
||||
console.error('Failed to set PO on order from booking', e);
|
||||
}
|
||||
@@ -197,29 +192,91 @@ const applyPendingBookingFromSelection = async () => {
|
||||
}
|
||||
items = Array.isArray(fullBookingData.items) ? fullBookingData.items : [];
|
||||
// Apply reference and notes from full booking
|
||||
if (fullBookingData.reference && fullBookingData.reference.trim() !== '') {
|
||||
const bookingReference = String(fullBookingData.reference ?? fullBookingData.reference_number ?? '').trim();
|
||||
if (bookingReference !== '') {
|
||||
try {
|
||||
await SessionUser.objects.orders.set.reference(order_id.value, fullBookingData.reference);
|
||||
console.log('Applied reference from booking:', fullBookingData.reference);
|
||||
await SessionUser.objects.orders.set.reference(order_id.value, bookingReference);
|
||||
persistedReference.value = bookingReference;
|
||||
metadata.setReference(bookingReference);
|
||||
hydrateReferenceFromSources();
|
||||
} catch (setRefError) {
|
||||
console.error('Failed to set reference on order from booking:', setRefError);
|
||||
}
|
||||
}
|
||||
if (fullBookingData.notes !== undefined && fullBookingData.notes !== null && fullBookingData.notes.trim() !== '') {
|
||||
const bookingNotes = String(fullBookingData.notes ?? fullBookingData.note ?? '').trim();
|
||||
if (bookingNotes !== '') {
|
||||
try {
|
||||
await SessionUser.objects.orders.set.notes(order_id.value, fullBookingData.notes);
|
||||
console.log('Applied notes from booking:', fullBookingData.notes);
|
||||
await SessionUser.objects.orders.set.notes(order_id.value, bookingNotes);
|
||||
order_notes.value = bookingNotes;
|
||||
notes.value = bookingNotes;
|
||||
metadata.setNotes(bookingNotes);
|
||||
} catch (setNotesError) {
|
||||
console.error('Failed to set notes on order from booking:', setNotesError);
|
||||
}
|
||||
}
|
||||
|
||||
const bookingReg1 = normalizeRegistrationValue(fullBookingData.reg_1 ?? booking.reg_1 ?? reg_1.value);
|
||||
const bookingReg2 = normalizeRegistrationValue(fullBookingData.reg_2 ?? booking.reg_2 ?? '');
|
||||
const currentReg1 = normalizeRegistrationValue(reg_1.value);
|
||||
const currentReg2 = normalizeRegistrationValue(reg_2.value);
|
||||
|
||||
if (bookingReg1 && bookingReg1 !== currentReg1) {
|
||||
reg_1.value = bookingReg1;
|
||||
await SessionUser.objects.orders.set.reg_1(order_id.value, bookingReg1);
|
||||
}
|
||||
|
||||
if (bookingReg2 !== currentReg2) {
|
||||
reg_2.value = bookingReg2;
|
||||
await SessionUser.objects.orders.set.reg_2(order_id.value, bookingReg2);
|
||||
}
|
||||
|
||||
const bookingMatches = Array.isArray(vehicles.vehicle_1.value?.booking_matches)
|
||||
? vehicles.vehicle_1.value.booking_matches
|
||||
: [booking];
|
||||
const bookingCustomerNumber =
|
||||
Number.parseInt(
|
||||
String(
|
||||
fullBookingData?.customer_number ??
|
||||
fullBookingData?.customer_id ??
|
||||
booking?.customer_number ??
|
||||
booking?.customer_id ??
|
||||
vehicles.vehicle_1.value?.customer_id ??
|
||||
customer_id.value ??
|
||||
0
|
||||
),
|
||||
10
|
||||
) || 0;
|
||||
|
||||
vehicles.select(1, {
|
||||
...(vehicles.vehicle_1.value || {}),
|
||||
reg: bookingReg1 || vehicles.vehicle_1.value?.reg || '',
|
||||
customer_id: bookingCustomerNumber,
|
||||
status: 'booked',
|
||||
barred: false,
|
||||
booking_id: booking.id,
|
||||
booking_matches: bookingMatches,
|
||||
reference: bookingReference || vehicles.vehicle_1.value?.reference || null,
|
||||
});
|
||||
|
||||
if (bookingReg2) {
|
||||
vehicles.select(2, {
|
||||
...(vehicles.vehicle_2.value || {}),
|
||||
reg: bookingReg2,
|
||||
customer_id: bookingCustomerNumber,
|
||||
status: 'booked',
|
||||
barred: false,
|
||||
booking_id: booking.id,
|
||||
booking_matches: bookingMatches,
|
||||
});
|
||||
} else {
|
||||
vehicles.select(2, null);
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.error('Failed to fetch full booking for items:', fetchError);
|
||||
lastAppliedBookingId.value = booking.id;
|
||||
return false;
|
||||
}
|
||||
if (!items.length) {
|
||||
console.warn('Full booking fetched but no items found.');
|
||||
lastAppliedBookingId.value = booking.id;
|
||||
return false;
|
||||
}
|
||||
@@ -236,8 +293,6 @@ const applyPendingBookingFromSelection = async () => {
|
||||
if (typeof primaryRaw.price === 'number') primaryProduct.price = primaryRaw.price;
|
||||
primaryProduct.name = primaryRaw?.name || primaryProduct.name;
|
||||
primaryProduct.quantity = 1; // Primary item quantity is handled as 1 in order creation
|
||||
// Apply as primary item
|
||||
transactionItems.setPrimaryItem(primaryProduct as any);
|
||||
|
||||
// Remaining items become addons
|
||||
const addonRawList = items.slice(1);
|
||||
@@ -253,35 +308,32 @@ const applyPendingBookingFromSelection = async () => {
|
||||
const preparedAddons = addonProducts
|
||||
.filter(p => !!p)
|
||||
.map(p => transactionItems.convertProductToAddon(p as any, { quantity: (p as any)?.quantity || 1 }));
|
||||
const bookingIncludesWashCertificate = preparedAddons.some((addon: any) =>
|
||||
addon?.id === 41 || addon?.product?.id === 41
|
||||
);
|
||||
attachments.setWashCertificate(bookingIncludesWashCertificate);
|
||||
|
||||
if (!transactionItems.primaryItem.value) {
|
||||
// Safety: if primary was cleared/changed while awaiting fetches, set again
|
||||
transactionItems.setPrimaryItem(primaryProduct as any);
|
||||
let effectivePrimaryProduct = primaryProduct;
|
||||
if (!primaryProduct.is_wash) {
|
||||
const firstWash = await getFirstWashProduct();
|
||||
if (firstWash) {
|
||||
effectivePrimaryProduct = firstWash;
|
||||
}
|
||||
}
|
||||
// Attach addons to primary
|
||||
effectivePrimaryProduct.addons = preparedAddons as any;
|
||||
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
|
||||
if (transactionItems.primaryItem.value) {
|
||||
// Keep any existing addons that may have been set previously only if they are different.
|
||||
transactionItems.primaryItem.value.addons = preparedAddons as any;
|
||||
primaryProduct.addons = preparedAddons as any;
|
||||
}
|
||||
|
||||
lastAppliedBookingId.value = booking.id;
|
||||
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
|
||||
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
|
||||
lastFetchedPrimaryItemProduct.value = primaryProduct; // Update last fetched primary item
|
||||
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
|
||||
|
||||
// Set vehicle type for consistency
|
||||
vehicles.vehicle_1.value.type = primaryProduct.id;
|
||||
|
||||
// Override with first wash if primary not wash
|
||||
if (!primaryProduct.is_wash) {
|
||||
const firstWash = await getFirstWashProduct();
|
||||
if (firstWash) {
|
||||
firstWash.addons = primaryProduct.addons;
|
||||
transactionItems.setPrimaryItem(firstWash as any);
|
||||
lastFetchedPrimaryItemProduct.value = firstWash;
|
||||
vehicles.vehicle_1.value.type = firstWash.id;
|
||||
}
|
||||
if (vehicles.vehicle_1.value) {
|
||||
vehicles.vehicle_1.value.type = effectivePrimaryProduct.id;
|
||||
}
|
||||
|
||||
// Hide the vehicle selection as booking has been applied
|
||||
@@ -294,7 +346,7 @@ const applyPendingBookingFromSelection = async () => {
|
||||
}
|
||||
|
||||
const initializeStepTwo = async () => {
|
||||
const hasBookingSelection = !!getSelectedBookingId() || !!getSelectedPendingBooking();
|
||||
const hasBookingSelection = !!getSelectedBookingId();
|
||||
if (!hasBookingSelection) {
|
||||
fetchPrimaryItemProduct();
|
||||
return;
|
||||
@@ -309,50 +361,50 @@ const initializeStepTwo = async () => {
|
||||
const fetchPrimaryItemProduct = () => {
|
||||
if (!vehicles.vehicle_1.value?.type && !transactionItems.primaryItem.value) {
|
||||
vehicleSelection.value = true; // Force the user to select a product.
|
||||
} else {
|
||||
// Load the product from the type id.
|
||||
if (!vehicles.vehicle_1.value?.type) {
|
||||
return; // If no vehicle type is set, do not proceed.
|
||||
}
|
||||
// If the primary item is already set and matches the vehicle type, do not fetch again.
|
||||
//if (transactionItems.primaryItem.value && transactionItems.primaryItem.value.id === vehicles.vehicle_1.value.type) {
|
||||
// console.warn('Primary item already set and matches vehicle type, skipping fetch.');
|
||||
// return;
|
||||
//}
|
||||
SessionUser.objects.products.get.single(vehicles.vehicle_1.value.type, {department_id: department_id.value, customer_id: metadata.getCustomerId(), category_id: null, final_price: true})
|
||||
.then(product => {
|
||||
// If the product is found, set it as the primary item
|
||||
lastFetchedPrimaryItemProduct.value = product; // Store the last fetched primary item product
|
||||
console.warn('Setting primary item to vehicle type product:', product);
|
||||
// If the primary item is already set, simply update the product
|
||||
if (transactionItems.primaryItem.value) {
|
||||
transactionItems.primaryItem.value = {
|
||||
...transactionItems.primaryItem.value,
|
||||
...product,
|
||||
addons: transactionItems.primaryItem.value.addons.map(addon => ({
|
||||
...addon,
|
||||
product: {
|
||||
...addon.product,
|
||||
price: product.price // Update the addon price to match the new primary item price
|
||||
}
|
||||
}))
|
||||
};
|
||||
return;
|
||||
}
|
||||
transactionItems.setPrimaryItem(product);
|
||||
})
|
||||
.catch(error => vehicleSelection.value = true); // If there's an error, force the user to select a product.
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the product from the type id.
|
||||
if (!vehicles.vehicle_1.value?.type) {
|
||||
return; // If no vehicle type is set, do not proceed.
|
||||
}
|
||||
|
||||
vehicleSelection.value = false;
|
||||
// If the primary item is already set and matches the vehicle type, do not fetch again.
|
||||
//if (transactionItems.primaryItem.value && transactionItems.primaryItem.value.id === vehicles.vehicle_1.value.type) {
|
||||
// console.warn('Primary item already set and matches vehicle type, skipping fetch.');
|
||||
// return;
|
||||
//}
|
||||
SessionUser.objects.products.get.single(vehicles.vehicle_1.value.type, {department_id: department_id.value, customer_id: metadata.getCustomerId(), category_id: null, final_price: true})
|
||||
.then(product => {
|
||||
// If the product is found, set it as the primary item
|
||||
lastFetchedPrimaryItemProduct.value = product; // Store the last fetched primary item product
|
||||
// If the primary item is already set, simply update the product
|
||||
if (transactionItems.primaryItem.value) {
|
||||
transactionItems.primaryItem.value = {
|
||||
...transactionItems.primaryItem.value,
|
||||
...product,
|
||||
addons: transactionItems.primaryItem.value.addons.map(addon => ({
|
||||
...addon,
|
||||
product: {
|
||||
...addon.product,
|
||||
price: product.price // Update the addon price to match the new primary item price
|
||||
}
|
||||
}))
|
||||
};
|
||||
return;
|
||||
}
|
||||
transactionItems.setPrimaryItem(product);
|
||||
})
|
||||
.catch(error => vehicleSelection.value = true); // If there's an error, force the user to select a product.
|
||||
}
|
||||
|
||||
const fetchLastOrder = (vehicleIndex: number) => {
|
||||
// This function fetches the last order for a given vehicle ID (Provided it has a last_order_id
|
||||
const tmp_order_id = vehicles[`vehicle_${vehicleIndex}`]?.value?.last_order_id || 0;
|
||||
if (!tmp_order_id) {
|
||||
console.warn(`No last order ID found for vehicle ${vehicleIndex}`);
|
||||
return; // If no last order ID is found, do not proceed.
|
||||
}
|
||||
console.log('Fetching last order details for vehicle', vehicleIndex, tmp_order_id);
|
||||
SessionUser.objects.orders.get.single(tmp_order_id)
|
||||
.then(order => lastOrders.set(vehicleIndex, {...order}))
|
||||
.catch(error => console.error('Error fetching last order:', error));
|
||||
@@ -371,7 +423,6 @@ const updateLastOrderItemPrices = (items: PosOrderItem[]) => {
|
||||
// Update the prices of the last order items to reflect any changes in the product prices
|
||||
return items.map(item => {
|
||||
SessionUser.objects.products.get.single(item.product_id, {category_id: null, department_id: department_id.value, customer_id: customer_id.value, final_price: true}).then(product => {
|
||||
console.warn('Fetched product for last order item:', product);
|
||||
transactionItems.updateTransactionPrices([product]); // Update the transaction prices with the fetched product
|
||||
}).catch(error => {
|
||||
console.error('Error fetching product for last order item:', error);
|
||||
@@ -437,77 +488,382 @@ onUnmounted(() => {
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
});
|
||||
|
||||
const onBeforeComplete = () => {
|
||||
// This function is called before the step is completed
|
||||
console.log('On before complete called');
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if the primary item is set
|
||||
if (!transactionItems.primaryItem.value) {
|
||||
reject(new Error('No primary item selected'));
|
||||
return;
|
||||
}
|
||||
// If the transaction items contain a wash certificate, create a booking for it
|
||||
const hasWashCertificate = transactionItems.containsWashCertificate();
|
||||
// Add the order items to the transaction
|
||||
SessionUser.objects.global.add.object(
|
||||
'/order/items',
|
||||
{
|
||||
order_id: parseInt(order_id.value), // The order ID to which the item will be added
|
||||
product_id: primaryItem.value.id, // The product ID of the primary item
|
||||
quantity: 1,
|
||||
price: primaryItem.value.price,
|
||||
notes: primaryItem.value?.notes || '', // Notes can be added here if needed
|
||||
related_item_id: null,
|
||||
}, // The order item to create
|
||||
{authenticated: true}
|
||||
).then((response) => {
|
||||
const relatedItemId = response.data.data.id; // Get the ID of the created order item
|
||||
// Create the addon items (With a quantity of > 0)
|
||||
const addonItems = transactionItems.primaryItem.value.addons.filter(addon => addon.quantity > 0);
|
||||
const addonPromises = addonItems.map(addon => {
|
||||
// For each addon, create an order item
|
||||
return SessionUser.objects.global.add.object(
|
||||
'/order/items',
|
||||
{
|
||||
order_id: parseInt(order_id.value), // The order ID to which the addon will be added
|
||||
product_id: addon.product.id, // The product ID of the addon
|
||||
quantity: addon.quantity,
|
||||
price: addon.product.price,
|
||||
notes: addon.product?.notes || '', // Notes can be added here if needed
|
||||
related_item_id: relatedItemId, // Link the addon to the primary item
|
||||
},
|
||||
{authenticated: true}
|
||||
);
|
||||
});
|
||||
// Create the additional items (With a quantity of > 0)
|
||||
const additionalItems = transactionItems.additionalItems.value.filter(item => item.quantity > 0);
|
||||
const additionalPromises = additionalItems.map(item => {
|
||||
// For each additional item, create an order item
|
||||
return SessionUser.objects.global.add.object(
|
||||
'/order/items',
|
||||
{
|
||||
order_id: parseInt(order_id.value), // The order ID to which the additional item will be added
|
||||
product_id: item.id, // The product ID of the additional item
|
||||
quantity: item.quantity,
|
||||
price: item.price,
|
||||
notes: item?.notes || '', // Notes can be added here if needed
|
||||
related_item_id: null, // Additional items are not linked to the primary item
|
||||
},
|
||||
{authenticated: true}
|
||||
);
|
||||
});
|
||||
// Combine addon and additional item promises
|
||||
addonPromises.push(...additionalPromises);
|
||||
// IF the booking is being created, wait for it to finish first
|
||||
return Promise.all(addonPromises);
|
||||
}).then(() => {
|
||||
// After successfully adding the order item, resolve the promise
|
||||
resolve(true);
|
||||
}).catch(error => {
|
||||
// If there's an error, reject the promise
|
||||
reject(error);
|
||||
});
|
||||
const getNormalizedOrderId = () => {
|
||||
const parsedOrderId = Number.parseInt(String(order_id.value), 10);
|
||||
return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null;
|
||||
}
|
||||
|
||||
const normalizeReferenceValue = (value: unknown) => String(value ?? "");
|
||||
const hasReferenceValue = (value: unknown) => normalizeReferenceValue(value).trim() !== "";
|
||||
|
||||
const syncVehicleRegistrationFromOrder = (vehicleIndex: number, value: string | null | undefined) => {
|
||||
const normalizedValue = normalizeRegistrationValue(value);
|
||||
if (!normalizedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentVehicle = vehicles.get(vehicleIndex);
|
||||
if (currentVehicle?.reg === normalizedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
vehicles.select(vehicleIndex, {
|
||||
...(currentVehicle ?? {}),
|
||||
reg: normalizedValue,
|
||||
});
|
||||
};
|
||||
|
||||
const notesAutosave = useOrderMetadataAutosave({
|
||||
source: order_notes,
|
||||
saveValue: async (value) => {
|
||||
const normalizedOrderId = getNormalizedOrderId();
|
||||
if (!normalizedOrderId) {
|
||||
return value;
|
||||
}
|
||||
|
||||
await SessionUser.objects.orders.set.notes(normalizedOrderId, value);
|
||||
return value;
|
||||
},
|
||||
onSaved: async (value) => {
|
||||
order_notes.value = value;
|
||||
metadata.setNotes(value);
|
||||
},
|
||||
});
|
||||
|
||||
const referenceAutosave = useOrderMetadataAutosave({
|
||||
source: persistedReference,
|
||||
saveValue: async (value) => {
|
||||
const normalizedOrderId = getNormalizedOrderId();
|
||||
if (!normalizedOrderId) {
|
||||
return value;
|
||||
}
|
||||
|
||||
await SessionUser.objects.orders.set.reference(normalizedOrderId, value);
|
||||
return value;
|
||||
},
|
||||
onSaved: async (value) => {
|
||||
persistedReference.value = value;
|
||||
metadata.setReference(value);
|
||||
},
|
||||
});
|
||||
|
||||
const safetySealAutosave = useOrderMetadataAutosave({
|
||||
source: order_safety_seal,
|
||||
saveValue: async (value) => {
|
||||
const normalizedOrderId = getNormalizedOrderId();
|
||||
if (!normalizedOrderId) {
|
||||
return value;
|
||||
}
|
||||
|
||||
await SessionUser.objects.orders.set.safety_seal(normalizedOrderId, value);
|
||||
return value;
|
||||
},
|
||||
onSaved: async (value) => {
|
||||
order_safety_seal.value = value;
|
||||
metadata.setSafetySeal(value);
|
||||
},
|
||||
});
|
||||
|
||||
const stepTwoNotesInput = notesAutosave.draft;
|
||||
const stepTwoReferenceInput = referenceAutosave.draft;
|
||||
const stepTwoSafetySealInput = safetySealAutosave.draft;
|
||||
const showSafetySealField = computed(() => transactionItems.containsWashCertificate());
|
||||
|
||||
const resolveReferenceFromSources = () => {
|
||||
const orderReference = normalizeReferenceValue(persistedReference.value);
|
||||
if (hasReferenceValue(orderReference)) {
|
||||
return orderReference;
|
||||
}
|
||||
|
||||
const metadataReference = normalizeReferenceValue(metadata.getReference?.() ?? reference.value);
|
||||
if (hasReferenceValue(metadataReference)) {
|
||||
return metadataReference;
|
||||
}
|
||||
|
||||
const vehicleReference = normalizeReferenceValue(vehicles?.vehicle_1?.value?.reference);
|
||||
if (hasReferenceValue(vehicleReference)) {
|
||||
return vehicleReference;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
function syncReferenceState(value: unknown) {
|
||||
const normalizedValue = normalizeReferenceValue(value);
|
||||
reference.value = normalizedValue;
|
||||
metadata.setReference(normalizedValue);
|
||||
}
|
||||
|
||||
const resolveSafetySealFromSources = () => {
|
||||
const orderSafetySeal = normalizeReferenceValue(order_safety_seal.value);
|
||||
if (hasReferenceValue(orderSafetySeal)) {
|
||||
return orderSafetySeal;
|
||||
}
|
||||
|
||||
const metadataSafetySeal = normalizeReferenceValue(metadata.getSafetySeal?.());
|
||||
if (hasReferenceValue(metadataSafetySeal)) {
|
||||
return metadataSafetySeal;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
function syncSafetySealState(value: unknown) {
|
||||
const normalizedValue = normalizeReferenceValue(value);
|
||||
order_safety_seal.value = normalizedValue;
|
||||
metadata.setSafetySeal(normalizedValue);
|
||||
}
|
||||
|
||||
function hydrateReferenceFromSources() {
|
||||
const currentDraft = normalizeReferenceValue(stepTwoReferenceInput.value);
|
||||
|
||||
if (referenceAutosave.isDirty.value && hasReferenceValue(currentDraft)) {
|
||||
syncReferenceState(currentDraft);
|
||||
return currentDraft;
|
||||
}
|
||||
|
||||
const resolvedReference = resolveReferenceFromSources();
|
||||
if (!hasReferenceValue(resolvedReference)) {
|
||||
if (hasReferenceValue(currentDraft)) {
|
||||
syncReferenceState(currentDraft);
|
||||
}
|
||||
return currentDraft;
|
||||
}
|
||||
|
||||
const lastSavedReference = normalizeReferenceValue(referenceAutosave.lastSavedValue.value);
|
||||
const canHydrateDraft = !hasReferenceValue(currentDraft) || currentDraft === lastSavedReference;
|
||||
|
||||
if (canHydrateDraft && currentDraft !== resolvedReference) {
|
||||
referenceAutosave.syncFromSource(resolvedReference);
|
||||
}
|
||||
|
||||
syncReferenceState(canHydrateDraft ? resolvedReference : currentDraft);
|
||||
return canHydrateDraft ? resolvedReference : currentDraft;
|
||||
}
|
||||
|
||||
function hydrateSafetySealFromSources() {
|
||||
const currentDraft = normalizeReferenceValue(stepTwoSafetySealInput.value);
|
||||
|
||||
if (safetySealAutosave.isDirty.value && hasReferenceValue(currentDraft)) {
|
||||
syncSafetySealState(currentDraft);
|
||||
return currentDraft;
|
||||
}
|
||||
|
||||
const resolvedSafetySeal = resolveSafetySealFromSources();
|
||||
if (!hasReferenceValue(resolvedSafetySeal)) {
|
||||
if (hasReferenceValue(currentDraft)) {
|
||||
syncSafetySealState(currentDraft);
|
||||
}
|
||||
return currentDraft;
|
||||
}
|
||||
|
||||
const lastSavedSafetySeal = normalizeReferenceValue(safetySealAutosave.lastSavedValue.value);
|
||||
const canHydrateDraft = !hasReferenceValue(currentDraft) || currentDraft === lastSavedSafetySeal;
|
||||
|
||||
if (canHydrateDraft && currentDraft !== resolvedSafetySeal) {
|
||||
safetySealAutosave.syncFromSource(resolvedSafetySeal);
|
||||
}
|
||||
|
||||
syncSafetySealState(canHydrateDraft ? resolvedSafetySeal : currentDraft);
|
||||
return canHydrateDraft ? resolvedSafetySeal : currentDraft;
|
||||
}
|
||||
|
||||
watch(stepTwoNotesInput, (value) => {
|
||||
metadata.setNotes(value);
|
||||
|
||||
if (getNormalizedOrderId()) {
|
||||
notesAutosave.scheduleSave();
|
||||
}
|
||||
});
|
||||
|
||||
watch(stepTwoReferenceInput, (value) => {
|
||||
syncReferenceState(value);
|
||||
|
||||
if (getNormalizedOrderId()) {
|
||||
referenceAutosave.scheduleSave();
|
||||
}
|
||||
});
|
||||
|
||||
watch(stepTwoSafetySealInput, (value) => {
|
||||
syncSafetySealState(value);
|
||||
|
||||
if (getNormalizedOrderId()) {
|
||||
safetySealAutosave.scheduleSave();
|
||||
}
|
||||
});
|
||||
|
||||
watch(persistedReference, (newValue, oldValue) => {
|
||||
if (newValue !== oldValue) {
|
||||
hydrateReferenceFromSources();
|
||||
}
|
||||
});
|
||||
|
||||
watch(order_safety_seal, (newValue, oldValue) => {
|
||||
if (newValue !== oldValue) {
|
||||
hydrateSafetySealFromSources();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => getNormalizedOrderId(), (normalizedOrderId) => {
|
||||
if (!normalizedOrderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (notesAutosave.isDirty.value) {
|
||||
notesAutosave.scheduleSave();
|
||||
}
|
||||
|
||||
if (referenceAutosave.isDirty.value) {
|
||||
referenceAutosave.scheduleSave();
|
||||
}
|
||||
|
||||
if (safetySealAutosave.isDirty.value) {
|
||||
safetySealAutosave.scheduleSave();
|
||||
}
|
||||
});
|
||||
|
||||
watch([reg_1, reg_2, reg_3], ([nextReg1, nextReg2, nextReg3]) => {
|
||||
if (!getNormalizedOrderId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncVehicleRegistrationFromOrder(1, nextReg1);
|
||||
syncVehicleRegistrationFromOrder(2, nextReg2);
|
||||
syncVehicleRegistrationFromOrder(3, nextReg3);
|
||||
}, { immediate: true });
|
||||
|
||||
const normalizeOrderItemShape = (item: any) => ({
|
||||
product_id: Number(item?.product_id ?? item?.product?.id ?? 0),
|
||||
quantity: Number(item?.quantity ?? 0),
|
||||
related_item_id: item?.related_item_id === null || item?.related_item_id === undefined
|
||||
? null
|
||||
: Number(item.related_item_id),
|
||||
price: Number(item?.price ?? 0),
|
||||
notes: String(item?.notes ?? ''),
|
||||
})
|
||||
|
||||
const buildDesiredOrderItemShapes = () => {
|
||||
if (!transactionItems.primaryItem.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const primaryShape = {
|
||||
kind: 'primary',
|
||||
relatedKey: 'primary',
|
||||
product_id: Number(transactionItems.primaryItem.value.id),
|
||||
quantity: 1,
|
||||
related_item_id: null,
|
||||
price: Number(transactionItems.primaryItem.value.price ?? 0),
|
||||
notes: String(transactionItems.primaryItem.value?.notes ?? ''),
|
||||
};
|
||||
|
||||
const addonShapes = (transactionItems.primaryItem.value.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||
.map((addon: any) => ({
|
||||
kind: 'addon',
|
||||
relatedKey: 'primary',
|
||||
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
|
||||
quantity: Number(addon?.quantity ?? 0),
|
||||
related_item_id: '__PRIMARY__',
|
||||
price: Number(addon?.product?.price ?? addon?.price ?? 0),
|
||||
notes: String(addon?.product?.notes ?? ''),
|
||||
}));
|
||||
|
||||
const additionalShapes = (transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||
.map((item: any) => ({
|
||||
kind: 'additional',
|
||||
relatedKey: null,
|
||||
product_id: Number(item?.id ?? 0),
|
||||
quantity: Number(item?.quantity ?? 0),
|
||||
related_item_id: null,
|
||||
price: Number(item?.price ?? 0),
|
||||
notes: String(item?.notes ?? ''),
|
||||
}));
|
||||
|
||||
return [primaryShape, ...addonShapes, ...additionalShapes];
|
||||
}
|
||||
|
||||
const normalizeExistingOrderItemShapes = (items: any[]) => {
|
||||
const primaryItems = items.filter((item: any) => item?.related_item_id === null || item?.related_item_id === undefined);
|
||||
if (primaryItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const additionalItems = primaryItems.filter((item: any) => Number(item?.product?.id ?? item?.product_id ?? 0) !== Number(transactionItems.primaryItem.value?.id ?? 0));
|
||||
const primaryItemShape = normalizeOrderItemShape(primaryItems[0]);
|
||||
const addonShapes = items
|
||||
.filter((item: any) => item?.related_item_id === primaryItems[0]?.id)
|
||||
.map(normalizeOrderItemShape);
|
||||
const additionalShapes = additionalItems.map(normalizeOrderItemShape);
|
||||
|
||||
return [primaryItemShape, ...addonShapes, ...additionalShapes];
|
||||
}
|
||||
|
||||
const syncCurrentTransactionToOrder = async () => {
|
||||
const normalizedOrderId = getNormalizedOrderId();
|
||||
if (!normalizedOrderId) {
|
||||
throw new Error('Order ID is required');
|
||||
}
|
||||
if (!transactionItems.primaryItem.value) {
|
||||
throw new Error('No primary item selected');
|
||||
}
|
||||
|
||||
const existingItemsResponse = await getOrderItems(normalizedOrderId);
|
||||
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
|
||||
|
||||
const desiredShapes = buildDesiredOrderItemShapes();
|
||||
const currentShapes = normalizeExistingOrderItemShapes(existingItems);
|
||||
|
||||
if (JSON.stringify(currentShapes) === JSON.stringify(desiredShapes.map(({ kind, relatedKey, ...shape }) => shape))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await Promise.all(existingItems.map((item: any) => removeOrderItem(item.id)));
|
||||
|
||||
const createdPrimaryItemResponse = await createOrderItem(
|
||||
normalizedOrderId,
|
||||
transactionItems.primaryItem.value.id,
|
||||
1,
|
||||
null,
|
||||
transactionItems.primaryItem.value?.notes || '',
|
||||
transactionItems.primaryItem.value.price
|
||||
);
|
||||
const createdPrimaryItemId = createdPrimaryItemResponse?.data?.data?.id;
|
||||
|
||||
const addonPromises = (transactionItems.primaryItem.value.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||
.map((addon: any) => createOrderItem(
|
||||
normalizedOrderId,
|
||||
addon.product.id,
|
||||
Number(addon.quantity),
|
||||
createdPrimaryItemId,
|
||||
addon.product?.notes || '',
|
||||
addon.product.price
|
||||
));
|
||||
|
||||
const additionalPromises = (transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||
.map((item: any) => createOrderItem(
|
||||
normalizedOrderId,
|
||||
item.id,
|
||||
Number(item.quantity),
|
||||
null,
|
||||
item?.notes || '',
|
||||
item.price
|
||||
));
|
||||
|
||||
await Promise.all([...addonPromises, ...additionalPromises]);
|
||||
return true;
|
||||
}
|
||||
|
||||
const onBeforeComplete = async () => {
|
||||
if (!transactionItems.primaryItem.value) {
|
||||
throw new Error('No primary item selected');
|
||||
}
|
||||
|
||||
await syncCurrentTransactionToOrder();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Re-try applying booking whenever booking id or vehicle reg changes
|
||||
@@ -522,15 +878,22 @@ watch(() => vehicles?.vehicle_1?.value?.reg, () => {
|
||||
// Utility: map source addons and carry over quantities from previous addons by product.id
|
||||
const mapAddonsWithQuantity = (sourceAddons = [], previousAddons = []) =>
|
||||
sourceAddons.map(addon => {
|
||||
const carriedQty = previousAddons.find(a => a.product.id === addon.product.id)?.quantity ?? 0;
|
||||
return { ...addon, quantity: carriedQty };
|
||||
const previousAddon = previousAddons.find(a => a.product.id === addon.product.id);
|
||||
const nextQuantity = previousAddon?.quantity ?? addon?.quantity ?? addon?.product?.quantity ?? 0;
|
||||
return {
|
||||
...addon,
|
||||
quantity: nextQuantity,
|
||||
product: addon?.product ? {
|
||||
...addon.product,
|
||||
quantity: nextQuantity,
|
||||
} : addon?.product,
|
||||
};
|
||||
});
|
||||
|
||||
watch(
|
||||
() => transactionItems.primaryItem.value,
|
||||
(nextPrimary, prevPrimary) => {
|
||||
if (!nextPrimary) {
|
||||
console.warn('Primary item is not set, cannot proceed with the transaction.');
|
||||
fetchPrimaryItemProduct();
|
||||
return;
|
||||
}
|
||||
@@ -541,7 +904,6 @@ watch(
|
||||
|
||||
if (isNewPrimary) {
|
||||
lastFetchedPrimaryItemProduct.value = nextPrimary;
|
||||
console.log('Primary item changed from:', prevPrimary ?? null, 'to:', nextPrimary);
|
||||
fetchPrimaryItemProduct();
|
||||
|
||||
if (Array.isArray(nextPrimary.addons) && nextPrimary.addons.length > 0) {
|
||||
@@ -556,12 +918,6 @@ watch(
|
||||
// Same product as last fetched: merge/normalize addons from last fetched into the current primary item
|
||||
const sourceAddons = lastFetched?.addons ?? [];
|
||||
if (sourceAddons.length > 0) {
|
||||
console.log(
|
||||
'Merging addons from last fetched primary item product:',
|
||||
sourceAddons,
|
||||
'into primary item:',
|
||||
transactionItems.primaryItem.value
|
||||
);
|
||||
transactionItems.primaryItem.value.addons = mapAddonsWithQuantity(
|
||||
sourceAddons,
|
||||
transactionItems.primaryItem.value.addons ?? []
|
||||
@@ -574,39 +930,39 @@ watch(
|
||||
// Watch for changes in the last orders and fetch the order items when they change
|
||||
watch(() => lastOrders.get(1), (newValue, oldValue) => {
|
||||
if (newValue && newValue.id !== oldValue?.id) {
|
||||
console.log('Last order 1 changed:', newValue);
|
||||
fetchLastOrderItems(1); // Fetch the order items for the last order 1
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => lastOrders.get(2), (newValue, oldValue) => {
|
||||
if (newValue && newValue.id !== oldValue?.id) {
|
||||
console.log('Last order 2 changed:', newValue);
|
||||
fetchLastOrderItems(2); // Fetch the order items for the last order 2
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => lastOrders.get(3), (newValue, oldValue) => {
|
||||
if (newValue && newValue.id !== oldValue?.id) {
|
||||
console.log('Last order 3 changed:', newValue);
|
||||
fetchLastOrderItems(3); // Fetch the order items for the last order 3
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// Watch for changes in the vehicle 1 last_order_id and fetch the last order when it changes
|
||||
watch(() => vehicles.vehicle_1.value.last_order_id, (newValue, oldValue) => {
|
||||
watch(() => vehicles.vehicle_1.value?.last_order_id, (newValue, oldValue) => {
|
||||
if (newValue && newValue !== oldValue) {
|
||||
console.log('Vehicle 1 last_order_id changed:', newValue);
|
||||
fetchLastOrder(1); // Fetch the last order for vehicle 1
|
||||
} else {
|
||||
lastOrders.set(1, null); // Clear the last order if the last_order_id is removed
|
||||
}
|
||||
});
|
||||
watch(() => vehicles.vehicle_1.value?.type, (newValue, oldValue) => {
|
||||
if (newValue && newValue !== oldValue && !transactionItems.primaryItem.value) {
|
||||
fetchPrimaryItemProduct();
|
||||
}
|
||||
});
|
||||
// Watch for changes in the vehicle 1 reference and update the reference field when it changes
|
||||
watch(() => vehicles.vehicle_1.value.reference, (newValue, oldValue) => {
|
||||
watch(() => vehicles.vehicle_1.value?.reference, (newValue, oldValue) => {
|
||||
if (newValue && newValue !== oldValue) {
|
||||
console.log('Vehicle 1 reference changed:', newValue);
|
||||
reference.value = newValue; // Update the reference field
|
||||
hydrateReferenceFromSources();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -627,16 +983,16 @@ const filteredAddons = computed(() => {
|
||||
<template v-if="vehicleSelection">
|
||||
<PosDepartmentStep2MobileVehicleSelection @close="vehicleSelection = false"/>
|
||||
<!-- Buttons -->
|
||||
<PosDepartmentStepMobileFixedBottomControl>
|
||||
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
|
||||
<!-- Buttons -->
|
||||
<!-- Next button -->
|
||||
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => { vehicleSelection = false; fetchPrimaryItemProduct(); }" :buttonClasses="['has-background-primary', 'has-text-black']" v-show="vehicles.vehicle_1?.value?.type">
|
||||
<span class="is-float-left " v-if="false">{{ SessionUser.objects.global.language.next }}</span>
|
||||
<span class="is-float-left">{{ primaryItem ? primaryItem.name : SessionUser.objects.global.language.next }}</span>
|
||||
<span class="is-float-right ">
|
||||
<!-- Arrow right icon -->
|
||||
<span class="pos-mobile-cta-content">
|
||||
<span class="pos-mobile-cta-label">{{ primaryItem ? primaryItem.name : SessionUser.objects.global.language.next }}</span>
|
||||
<span class="pos-mobile-cta-value">
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</span>
|
||||
</span>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
</PosDepartmentStepMobileFixedBottomControl>
|
||||
</template>
|
||||
@@ -644,9 +1000,9 @@ const filteredAddons = computed(() => {
|
||||
<PosDepartmentStepMobile2AdditionalItems :label="SessionUser.objects.global.language.additional_items" :subtitle="''" @close="additionalItemSelection = false"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="is-flex is-flex-direction-column is-justify-content-space-between is-gap-2">
|
||||
<div class="pos-mobile-step-layout" data-testid="pos-mobile-step-2">
|
||||
<!-- Customer selection -->
|
||||
<h3 class="is-size-5 has-text-weight-bold">{{ customer_name }}</h3>
|
||||
<h3 class="is-size-5 has-text-weight-bold" data-testid="pos-mobile-customer-name">{{ customer_name }}</h3>
|
||||
<PosDepartmentStepMobile2Customer subtitle="" :label="customer_name" v-show="false"/>
|
||||
<!-- Registration numbers -->
|
||||
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
|
||||
@@ -670,31 +1026,67 @@ const filteredAddons = computed(() => {
|
||||
<!-- Notes -->
|
||||
<ControlField>
|
||||
<ControlFieldInputLabel label="Notes" :classes="layout.classes" :optional="true"/>
|
||||
<ControlFieldInput type="text" placeholder="" :classes="layout.classes" :vmodel="order_notes" @change="console.warn('Setting notes to', $event); metadata.setNotes($event); SessionUser.objects.orders.set.notes(order_id, $event);"/>
|
||||
<ControlFieldInput
|
||||
testId="pos-mobile-notes-input"
|
||||
type="text"
|
||||
placeholder=""
|
||||
:classes="layout.classes"
|
||||
:vmodel="stepTwoNotesInput"
|
||||
@input="stepTwoNotesInput = $event"
|
||||
@change="notesAutosave.flush()"
|
||||
/>
|
||||
</ControlField>
|
||||
<!-- Reference -->
|
||||
<ControlField>
|
||||
<ControlFieldInputLabel label="Reference" :classes="layout.classes" :optional="true"/> <!-- TODO: Make this required, if the customer requires it -->
|
||||
<ControlFieldInput type="text" placeholder="" :classes="layout.classes" :vmodel="reference" @change="console.warn('Setting ref. to', $event); metadata.setReference($event); SessionUser.objects.orders.set.reference(order_id, $event);"/>
|
||||
<ControlFieldInput
|
||||
testId="pos-mobile-reference-step-2-input"
|
||||
type="text"
|
||||
placeholder=""
|
||||
:classes="layout.classes"
|
||||
:vmodel="stepTwoReferenceInput"
|
||||
@input="stepTwoReferenceInput = $event"
|
||||
@change="referenceAutosave.flush()"
|
||||
/>
|
||||
</ControlField>
|
||||
<!-- Spacer to push the buttons to the bottom -->
|
||||
<ControlField>
|
||||
<!-- Buttons -->
|
||||
<PosDepartmentStepMobileFixedBottomControl>
|
||||
<ControlField v-if="showSafetySealField">
|
||||
<ControlFieldInputLabel label="Safety seal" :classes="layout.classes" :optional="true"/>
|
||||
<ControlFieldInput
|
||||
testId="pos-mobile-safety-seal-step-2-input"
|
||||
type="text"
|
||||
placeholder=""
|
||||
:classes="layout.classes"
|
||||
:vmodel="stepTwoSafetySealInput"
|
||||
@input="stepTwoSafetySealInput = $event"
|
||||
@change="safetySealAutosave.flush()"
|
||||
/>
|
||||
</ControlField>
|
||||
<PosDepartmentStepMobileFixedBottomControl variant="pos-step" :reserveSpace="false">
|
||||
<!-- Complete button -->
|
||||
<PosDepartmentStepMobileButtonNextStep :isDark="false" :onBeforeStep="() => onBeforeComplete()" :buttonClasses="['has-background-primary', 'has-text-white']">
|
||||
<span class="is-float-left">Afslut</span>
|
||||
<span class="is-float-right">{{ SessionUser.functions.currency.toLocal(transactionItems.getTransactionTotal())}}</span>
|
||||
<span class="pos-mobile-cta-content">
|
||||
<span class="pos-mobile-cta-label">Afslut</span>
|
||||
<span class="pos-mobile-cta-value">{{ SessionUser.functions.currency.toLocal(transactionItems.getTransactionTotal())}}</span>
|
||||
</span>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
<div>
|
||||
<PosDepartmentStepMobileButtonClearAll/>
|
||||
</div>
|
||||
</PosDepartmentStepMobileFixedBottomControl>
|
||||
</ControlField>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pos-mobile-step-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1rem calc(var(--pos-mobile-fixed-bottom-height, 0px) + 1rem);
|
||||
scroll-padding-bottom: calc(var(--pos-mobile-fixed-bottom-height, 0px) + 1rem);
|
||||
}
|
||||
|
||||
.pos-mobile-step-layout :deep(.control-field-input) {
|
||||
scroll-margin-bottom: calc(var(--pos-mobile-fixed-bottom-height, 0px) + 1rem);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import Swal from "sweetalert2";
|
||||
import PayWithStripeButton from "@/components/displays/department/pos/displays/PayWithStripeButton.vue";
|
||||
import {
|
||||
customer_id,
|
||||
department_id,
|
||||
order_id,
|
||||
order_items,
|
||||
reg_1,
|
||||
reset_all_values,
|
||||
setStep,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { StripeModule } from "@/components/stripe/StripeModule.vue";
|
||||
import {
|
||||
metadata,
|
||||
popups,
|
||||
resetPos,
|
||||
transactionItems,
|
||||
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { finalizeCurrentMobileOrder } from "@/components/displays/department/pos/steps/mobile/objects/mobileOrderCompletion.js";
|
||||
|
||||
const isCompleting = ref(false);
|
||||
const completionError = ref(null);
|
||||
const completedOrderId = ref(null);
|
||||
|
||||
const normalizedOrderId = computed(() => {
|
||||
const parsed = Number.parseInt(String(order_id.value), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
});
|
||||
|
||||
const normalizedDepartmentId = computed(() => {
|
||||
const parsed = Number.parseInt(String(department_id.value), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||
});
|
||||
|
||||
const currentPaymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
|
||||
const paymentIntentState = computed(() => StripeModule.paymentIntents.getPaymentIntentState(currentPaymentIntent.value));
|
||||
const hasActivePaymentIntent = computed(() => StripeModule.paymentIntents.isPaymentIntentActive(currentPaymentIntent.value));
|
||||
|
||||
const orderItemsTotal = computed(() => {
|
||||
return (Array.isArray(order_items.value) ? order_items.value : []).reduce((sum, item) => {
|
||||
const quantity = Number(item?.quantity ?? 1);
|
||||
const price = Number(item?.price ?? item?.product?.price ?? 0);
|
||||
return sum + (Number.isFinite(quantity) ? quantity : 0) * (Number.isFinite(price) ? price : 0);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const mobileOrderTotal = computed(() => {
|
||||
const transactionTotal = Number(transactionItems.getTransactionTotal?.() ?? 0);
|
||||
if (Number.isFinite(transactionTotal) && transactionTotal > 0) {
|
||||
return transactionTotal;
|
||||
}
|
||||
return orderItemsTotal.value;
|
||||
});
|
||||
|
||||
const orderReference = computed(() => metadata.getReference?.() || '');
|
||||
const orderNotes = computed(() => metadata.getNotes?.() || '');
|
||||
const orderRegistration = computed(() => reg_1.value || 'Unknown vehicle');
|
||||
const isBackDisabled = computed(() => isCompleting.value || paymentIntentState.value === 'succeeded');
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('da-DK', {
|
||||
style: 'currency',
|
||||
currency: 'DKK',
|
||||
}).format(Number(amount || 0));
|
||||
};
|
||||
|
||||
const navigateToItems = () => {
|
||||
setStep(2);
|
||||
if (normalizedOrderId.value) {
|
||||
window.history.pushState({}, '', `?id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
|
||||
return;
|
||||
}
|
||||
window.history.pushState({}, '', `?step=2`);
|
||||
};
|
||||
|
||||
const clearSuccessfulMobileCardFlow = () => {
|
||||
window.setTimeout(() => {
|
||||
reset_all_values();
|
||||
order_id.value = 0;
|
||||
popups.clear();
|
||||
resetPos();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const completeMobileCardPayment = async () => {
|
||||
if (!normalizedOrderId.value) {
|
||||
return;
|
||||
}
|
||||
if (isCompleting.value || completedOrderId.value === normalizedOrderId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isCompleting.value = true;
|
||||
completionError.value = null;
|
||||
completedOrderId.value = normalizedOrderId.value;
|
||||
|
||||
try {
|
||||
await finalizeCurrentMobileOrder();
|
||||
popups.select('completed_transaction', {
|
||||
message: `Order #${normalizedOrderId.value} successfully created.`,
|
||||
});
|
||||
clearSuccessfulMobileCardFlow();
|
||||
} catch (error) {
|
||||
completedOrderId.value = null;
|
||||
completionError.value = SessionUser.functions.parseErrorMessage(error) || 'Unable to complete card payment.';
|
||||
console.error('Failed to complete mobile card payment:', error);
|
||||
} finally {
|
||||
isCompleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onBackClick = async () => {
|
||||
if (paymentIntentState.value === 'succeeded') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasActivePaymentIntent.value) {
|
||||
navigateToItems();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: 'Payment in progress',
|
||||
text: 'Resume the current payment or cancel it before returning to the item step.',
|
||||
icon: 'warning',
|
||||
showDenyButton: true,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Resume payment',
|
||||
denyButtonText: 'Cancel payment',
|
||||
cancelButtonText: 'Stay here',
|
||||
reverseButtons: true,
|
||||
});
|
||||
|
||||
if (result.isConfirmed || result.dismiss) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.isDenied && normalizedOrderId.value) {
|
||||
try {
|
||||
await StripeModule.paymentIntents.deletePaymentIntent(normalizedOrderId.value);
|
||||
navigateToItems();
|
||||
} catch (error) {
|
||||
completionError.value = SessionUser.functions.parseErrorMessage(error) || 'Unable to cancel the current payment.';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mobile-card-payment-view" data-testid="pos-mobile-step-3">
|
||||
<div class="mobile-card-payment-view__header">
|
||||
<div>
|
||||
<p class="mobile-card-payment-view__eyebrow">Mobile POS</p>
|
||||
<h3 class="mobile-card-payment-view__title">Card payment</h3>
|
||||
</div>
|
||||
<button
|
||||
class="button is-small is-light"
|
||||
type="button"
|
||||
data-testid="pos-mobile-step-3-back"
|
||||
:disabled="isBackDisabled"
|
||||
@click="onBackClick"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mobile-card-payment-view__summary box">
|
||||
<div class="mobile-card-payment-view__summary-row">
|
||||
<span>Order</span>
|
||||
<strong data-testid="pos-mobile-step-3-order-id">
|
||||
#{{ normalizedOrderId || 'Pending' }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="mobile-card-payment-view__summary-row">
|
||||
<span>Total</span>
|
||||
<strong data-testid="pos-mobile-step-3-total">
|
||||
{{ formatCurrency(mobileOrderTotal) }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="mobile-card-payment-view__summary-row">
|
||||
<span>Vehicle</span>
|
||||
<strong data-testid="pos-mobile-step-3-registration">{{ orderRegistration }}</strong>
|
||||
</div>
|
||||
<div v-if="orderReference" class="mobile-card-payment-view__summary-row">
|
||||
<span>Reference</span>
|
||||
<strong data-testid="pos-mobile-step-3-reference">{{ orderReference }}</strong>
|
||||
</div>
|
||||
<div v-if="orderNotes" class="mobile-card-payment-view__notes" data-testid="pos-mobile-step-3-notes">
|
||||
{{ orderNotes }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="paymentIntentState === 'succeeded' && isCompleting"
|
||||
class="notification is-info is-light"
|
||||
data-testid="pos-mobile-step-3-completing"
|
||||
>
|
||||
Finalizing the paid order.
|
||||
</div>
|
||||
|
||||
<div v-if="completionError" class="notification is-danger is-light" data-testid="pos-mobile-step-3-error">
|
||||
<div>{{ completionError }}</div>
|
||||
<button
|
||||
v-if="paymentIntentState === 'succeeded'"
|
||||
class="button is-small is-danger is-light mobile-card-payment-view__retry"
|
||||
type="button"
|
||||
data-testid="pos-mobile-step-3-retry-complete"
|
||||
:disabled="isCompleting"
|
||||
@click="completeMobileCardPayment"
|
||||
>
|
||||
Retry completion
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mobile-card-payment-view__description">
|
||||
Use the selected Stripe Terminal reader to collect and capture the card payment for this order.
|
||||
</p>
|
||||
|
||||
<PayWithStripeButton
|
||||
:department-id="normalizedDepartmentId"
|
||||
:order_id="normalizedOrderId || 0"
|
||||
:order_items="order_items"
|
||||
:mobile-mode="true"
|
||||
:on-payment-success="completeMobileCardPayment"
|
||||
label="Start card payment"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-card-payment-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mobile-card-payment-view__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mobile-card-payment-view__eyebrow {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.mobile-card-payment-view__title {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mobile-card-payment-view__summary {
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.mobile-card-payment-view__summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.1rem 0;
|
||||
}
|
||||
|
||||
.mobile-card-payment-view__summary-row span {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.mobile-card-payment-view__description {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mobile-card-payment-view__notes {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||
color: #374151;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mobile-card-payment-view__retry {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
+1
-2
@@ -11,7 +11,6 @@ const { coords, locatedAt, error, resume, pause } = useGeolocation({
|
||||
timeout: 27000,
|
||||
});
|
||||
const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => {
|
||||
console.log("Geolocation updated:", newCoords);
|
||||
if (newCoords.latitude && newCoords.longitude) {
|
||||
locations.set({
|
||||
coords: {
|
||||
@@ -48,4 +47,4 @@ watch(coords, (newCoords) => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+6
-1
@@ -10,6 +10,10 @@ const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
inputTestId: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue', 'update:hasFocus', 'onInput']);
|
||||
@@ -58,6 +62,7 @@ const onInput = (value: string) => {
|
||||
<div class="control">
|
||||
<input
|
||||
class="custom-input"
|
||||
:data-testid="props.inputTestId || undefined"
|
||||
:class="{ 'is-searched': hasFocus }"
|
||||
type="text"
|
||||
v-model="modelValue"
|
||||
@@ -214,4 +219,4 @@ const onInput = (value: string) => {
|
||||
flex-grow: 0;
|
||||
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+222
-53
@@ -1,19 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import PosDepartmentStepMobile1RegistrationNumber1
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber1.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumber2
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber2.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumber3
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber3.vue";
|
||||
import { activeVehicleIndex, setActiveVehicleIndex, vehicles, popups, metadata, reference } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import PosDepartmentStepMobileButtonClearAll
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
|
||||
import PosDepartmentStepMobileReference
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileReference.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumber1 from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber1.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumber2 from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber2.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumber3 from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber3.vue";
|
||||
import {
|
||||
activeVehicleIndex,
|
||||
setActiveVehicleIndex,
|
||||
vehicles,
|
||||
popups,
|
||||
metadata,
|
||||
reference,
|
||||
} from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
|
||||
import LongPressListener from "@/components/viewport/elements/wrappers/LongPressListener.vue";
|
||||
import {ref, watch} from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { getNotes } from "@/components/shop/CustomerNotes.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
import { customerRequiresReferenceNumber, isBlankPosMetadataValue } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
const isActive = (number: number): boolean => {
|
||||
return activeVehicleIndex.value === number;
|
||||
};
|
||||
@@ -26,15 +28,16 @@ const setActive = (number: number): void => {
|
||||
setActiveVehicleIndex(number);
|
||||
};
|
||||
|
||||
|
||||
const onLongPress = (number: number) => {
|
||||
console.warn('Long press detected in parent for number', number);
|
||||
console.warn("Long press detected in parent for number", number);
|
||||
// Show the select vehicle popup
|
||||
popups.select('select_vehicle', { props: { vehicleNumber: number } });
|
||||
|
||||
}
|
||||
popups.select("select_vehicle", { props: { vehicleNumber: number } });
|
||||
};
|
||||
|
||||
const showNewDemo = ref(true);
|
||||
const registrationRowElement = ref<HTMLElement | null>(null);
|
||||
const referenceWidth = ref(174);
|
||||
let registrationRowResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// Get the customers notes (if any)
|
||||
const customerNotes = ref([]); // TODO: define type
|
||||
@@ -44,73 +47,174 @@ const getCustomerNotes = async () => {
|
||||
customerNotes.value = response.data.data;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching customer notes:', error);
|
||||
console.error("Error fetching customer notes:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for changes to the customer number, and fetch the notes
|
||||
watch(() => metadata.getCustomerId(), (newCustomerId) => {
|
||||
if (newCustomerId) {
|
||||
getCustomerNotes();
|
||||
} else {
|
||||
customerNotes.value = [];
|
||||
}
|
||||
}, { immediate: true });
|
||||
watch(
|
||||
() => metadata.getCustomerId(),
|
||||
(newCustomerId) => {
|
||||
if (newCustomerId) {
|
||||
getCustomerNotes();
|
||||
} else {
|
||||
customerNotes.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const updateReferenceWidth = () => {
|
||||
const rowWidth = registrationRowElement.value?.getBoundingClientRect().width ?? 0;
|
||||
referenceWidth.value = Math.max(174, Math.round(rowWidth));
|
||||
};
|
||||
const showReferenceWarning = computed(() => {
|
||||
return customerRequiresReferenceNumber() && isBlankPosMetadataValue(reference.value);
|
||||
});
|
||||
|
||||
const observeRegistrationRow = () => {
|
||||
registrationRowResizeObserver?.disconnect();
|
||||
registrationRowResizeObserver = null;
|
||||
|
||||
if (!registrationRowElement.value || typeof ResizeObserver === "undefined") {
|
||||
updateReferenceWidth();
|
||||
return;
|
||||
}
|
||||
|
||||
registrationRowResizeObserver = new ResizeObserver(() => {
|
||||
updateReferenceWidth();
|
||||
});
|
||||
registrationRowResizeObserver.observe(registrationRowElement.value);
|
||||
updateReferenceWidth();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
observeRegistrationRow();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
registrationRowResizeObserver?.disconnect();
|
||||
});
|
||||
|
||||
watch(registrationRowElement, async () => {
|
||||
await nextTick();
|
||||
observeRegistrationRow();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [vehicles.get(1)?.reg?.length ?? 0, vehicles.get(2)?.reg?.length ?? 0, vehicles.get(3)?.reg?.length ?? 0],
|
||||
async () => {
|
||||
await nextTick();
|
||||
updateReferenceWidth();
|
||||
},
|
||||
{ flush: "post" }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="max-height: 25vh; overflow-y: auto; padding-bottom: 10px;">
|
||||
<div class="registration-number-list">
|
||||
<template v-if="!showNewDemo">
|
||||
<LongPressListener @long-press="onLongPress(1)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber1 :active="isActive(1)" @click="setActive(1)"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber1 :active="isActive(1)" @click="setActive(1)" />
|
||||
</LongPressListener>
|
||||
<LongPressListener @long-press="onLongPress(2)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber2 :active="isActive(2)" @click="setActive(2)"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber2 :active="isActive(2)" @click="setActive(2)" />
|
||||
</LongPressListener>
|
||||
<LongPressListener @long-press="onLongPress(3)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber3 :active="isActive(3)" @click="setActive(3)"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber3 :active="isActive(3)" @click="setActive(3)" />
|
||||
</LongPressListener>
|
||||
<PosDepartmentStepMobileButtonClearAll/>
|
||||
<PosDepartmentStepMobileButtonClearAll />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<div class="columns is-mobile is-vcentered is-centered is-gapless">
|
||||
<div class="column is-narrow mr-1">
|
||||
<div class="registration-summary">
|
||||
<div ref="registrationRowElement" class="registration-row" data-testid="pos-mobile-step-1-registration-row">
|
||||
<div class="registration-slot">
|
||||
<LongPressListener @long-press="onLongPress(1)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber1 :active="isActive(1)" @click="setActive(1)" :showLabel="false" :isWider="!vehicles.get(1)?.reg?.length && !vehicles.get(2)?.reg?.length && !vehicles.get(3)?.reg?.length"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber1
|
||||
:active="isActive(1)"
|
||||
@click="setActive(1)"
|
||||
:showLabel="false"
|
||||
:isWider="
|
||||
!vehicles.get(1)?.reg?.length && !vehicles.get(2)?.reg?.length && !vehicles.get(3)?.reg?.length
|
||||
"
|
||||
/>
|
||||
</LongPressListener>
|
||||
</div>
|
||||
<div class="column is-narrow ml-1" v-if="vehicles.get(1)?.reg?.length || vehicles.get(2)?.reg?.length">
|
||||
<div class="registration-slot" v-if="vehicles.get(1)?.reg?.length || vehicles.get(2)?.reg?.length">
|
||||
<LongPressListener @long-press="onLongPress(2)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber2 :active="isActive(2)" @click="setActive(2)" :showLabel="false" :class="{'opacity-invisible': !vehicles.get(1)?.reg?.length}"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber2
|
||||
:active="isActive(2)"
|
||||
@click="setActive(2)"
|
||||
:showLabel="false"
|
||||
:class="{ 'opacity-invisible': !vehicles.get(1)?.reg?.length }"
|
||||
/>
|
||||
</LongPressListener>
|
||||
</div>
|
||||
<div class="column is-narrow ml-1" v-if="vehicles.get(3)?.reg?.length">
|
||||
<div class="registration-slot" v-if="vehicles.get(3)?.reg?.length">
|
||||
<LongPressListener @long-press="onLongPress(3)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber3 :active="isActive(3)" @click="setActive(3)" :showLabel="false" :class="{'opacity-invisible': !vehicles.get(2)?.reg?.length}"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber3
|
||||
:active="isActive(3)"
|
||||
@click="setActive(3)"
|
||||
:showLabel="false"
|
||||
:class="{ 'opacity-invisible': !vehicles.get(2)?.reg?.length }"
|
||||
/>
|
||||
</LongPressListener>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reference -->
|
||||
<div
|
||||
class="mt-2 mb-2 has-text-centered custom-button-secondary reference-trigger"
|
||||
:class="{
|
||||
'opacity-invisible': !vehicles.get(1)?.reg?.length,
|
||||
'reference-trigger--danger': showReferenceWarning,
|
||||
}"
|
||||
:style="{ width: `${referenceWidth}px` }"
|
||||
data-testid="pos-mobile-step-1-reference-trigger"
|
||||
:data-warning-state="showReferenceWarning ? 'danger' : undefined"
|
||||
@click="popups.select('change_reference')"
|
||||
>
|
||||
<span class="reference-trigger__content">
|
||||
<span
|
||||
v-if="showReferenceWarning"
|
||||
class="reference-trigger__icon"
|
||||
data-testid="pos-mobile-step-1-reference-warning-icon"
|
||||
>
|
||||
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span
|
||||
v-if="reference.length > 0"
|
||||
class="reference-trigger__text has-overflow-ellipsis has-text-weight-bold mx-3"
|
||||
:class="{ 'has-text-white': !showReferenceWarning }"
|
||||
>{{ reference }}</span
|
||||
>
|
||||
<span v-else class="reference-trigger__text is-italic">Indtast reference...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reference -->
|
||||
<div class="mt-2 mb-2 has-text-centered custom-button-secondary" style="min-width: 174px;" :class="{'opacity-invisible': !vehicles.get(1)?.reg?.length}" @click="popups.select('change_reference')">
|
||||
<p v-if="reference.length > 0"
|
||||
class="has-overflow-ellipsis has-text-weight-bold has-text-white mx-3"
|
||||
style="max-height: 20px; max-width: 90vw;"
|
||||
>{{ reference }}</p>
|
||||
<p v-else>
|
||||
<span class="is-italic">Indtast reference...</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Reference -->
|
||||
<div class="mt-2 mb-2 has-text-centered custom-button-secondary px-2" style="min-width: 174px;" :class="{'opacity-invisible': customerNotes.length === 0, 'has-background-warning': customerNotes.length > 0}" @click="popups.select('customer_notes')" v-if="customerNotes.length > 0">
|
||||
<div
|
||||
class="mt-2 mb-2 has-text-centered custom-button-secondary px-2 notes-trigger"
|
||||
:class="{ 'opacity-invisible': customerNotes.length === 0, 'has-background-warning': customerNotes.length > 0 }"
|
||||
:style="{ width: `${referenceWidth}px` }"
|
||||
@click="popups.select('customer_notes')"
|
||||
v-if="customerNotes.length > 0"
|
||||
data-testid="pos-mobile-customer-notes-trigger"
|
||||
>
|
||||
<p>
|
||||
<!-- If there are notes, show a warning icon -->
|
||||
<span v-if="customerNotes.length > 0" class="icon is-small has-text-dark pr-1">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</span>
|
||||
<span class="has-overflow-ellipsis" :class="{'has-text-dark': customerNotes.length > 0, 'has-text-weight-bold': customerNotes.length > 0, 'is-italic': customerNotes.length === 0}">{{ SessionUser.objects.global.language.customer_notes}}</span>
|
||||
<span
|
||||
class="has-overflow-ellipsis"
|
||||
:class="{
|
||||
'has-text-dark': customerNotes.length > 0,
|
||||
'has-text-weight-bold': customerNotes.length > 0,
|
||||
'is-italic': customerNotes.length === 0,
|
||||
}"
|
||||
>{{ SessionUser.objects.global.language.customer_notes }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -118,8 +222,33 @@ watch(() => metadata.getCustomerId(), (newCustomerId) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.registration-number-list {
|
||||
max-height: 25vh;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.registration-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.registration-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.registration-slot {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.is-wider {
|
||||
width: 200px!important;
|
||||
width: 200px !important;
|
||||
}
|
||||
.opacity-invisible {
|
||||
opacity: 0.3;
|
||||
@@ -153,4 +282,44 @@ watch(() => metadata.getCustomerId(), (newCustomerId) => {
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
.reference-trigger,
|
||||
.notes-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 174px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.reference-trigger--danger {
|
||||
background: #fff0f0;
|
||||
border-color: #dc9f9f;
|
||||
color: #8f2c2c;
|
||||
}
|
||||
|
||||
.reference-trigger__content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-inline: 0.45rem;
|
||||
}
|
||||
|
||||
.reference-trigger__text {
|
||||
display: inline-block;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.reference-trigger__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
+39
-15
@@ -169,7 +169,7 @@ const onClickAddProduct = async (product: PosProduct) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div data-testid="pos-mobile-additional-items">
|
||||
<!-- Minimal view, when not set as fullscreen view -->
|
||||
<WhiteBoxCard :toggleable="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length == 0"
|
||||
:defaultOpen="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0"
|
||||
@@ -178,13 +178,15 @@ const onClickAddProduct = async (product: PosProduct) => {
|
||||
v-if="!pos.views.additionalItemSelection.value">
|
||||
<!-- Header -->
|
||||
<template #header>
|
||||
<div class="card-header-title">{{ displayLabel }}</div>
|
||||
<div class="card-header-icon">
|
||||
<!-- Right arrow, if there's no items, down arrow if there are items -->
|
||||
<span class="icon">
|
||||
<i v-if="!pos.transactionItems.additionalItems.value || pos.transactionItems.additionalItems.value.length === 0" class="fas fa-angle-right"></i>
|
||||
<i v-else class="fas fa-angle-down"></i>
|
||||
</span>
|
||||
<div class="pos-mobile-additional-items-header" data-testid="pos-mobile-additional-items-open">
|
||||
<div class="card-header-title pos-mobile-additional-items-header__title" data-testid="pos-mobile-additional-items-header-title">{{ displayLabel }}</div>
|
||||
<div class="card-header-icon pos-mobile-additional-items-header__icon" data-testid="pos-mobile-additional-items-header-icon">
|
||||
<!-- Right arrow, if there's no items, down arrow if there are items -->
|
||||
<span class="icon">
|
||||
<i v-if="!pos.transactionItems.additionalItems.value || pos.transactionItems.additionalItems.value.length === 0" class="fas fa-angle-right"></i>
|
||||
<i v-else class="fas fa-angle-down"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Content -->
|
||||
@@ -201,7 +203,7 @@ const onClickAddProduct = async (product: PosProduct) => {
|
||||
<!-- Footer -->
|
||||
<template #footer>
|
||||
<!-- Select other product button -->
|
||||
<a class="card-footer-item" @click="onClickAddOtherProduct">
|
||||
<a class="card-footer-item" data-testid="pos-mobile-additional-items-open" @click="onClickAddOtherProduct">
|
||||
<span class="icon">
|
||||
<i class="fas fa-plus"></i>
|
||||
</span>
|
||||
@@ -211,23 +213,45 @@ const onClickAddProduct = async (product: PosProduct) => {
|
||||
</WhiteBoxCard>
|
||||
<!-- Fullscreen view, when selecting other products -->
|
||||
<template v-else>
|
||||
<div data-testid="pos-mobile-additional-items-selection">
|
||||
<!-- Categories of products -->
|
||||
<PosDepartmentStep2MobileVehicleSelection :onAddProduct="onClickAddProduct" :onSearchClick="() => console.warn('AdditionalItem Search Clicked')"/><!-- :asAddons="true" :addons="availableAdditionalItems" @update:addons="availableAdditionalItems = $event"/>-->
|
||||
<!-- Buttons -->
|
||||
<PosDepartmentStepMobileFixedBottomControl>
|
||||
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
|
||||
<!-- Floating Card -->
|
||||
<PosDepartmentStepMobile2FloatingCart/>
|
||||
<!-- Next button -->
|
||||
<PosDepartmentStepMobileButtonNextStep :isDark="true" :customAction="onClickAddOtherProduct">
|
||||
<span class="is-float-left has-text-white">{{ SessionUser.objects.global.language.next }}</span>
|
||||
<span class="is-float-right has-text-white">
|
||||
<!-- Arrow right icon -->
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
<span class="pos-mobile-cta-content">
|
||||
<span class="pos-mobile-cta-label has-text-white">{{ SessionUser.objects.global.language.next }}</span>
|
||||
<span class="pos-mobile-cta-value has-text-white">
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</span>
|
||||
</span>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
</PosDepartmentStepMobileFixedBottomControl>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
</style>
|
||||
.pos-mobile-additional-items-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-mobile-additional-items-header__title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pos-mobile-additional-items-header__icon {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
+3
-1
@@ -116,11 +116,13 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
|
||||
:price="addon.price"
|
||||
:label="getLabel(addon)"
|
||||
:piktogram="addon.product.piktogram"
|
||||
:testId="`pos-mobile-addon-${addon.product.id}`"
|
||||
:showPrices="props.showPrices"
|
||||
@addProduct="onAddProduct(addon)"
|
||||
:customButton="addon.quantity > 0">
|
||||
<span class="custom-select-quantity-container">
|
||||
<ControlSelectAmount
|
||||
:testIdPrefix="`pos-mobile-addon-${addon.product.id}`"
|
||||
@update:quantity="addon.quantity = $event; emit('update:addons', [...props.addons])"
|
||||
v-model:quantity="addon.quantity"
|
||||
:max="addon.max"
|
||||
@@ -262,4 +264,4 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
|
||||
flex-grow: 0;
|
||||
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+2
-2
@@ -53,7 +53,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="columns is-mobile is-multiline is-gapless">
|
||||
<div class="column is-narrow" v-for="category in pos.categories.get()" :key="category.id">
|
||||
<div class="column is-narrow" v-for="category in pos.categories.get()" :key="category.id" :data-testid="`pos-mobile-category-${category.id}`">
|
||||
<GenericTag
|
||||
:active="pos.categories.isCategorySelected(category)"
|
||||
@click="pos.categories.select(category)"
|
||||
@@ -68,4 +68,4 @@ onMounted(() => {
|
||||
.custom-gap {
|
||||
margin: 6px 5px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+6
-2
@@ -33,6 +33,10 @@ const props = defineProps({
|
||||
showPrices: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
testId: {
|
||||
type: String,
|
||||
default: '',
|
||||
}
|
||||
});
|
||||
|
||||
@@ -72,7 +76,7 @@ const imageStyleCompact = {
|
||||
:style="props.compact ? imageStyleCompact : imageSize"
|
||||
style="border-radius: 8px; object-fit: contain;" />
|
||||
</div>
|
||||
<div class="column" @click="onClick" style="cursor: pointer;">
|
||||
<div class="column" @click="onClick" style="cursor: pointer;" :data-testid="props.testId || undefined">
|
||||
<template v-if="!props.compact">
|
||||
<h1 class="title is-6">{{ props.label }}</h1>
|
||||
<h2 class="subtitle is-6" v-if="props.showPrices">{{ SessionUser.functions.currency.toLocal(props.price) }}</h2>
|
||||
@@ -152,4 +156,4 @@ const imageStyleCompact = {
|
||||
.has-quantity .column .subtitle {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+2
-2
@@ -53,7 +53,7 @@ const displaySubtitle = computed(() => {
|
||||
|
||||
<template>
|
||||
<!-- Box -->
|
||||
<div class="box" style="width: inherit; height: inherit;">
|
||||
<div class="box" style="width: inherit; height: inherit;" data-testid="pos-mobile-last-order">
|
||||
<!-- Last order date & radio button -->
|
||||
<div class="columns is-mobile is-vcentered is-multiline is-gapless">
|
||||
<!-- Last order date -->
|
||||
@@ -176,4 +176,4 @@ const displaySubtitle = computed(() => {
|
||||
|
||||
color: #000000;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ const props = defineProps({
|
||||
|
||||
<template>
|
||||
<!-- Box -->
|
||||
<div class="box" style="border: 3px solid rgb(0,45,255);">
|
||||
<div class="box" data-testid="pos-mobile-primary-product-card" style="border: 3px solid rgb(0,45,255);">
|
||||
<!-- Product image -->
|
||||
<div class="has-text-centered">
|
||||
<img :src="getPicture(props?.product?.piktogram || props?.product?.id || '')" :alt="props?.product?.name || 'N/A'" class="product-image">
|
||||
@@ -145,4 +145,4 @@ const props = defineProps({
|
||||
order: 1;
|
||||
flex-grow: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+2
-4
@@ -34,7 +34,6 @@ const slots = defineSlots();
|
||||
const fetchProducts = async (category: PosCategory, departmentId: number) => {
|
||||
await SessionUser.objects.products.get.category(category.id, departmentId, (customer_id.value ? parseInt(customer_id.value) : null), true)
|
||||
.then((response) => {
|
||||
console.log("Products fetched for category:", category.name, response);
|
||||
pos.productList.clear();
|
||||
orderProducts(response).forEach((result: PosProduct) => {
|
||||
pos.productList.add(result);
|
||||
@@ -57,14 +56,12 @@ const fetchSuggestedProducts = async () => {
|
||||
reg_3: pos.vehicles.vehicle_3?.value?.reg || null,
|
||||
}
|
||||
).then((response) => {
|
||||
console.log("Suggested products fetched:", response);
|
||||
});
|
||||
};
|
||||
|
||||
// Watch for changes in the selected category
|
||||
watch(() => pos.categories.selection(), (newCategory) => {
|
||||
if (newCategory) {
|
||||
console.log("Selected category changed:", newCategory);
|
||||
// If the category id is -1, get the suggested products
|
||||
if (newCategory.id === -1) {
|
||||
fetchSuggestedProducts();
|
||||
@@ -121,6 +118,7 @@ const filteredProducts = computed(() => {
|
||||
:price="product.price"
|
||||
:label="product.name"
|
||||
:piktogram="product.piktogram"
|
||||
:testId="`pos-mobile-product-${product.id}`"
|
||||
>
|
||||
<template #right>
|
||||
<slot name="right" :product="product"/>
|
||||
@@ -136,4 +134,4 @@ const filteredProducts = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+166
-40
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import { defineProps, defineEmits, onBeforeUnmount, watch } from 'vue';
|
||||
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -21,6 +21,30 @@ type attachment = {
|
||||
updated_at: string | null;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
const getAttachmentContent = (attachmentEntry: attachment) => {
|
||||
if (!attachmentEntry.content) {
|
||||
return {
|
||||
image: null,
|
||||
document: null,
|
||||
relation: null,
|
||||
other: null,
|
||||
src: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
image: attachmentEntry.content.image ?? null,
|
||||
document: attachmentEntry.content.document ?? null,
|
||||
relation: attachmentEntry.content.relation ?? null,
|
||||
other: attachmentEntry.content.other ?? null,
|
||||
src: attachmentEntry.content.src ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const getAttachmentOtherText = (attachmentEntry: attachment) => {
|
||||
return getAttachmentContent(attachmentEntry).other || '';
|
||||
};
|
||||
const props = defineProps({
|
||||
attachments: {
|
||||
type: Array as () => attachment[],
|
||||
@@ -29,12 +53,17 @@ const props = defineProps({
|
||||
getPreviewLink: {
|
||||
type: Function as () => (id: number) => Promise<string>,
|
||||
required: true
|
||||
},
|
||||
showAttachWashCertificateAction: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
const emits = defineEmits<{
|
||||
(e: 'delete', id: number): void;
|
||||
(e: 'upload', payload: { filename: string; base64String: string | ArrayBuffer | null }): void;
|
||||
(e: 'download', id: number): void;
|
||||
(e: 'attach-wash-certificate'): void;
|
||||
}>();
|
||||
|
||||
const onClickDelete = (id: number) => {
|
||||
@@ -42,10 +71,11 @@ const onClickDelete = (id: number) => {
|
||||
}
|
||||
|
||||
const determineAttachmentType = (attachment: attachment): 'image' | 'document' | 'relation' | 'other' | 'unknown' => {
|
||||
if (attachment.content.image) return 'image';
|
||||
if (attachment.content.document) return 'document';
|
||||
if (attachment.content.relation) return 'relation';
|
||||
if (attachment.content.other) return 'other';
|
||||
const content = getAttachmentContent(attachment);
|
||||
if (content.image) return 'image';
|
||||
if (content.document) return 'document';
|
||||
if (content.relation) return 'relation';
|
||||
if (content.other) return 'other';
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
@@ -68,6 +98,99 @@ const getAttachmentTypeIcon = (attachment: attachment): string => {
|
||||
const isAttachmentTypeIn = (types: ('image' | 'document' | 'relation' | 'other' | 'unknown')[]): boolean => {
|
||||
return props.attachments.some(att => types.includes(determineAttachmentType(att)));
|
||||
};
|
||||
const isOfficeDocument = (attachmentEntry: attachment): boolean => {
|
||||
const otherText = getAttachmentOtherText(attachmentEntry).toLowerCase();
|
||||
return ['.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx'].some(extension => otherText.endsWith(extension));
|
||||
};
|
||||
const previewRequestsInFlight = new Set<number>();
|
||||
const generatedObjectUrls = new Set<string>();
|
||||
|
||||
const isObjectUrl = (url: string | null): boolean => Boolean(url && url.startsWith('blob:'));
|
||||
|
||||
const releaseObjectUrl = (url: string | null): void => {
|
||||
if (!isObjectUrl(url)) {
|
||||
return;
|
||||
}
|
||||
URL.revokeObjectURL(url as string);
|
||||
generatedObjectUrls.delete(url as string);
|
||||
};
|
||||
|
||||
const shouldLoadDocumentPreview = (attachmentEntry: attachment): boolean => {
|
||||
const content = getAttachmentContent(attachmentEntry);
|
||||
return determineAttachmentType(attachmentEntry) === 'document'
|
||||
&& Boolean(content.document)
|
||||
&& !isOfficeDocument(attachmentEntry)
|
||||
&& !content.src;
|
||||
};
|
||||
|
||||
const createEmbeddablePreviewUrl = async (downloadLink: string | null): Promise<string | null> => {
|
||||
if (!downloadLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(downloadLink, { method: 'GET' });
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileBlob = await response.blob();
|
||||
if (!fileBlob || fileBlob.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(fileBlob);
|
||||
generatedObjectUrls.add(objectUrl);
|
||||
return objectUrl;
|
||||
} catch (error) {
|
||||
console.warn('Unable to create embeddable preview blob', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadPreviewLink = async (id: number): Promise<void> => {
|
||||
if (previewRequestsInFlight.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewRequestsInFlight.add(id);
|
||||
try {
|
||||
const downloadLink = await props.getPreviewLink(id);
|
||||
const src = await createEmbeddablePreviewUrl(downloadLink);
|
||||
const attachment = props.attachments.find(att => att.id === id);
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = getAttachmentContent(attachment);
|
||||
if (content.src && content.src !== src) {
|
||||
releaseObjectUrl(content.src);
|
||||
}
|
||||
content.src = src || null;
|
||||
attachment.content = content;
|
||||
} catch (error) {
|
||||
console.warn('Error loading preview link for attachment', id, error);
|
||||
} finally {
|
||||
previewRequestsInFlight.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.attachments.map((attachment) => `${attachment.id}:${getAttachmentContent(attachment).src ?? ''}:${getAttachmentOtherText(attachment)}`),
|
||||
() => {
|
||||
props.attachments.forEach((attachment) => {
|
||||
if (shouldLoadDocumentPreview(attachment)) {
|
||||
void loadPreviewLink(attachment.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
generatedObjectUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
generatedObjectUrls.clear();
|
||||
});
|
||||
|
||||
const uploadAttachment = (file: File) => {
|
||||
// Implement the upload logic here
|
||||
@@ -96,21 +219,10 @@ const onClickUpload = () => {
|
||||
fileInput.click();
|
||||
};
|
||||
|
||||
const getPreviewLink = async (id: number): Promise<boolean> => {
|
||||
const result = props.getPreviewLink(id);
|
||||
console.warn('Preview link for attachment', id, ':', result);
|
||||
// Store the preview link in the attachment object for later use
|
||||
const attachment = props.attachments.find(att => att.id === id);
|
||||
if (attachment) {
|
||||
attachment.content.src = await result;
|
||||
}
|
||||
return false; // Default to false if not found or no preview available (yet)
|
||||
const onClickAttachWashCertificate = () => {
|
||||
emits('attach-wash-certificate');
|
||||
};
|
||||
|
||||
const filterOutQuery = (url: string | null): string => {
|
||||
if (!url) return '';
|
||||
return url.split('?')[0];
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -118,7 +230,7 @@ const filterOutQuery = (url: string | null): string => {
|
||||
<div class="columns is-mobile is-multiline is-variable is-1">
|
||||
<!-- Add Attachment Button -->
|
||||
<div class="column is-12">
|
||||
<WhiteBoxCard :toggleable="true">
|
||||
<WhiteBoxCard :toggleable="true" data-testid="pos-order-attachments-add-card">
|
||||
<template #header>
|
||||
<div class="card-header-icon">
|
||||
<span class="icon">
|
||||
@@ -139,9 +251,21 @@ const filterOutQuery = (url: string | null): string => {
|
||||
<p>{{ t('admin.pos.attachments_upload_description') }}</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<a class="card-footer-item" @click="onClickUpload">
|
||||
<a
|
||||
class="card-footer-item"
|
||||
data-testid="pos-order-attachments-upload-action"
|
||||
@click="onClickUpload"
|
||||
>
|
||||
<!-- Footer actions can go here -->
|
||||
{{ t('upload') }}
|
||||
{{ t('admin.pos.attachments_upload_action') }}
|
||||
</a>
|
||||
<a
|
||||
v-if="props.showAttachWashCertificateAction"
|
||||
class="card-footer-item"
|
||||
data-testid="pos-order-attachments-attach-wash-certificate"
|
||||
@click="onClickAttachWashCertificate"
|
||||
>
|
||||
{{ t('admin.pos.settings_wheel.attach_wash_certificate') }}
|
||||
</a>
|
||||
</template>
|
||||
</WhiteBoxCard>
|
||||
@@ -149,7 +273,7 @@ const filterOutQuery = (url: string | null): string => {
|
||||
<!-- Existing Attachments -->
|
||||
<template v-for="attachment in props.attachments" :key="attachment.id">
|
||||
<div class="column is-12">
|
||||
<WhiteBoxCard :toggleable="true">
|
||||
<WhiteBoxCard :toggleable="true" :data-testid="`pos-order-attachment-card-${attachment.id}`">
|
||||
<template #header>
|
||||
<div class="card-header-icon">
|
||||
<span class="icon">
|
||||
@@ -171,32 +295,28 @@ const filterOutQuery = (url: string | null): string => {
|
||||
<template #content>
|
||||
<!--{{ attachment }}-->
|
||||
<!-- DOCUMENT PREVIEW -->
|
||||
<template v-if="determineAttachmentType(attachment) === 'document' && attachment.content.document">
|
||||
<template v-if="determineAttachmentType(attachment) === 'document' && getAttachmentContent(attachment).document">
|
||||
<!-- If the file extension is office document, you can use an online viewer -->
|
||||
<template v-if="attachment.content.other.endsWith('.doc') || attachment.content.other.endsWith('.docx') || attachment.content.other.endsWith('.xls') || attachment.content.other.endsWith('.xlsx') || attachment.content.other.endsWith('.ppt') || attachment.content.other.endsWith('.pptx')">
|
||||
<!--<iframe
|
||||
v-if="getPreviewLink(attachment.id)"
|
||||
:src="`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(filterOutQuery(attachment.content.src))}`"
|
||||
style="width: 100%; height: 400px; border: none;"
|
||||
></iframe>-->
|
||||
<span>{{ t('admin.pos.attachments_office_preview_unavailable') }} <a @click="$emit('download', attachment.id)">{{ t('admin.pos.download') }}</a></span>
|
||||
<template v-if="isOfficeDocument(attachment)">
|
||||
<span>{{ t('admin.pos.attachments_office_preview_unavailable') }} <a @click="$emit('download', attachment.id)">{{ t('admin.pos.attachments_download_action') }}</a></span>
|
||||
</template>
|
||||
<iframe
|
||||
v-else-if="getPreviewLink(attachment.id)"
|
||||
:src="attachment.content.src"
|
||||
v-else-if="getAttachmentContent(attachment).src"
|
||||
:src="getAttachmentContent(attachment).src || ''"
|
||||
style="width: 100%; height: 400px; border: none;"
|
||||
></iframe>
|
||||
<span v-else>{{ t('admin.pos.attachments_no_preview') }}</span>
|
||||
</template>
|
||||
<!-- OTHER PREVIEW -->
|
||||
<template v-else-if="determineAttachmentType(attachment) === 'other' && attachment.content.other">
|
||||
<template v-else-if="determineAttachmentType(attachment) === 'other' && getAttachmentContent(attachment).other">
|
||||
<!-- If the other type is a URL, you can create a link -->
|
||||
<template v-if="attachment.content.other.startsWith('http')">
|
||||
<a :href="attachment.content.other" target="_blank" rel="noopener noreferrer">
|
||||
{{ attachment.content.other }}
|
||||
<template v-if="getAttachmentContent(attachment).other.startsWith('http')">
|
||||
<a :href="getAttachmentContent(attachment).other" target="_blank" rel="noopener noreferrer">
|
||||
{{ getAttachmentContent(attachment).other }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ attachment.content.other }}</span>
|
||||
<span>{{ getAttachmentContent(attachment).other }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<!-- NO PREVIEW -->
|
||||
@@ -213,8 +333,13 @@ const filterOutQuery = (url: string | null): string => {
|
||||
{{ t('admin.pos.delete') }}
|
||||
</a>
|
||||
<!-- Download, if applicable -->
|
||||
<a class="card-footer-item" v-if="isAttachmentTypeIn(['image', 'document']) && (attachment.content.image || attachment.content.document)" @click="$emit('download', attachment.id)">
|
||||
{{ t('admin.pos.download') }}
|
||||
<a
|
||||
class="card-footer-item"
|
||||
:data-testid="`pos-order-attachment-download-${attachment.id}`"
|
||||
v-if="isAttachmentTypeIn(['image', 'document']) && (getAttachmentContent(attachment).image || getAttachmentContent(attachment).document)"
|
||||
@click="$emit('download', attachment.id)"
|
||||
>
|
||||
{{ t('admin.pos.attachments_download_action') }}
|
||||
</a>
|
||||
</template>
|
||||
</WhiteBoxCard>
|
||||
@@ -226,4 +351,5 @@ const filterOutQuery = (url: string | null): string => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
|
||||
+10
-10
@@ -199,7 +199,7 @@ const isAttachmentsVisible = () => {
|
||||
<div>
|
||||
<div class="columns is-mobile is-vcentered">
|
||||
<!-- Clear All Button -->
|
||||
<div class="column is-narrow" v-if="isDefaultControlsVisible() && props.showClearAllButton" @click="onClickClearAllButton" style="cursor: pointer;">
|
||||
<div class="column is-narrow" data-testid="pos-mobile-clear-all" v-if="isDefaultControlsVisible() && props.showClearAllButton" @click="onClickClearAllButton" style="cursor: pointer;">
|
||||
<div class="columns is-mobile is-vcentered">
|
||||
<div class="column is-narrow pr-1">
|
||||
<span class="icon is-small">
|
||||
@@ -214,7 +214,7 @@ const isAttachmentsVisible = () => {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Attachment Icon and Label -->
|
||||
<div class="column is-narrow" v-if="isDefaultControlsVisible() && showAttachButton" @click="onClickAttachmentButton" style="cursor: pointer;">
|
||||
<div class="column is-narrow" data-testid="pos-mobile-attachments-toggle" v-if="isDefaultControlsVisible() && showAttachButton" @click="onClickAttachmentButton" style="cursor: pointer;">
|
||||
<div>
|
||||
<!-- Count Badge -->
|
||||
<div class="is-pulled-right" v-if="attachments.count() > 0">
|
||||
@@ -239,7 +239,7 @@ const isAttachmentsVisible = () => {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reference Display -->
|
||||
<div class="column is-narrow" v-if="isDefaultControlsVisible() && props.showReferenceButton" @click="onClickReferenceButton" style="cursor: pointer;">
|
||||
<div class="column is-narrow" data-testid="pos-mobile-change-reference" v-if="isDefaultControlsVisible() && props.showReferenceButton" @click="onClickReferenceButton" style="cursor: pointer;">
|
||||
<div>
|
||||
<!-- Invisible badge to keep alignment -->
|
||||
<div class="is-pulled-right" v-if="true" style="visibility: hidden;">
|
||||
@@ -265,7 +265,7 @@ const isAttachmentsVisible = () => {
|
||||
</div>
|
||||
</div>
|
||||
<!-- History Icon and Label -->
|
||||
<div class="column is-narrow" v-if="isDefaultControlsVisible() && props.showHistoryButton" @click="onClickTransactionHistoryButton" style="cursor: pointer;">
|
||||
<div class="column is-narrow" data-testid="pos-mobile-transaction-history" v-if="isDefaultControlsVisible() && props.showHistoryButton" @click="onClickTransactionHistoryButton" style="cursor: pointer;">
|
||||
<div>
|
||||
<!-- Count Badge -->
|
||||
<div class="is-pulled-right" v-if="transactionHistory.getPending().length > 0">
|
||||
@@ -290,7 +290,7 @@ const isAttachmentsVisible = () => {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Zoom -->
|
||||
<div class="column is-narrow" v-if="isDefaultControlsVisible() && props.showZoomButton" @click="camera.setZoom(camera.getZoom() === 1 ? 2 : 1)" style="cursor: pointer;">
|
||||
<div class="column is-narrow" data-testid="pos-mobile-camera-zoom" v-if="isDefaultControlsVisible() && props.showZoomButton" @click="camera.setZoom(camera.getZoom() === 1 ? 2 : 1)" style="cursor: pointer;">
|
||||
<div class="columns is-mobile is-vcentered">
|
||||
<div class="column is-narrow pr-1">
|
||||
<span class="icon is-small">
|
||||
@@ -307,7 +307,7 @@ const isAttachmentsVisible = () => {
|
||||
</div>
|
||||
<!-- Attachment Selector Options -->
|
||||
<!-- Back button -->
|
||||
<div class="column is-narrow" v-if="isAttachmentSelectorVisible && props.showBackButton" @click="onClickAttachmentButton" style="cursor: pointer;">
|
||||
<div class="column is-narrow" data-testid="pos-mobile-attachments-back" v-if="isAttachmentSelectorVisible && props.showBackButton" @click="onClickAttachmentButton" style="cursor: pointer;">
|
||||
<div class="columns is-mobile is-vcentered">
|
||||
<div class="column is-narrow pr-1">
|
||||
<span class="icon is-small">
|
||||
@@ -322,7 +322,7 @@ const isAttachmentsVisible = () => {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Take picture -->
|
||||
<div class="column is-narrow" v-if="isAttachmentSelectorVisible && props.showTakePictureButton" @click="onClickAttachPicture" style="cursor: pointer;">
|
||||
<div class="column is-narrow" data-testid="pos-mobile-attachments-take-picture" v-if="isAttachmentSelectorVisible && props.showTakePictureButton" @click="onClickAttachPicture" style="cursor: pointer;">
|
||||
<div class="columns is-mobile is-vcentered">
|
||||
<div class="column is-narrow pr-1">
|
||||
<span class="icon is-small">
|
||||
@@ -337,7 +337,7 @@ const isAttachmentsVisible = () => {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload from device -->
|
||||
<div class="column is-narrow" v-if="isAttachmentSelectorVisible && props.showUploadFileButton" @click="onClickAttachFile" style="cursor: pointer;">
|
||||
<div class="column is-narrow" data-testid="pos-mobile-attachments-upload-file" v-if="isAttachmentSelectorVisible && props.showUploadFileButton" @click="onClickAttachFile" style="cursor: pointer;">
|
||||
<div class="columns is-mobile is-vcentered">
|
||||
<div class="column is-narrow pr-1">
|
||||
<span class="icon is-small">
|
||||
@@ -352,7 +352,7 @@ const isAttachmentsVisible = () => {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add wash certificate -->
|
||||
<div class="column is-narrow" v-if="isAttachmentSelectorVisible" @click="onClickAttachmentWashCertificate" style="cursor: pointer;">
|
||||
<div class="column is-narrow" data-testid="pos-mobile-attachments-wash-certificate" v-if="isAttachmentSelectorVisible" @click="onClickAttachmentWashCertificate" style="cursor: pointer;">
|
||||
<div class="columns is-mobile is-vcentered">
|
||||
<div class="column is-narrow pr-1">
|
||||
<span class="icon is-small">
|
||||
@@ -387,4 +387,4 @@ const isAttachmentsVisible = () => {
|
||||
border-bottom: 1px solid #ddd;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+2
-1
@@ -28,6 +28,7 @@ const onClickClearAll = () => {
|
||||
|
||||
<template>
|
||||
<div class="button is-fullwidth is-white mt-2"
|
||||
data-testid="pos-mobile-clear-all-button"
|
||||
@click="onClickClearAll()">
|
||||
<div class="columns is-mobile is-vcentered has-text-centered">
|
||||
<!-- Clear all -->
|
||||
@@ -51,4 +52,4 @@ const onClickClearAll = () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+234
-138
@@ -1,18 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {defineProps, onMounted, computed, ref} from "vue";
|
||||
import { reset_all_values, customer_name, nextStep, searchAndSelectCustomer, isCustomerSelected, order_id, customer_id, step, reg_1, reg_2, reg_3, reference, order_notes, loadCustomerAttributes, hasAttribute, uploadAttachment } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { reset_all_values, customer_name, nextStep, searchAndSelectCustomer, isCustomerSelected, order_id, customer_id, step, reg_1, reg_2, reg_3, reference, order_notes, order_safety_seal, loadCustomerAttributes, hasAttribute, uploadAttachment, restoreStoredPosOrderId } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import * as POSDepartmentProcess from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { errors } from "@/components/request/HandleGlobalError.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
|
||||
import ContinueArrow from "@/components/viewport/elements/icons/ContinueArrow.vue";
|
||||
import { vehicles, metadata, getCustomerId, popups, resetPos, attachments, transactionHistory } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { vehicles, metadata, getCustomerId, popups, resetPos, attachments, transactionHistory, transactionItems } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { finalizeCurrentMobileOrder, getResolvedMobileSafetySeal, syncMobileSafetySealState } from "../objects/mobileOrderCompletion.js";
|
||||
import Swal from "sweetalert2";
|
||||
import LongPressListener from "@/components/viewport/elements/wrappers/LongPressListener.vue";
|
||||
import {views} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const selectCustomerString = `${SessionUser.objects.global.language.select} ${SessionUser.objects.global.language.customer.toLowerCase()}`;
|
||||
const isCreatingOrder = computed(() => POSDepartmentProcess.isCreatingOrder?.value ?? false);
|
||||
|
||||
const props = defineProps({
|
||||
// If true, the button will have a white background. Default is true.
|
||||
@@ -61,6 +64,11 @@ const updateNotes = () => {
|
||||
SessionUser.objects.orders.set.notes(order_id.value, order_notes.value);
|
||||
}
|
||||
|
||||
const updateSafetySeal = () => {
|
||||
if (!!order_id.value && order_id.value > 0)
|
||||
SessionUser.objects.orders.set.safety_seal(order_id.value, order_safety_seal.value);
|
||||
}
|
||||
|
||||
// Helpers: extracted for clarity and reuse
|
||||
const isNonEmptyString = (val) => typeof val === 'string' && val.trim().length > 0;
|
||||
|
||||
@@ -152,6 +160,7 @@ const step1 = () => {
|
||||
// Merge references and notes
|
||||
mergeReferences();
|
||||
mergeNotes();
|
||||
mergeSafetySeal();
|
||||
// We only need to check if the customer requires reference, if the field is empty.
|
||||
if (!isNonEmptyString(reference.value)) {
|
||||
validateReferenceRequirements().then((isValid) => {
|
||||
@@ -192,9 +201,16 @@ const mergeNotes = () => {
|
||||
updateNotes();
|
||||
}
|
||||
}
|
||||
const mergeSafetySeal = () => {
|
||||
const resolvedSafetySeal = getResolvedMobileSafetySeal();
|
||||
order_safety_seal.value = resolvedSafetySeal;
|
||||
syncMobileSafetySealState(resolvedSafetySeal);
|
||||
updateSafetySeal();
|
||||
}
|
||||
const finalizeStep1 = () => {
|
||||
mergeReferences();
|
||||
mergeNotes();
|
||||
mergeSafetySeal();
|
||||
// Upload the attachments if there are any (And the order id has been created)
|
||||
if (attachments.getBase64().length > 0 && order_id.value > 0) {
|
||||
for (const attachment of attachments.getBase64()) {
|
||||
@@ -224,114 +240,73 @@ const addTransactionToHistory = async (orderId: number) => {
|
||||
});
|
||||
};
|
||||
|
||||
const step2 = () => {
|
||||
/** This function can be used to perform any specific actions for step 2 */
|
||||
// Add all additional items to the order
|
||||
const toPositiveInteger = (value: any) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ''), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
}
|
||||
|
||||
const getSelectedBookingId = () => {
|
||||
return toPositiveInteger(metadata.getBookingId?.() ?? metadata.bookingId?.value ?? null);
|
||||
}
|
||||
|
||||
const completeStep2Order = async ({
|
||||
bookingSafetySeal = null,
|
||||
markOrderCompleted = false,
|
||||
}: {
|
||||
bookingSafetySeal?: string | null;
|
||||
markOrderCompleted?: boolean;
|
||||
} = {}) => {
|
||||
await finalizeCurrentMobileOrder({
|
||||
bookingSafetySeal,
|
||||
markOrderCompleted,
|
||||
});
|
||||
popups.select('completed_transaction', {
|
||||
message: `Order #${order_id.value} successfully created.`,
|
||||
});
|
||||
completeOrder();
|
||||
}
|
||||
|
||||
const step2 = async () => {
|
||||
/** This function can be used to perform any specific actions for step 2 */
|
||||
// If the customer is paying with a card, go to the payment step
|
||||
if (isCustomerSelected() && getCustomerId() === 999) {
|
||||
nextStep({isMobile: true});
|
||||
return;
|
||||
}
|
||||
if (metadata.getBookingId() && metadata.getBookingId() > 0) {
|
||||
// If there is a booking ID, complete the booking
|
||||
SessionUser.objects.orders.set.booking_id(order_id.value, metadata.getBookingId());
|
||||
// Check if the booking has been completed
|
||||
SessionUser.objects.order_bookings.get.single(metadata.getBookingId()).then((booking: any) => {
|
||||
const isBookingCompleted = booking?.order_id && booking.order_id > 0;
|
||||
if (!isBookingCompleted) {
|
||||
// Set the order bookings id to the metadata
|
||||
if (metadata.getBookingId() && metadata.getBookingId() > 0) {
|
||||
SessionUser.objects.order_bookings.set.order_id(metadata.getBookingId(), order_id.value).then(() => {
|
||||
// Check if order requires safety seal popup (product id 41 present as item or addon)
|
||||
try {
|
||||
const posStr = localStorage.getItem('pos');
|
||||
let hasSeal = false;
|
||||
if (posStr) {
|
||||
try {
|
||||
const posData = JSON.parse(posStr);
|
||||
const primaryItem = posData?.transactionItems?.primaryItem;
|
||||
const additionalItems = posData?.transactionItems?.additionalItems || [];
|
||||
const allItems = [primaryItem, ...additionalItems].filter(Boolean);
|
||||
hasSeal = allItems.some((item: any) =>
|
||||
item?.product?.id === 41 ||
|
||||
(item?.addons && item.addons.some((addon: any) => addon?.product?.id === 41))
|
||||
);
|
||||
} catch (parseErr) {
|
||||
console.error('Failed to parse pos data:', parseErr);
|
||||
}
|
||||
}
|
||||
if (!hasSeal) {
|
||||
metadata.setLoadingState(true);
|
||||
metadata.setLoadingMessage(t('admin.pos.waiting_for_booking'));
|
||||
SessionUser.objects.order_bookings.functions.complete(metadata.getBookingId(), null, (response: any) => {
|
||||
console.log('Auto-completed booking without safety seal prompt (mobile POS - localStorage)');
|
||||
completeOrder();
|
||||
});
|
||||
} else {
|
||||
popups.select('complete_booking', {});
|
||||
awaitBookingCompletion(() => {
|
||||
completeOrder();
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking safety seal via localStorage:', err);
|
||||
popups.select('complete_booking', {});
|
||||
awaitBookingCompletion(() => {
|
||||
completeOrder();
|
||||
});
|
||||
}
|
||||
}).catch((err: any) => {
|
||||
console.error('Error setting order_id on booking:', err);
|
||||
errors.value.push(err);
|
||||
completeOrder();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// The booking has been completed, complete the order
|
||||
completeOrder();
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
errors.value.push(error);
|
||||
// An error occurred, log it and complete the order anyway
|
||||
console.warn('An error occurred while checking if the booking has been completed:', error);
|
||||
completeOrder();
|
||||
});
|
||||
} else {
|
||||
completeOrder();
|
||||
}
|
||||
};
|
||||
|
||||
const awaitBookingCompletion = (callback: Function) => {
|
||||
// Lock the next step button to a loading state
|
||||
metadata.setLoadingState(true);
|
||||
metadata.setLoadingMessage(t('admin.pos.waiting_for_booking'));
|
||||
const interval = setInterval(() => {
|
||||
// If the popup is still open, we know the booking is not completed yet
|
||||
if (popups.isSet.value) {
|
||||
// The popup is still open, so the booking is not completed yet
|
||||
const selectedBookingId = getSelectedBookingId();
|
||||
const hasWashCertificateInBasket = Boolean(transactionItems.containsWashCertificate?.());
|
||||
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
|
||||
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
|
||||
|
||||
try {
|
||||
if (requiresBookingCompletionPopup) {
|
||||
const onCompleteBookingWithSafetySeal = async ({ safetySeal = '' } = {}) => {
|
||||
syncMobileSafetySealState(safetySeal);
|
||||
await completeStep2Order({
|
||||
bookingSafetySeal: String(safetySeal ?? ''),
|
||||
markOrderCompleted: false,
|
||||
});
|
||||
};
|
||||
|
||||
popups.select('complete_booking', {
|
||||
props: {
|
||||
onCompleteWithCertificate: onCompleteBookingWithSafetySeal,
|
||||
onCompleteWithoutCertificate: onCompleteBookingWithSafetySeal,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Check if the booking has been completed
|
||||
SessionUser.objects.bookings.functions.isBookingCompleted(metadata.getBookingId()).then((isBookingCompleted: boolean) => {
|
||||
if (isBookingCompleted) {
|
||||
clearInterval(interval);
|
||||
callback();
|
||||
} // Else, console.warn and wait for the next interval
|
||||
else {
|
||||
console.warn('Booking is not completed, but the popup is closed. Expecting cancelation or error.');
|
||||
clearInterval(interval);
|
||||
callback(); // Proceed to complete the order anyway
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
errors.value.push(error);
|
||||
console.warn('An error occurred while checking if the booking has been completed:', error);
|
||||
completeOrder();
|
||||
|
||||
await completeStep2Order({
|
||||
markOrderCompleted: shouldMarkOrderAsCompleted,
|
||||
});
|
||||
}, 250) // Check every 250ms (Should be quick enough to catch the popup close event);
|
||||
}
|
||||
} catch (error: any) {
|
||||
errors.value.push(error);
|
||||
console.warn('An error occurred while completing the mobile order:', error);
|
||||
popups.select('error');
|
||||
}
|
||||
};
|
||||
|
||||
const completeOrder = () => {
|
||||
addTransactionToHistory(order_id.value);
|
||||
@@ -351,46 +326,73 @@ const step3 = () => {
|
||||
console.warn('Step 3: Card payment step is not defined.');
|
||||
};
|
||||
|
||||
const onClick = () => {
|
||||
const isProcessingClick = ref(false);
|
||||
const isBusy = computed(() => metadata.getLoadingState() || isCreatingOrder.value || isProcessingClick.value);
|
||||
|
||||
const onClick = async () => {
|
||||
// Check if the click is cancelled
|
||||
if (isClickCancelled.value) {
|
||||
if (isClickCancelled.value || isProcessingClick.value) {
|
||||
return;
|
||||
}
|
||||
// Check if a custom action is provided
|
||||
if (props.customAction) {
|
||||
props.customAction();
|
||||
isProcessingClick.value = true;
|
||||
try {
|
||||
await Promise.resolve(props.customAction());
|
||||
} finally {
|
||||
isProcessingClick.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isCustomerSelected() && getCustomerId() > 0) {
|
||||
props.onBeforeStep().then(() => {
|
||||
// Proceed to the next step
|
||||
switch (step.value) {
|
||||
case 1:
|
||||
step1();
|
||||
// Set the order ID (If it's stored in pos_order_id in local storage)
|
||||
if (localStorage.getItem('pos_order_id')) {
|
||||
order_id.value = parseInt(localStorage.getItem('pos_order_id') || '0');
|
||||
console.warn('Order ID retrieved from local storage:', order_id.value);
|
||||
nextStep({isMobile: true, orderCreation: false});
|
||||
} else {
|
||||
nextStep({isMobile: true, orderCreation: true});
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
step1();
|
||||
step2();
|
||||
break;
|
||||
case 3:
|
||||
// This step is not defined, but you can add your logic here if needed
|
||||
step3();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (!(isCustomerSelected() && getCustomerId() > 0)) {
|
||||
// Show the popup to select a customer
|
||||
popups.select('select_customer');
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessingClick.value = true;
|
||||
try {
|
||||
await props.onBeforeStep();
|
||||
// Proceed to the next step
|
||||
switch (step.value) {
|
||||
case 1:
|
||||
step1();
|
||||
const restoredOrderId = await restoreStoredPosOrderId({
|
||||
validateOrder: true,
|
||||
customerId: getCustomerId(),
|
||||
departmentId: POSDepartmentProcess.getDepartment(),
|
||||
allowCompleted: false,
|
||||
});
|
||||
if (restoredOrderId) {
|
||||
console.warn('Order ID retrieved from local storage:', restoredOrderId);
|
||||
nextStep({
|
||||
isMobile: true,
|
||||
orderCreation: false,
|
||||
bookingId: metadata.getBookingId?.() ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
nextStep({
|
||||
isMobile: true,
|
||||
orderCreation: true,
|
||||
bookingId: metadata.getBookingId?.() ?? null,
|
||||
});
|
||||
break;
|
||||
case 2:
|
||||
step1();
|
||||
await step2();
|
||||
break;
|
||||
case 3:
|
||||
// This step is not defined, but you can add your logic here if needed
|
||||
step3();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Next-step action was interrupted:', error);
|
||||
} finally {
|
||||
isProcessingClick.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -436,8 +438,8 @@ const isRequirementsForClickMet = () => {
|
||||
if (popups.isSet.value) {
|
||||
return false;
|
||||
}
|
||||
// If the metadata state is loading, the button cannot be clicked.
|
||||
if (metadata.getLoadingState()) {
|
||||
// Prevent duplicate click handling while one action is in-flight.
|
||||
if (isBusy.value) {
|
||||
return false;
|
||||
}
|
||||
if (!isCustomerSelected()) {
|
||||
@@ -494,17 +496,16 @@ const defaultLongPressBehavior = () => {
|
||||
|
||||
<template>
|
||||
<LongPressListener @long-press="onLongPress">
|
||||
<GenericButton @click="onClick" :class="{'white': props.isWhite, 'has-background-black': props.isDark, 'is-loading': metadata.getLoadingState(), ...(props.buttonClasses.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}))}"
|
||||
<GenericButton @click="onClick" :class="{'white': props.isWhite, 'has-background-black': props.isDark, 'is-loading': isBusy, ...(props.buttonClasses.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}))}"
|
||||
data-testid="pos-mobile-next-step"
|
||||
class="is-size-6-mobile is-size-5-tablet is-size-4-desktop"
|
||||
:disabled="!isRequirementsForClickMet()" v-show="isVisible">
|
||||
<!-- Default content if no slot is provided -->
|
||||
<template v-if="!$slots.default">
|
||||
<template v-if="isCustomerSelected()">
|
||||
<span class="is-float-left" style="width: fit-content;">
|
||||
{{ customer_name }}
|
||||
</span>
|
||||
<span class="is-float-right" style="width: fit-content; height: fit-content;">
|
||||
<span class="icon is-medium is-clickable" style="width: fit-content; height: fit-content;">
|
||||
<span class="pos-mobile-cta-content">
|
||||
<span class="pos-mobile-cta-label">{{ customer_name }}</span>
|
||||
<span class="pos-mobile-cta-value">
|
||||
<ContinueArrow style="width: 100%; height: 100%; padding: 6px 12px; gap: 5px;" />
|
||||
</span>
|
||||
</span>
|
||||
@@ -523,9 +524,104 @@ const defaultLongPressBehavior = () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pos-mobile-cta-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pos-mobile-cta-label {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: -webkit-box;
|
||||
text-align: left;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.pos-mobile-cta-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.has-text-black >:not(.icon) {
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
:deep(.pos-mobile-cta-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.pos-mobile-cta-label) {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: -webkit-box;
|
||||
text-align: left;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
:deep(.pos-mobile-cta-value) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.pos-mobile-action-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
:deep(.pos-mobile-action-label) {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:deep(.pos-mobile-action-icon) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
min-width: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:deep(.pos-mobile-action-icon i) {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:deep(.generic-button__content) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Loading state */
|
||||
.is-loading {
|
||||
pointer-events: none;
|
||||
@@ -561,4 +657,4 @@ const defaultLongPressBehavior = () => {
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+70
-10
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {defineProps, computed, ref} from "vue";
|
||||
import {computed, ref, nextTick, onMounted, onUnmounted} from "vue";
|
||||
const props = defineProps({
|
||||
// Blur amount
|
||||
blurAmount: {
|
||||
@@ -11,8 +11,19 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value: string) => ['default', 'pos-step'].includes(value),
|
||||
},
|
||||
reserveSpace: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const FIXED_BOTTOM_HEIGHT_CSS_VAR = '--pos-mobile-fixed-bottom-height';
|
||||
|
||||
const style = computed(() => {
|
||||
// Smooth blur transition
|
||||
if (props.smoothBlur) {
|
||||
@@ -32,20 +43,55 @@ const style = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Get height of control box dynamically
|
||||
const controlBox = ref<HTMLDivElement | null>(null);
|
||||
const heightOfControlBox = computed(() => {
|
||||
if (controlBox.value) {
|
||||
return controlBox.value.offsetHeight
|
||||
const reservedHeight = ref(0);
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const syncReservedHeight = () => {
|
||||
reservedHeight.value = controlBox.value?.offsetHeight ?? 0;
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.style.setProperty(FIXED_BOTTOM_HEIGHT_CSS_VAR, `${reservedHeight.value}px`);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(syncReservedHeight);
|
||||
if (typeof ResizeObserver !== "undefined" && controlBox.value) {
|
||||
resizeObserver = new ResizeObserver(() => syncReservedHeight());
|
||||
resizeObserver.observe(controlBox.value);
|
||||
}
|
||||
window.addEventListener("resize", syncReservedHeight);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", syncReservedHeight);
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.style.removeProperty(FIXED_BOTTOM_HEIGHT_CSS_VAR);
|
||||
}
|
||||
});
|
||||
|
||||
const anchorStyle = computed(() => {
|
||||
if (!props.reserveSpace) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
minHeight: `${reservedHeight.value}px`,
|
||||
};
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{ minHeight: heightOfControlBox + 'px' }">
|
||||
<div class="custom-mobile-fixed-bottom-control" :style="style" ref="controlBox">
|
||||
<div class="p-4">
|
||||
<div :style="anchorStyle" :class="['custom-mobile-fixed-bottom-anchor', `custom-mobile-fixed-bottom-anchor--${props.variant}`]">
|
||||
<div
|
||||
class="custom-mobile-fixed-bottom-control"
|
||||
:class="[`custom-mobile-fixed-bottom-control--${props.variant}`]"
|
||||
:style="style"
|
||||
ref="controlBox"
|
||||
:data-testid="props.variant === 'pos-step' ? 'pos-mobile-fixed-actions' : undefined"
|
||||
>
|
||||
<div class="custom-mobile-fixed-bottom-control__inner">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,7 +104,21 @@ const heightOfControlBox = computed(() => {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
</style>
|
||||
|
||||
.custom-mobile-fixed-bottom-control__inner {
|
||||
width: min(100%, 48rem);
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.custom-mobile-fixed-bottom-control--pos-step {
|
||||
border-top-left-radius: 1.2rem;
|
||||
border-top-right-radius: 1.2rem;
|
||||
box-shadow: 0 -12px 30px rgba(8, 28, 52, 0.14);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
</style>
|
||||
|
||||
+260
-183
@@ -1,235 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { computed, defineEmits, watch, ref } from "vue";
|
||||
import { PosSearchResult } from "../objects/PosSearchResult.vue";
|
||||
import CustomerSearchField from "@/components/search/economic/customerSearchField.vue";
|
||||
import { searchCustomerResults, isSearching } from "@/components/search/economic/customerSearch.vue";
|
||||
import ControlFieldInputSearchResults
|
||||
from "@/components/viewport/elements/controls/fields/search/ControlFieldInputSearchResults.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { computed, defineEmits, onBeforeUnmount, reactive, ref, watch } from "vue";
|
||||
import { searchAndSelectCustomer } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { metadata, vehicles } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import VehicleCustomerSuggestionsPos from "@/components/forms/department/pos/input/vehicleCustomerSuggestionsPos.vue";
|
||||
import {
|
||||
actionButtons,
|
||||
popups
|
||||
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { actionButtons, metadata, popups } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
import {
|
||||
applyPosAddCustomerSearchResult,
|
||||
createPosAddCustomerFields,
|
||||
createPosAddCustomerManualOverrides,
|
||||
createPosAddCustomerRequestState,
|
||||
isValidPosAddCustomerSearchResult,
|
||||
nextPosAddCustomerRequestState,
|
||||
resolvePosAddCustomerRequestState,
|
||||
setPosAddCustomerManualOverride,
|
||||
} from "./posAddCustomerAutofill.js";
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
const ADD_CUSTOMER_SUBMIT_TEST_ID = "pos-mobile-add-customer-submit";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: "close"): void;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Variables
|
||||
*/
|
||||
const searchQuery = ref("");
|
||||
const searchResult = ref(/**{
|
||||
"vat": 41004355,
|
||||
"status": "Normal",
|
||||
"name": "Truckwash ApS",
|
||||
"address": "Letland Alle 2",
|
||||
"zipcode": 2630,
|
||||
"city": "Taastrup",
|
||||
"protected": true,
|
||||
"phone": "21754690",
|
||||
"website": null,
|
||||
"email": "mikkel@truckwash.dk",
|
||||
"fax": null,
|
||||
"startdate": "2019-12-11",
|
||||
"enddate": null,
|
||||
"employees": 14,
|
||||
"industrycode": 953190,
|
||||
"industrydesc": "Reparation og vedligeholdelse af motorkøretøjer i.a.n.",
|
||||
"companytype": "APS",
|
||||
"companydesc": "Anpartsselskab",
|
||||
"owners": [
|
||||
"DELOITTE STATSAUTORISERET REVISIONSPARTNERSELSKAB",
|
||||
"MBL Revision I/S",
|
||||
"WASH GROUP A/S"
|
||||
]
|
||||
}*/null);
|
||||
const lastSearchResult = ref(null); // This is used to change the autofilled fields, when the search result changes
|
||||
const searchResult = ref<any | null>(null);
|
||||
const searching = ref(false);
|
||||
const error = ref(popups.get().props.error); // Used to display an error message if the customer creation fails
|
||||
const search = (query: string) => {
|
||||
return SessionUser.request("/cvr/search", "GET", {
|
||||
query: query,
|
||||
}).then((response) => {
|
||||
console.warn(response, 'response');
|
||||
searchResult.value = response.data.data;
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
searchResult.value = null;
|
||||
}).finally(() => {
|
||||
searching.value = false;
|
||||
changeAutofilledFields();
|
||||
});
|
||||
};
|
||||
/**
|
||||
* Form fields
|
||||
*/
|
||||
const cvr = ref("");
|
||||
const companyPhone = ref("");
|
||||
const invoiceEmail = ref("");
|
||||
const contactEmail = ref("");
|
||||
const contactPhone = ref("");
|
||||
/**
|
||||
* Autofill fields, when search result changes
|
||||
* If the operator has changed the values in the fields, we want to keep them, so we can autofill them again when the search result changes
|
||||
*/
|
||||
const changeAutofilledFields = () => {
|
||||
if (lastSearchResult.value !== null) {
|
||||
cvr.value = "";
|
||||
if (lastSearchResult.value.phone === companyPhone.value) {
|
||||
companyPhone.value = "";
|
||||
}
|
||||
if (lastSearchResult.value.phone === contactPhone.value) {
|
||||
contactPhone.value = "";
|
||||
}
|
||||
if (lastSearchResult.value.email === contactEmail.value) {
|
||||
contactEmail.value = "";
|
||||
}
|
||||
if (lastSearchResult.value.email === invoiceEmail.value) {
|
||||
invoiceEmail.value = "";
|
||||
}
|
||||
}
|
||||
// Fill the fields with the new search result
|
||||
if (searchResult.value !== null) {
|
||||
if (cvr.value === "") {
|
||||
cvr.value = searchResult.value.vat.toString();
|
||||
}
|
||||
if (companyPhone.value === "") {
|
||||
companyPhone.value = searchResult.value.phone;
|
||||
}
|
||||
if (contactPhone.value === "") {
|
||||
contactPhone.value = searchResult.value.phone;
|
||||
}
|
||||
if (contactEmail.value === "") {
|
||||
contactEmail.value = searchResult.value.email;
|
||||
}
|
||||
if (invoiceEmail.value === "") {
|
||||
invoiceEmail.value = searchResult.value.email;
|
||||
}
|
||||
}
|
||||
lastSearchResult.value = searchResult.value;
|
||||
}
|
||||
const requestState = ref(createPosAddCustomerRequestState());
|
||||
const manualOverrides = ref(createPosAddCustomerManualOverrides());
|
||||
const form = reactive(createPosAddCustomerFields());
|
||||
|
||||
/**
|
||||
* Watch the validity of the fields and emit an event to the parent component
|
||||
* If the fields are valid, we can enable the "Add Customer" button
|
||||
*/
|
||||
watch([cvr, companyPhone, invoiceEmail, contactEmail, contactPhone], (newValues) => {
|
||||
// Remove the "Add Customer" button if it exists
|
||||
const index = popups.get().actionButtons.findIndex((button) => button.label === t('admin.pos.add_customer'));
|
||||
if (index !== -1) {
|
||||
popups.get().actionButtons.splice(index, 1);
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const isValidSearchResult = computed(() =>
|
||||
isValidPosAddCustomerSearchResult(searchResult.value, t("admin.pos.not_found"))
|
||||
);
|
||||
|
||||
const canCreateCustomer = computed(() => {
|
||||
const requiredFields = [form.cvr, form.companyPhone, form.contactPhone];
|
||||
|
||||
return isValidSearchResult.value && requiredFields.every((field) => String(field).trim() !== "");
|
||||
});
|
||||
|
||||
const clearQueuedSearch = () => {
|
||||
if (searchDebounceTimer !== null) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = null;
|
||||
}
|
||||
// Add the "Add Customer" button if all fields are filled
|
||||
const requiredFields = [cvr, companyPhone, contactPhone];
|
||||
if (searchResult.value !== null &&
|
||||
searchResult.value.name !== t('admin.pos.not_found') &&
|
||||
requiredFields.every((field) => field.value !== "")) {
|
||||
popups.get().actionButtons.push({
|
||||
label: t('admin.pos.add_customer'),
|
||||
onClick: () => {
|
||||
popups.get().actionButtons = []; // Remove all action buttons to prevent multiple clicks
|
||||
try {
|
||||
SessionUser.request("/auth/register/cvr", "POST", {
|
||||
cvr: cvr.value,
|
||||
companyPhone: parseInt(companyPhone.value),
|
||||
invoiceEmail: invoiceEmail.value,
|
||||
contactEmail: contactEmail.value,
|
||||
contactPhone: parseInt(contactPhone.value),
|
||||
searchResult: searchResult.value,
|
||||
}).then((response) => {
|
||||
console.warn(response, 'response register cvr');
|
||||
searchAndSelectCustomer(companyPhone.value);
|
||||
metadata.setCustomerId(parseInt(companyPhone.value));
|
||||
emit('close');
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
const errorResponse = SessionUser.functions.parseErrorMessage(error);
|
||||
if (errorResponse !== null) {
|
||||
popups.get().props.error = t('admin.pos.customer_creation_error', { error: errorResponse });
|
||||
// Reset the buttons
|
||||
popups.get().actionButtons = popups.get().actionButtons = [{...actionButtons.default.value.cancel}];
|
||||
return;
|
||||
}
|
||||
popups.get().props.error = t('admin.pos.customer_creation_error_generic');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
popups.get().props.error = t('admin.pos.customer_creation_error_generic');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const syncPopupActionButtons = () => {
|
||||
const popup = popups.get();
|
||||
if (!popup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextButtons = [{ ...actionButtons.default.value.cancel }];
|
||||
|
||||
if (canCreateCustomer.value) {
|
||||
nextButtons.push({
|
||||
label: t("admin.pos.add_customer"),
|
||||
onClick: submitCustomer,
|
||||
color: "primary",
|
||||
testId: ADD_CUSTOMER_SUBMIT_TEST_ID,
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
popup.actionButtons = nextButtons;
|
||||
};
|
||||
|
||||
const applySearchResult = (nextSearchResult: any | null) => {
|
||||
searchResult.value = nextSearchResult;
|
||||
|
||||
if (!isValidPosAddCustomerSearchResult(nextSearchResult, t("admin.pos.not_found"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(
|
||||
form,
|
||||
applyPosAddCustomerSearchResult({
|
||||
fields: form,
|
||||
manualOverrides: manualOverrides.value,
|
||||
searchResult: nextSearchResult,
|
||||
notFoundLabel: t("admin.pos.not_found"),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const runSearch = async (query: string) => {
|
||||
const normalizedQuery = String(query ?? "").trim();
|
||||
|
||||
if (normalizedQuery === "") {
|
||||
searchResult.value = null;
|
||||
searching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRequest = nextPosAddCustomerRequestState(requestState.value);
|
||||
requestState.value = nextRequest.state;
|
||||
|
||||
try {
|
||||
const response = await SessionUser.request("/cvr/search", "GET", {
|
||||
query: normalizedQuery,
|
||||
});
|
||||
|
||||
const resolution = resolvePosAddCustomerRequestState(requestState.value, nextRequest.requestId);
|
||||
if (!resolution.shouldApply) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestState.value = resolution.state;
|
||||
applySearchResult(response?.data?.data ?? null);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
const resolution = resolvePosAddCustomerRequestState(requestState.value, nextRequest.requestId);
|
||||
if (!resolution.shouldApply) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestState.value = resolution.state;
|
||||
searchResult.value = null;
|
||||
} finally {
|
||||
if (nextRequest.requestId === requestState.value.latestRequestedId) {
|
||||
searching.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const queueSearch = (query: string) => {
|
||||
clearQueuedSearch();
|
||||
|
||||
const normalizedQuery = String(query ?? "").trim();
|
||||
popups.get().props.error = null;
|
||||
|
||||
if (normalizedQuery === "") {
|
||||
searchResult.value = null;
|
||||
searching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
searching.value = true;
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
searchDebounceTimer = null;
|
||||
runSearch(normalizedQuery);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const handleAutofillFieldInput = (field: keyof typeof manualOverrides.value) => {
|
||||
manualOverrides.value = setPosAddCustomerManualOverride(manualOverrides.value, field, form[field]);
|
||||
popups.get().props.error = null;
|
||||
};
|
||||
|
||||
const submitCustomer = async () => {
|
||||
const popup = popups.get();
|
||||
if (!popup) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup.actionButtons = [];
|
||||
|
||||
try {
|
||||
await SessionUser.request("/auth/register/cvr", "POST", {
|
||||
cvr: form.cvr,
|
||||
companyPhone: Number.parseInt(form.companyPhone, 10),
|
||||
invoiceEmail: form.invoiceEmail,
|
||||
contactEmail: form.contactEmail,
|
||||
contactPhone: Number.parseInt(form.contactPhone, 10),
|
||||
searchResult: searchResult.value,
|
||||
});
|
||||
|
||||
await searchAndSelectCustomer(form.companyPhone);
|
||||
metadata.setCustomerId(Number.parseInt(form.companyPhone, 10));
|
||||
emit("close");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const errorResponse = SessionUser.functions.parseErrorMessage(error);
|
||||
|
||||
if (errorResponse !== null) {
|
||||
popups.get().props.error = t("admin.pos.customer_creation_error", { error: errorResponse });
|
||||
} else {
|
||||
popups.get().props.error = t("admin.pos.customer_creation_error_generic");
|
||||
}
|
||||
} finally {
|
||||
syncPopupActionButtons();
|
||||
}
|
||||
};
|
||||
|
||||
watch(searchQuery, queueSearch);
|
||||
|
||||
watch(
|
||||
() => [
|
||||
form.cvr,
|
||||
form.companyPhone,
|
||||
form.invoiceEmail,
|
||||
form.contactEmail,
|
||||
form.contactPhone,
|
||||
canCreateCustomer.value,
|
||||
searchResult.value,
|
||||
],
|
||||
syncPopupActionButtons,
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearQueuedSearch();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="overflow-y: auto; overflow-x: hidden; height: 100%; padding-bottom: 10px;">
|
||||
<div
|
||||
style="overflow-y: auto; overflow-x: hidden; height: 100%; padding-bottom: 10px;"
|
||||
data-testid="pos-mobile-add-customer-popup"
|
||||
>
|
||||
<div class="mx-3">
|
||||
<div class="field">
|
||||
<label class="label"><small>CVR</small></label>
|
||||
<div class="control has-icons-right" :class="{'is-loading': searching}">
|
||||
<div class="control has-icons-right" :class="{ 'is-loading': searching }">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@input="searching = true; search(searchQuery);"
|
||||
class="input is-searched"
|
||||
:class="{'is-danger': searchResult === null, 'is-success': searchResult !== null && searchResult.name !== $t('admin.pos.not_found')}"
|
||||
type="text"
|
||||
:placeholder="$t('global.search_cvr')"
|
||||
v-model="searchQuery"
|
||||
class="input is-searched"
|
||||
:class="{
|
||||
'is-danger': searchResult === null || !isValidSearchResult,
|
||||
'is-success': isValidSearchResult,
|
||||
}"
|
||||
type="text"
|
||||
:placeholder="$t('global.search_cvr')"
|
||||
data-testid="pos-mobile-add-customer-search-input"
|
||||
/>
|
||||
<span class="icon is-right" :class="{'has-text-danger': (searchResult === null || searchResult.name === $t('admin.pos.not_found')) && !searching, 'has-text-success': (searchResult !== null && searchResult.name !== $t('admin.pos.not_found')) && !searching}">
|
||||
<i class="fas fa-check" v-if="(searchResult !== null && searchResult.name !== $t('admin.pos.not_found')) && !searching"></i>
|
||||
<i class="fas fa-exclamation-triangle" v-else-if="(searchResult === null || searchResult.name === $t('admin.pos.not_found')) && !searching"></i>
|
||||
<span
|
||||
class="icon is-right"
|
||||
:class="{
|
||||
'has-text-danger': !isValidSearchResult && !searching,
|
||||
'has-text-success': isValidSearchResult && !searching,
|
||||
}"
|
||||
>
|
||||
<i class="fas fa-check" v-if="isValidSearchResult && !searching"></i>
|
||||
<i class="fas fa-exclamation-triangle" v-else-if="!isValidSearchResult && !searching"></i>
|
||||
<i class="fas fa-search" v-else-if="!searching"></i>
|
||||
</span>
|
||||
</div>
|
||||
<p class="help" v-if="searchResult !== null && searchResult.name !== $t('admin.pos.not_found')">{{ searchResult.name }}</p>
|
||||
<p class="help" v-if="isValidSearchResult">{{ searchResult.name }}</p>
|
||||
</div>
|
||||
<!-- Company phone -->
|
||||
<div class="field">
|
||||
<label class="label"><small>{{ $t('admin.pos.company_phone') }}</small></label>
|
||||
<label class="label"><small>{{ $t("admin.pos.company_phone") }}</small></label>
|
||||
<div class="control">
|
||||
<input v-model="companyPhone" class="input" type="text" :placeholder="$t('admin.pos.company_phone')" :disabled="searchResult === null" :class="{'is-danger': searchResult !== null && companyPhone === ''}"/>
|
||||
<input
|
||||
v-model="form.companyPhone"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('admin.pos.company_phone')"
|
||||
:disabled="searchResult === null"
|
||||
:class="{ 'is-danger': searchResult !== null && form.companyPhone === '' }"
|
||||
data-testid="pos-mobile-add-customer-company-phone"
|
||||
@input="handleAutofillFieldInput('companyPhone')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Invoice email -->
|
||||
<div class="field">
|
||||
<label class="label"><small>{{ $t('admin.pos.invoice_email') }}</small></label>
|
||||
<label class="label"><small>{{ $t("admin.pos.invoice_email") }}</small></label>
|
||||
<div class="control">
|
||||
<input v-model="invoiceEmail" class="input" type="text" :placeholder="$t('admin.pos.invoice_email')" :disabled="searchResult === null"/>
|
||||
<input
|
||||
v-model="form.invoiceEmail"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('admin.pos.invoice_email')"
|
||||
:disabled="searchResult === null"
|
||||
data-testid="pos-mobile-add-customer-invoice-email"
|
||||
@input="handleAutofillFieldInput('invoiceEmail')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Contact email -->
|
||||
<div class="field">
|
||||
<label class="label"><small>{{ $t('admin.pos.contact_email') }}</small></label>
|
||||
<label class="label"><small>{{ $t("admin.pos.contact_email") }}</small></label>
|
||||
<div class="control">
|
||||
<input v-model="contactEmail" class="input" type="text" :placeholder="$t('admin.pos.contact_email')" :disabled="searchResult === null"/>
|
||||
<input
|
||||
v-model="form.contactEmail"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('admin.pos.contact_email')"
|
||||
:disabled="searchResult === null"
|
||||
data-testid="pos-mobile-add-customer-contact-email"
|
||||
@input="handleAutofillFieldInput('contactEmail')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Contact phone -->
|
||||
<div class="field">
|
||||
<label class="label"><small>{{ $t('admin.pos.contact_phone') }}</small></label>
|
||||
<label class="label"><small>{{ $t("admin.pos.contact_phone") }}</small></label>
|
||||
<div class="control">
|
||||
<input v-model="contactPhone" class="input" type="text" :placeholder="$t('admin.pos.contact_phone')" :disabled="searchResult === null" :class="{'is-danger': searchResult !== null && contactPhone === ''}"/>
|
||||
<input
|
||||
v-model="form.contactPhone"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('admin.pos.contact_phone')"
|
||||
:disabled="searchResult === null"
|
||||
:class="{ 'is-danger': searchResult !== null && form.contactPhone === '' }"
|
||||
data-testid="pos-mobile-add-customer-contact-phone"
|
||||
@input="handleAutofillFieldInput('contactPhone')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Error message -->
|
||||
<div class="notification is-danger" v-if="popups.get().props.error !== null">
|
||||
{{ popups.get().props.error}}
|
||||
{{ popups.get().props.error }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Search results -->
|
||||
<!--{{ searchResult }}-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+81
-12
@@ -1,19 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineEmits, watch, ref } from "vue";
|
||||
import { metadata } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import FormDisplay from "@/components/displays/FormDisplay.vue";
|
||||
import { computed, defineEmits, ref } from "vue";
|
||||
import { metadata, popups } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
|
||||
const onSuccessfulCertificateSubmission = (response: any) => {
|
||||
console.log("Certificate generated successfully:", response);
|
||||
emit('close');
|
||||
}
|
||||
|
||||
const popupProps = computed(() => popups.get()?.props || {});
|
||||
const isSubmitting = ref(false);
|
||||
const safetySealNumber = ref("");
|
||||
|
||||
const closePopup = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const runPopupCompletionCallback = async (callback: any, safetySeal: string) => {
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await Promise.resolve(
|
||||
callback({
|
||||
bookingId: metadata.getBookingId?.() ?? null,
|
||||
safetySeal,
|
||||
})
|
||||
);
|
||||
closePopup();
|
||||
} catch (error) {
|
||||
console.warn("Failed to complete booking from popup callback:", error);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitBookingCompletionFallback = async (safetySeal: string) => {
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await SessionUser.objects.order_bookings.functions.complete(
|
||||
metadata.getBookingId(),
|
||||
safetySeal === "" ? null : safetySeal
|
||||
);
|
||||
closePopup();
|
||||
} catch (error) {
|
||||
console.warn("Failed to complete booking from popup fallback action:", error);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const completeWithoutCertificate = async () => {
|
||||
if (isSubmitting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callback = popupProps.value?.onCompleteWithoutCertificate || popupProps.value?.onComplete;
|
||||
if (typeof callback === "function") {
|
||||
await runPopupCompletionCallback(callback, "");
|
||||
return;
|
||||
}
|
||||
await submitBookingCompletionFallback("");
|
||||
};
|
||||
|
||||
const completeWithCertificate = async () => {
|
||||
if (isSubmitting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSafetySeal = String(safetySealNumber.value ?? "").trim();
|
||||
const callback = popupProps.value?.onCompleteWithCertificate || popupProps.value?.onComplete;
|
||||
if (typeof callback === "function") {
|
||||
await runPopupCompletionCallback(callback, normalizedSafetySeal);
|
||||
return;
|
||||
}
|
||||
await submitBookingCompletionFallback(normalizedSafetySeal);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,6 +84,7 @@ const safetySealNumber = ref("");
|
||||
<input
|
||||
type="text"
|
||||
class="input is-searched"
|
||||
data-testid="pos-mobile-booking-safety-seal-input"
|
||||
placeholder="Indtast safety seal nummer"
|
||||
v-model="safetySealNumber"
|
||||
/>
|
||||
@@ -33,12 +92,22 @@ const safetySealNumber = ref("");
|
||||
</div>
|
||||
<!-- Generate certificate button -->
|
||||
<div class="buttons is-centered mb-4">
|
||||
<button class="button is-light" @click="SessionUser.objects.order_bookings.functions.complete(metadata.getBookingId(), '', onSuccessfulCertificateSubmission)">
|
||||
<button
|
||||
class="button is-light"
|
||||
data-testid="pos-mobile-booking-complete-without-certificate"
|
||||
:class="{ 'is-loading': isSubmitting }"
|
||||
:disabled="isSubmitting"
|
||||
@click="completeWithoutCertificate"
|
||||
>
|
||||
Intet vaskecertifikat
|
||||
</button>
|
||||
<button
|
||||
class="button is-primary"
|
||||
@click="SessionUser.objects.order_bookings.functions.complete(metadata.getBookingId(), safetySealNumber, onSuccessfulCertificateSubmission)">
|
||||
data-testid="pos-mobile-booking-complete-with-certificate"
|
||||
:class="{ 'is-loading': isSubmitting }"
|
||||
:disabled="isSubmitting"
|
||||
@click="completeWithCertificate"
|
||||
>
|
||||
Generer vaskecertifikat
|
||||
</button>
|
||||
</div>
|
||||
@@ -145,4 +214,4 @@ input.is-searched {
|
||||
flex-grow: 0;
|
||||
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+90
-7
@@ -3,20 +3,42 @@ import { popups } from '../objects/PosDepartmentStepMobileFlow.vue';
|
||||
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
||||
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
|
||||
import { popupComponentKeyToComponent } from "@/components/displays/department/pos/steps/mobile/objects/PosPopup.vue";
|
||||
|
||||
const handleHeaderClose = () => {
|
||||
const activePopup = popups.get();
|
||||
if (activePopup?.onHeaderClose) {
|
||||
activePopup.onHeaderClose();
|
||||
return;
|
||||
}
|
||||
|
||||
popups.clear();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="popup-container" v-if="popups.isSet.value">
|
||||
<div class="popup-content">
|
||||
<div class="popup-container" data-testid="pos-mobile-popup" :data-popup-id="popups.get()?.id || undefined" v-if="popups.isSet.value">
|
||||
<div class="popup-content" :style="{ ...(popups.get()?.style || {}) }">
|
||||
<!-- Icon? and title? -->
|
||||
<WhiteBoxCard :defaultOpen="true" :hideHeader="popups.get()?.hideHeader || false">
|
||||
<template #header>
|
||||
<div class="card-header-icon">
|
||||
<UnknownCustomer/>
|
||||
</div>
|
||||
<div class="card-header-title">
|
||||
<div class="card-header-title popup-header-title">
|
||||
{{ popups.get()?.title || 'Default Title' }}
|
||||
</div>
|
||||
<button
|
||||
v-if="popups.get()?.showHeaderClose"
|
||||
class="card-header-icon popup-header-close"
|
||||
type="button"
|
||||
:data-testid="popups.get()?.headerCloseTestId"
|
||||
aria-label="Close popup"
|
||||
@click.stop="handleHeaderClose"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<component :is="popupComponentKeyToComponent(popups.get()?.component)" @close="popups.clear()" :style="{... popups.get()?.style || {} }"/>
|
||||
@@ -28,6 +50,7 @@ import { popupComponentKeyToComponent } from "@/components/displays/department/p
|
||||
>
|
||||
<a class="card-footer-item"
|
||||
:class="'is-' + (actionButton?.color || 'primary')"
|
||||
:data-testid="actionButton.testId"
|
||||
@click="actionButton.onClick ? actionButton.onClick() : null">
|
||||
{{ actionButton.label }}
|
||||
</a>
|
||||
@@ -47,18 +70,78 @@ import { popupComponentKeyToComponent } from "@/components/displays/department/p
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
padding: max(4.5rem, env(safe-area-inset-top, 0px) + 1rem) 1rem max(1rem, env(safe-area-inset-bottom, 0px) + 0.75rem);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.popup-content {
|
||||
background-color: white;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 80%;
|
||||
width: min(92vw, 26rem);
|
||||
max-width: 26rem;
|
||||
max-height: min(80dvh, calc(100vh - 6rem));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(7, 18, 46, 0.32);
|
||||
}
|
||||
.popup-content h2 {
|
||||
}
|
||||
</style>
|
||||
|
||||
.popup-content :deep(.white-box) {
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.popup-content :deep(.card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.popup-content :deep(.card-header) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.popup-header-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.popup-header-close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #213047;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem;
|
||||
margin-left: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popup-header-close:hover {
|
||||
color: #121b29;
|
||||
}
|
||||
|
||||
.popup-content :deep(.card-content) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.popup-content :deep(.card-footer) {
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e5ebf3;
|
||||
}
|
||||
|
||||
.popup-content :deep(.card-footer-item) {
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
+21
-4
@@ -49,14 +49,21 @@ const onClickSuggestion = (customerId: number) => {
|
||||
metadata.setCustomerId(customerId);
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onClickDirectCardPayment = () => {
|
||||
const cardCustomerId = 999;
|
||||
searchAndSelectCustomer(cardCustomerId);
|
||||
metadata.setCustomerId(cardCustomerId);
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="overflow-y: auto; overflow-x: hidden; height: 100%; padding-bottom: 10px;">
|
||||
<div style="overflow-y: auto; overflow-x: hidden; height: 100%; padding-bottom: 10px;" data-testid="pos-mobile-customer-popup">
|
||||
<!-- Search input field, with it's icon -->
|
||||
<div class="field underlined-input-field">
|
||||
<div class="control has-icons-right">
|
||||
<CustomerSearchField/>
|
||||
<CustomerSearchField testId="pos-mobile-customer-search-input"/>
|
||||
<span class="icon is-right">
|
||||
<i class="fas fa-search"></i>
|
||||
</span>
|
||||
@@ -64,8 +71,18 @@ const onClickSuggestion = (customerId: number) => {
|
||||
</div>
|
||||
<template v-if="formattedSearchResults.length > 0">
|
||||
<!-- Search results (If any) -->
|
||||
<ControlFieldInputSearchResults v-bind:results="formattedSearchResults" @result-clicked="onClick"/>
|
||||
<ControlFieldInputSearchResults v-bind:results="formattedSearchResults" testIdPrefix="pos-mobile-customer-search-result" @result-clicked="onClick"/>
|
||||
</template>
|
||||
<div class="mt-2">
|
||||
<button
|
||||
class="button is-text is-fullwidth"
|
||||
type="button"
|
||||
data-testid="pos-mobile-direct-card-payment"
|
||||
@click="onClickDirectCardPayment"
|
||||
>
|
||||
Vælg direkte betaling med betalingskort
|
||||
</button>
|
||||
</div>
|
||||
<!-- Customer suggestions (If any) -->
|
||||
<VehicleCustomerSuggestionsPos v-if="vehicles?.vehicle_1?.value?.reg" :reg_1="vehicles.vehicle_1.value.reg" @customerSelected="onClickSuggestion"/>
|
||||
</div>
|
||||
@@ -164,4 +181,4 @@ input.is-searched {
|
||||
flex-grow: 0;
|
||||
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+782
@@ -0,0 +1,782 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { popups } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import {
|
||||
getOrderBookingItemPreviewRows,
|
||||
getOrderBookingItemPreviewTotal,
|
||||
getOrderBookingNotesValue,
|
||||
getOrderBookingPlateText,
|
||||
getOrderBookingPoValue,
|
||||
getOrderBookingReferenceValue,
|
||||
getOrderBookingServiceLabels,
|
||||
hasOrderBookingDisplayDetails,
|
||||
} from "@/components/displays/department/pos/utils/orderBookingDisplay.js";
|
||||
|
||||
type BookingRefreshResult = any[] | { bookings?: any[] } | void;
|
||||
|
||||
type OrderBookingPopupProps = {
|
||||
bookings?: any[];
|
||||
onSelect?: (booking: any) => Promise<void> | void;
|
||||
onSkip?: () => Promise<void> | void;
|
||||
onRefreshBookings?: () => Promise<BookingRefreshResult> | BookingRefreshResult;
|
||||
departmentId?: number | string | null;
|
||||
matchedPlate?: string;
|
||||
};
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const popupProps = computed<OrderBookingPopupProps>(() => {
|
||||
return (popups.get()?.props || {}) as OrderBookingPopupProps;
|
||||
});
|
||||
|
||||
const bookingsFromProps = computed(() => (Array.isArray(popupProps.value.bookings) ? popupProps.value.bookings : []));
|
||||
const bookings = ref<any[]>([]);
|
||||
|
||||
const detailedBookingsById = ref<Record<number, any>>({});
|
||||
const loadingBookingIds = ref<number[]>([]);
|
||||
const failedBookingIds = ref<number[]>([]);
|
||||
const isRefreshingBookings = ref(false);
|
||||
|
||||
const toPositiveInteger = (value: any) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const normalizePlateValue = (value: any) =>
|
||||
String(value ?? "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
|
||||
const formatBookingDateTime = (booking: any) => {
|
||||
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
|
||||
if (!rawValue) {
|
||||
return t("admin.pos.not_found");
|
||||
}
|
||||
|
||||
const parsedValue = new Date(rawValue);
|
||||
if (Number.isNaN(parsedValue.getTime())) {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value || undefined, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
}).format(parsedValue);
|
||||
};
|
||||
|
||||
const getBookingDateValue = (booking: any) => {
|
||||
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
|
||||
if (!rawValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = new Date(rawValue);
|
||||
return Number.isNaN(parsedValue.getTime()) ? null : parsedValue;
|
||||
};
|
||||
|
||||
const isSameCalendarDay = (left: Date | null, right: Date | null) => {
|
||||
if (!(left instanceof Date) || !(right instanceof Date)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
left.getFullYear() === right.getFullYear() &&
|
||||
left.getMonth() === right.getMonth() &&
|
||||
left.getDate() === right.getDate()
|
||||
);
|
||||
};
|
||||
|
||||
const getDetailedBooking = (booking: any) => {
|
||||
const bookingId = toPositiveInteger(booking?.id);
|
||||
return bookingId ? detailedBookingsById.value[bookingId] || null : null;
|
||||
};
|
||||
|
||||
const getDisplayBooking = (booking: any) => {
|
||||
const detailedBooking = getDetailedBooking(booking);
|
||||
return detailedBooking ? { ...booking, ...detailedBooking } : booking;
|
||||
};
|
||||
|
||||
const isBookingLoading = (booking: any) => {
|
||||
const bookingId = toPositiveInteger(booking?.id);
|
||||
return bookingId ? loadingBookingIds.value.includes(bookingId) : false;
|
||||
};
|
||||
|
||||
const didBookingDetailFail = (booking: any) => {
|
||||
const bookingId = toPositiveInteger(booking?.id);
|
||||
return bookingId ? failedBookingIds.value.includes(bookingId) : false;
|
||||
};
|
||||
|
||||
const getBookingServiceLabels = (booking: any) => getOrderBookingServiceLabels(getDisplayBooking(booking));
|
||||
|
||||
const getBookingItemPreviewRows = (booking: any) => getOrderBookingItemPreviewRows(getDisplayBooking(booking));
|
||||
|
||||
const getBookingItemPreviewTotal = (booking: any) => getOrderBookingItemPreviewTotal(getDisplayBooking(booking));
|
||||
|
||||
const getBookingNote = (booking: any) => getOrderBookingNotesValue(getDisplayBooking(booking));
|
||||
|
||||
const getBookingReference = (booking: any) => getOrderBookingReferenceValue(getDisplayBooking(booking));
|
||||
|
||||
const getBookingPo = (booking: any) => getOrderBookingPoValue(getDisplayBooking(booking));
|
||||
|
||||
const getBookingPlates = (booking: any) => getOrderBookingPlateText(getDisplayBooking(booking));
|
||||
|
||||
const formatCurrencyAmount = (value: any) => {
|
||||
const parsedValue = Number(value);
|
||||
if (!Number.isFinite(parsedValue)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return SessionUser.functions.currency.toLocal(parsedValue);
|
||||
};
|
||||
|
||||
const totalAmountLabel = computed(() => SessionUser.objects.orders.columns.total_net_amount?.label || "Total");
|
||||
|
||||
const resolvedBookingCount = computed(() => {
|
||||
return bookings.value.filter((booking) => {
|
||||
const displayBooking = getDisplayBooking(booking);
|
||||
return hasOrderBookingDisplayDetails(displayBooking) || !isBookingLoading(booking) || didBookingDetailFail(booking);
|
||||
}).length;
|
||||
});
|
||||
|
||||
const isLoadingAnyBookingDetails = computed(() => loadingBookingIds.value.length > 0);
|
||||
const isAnyBookingFetchInProgress = computed(() => isLoadingAnyBookingDetails.value || isRefreshingBookings.value);
|
||||
|
||||
const sortedBookings = computed(() => {
|
||||
const now = new Date();
|
||||
return bookings.value
|
||||
.map((booking, index) => {
|
||||
const displayBooking = getDisplayBooking(booking);
|
||||
const bookingDate = getBookingDateValue(displayBooking);
|
||||
const isToday = isSameCalendarDay(bookingDate, now);
|
||||
|
||||
return {
|
||||
booking,
|
||||
index,
|
||||
bookingDate,
|
||||
isToday,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.isToday !== right.isToday) {
|
||||
return left.isToday ? -1 : 1;
|
||||
}
|
||||
|
||||
if (left.bookingDate && right.bookingDate) {
|
||||
const timeDifference = left.bookingDate.getTime() - right.bookingDate.getTime();
|
||||
if (timeDifference !== 0) {
|
||||
return timeDifference;
|
||||
}
|
||||
} else if (left.bookingDate || right.bookingDate) {
|
||||
return left.bookingDate ? -1 : 1;
|
||||
}
|
||||
|
||||
return left.index - right.index;
|
||||
})
|
||||
.map((entry) => entry.booking);
|
||||
});
|
||||
|
||||
const fetchBookingDetails = async (bookingsToInspect: any[]) => {
|
||||
const pendingBookings = (Array.isArray(bookingsToInspect) ? bookingsToInspect : []).filter(Boolean);
|
||||
const bookingsToLoad = pendingBookings.filter((booking) => {
|
||||
const bookingId = toPositiveInteger(booking?.id);
|
||||
if (!bookingId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (detailedBookingsById.value[bookingId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loadingBookingIds.value.includes(bookingId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (bookingsToLoad.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bookingIdsToLoad = bookingsToLoad
|
||||
.map((booking) => toPositiveInteger(booking?.id))
|
||||
.filter((bookingId): bookingId is number => bookingId !== null);
|
||||
|
||||
loadingBookingIds.value = [...new Set([...loadingBookingIds.value, ...bookingIdsToLoad])];
|
||||
|
||||
const bookingResults = await Promise.allSettled(
|
||||
bookingsToLoad.map((booking) =>
|
||||
SessionUser.objects.order_bookings.get.single(toPositiveInteger(booking?.id), {
|
||||
department_id: popupProps.value.departmentId ?? null,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const nextDetailedBookings = {
|
||||
...detailedBookingsById.value,
|
||||
};
|
||||
const nextFailedBookingIds = new Set(failedBookingIds.value);
|
||||
|
||||
bookingsToLoad.forEach((booking, index) => {
|
||||
const bookingId = bookingIdsToLoad[index];
|
||||
const result = bookingResults[index];
|
||||
|
||||
if (result.status === "fulfilled" && result.value?.id) {
|
||||
nextDetailedBookings[result.value.id] = result.value;
|
||||
nextFailedBookingIds.delete(result.value.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (bookingId) {
|
||||
nextFailedBookingIds.add(bookingId);
|
||||
}
|
||||
});
|
||||
|
||||
detailedBookingsById.value = nextDetailedBookings;
|
||||
failedBookingIds.value = Array.from(nextFailedBookingIds);
|
||||
loadingBookingIds.value = loadingBookingIds.value.filter((bookingId) => !bookingIdsToLoad.includes(bookingId));
|
||||
};
|
||||
|
||||
watch(
|
||||
bookingsFromProps,
|
||||
(nextBookings) => {
|
||||
bookings.value = Array.isArray(nextBookings) ? [...nextBookings] : [];
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => bookings.value.map((booking) => booking?.id).join("|"),
|
||||
() => {
|
||||
void fetchBookingDetails(bookings.value);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const onSelect = async (booking: any) => {
|
||||
if (typeof popupProps.value.onSelect === "function") {
|
||||
await popupProps.value.onSelect(getDisplayBooking(booking));
|
||||
}
|
||||
};
|
||||
|
||||
const emitRefreshBookings = async () => {
|
||||
if (typeof popupProps.value.onRefreshBookings !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
isRefreshingBookings.value = true;
|
||||
try {
|
||||
const refreshResult = await popupProps.value.onRefreshBookings();
|
||||
if (Array.isArray(refreshResult)) {
|
||||
bookings.value = refreshResult;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(refreshResult?.bookings)) {
|
||||
bookings.value = refreshResult.bookings;
|
||||
}
|
||||
} finally {
|
||||
isRefreshingBookings.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getActionBooking = (booking: any) => getDisplayBooking(booking);
|
||||
|
||||
const getActionCustomerNumber = (booking: any) => {
|
||||
const displayBooking = getActionBooking(booking);
|
||||
return toPositiveInteger(displayBooking?.customer_number ?? displayBooking?.customer_id);
|
||||
};
|
||||
|
||||
const getActionDepartmentId = (booking: any) => {
|
||||
const displayBooking = getActionBooking(booking);
|
||||
return toPositiveInteger(displayBooking?.department ?? popupProps.value.departmentId);
|
||||
};
|
||||
|
||||
const isBookingScheduledForToday = (booking: any) => {
|
||||
return isSameCalendarDay(getBookingDateValue(getDisplayBooking(booking)), new Date());
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="booking-popup" data-testid="pos-mobile-order-booking-popup">
|
||||
<p class="booking-popup__intro">
|
||||
{{ popups.get()?.message || t("admin.pos.order_booking_selector.help_text") }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="isAnyBookingFetchInProgress"
|
||||
class="booking-popup__loading"
|
||||
data-testid="pos-mobile-order-booking-loading"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-spinner fa-spin" aria-hidden="true" />
|
||||
</span>
|
||||
<span>
|
||||
{{ SessionUser.objects.global.language.loading }}
|
||||
{{ resolvedBookingCount }} / {{ bookings.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="booking-popup__list" data-testid="pos-mobile-order-booking-list">
|
||||
<article
|
||||
v-for="booking in sortedBookings"
|
||||
:key="booking.id"
|
||||
class="booking-option"
|
||||
:class="{ 'booking-option--today': isBookingScheduledForToday(booking) }"
|
||||
:data-testid="`pos-mobile-order-booking-option-${booking.id}`"
|
||||
>
|
||||
<div class="booking-option__header">
|
||||
<div class="booking-option__header-main">
|
||||
<p class="booking-option__eyebrow">Booking #{{ booking.id }}</p>
|
||||
<h3 class="booking-option__title">{{ formatBookingDateTime(getDisplayBooking(booking)) }}</h3>
|
||||
</div>
|
||||
<div class="booking-option__header-actions">
|
||||
<span
|
||||
v-if="isBookingScheduledForToday(booking)"
|
||||
class="tag booking-option__today-tag"
|
||||
:data-testid="`pos-mobile-order-booking-today-${booking.id}`"
|
||||
>
|
||||
{{ t("global.time.today") }}
|
||||
</span>
|
||||
<span v-if="didBookingDetailFail(booking)" class="tag is-light booking-option__tag">
|
||||
{{ t("admin.pos.not_found") }}
|
||||
</span>
|
||||
<div class="booking-option__settings" :data-testid="`pos-mobile-order-booking-settings-${booking.id}`">
|
||||
<ActionSettingsWheelButton
|
||||
:order_booking_id="toPositiveInteger(getActionBooking(booking)?.id)"
|
||||
:order_id="toPositiveInteger(getActionBooking(booking)?.order_id)"
|
||||
:customer_number="getActionCustomerNumber(booking)"
|
||||
:department_id="getActionDepartmentId(booking)"
|
||||
:reg_1="getActionBooking(booking)?.reg_1 ?? null"
|
||||
:reg_2="getActionBooking(booking)?.reg_2 ?? null"
|
||||
:refreshFunction="emitRefreshBookings"
|
||||
:icon="'fas fa-ellipsis-v'"
|
||||
trigger-button-variant="text"
|
||||
>
|
||||
<template #actions />
|
||||
</ActionSettingsWheelButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="booking-option__customer">
|
||||
{{ getDisplayBooking(booking)?.customer_name || t("admin.pos.not_found") }}
|
||||
</p>
|
||||
|
||||
<dl class="booking-option__meta-grid">
|
||||
<div class="booking-option__meta-item">
|
||||
<dt>{{ t("admin.pos.order_booking_selector.plates_label") }}</dt>
|
||||
<dd>{{ getBookingPlates(booking) || t("admin.pos.not_found") }}</dd>
|
||||
</div>
|
||||
<div class="booking-option__meta-item">
|
||||
<dt>{{ t("admin.pos.order_booking_selector.reference_label") }}</dt>
|
||||
<dd>{{ getBookingReference(booking) || "-" }}</dd>
|
||||
</div>
|
||||
<div v-if="getBookingPo(booking)" class="booking-option__meta-item">
|
||||
<dt>{{ SessionUser.objects.orders.columns.po.label }}</dt>
|
||||
<dd>{{ getBookingPo(booking) }}</dd>
|
||||
</div>
|
||||
<div v-if="getBookingNote(booking)" class="booking-option__meta-item">
|
||||
<dt>{{ SessionUser.objects.orders.columns.notes.label }}</dt>
|
||||
<dd>{{ getBookingNote(booking) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div
|
||||
v-if="getBookingItemPreviewRows(booking).length > 0 || getBookingServiceLabels(booking).length > 0 || isBookingLoading(booking)"
|
||||
class="booking-option__services"
|
||||
:data-testid="`pos-mobile-order-booking-services-${booking.id}`"
|
||||
>
|
||||
<div class="booking-option__section-header">
|
||||
<p class="booking-option__section-title">{{ t("admin.pos.order_booking_selector.services_label") }}</p>
|
||||
<div
|
||||
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
|
||||
class="booking-option__section-total"
|
||||
:data-testid="`pos-mobile-order-booking-service-total-${booking.id}`"
|
||||
>
|
||||
<span>{{ totalAmountLabel }}</span>
|
||||
<strong>{{ formatCurrencyAmount(getBookingItemPreviewTotal(booking)) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isBookingLoading(booking)" class="booking-option__item-preview-skeleton">
|
||||
<div class="booking-option__item-preview-row is-skeleton" />
|
||||
<div class="booking-option__item-preview-row is-skeleton" />
|
||||
</div>
|
||||
<div v-else-if="getBookingItemPreviewRows(booking).length > 0" class="booking-option__item-preview">
|
||||
<div class="booking-option__item-preview-head">
|
||||
<span>{{ t("global.quantity") }}</span>
|
||||
<span>{{ t("admin.pos.order_booking_selector.services_label") }}</span>
|
||||
<span>{{ t("admin.pos.price_dkk") }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, index) in getBookingItemPreviewRows(booking)"
|
||||
:key="item.key"
|
||||
class="booking-option__item-preview-row"
|
||||
:data-testid="`pos-mobile-order-booking-service-row-${booking.id}-${index}`"
|
||||
>
|
||||
<span class="booking-option__item-preview-qty">{{ item.quantity }}x</span>
|
||||
<div class="booking-option__item-preview-main">
|
||||
<span
|
||||
class="booking-option__item-preview-name"
|
||||
:class="{ 'booking-option__item-preview-name--addon': item.depth > 0 }"
|
||||
>
|
||||
{{ item.depth > 0 ? "+ " : "" }}{{ item.label }}
|
||||
</span>
|
||||
<span v-if="item.unitPrice !== null && item.quantity > 1" class="booking-option__item-preview-meta">
|
||||
{{ formatCurrencyAmount(item.unitPrice) }} / stk
|
||||
</span>
|
||||
</div>
|
||||
<span class="booking-option__item-preview-amount">
|
||||
{{ item.totalPrice !== null ? formatCurrencyAmount(item.totalPrice) : "-" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="booking-option__service-list">
|
||||
<span
|
||||
v-for="service in getBookingServiceLabels(booking)"
|
||||
:key="`${booking.id}-${service}`"
|
||||
class="booking-option__service-pill"
|
||||
>
|
||||
{{ service }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="booking-option__actions">
|
||||
<button
|
||||
class="button is-primary is-fullwidth"
|
||||
type="button"
|
||||
:class="{ 'is-loading': isBookingLoading(booking) }"
|
||||
:disabled="isBookingLoading(booking)"
|
||||
:data-testid="`pos-mobile-order-booking-use-${booking.id}`"
|
||||
@click="onSelect(booking)"
|
||||
>
|
||||
{{ t("admin.pos.order_booking_selector.use_booking") }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.booking-popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.booking-popup__intro {
|
||||
margin-bottom: 0.75rem;
|
||||
color: #4c5a6e;
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.booking-popup__loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.8rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 35, 77, 0.08);
|
||||
color: #234;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.booking-popup__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.booking-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
border: 1px solid #d7e2f0;
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 22px rgba(21, 42, 76, 0.08);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.booking-option--today {
|
||||
border-color: #3d7ef0;
|
||||
background: linear-gradient(180deg, rgba(61, 126, 240, 0.08), rgba(61, 126, 240, 0.02)), #fff;
|
||||
}
|
||||
|
||||
.booking-option__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.booking-option__header-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.booking-option__header-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.booking-option__eyebrow {
|
||||
margin: 0;
|
||||
color: #5c6d84;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.booking-option__title {
|
||||
margin: 0.15rem 0 0;
|
||||
color: #0f234d;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.booking-option__today-tag {
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(61, 126, 240, 0.2);
|
||||
background: rgba(61, 126, 240, 0.12);
|
||||
color: #18408b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.booking-option__tag {
|
||||
color: #49576b;
|
||||
}
|
||||
|
||||
.booking-option__settings :deep(.dropdown-menu) {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.booking-option__settings :deep(.action-settings-wheel-trigger--text) {
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.booking-option__customer {
|
||||
margin: 0;
|
||||
color: #1b314f;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.booking-option__meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.booking-option__meta-item dt,
|
||||
.booking-option__section-title {
|
||||
margin: 0 0 0.25rem;
|
||||
color: #6b7c92;
|
||||
font-size: 0.71rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.booking-option__meta-item dd {
|
||||
margin: 0;
|
||||
color: #14243b;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.booking-option__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.booking-option__section-total {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
color: #173559;
|
||||
font-size: 0.71rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.booking-option__section-total strong {
|
||||
color: #0f234d;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.booking-option__item-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.booking-option__item-preview-head,
|
||||
.booking-option__item-preview-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2.5rem minmax(0, 1fr) auto;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.booking-option__item-preview-head {
|
||||
padding: 0 0.1rem;
|
||||
color: #6b7c92;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.booking-option__item-preview-head span:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.booking-option__item-preview-row {
|
||||
padding: 0.45rem 0.55rem;
|
||||
border: 1px solid #e5edf7;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, #fdfefe 0%, #f7fbff 100%);
|
||||
}
|
||||
|
||||
.booking-option__item-preview-qty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 1.55rem;
|
||||
padding: 0.1rem 0.38rem;
|
||||
border-radius: 999px;
|
||||
background: #e9f1fb;
|
||||
color: #18408b;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.booking-option__item-preview-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.booking-option__item-preview-name {
|
||||
color: #14243b;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.booking-option__item-preview-name--addon {
|
||||
color: #24456e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.booking-option__item-preview-meta {
|
||||
color: #6a7890;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.booking-option__item-preview-amount {
|
||||
color: #0f234d;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 800;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.booking-option__item-preview-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.booking-option__item-preview-row.is-skeleton {
|
||||
min-height: 2.7rem;
|
||||
border-color: transparent;
|
||||
color: transparent;
|
||||
background: linear-gradient(90deg, #ebf0f7 0%, #f7f9fc 50%, #ebf0f7 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: mobile-booking-selector-pulse 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.booking-option__service-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.booking-option__service-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 1.85rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: #eef4fb;
|
||||
color: #173559;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.booking-option__actions {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.booking-option__actions .button {
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@keyframes mobile-booking-selector-pulse {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 420px) {
|
||||
.booking-option__meta-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.booking-option__section-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.booking-option__item-preview-head,
|
||||
.booking-option__item-preview-row {
|
||||
grid-template-columns: 2.3rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.booking-option__item-preview-head span:last-child,
|
||||
.booking-option__item-preview-amount {
|
||||
grid-column: 2;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
export const POS_ADD_CUSTOMER_AUTOFILL_FIELDS = [
|
||||
"companyPhone",
|
||||
"invoiceEmail",
|
||||
"contactEmail",
|
||||
"contactPhone",
|
||||
];
|
||||
|
||||
export function normalizePosAddCustomerValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function createPosAddCustomerFields(overrides = {}) {
|
||||
return {
|
||||
cvr: "",
|
||||
companyPhone: "",
|
||||
invoiceEmail: "",
|
||||
contactEmail: "",
|
||||
contactPhone: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPosAddCustomerManualOverrides(overrides = {}) {
|
||||
return {
|
||||
companyPhone: false,
|
||||
invoiceEmail: false,
|
||||
contactEmail: false,
|
||||
contactPhone: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPosAddCustomerRequestState(overrides = {}) {
|
||||
return {
|
||||
latestRequestedId: 0,
|
||||
latestAppliedId: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function nextPosAddCustomerRequestState(currentState = createPosAddCustomerRequestState()) {
|
||||
const requestId = Number(currentState.latestRequestedId || 0) + 1;
|
||||
|
||||
return {
|
||||
requestId,
|
||||
state: {
|
||||
...currentState,
|
||||
latestRequestedId: requestId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePosAddCustomerRequestState(currentState, requestId) {
|
||||
const normalizedRequestId = Number(requestId || 0);
|
||||
|
||||
if (!currentState || normalizedRequestId !== Number(currentState.latestRequestedId || 0)) {
|
||||
return {
|
||||
shouldApply: false,
|
||||
state: currentState,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
shouldApply: true,
|
||||
state: {
|
||||
...currentState,
|
||||
latestAppliedId: normalizedRequestId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidPosAddCustomerSearchResult(searchResult, notFoundLabel = "") {
|
||||
if (!searchResult || typeof searchResult !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedName = normalizePosAddCustomerValue(searchResult.name).trim();
|
||||
if (normalizedName === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizedName !== normalizePosAddCustomerValue(notFoundLabel).trim();
|
||||
}
|
||||
|
||||
export function buildPosAddCustomerAutofillValues(searchResult) {
|
||||
return {
|
||||
cvr: normalizePosAddCustomerValue(searchResult?.vat),
|
||||
companyPhone: normalizePosAddCustomerValue(searchResult?.phone),
|
||||
invoiceEmail: normalizePosAddCustomerValue(searchResult?.email),
|
||||
contactEmail: normalizePosAddCustomerValue(searchResult?.email),
|
||||
contactPhone: normalizePosAddCustomerValue(searchResult?.phone),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyPosAddCustomerSearchResult({
|
||||
fields = createPosAddCustomerFields(),
|
||||
manualOverrides = createPosAddCustomerManualOverrides(),
|
||||
searchResult = null,
|
||||
notFoundLabel = "",
|
||||
} = {}) {
|
||||
const nextFields = {
|
||||
...createPosAddCustomerFields(),
|
||||
...fields,
|
||||
};
|
||||
|
||||
if (!isValidPosAddCustomerSearchResult(searchResult, notFoundLabel)) {
|
||||
return nextFields;
|
||||
}
|
||||
|
||||
const autofillValues = buildPosAddCustomerAutofillValues(searchResult);
|
||||
nextFields.cvr = autofillValues.cvr;
|
||||
|
||||
for (const field of POS_ADD_CUSTOMER_AUTOFILL_FIELDS) {
|
||||
if (!manualOverrides[field] || normalizePosAddCustomerValue(nextFields[field]) === "") {
|
||||
nextFields[field] = autofillValues[field];
|
||||
}
|
||||
}
|
||||
|
||||
return nextFields;
|
||||
}
|
||||
|
||||
export function setPosAddCustomerManualOverride(manualOverrides, field, nextValue) {
|
||||
if (!manualOverrides || !Object.prototype.hasOwnProperty.call(manualOverrides, field)) {
|
||||
return manualOverrides;
|
||||
}
|
||||
|
||||
return {
|
||||
...manualOverrides,
|
||||
[field]: normalizePosAddCustomerValue(nextValue).trim() !== "",
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export type PosActionButton = {
|
||||
description?: string; // Optional description
|
||||
onClick?: () => void;
|
||||
icon?: string; // Optional icon name
|
||||
testId?: string; // Optional test id
|
||||
disabled?: boolean; // Is the button disabled?
|
||||
disabled_description?: string; // Optional description when disabled
|
||||
color?: string | 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark'; // Button color
|
||||
@@ -27,4 +28,4 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
||||
+91
-55
@@ -9,6 +9,10 @@ import type {Addon} from "@/components/displays/department/pos/steps/mobile/obje
|
||||
import type { PosLocation } from './PosLocation.vue';
|
||||
import type { PosSoundEffect } from './PosSoundEffect.vue';
|
||||
import type { PosActionButton } from './PosActionButton.vue';
|
||||
import {
|
||||
doesTransactionContainWashCertificateProduct,
|
||||
WASH_CERTIFICATE_PRODUCT_ID,
|
||||
} from "@/components/displays/department/pos/utils/washCertificate.js";
|
||||
import i18n from '@/i18n';
|
||||
|
||||
/** Views */
|
||||
@@ -154,7 +158,6 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
|
||||
label: t('admin.pos.action_buttons.select_customer'),
|
||||
description: t('admin.pos.action_buttons.select_customer_desc'),
|
||||
onClick: () => {
|
||||
console.warn('Select Customer button clicked');
|
||||
// Open the select customer popup
|
||||
popups.select('select_customer');
|
||||
},
|
||||
@@ -164,7 +167,6 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
|
||||
label: t('admin.pos.action_buttons.add_customer'),
|
||||
description: t('admin.pos.action_buttons.add_customer_desc'),
|
||||
onClick: () => {
|
||||
console.warn('Add Customer button clicked');
|
||||
// Open the add customer popup
|
||||
popups.select('add_customer', { props: { error: null, canCreate: true } });
|
||||
},
|
||||
@@ -174,10 +176,8 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
|
||||
label: t('admin.pos.action_buttons.edit_reference'),
|
||||
description: t('admin.pos.action_buttons.edit_reference_desc'),
|
||||
onClick: () => {
|
||||
console.warn('Edit Reference button clicked');
|
||||
// Open the edit reference popup
|
||||
//popups.select('edit_reference');
|
||||
console.warn('Edit Reference button not yet implemented.');
|
||||
},
|
||||
color: 'primary'
|
||||
},
|
||||
@@ -185,7 +185,6 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
|
||||
label: t('admin.pos.action_buttons.add_note'),
|
||||
description: t('admin.pos.action_buttons.add_note_desc'),
|
||||
onClick: () => {
|
||||
console.warn('Add Note button clicked');
|
||||
// Open the add note popup
|
||||
popups.select('customer_notes', { props: { showInput: true, showNotes: false } });
|
||||
},
|
||||
@@ -205,20 +204,16 @@ const isPopupSet = computed(() => {
|
||||
// Function to get the current popup
|
||||
const getPopup = () => {
|
||||
if (popup.value) {
|
||||
console.log('Current popup:', popup.value);
|
||||
return popup.value;
|
||||
}
|
||||
console.warn('No popup is currently set.');
|
||||
return null;
|
||||
}
|
||||
// Function to set the popup
|
||||
const setPopup = (newPopup: PosPopup | null) => {
|
||||
console.log('Setting popup:', newPopup);
|
||||
popup.value = newPopup;
|
||||
}
|
||||
// Function to clear the popup
|
||||
const clearPopup = () => {
|
||||
console.log('Clearing popup');
|
||||
popup.value = null;
|
||||
}
|
||||
// Default list of popups
|
||||
@@ -231,7 +226,6 @@ const addPopup = (popup: PosPopup) => {
|
||||
return;
|
||||
}
|
||||
popupsList.value[popup.id] = {value: popup};
|
||||
console.log(`Added popup with id: ${popup.id}`);
|
||||
}
|
||||
// Function to add default popups to the list
|
||||
const addDefaultPopups = () => {
|
||||
@@ -241,9 +235,21 @@ const addDefaultPopups = () => {
|
||||
title: 'Vælg Kunde',
|
||||
message: 'Bekræft venligst valget af kunde til denne transaktion',
|
||||
component: 'select_customer',
|
||||
showHeaderClose: true,
|
||||
headerCloseTestId: 'pos-mobile-select-customer-header-close',
|
||||
style: {height: '50vh'},
|
||||
actionButtons: [{...defaultActionButtons.value.cancel}, {...defaultActionButtons.value.addCustomer, label: 'Ny kunde'}],
|
||||
});
|
||||
addPopup({
|
||||
id: 'select_order_booking',
|
||||
title: i18n.global.t('admin.pos.order_booking_selector.title'),
|
||||
message: i18n.global.t('admin.pos.order_booking_selector.help_text'),
|
||||
component: 'select_order_booking',
|
||||
showHeaderClose: true,
|
||||
headerCloseTestId: 'pos-mobile-order-booking-header-close',
|
||||
style: { maxHeight: '72dvh' },
|
||||
actionButtons: [],
|
||||
});
|
||||
// Completed
|
||||
addPopup({
|
||||
id: 'completed_transaction',
|
||||
@@ -285,6 +291,8 @@ const addDefaultPopups = () => {
|
||||
title: 'Vælg køretøj',
|
||||
message: 'Vælg venligst et køretøj til denne transaktion',
|
||||
component: 'select_vehicle', // This component should handle vehicle selection
|
||||
showHeaderClose: true,
|
||||
headerCloseTestId: 'pos-mobile-select-vehicle-header-close',
|
||||
style: {height: '50vh'},
|
||||
actionButtons: [
|
||||
{...defaultActionButtons.value.confirm},
|
||||
@@ -297,6 +305,8 @@ const addDefaultPopups = () => {
|
||||
title: 'Indtast reference',
|
||||
message: 'Indtast venligst en reference for denne transaktion',
|
||||
component: 'change_reference',
|
||||
showHeaderClose: true,
|
||||
headerCloseTestId: 'pos-mobile-change-reference-header-close',
|
||||
style: {maxHeight: '40vh'},
|
||||
actionButtons: [
|
||||
{...defaultActionButtons.value.confirm},
|
||||
@@ -309,6 +319,8 @@ const addDefaultPopups = () => {
|
||||
title: 'Kundebemærkninger',
|
||||
message: 'Læs venligst kundens bemærkninger',
|
||||
component: 'customer_notes',
|
||||
showHeaderClose: true,
|
||||
headerCloseTestId: 'pos-mobile-customer-notes-header-close',
|
||||
style: {maxHeight: '50vh'},
|
||||
actionButtons: [
|
||||
{...defaultActionButtons.value.addNote},
|
||||
@@ -322,6 +334,8 @@ const addDefaultPopups = () => {
|
||||
title: 'Billede',
|
||||
message: '',
|
||||
component: 'image_viewer',
|
||||
showHeaderClose: true,
|
||||
headerCloseTestId: 'pos-mobile-image-viewer-header-close',
|
||||
style: {maxHeight: '50vh'},
|
||||
actionButtons: [
|
||||
{...defaultActionButtons.value.close}
|
||||
@@ -334,6 +348,8 @@ const addDefaultPopups = () => {
|
||||
title: 'Tilføj kunde',
|
||||
message: 'Indtast venligst oplysninger for den nye kunde',
|
||||
component: 'add_customer',
|
||||
showHeaderClose: true,
|
||||
headerCloseTestId: 'pos-mobile-add-customer-header-close',
|
||||
style: {maxHeight: '60vh'},
|
||||
actionButtons: [
|
||||
{...defaultActionButtons.value.cancel}
|
||||
@@ -358,7 +374,6 @@ const getPopupByKey = (key: string): PosPopup | null => {
|
||||
const removePopupByKey = (key: string): boolean => {
|
||||
if (popupsList.value[key]) {
|
||||
delete popupsList.value[key];
|
||||
console.log(`Removed popup with key: ${key}`);
|
||||
return true;
|
||||
}
|
||||
console.warn(`Popup with key "${key}" not found.`);
|
||||
@@ -367,13 +382,11 @@ const removePopupByKey = (key: string): boolean => {
|
||||
// Function to clear the popups list
|
||||
const clearPopupsList = () => {
|
||||
popupsList.value = {};
|
||||
console.log('Cleared popups list');
|
||||
}
|
||||
// Function to select a popup by its ID
|
||||
const selectPopupById = (id: string, modifications?: Partial<PosPopup>): PosPopup | null => {
|
||||
const popupItem = popupsList.value[id];
|
||||
if (popupItem) {
|
||||
console.log(`Selected popup with id: ${id}`);
|
||||
setPopup({
|
||||
...popupItem.value,
|
||||
...modifications, // Apply any modifications if provided
|
||||
@@ -422,7 +435,6 @@ const setActiveVehicleIndex = (index: number): void => {
|
||||
|
||||
// Function to select a vehicle
|
||||
const selectVehicle = (index: number, vehicle: PosVehicle | null) => {
|
||||
console.log(`Selecting vehicle at index ${index}:`, vehicle);
|
||||
switch (index) {
|
||||
case 1:
|
||||
vehicle_1.value = vehicle;
|
||||
@@ -541,19 +553,24 @@ const primaryItem = ref<PosProduct | null>(null);
|
||||
// This could include things like additional products, services, or fees.
|
||||
// It is a separate list to allow for flexibility in the transaction.
|
||||
const additionalItems = ref<PosProduct[]>([]);
|
||||
const WASH_CERTIFICATE_PRODUCT_ID = 41;
|
||||
const normalizeAdditionalItem = (item: PosProduct): PosProduct => ({
|
||||
...item,
|
||||
quantity: Number(item?.quantity ?? 0) > 0 ? Number(item.quantity) : 1,
|
||||
});
|
||||
|
||||
// Function to add another item to the transaction
|
||||
const addAdditionalItem = (item: PosProduct) => {
|
||||
// If the additional item requires a note, prompt for it before adding
|
||||
if (item.requires_note) {
|
||||
promptForNotesIfRequired(item, (notes: string) => {
|
||||
item.notes = notes;
|
||||
additionalItems.value.push(item);
|
||||
additionalItems.value.push(normalizeAdditionalItem({
|
||||
...item,
|
||||
notes,
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
additionalItems.value.push(item);
|
||||
additionalItems.value.push(normalizeAdditionalItem(item));
|
||||
};
|
||||
// Function to remove an additional item from the transaction
|
||||
const removeAdditionalItem = (item: PosProduct) => {
|
||||
@@ -635,7 +652,6 @@ const removeWashCertificateIfPresent = (item: PosProduct | null): PosProduct | n
|
||||
}
|
||||
// Function to set the primary item for the transaction
|
||||
const setPrimaryItem = (item: PosProduct | null) => {
|
||||
console.warn('Setting primary item:', item);
|
||||
// Check if the attachments include a wash certificate and if the item requires it
|
||||
if (hasWashCertificate()) {
|
||||
item = applyWashCertificateIfPresent(item)
|
||||
@@ -647,10 +663,6 @@ const setPrimaryItem = (item: PosProduct | null) => {
|
||||
if (item && vehicles.vehicle_1.value) {
|
||||
vehicles.vehicle_1.value.type = item.id;
|
||||
}
|
||||
// Wait 0.5 second and then log the primary item value to ensure reactivity has updated it
|
||||
setTimeout(() => {
|
||||
primaryItem.value = item;
|
||||
}, 500);
|
||||
};
|
||||
// Function to clear the primary item from the transaction
|
||||
const clearPrimaryItem = () => {
|
||||
@@ -756,30 +768,11 @@ const promptForNotesIfRequired = (product: PosProduct, callback: (notes: string)
|
||||
}
|
||||
|
||||
const containsWashCertificate = (): boolean => {
|
||||
// Check if the transaction contains a wash certificate, either as an additional item or as an addon to any item
|
||||
const washCertificateId = WASH_CERTIFICATE_PRODUCT_ID;
|
||||
// Check primary item addons
|
||||
if (primaryItem.value && primaryItem.value.addons) {
|
||||
const hasAddon = primaryItem.value.addons.some(addon => addon.product.id === washCertificateId && (addon.quantity || 0) > 0);
|
||||
if (hasAddon) return true;
|
||||
}
|
||||
// Check additional items
|
||||
for (const item of additionalItems.value) {
|
||||
if (item.id === washCertificateId) {
|
||||
return true;
|
||||
}
|
||||
if (item.addons) {
|
||||
const hasAddon = item.addons.some(addon => addon.product.id === washCertificateId && (addon.quantity || 0) > 0);
|
||||
if (hasAddon) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile POS also allows wash certificate selection through attachments UI.
|
||||
if (attachments.hasWashCertificate()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return doesTransactionContainWashCertificateProduct({
|
||||
primaryItem: primaryItem.value,
|
||||
additionalItems: additionalItems.value,
|
||||
attachmentsHasWashCertificate: attachments.hasWashCertificate(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1128,8 +1121,10 @@ const camera = {
|
||||
const customerId = ref<number | null>(null);
|
||||
const notes = ref<string>('');
|
||||
const reference = ref<string>('');
|
||||
const safetySeal = ref<string>('');
|
||||
const washId = ref<string | null>(null); // The Wash ID is used to track the related wash event
|
||||
const bookingId = ref<number | null>(null); // The Booking ID is used to track the related booking event
|
||||
const bookingSelectionSkippedPlate = ref<string>('');
|
||||
const laneId = ref<number | null>(null); // The Lane ID is used to track the related lane event
|
||||
const loadingState = ref<boolean>(false); // Loading state for the transaction
|
||||
const loadingMessage = ref<string>('Indlæser...'); // Loading message for the transaction
|
||||
@@ -1163,21 +1158,29 @@ const getCustomerId = () => {
|
||||
return customerId.value;
|
||||
}
|
||||
// Function to set notes
|
||||
const setNotes = (newNotes: string) => {
|
||||
notes.value = newNotes;
|
||||
const setNotes = (newNotes: string | null | undefined) => {
|
||||
notes.value = newNotes ?? '';
|
||||
}
|
||||
// Function to get notes
|
||||
const getNotes = () => {
|
||||
return notes.value;
|
||||
}
|
||||
// Function to set reference
|
||||
const setReference = (newReference: string) => {
|
||||
reference.value = newReference;
|
||||
const setReference = (newReference: string | null | undefined) => {
|
||||
reference.value = newReference ?? '';
|
||||
}
|
||||
// Function to get reference
|
||||
const getReference = () => {
|
||||
return reference.value;
|
||||
}
|
||||
// Function to set safety seal
|
||||
const setSafetySeal = (newSafetySeal: string | null | undefined) => {
|
||||
safetySeal.value = newSafetySeal ?? '';
|
||||
}
|
||||
// Function to get safety seal
|
||||
const getSafetySeal = () => {
|
||||
return safetySeal.value;
|
||||
}
|
||||
// Function to set the wash ID
|
||||
const setWashId = (id: string | null) => {
|
||||
washId.value = id;
|
||||
@@ -1194,6 +1197,15 @@ const setBookingId = (id: number | null) => {
|
||||
const getBookingId = () => {
|
||||
return bookingId.value;
|
||||
}
|
||||
const setBookingSelectionSkippedPlate = (plate: string | null | undefined) => {
|
||||
bookingSelectionSkippedPlate.value = String(plate ?? '').replace(/\s/g, '').toUpperCase();
|
||||
}
|
||||
const getBookingSelectionSkippedPlate = () => {
|
||||
return bookingSelectionSkippedPlate.value;
|
||||
}
|
||||
const clearBookingSelectionSkippedPlate = () => {
|
||||
bookingSelectionSkippedPlate.value = '';
|
||||
}
|
||||
// Function to set the lane ID
|
||||
const setLaneId = (id: number | null) => {
|
||||
laneId.value = id;
|
||||
@@ -1221,6 +1233,10 @@ const metadata = {
|
||||
reference,
|
||||
setReference,
|
||||
getReference,
|
||||
// Safety seal
|
||||
safetySeal,
|
||||
setSafetySeal,
|
||||
getSafetySeal,
|
||||
// Wash ID
|
||||
washId,
|
||||
setWashId,
|
||||
@@ -1229,6 +1245,10 @@ const metadata = {
|
||||
bookingId,
|
||||
setBookingId,
|
||||
getBookingId,
|
||||
bookingSelectionSkippedPlate,
|
||||
setBookingSelectionSkippedPlate,
|
||||
getBookingSelectionSkippedPlate,
|
||||
clearBookingSelectionSkippedPlate,
|
||||
// Lane ID
|
||||
laneId,
|
||||
setLaneId,
|
||||
@@ -1307,17 +1327,14 @@ const selectLastVehicleOrder = (vehicleIndex: number) => {
|
||||
product: item.product,
|
||||
} as Addon;
|
||||
});
|
||||
console.log(`addons for last order:`, addons);
|
||||
const primaryItem = <PosProduct>{
|
||||
...lastOrder.items[0].product,
|
||||
addons: addons,
|
||||
}; // Assuming the first item is the primary item for the transaction.
|
||||
console.log(`primary item for last order:`, primaryItem);
|
||||
// If the last order exists, set it as the primary item for the transaction.
|
||||
vehicles.select(vehicleIndex, {...vehicles.get(vehicleIndex), type: primaryItem.id});
|
||||
transactionItems.setPrimaryItem({...primaryItem}); // Clear addons for the primary item
|
||||
transactionItems.setPrimaryItem({...primaryItem}); // Set the primary item for the transaction
|
||||
console.log(`Selected last vehicle order for vehicle ${vehicleIndex}:`, lastOrder);
|
||||
} else {
|
||||
console.warn(`No last vehicle order found for vehicle ${vehicleIndex}.`);
|
||||
}
|
||||
@@ -1379,8 +1396,10 @@ const resetMetadata = () => {
|
||||
metadata.customerId.value = null;
|
||||
metadata.notes.value = '';
|
||||
metadata.reference.value = '';
|
||||
metadata.safetySeal.value = '';
|
||||
metadata.washId.value = null;
|
||||
metadata.bookingId.value = null;
|
||||
metadata.bookingSelectionSkippedPlate.value = '';
|
||||
metadata.laneId.value = null;
|
||||
metadata.clearLoadingState();
|
||||
}
|
||||
@@ -1543,8 +1562,10 @@ const buildSnapshot = () => ({
|
||||
customerId: metadata.customerId.value,
|
||||
notes: metadata.notes.value,
|
||||
reference: metadata.reference.value,
|
||||
safetySeal: metadata.safetySeal.value,
|
||||
washId: metadata.washId.value,
|
||||
bookingId: metadata.bookingId.value,
|
||||
bookingSelectionSkippedPlate: metadata.bookingSelectionSkippedPlate.value,
|
||||
laneId: metadata.laneId.value,
|
||||
},
|
||||
attachments: {
|
||||
@@ -1627,8 +1648,11 @@ const retrievePos = () => {
|
||||
metadata.customerId.value = parsedData.metadata.customerId ?? null;
|
||||
metadata.notes.value = parsedData.metadata.notes ?? '';
|
||||
metadata.reference.value = parsedData.metadata.reference ?? '';
|
||||
metadata.safetySeal.value = parsedData.metadata.safetySeal ?? '';
|
||||
metadata.washId.value = parsedData.metadata.washId ?? null;
|
||||
metadata.bookingId.value = parsedData.metadata.bookingId ?? null;
|
||||
metadata.bookingSelectionSkippedPlate.value = parsedData.metadata.bookingSelectionSkippedPlate ?? '';
|
||||
metadata.laneId.value = parsedData.metadata.laneId ?? null;
|
||||
}
|
||||
|
||||
// Restore last vehicle orders
|
||||
@@ -1695,8 +1719,10 @@ watch(() => [
|
||||
metadata.customerId.value,
|
||||
metadata.notes.value,
|
||||
metadata.reference.value,
|
||||
metadata.safetySeal.value,
|
||||
metadata.washId.value,
|
||||
metadata.bookingId.value,
|
||||
metadata.bookingSelectionSkippedPlate.value,
|
||||
metadata.laneId.value
|
||||
], savePos);
|
||||
|
||||
@@ -1851,12 +1877,19 @@ export const Customer = {
|
||||
reference,
|
||||
setReference,
|
||||
getReference,
|
||||
safetySeal,
|
||||
setSafetySeal,
|
||||
getSafetySeal,
|
||||
washId,
|
||||
setWashId,
|
||||
getWashId,
|
||||
bookingId,
|
||||
setBookingId,
|
||||
getBookingId,
|
||||
bookingSelectionSkippedPlate,
|
||||
setBookingSelectionSkippedPlate,
|
||||
getBookingSelectionSkippedPlate,
|
||||
clearBookingSelectionSkippedPlate,
|
||||
};
|
||||
|
||||
export const Meta = {
|
||||
@@ -2026,6 +2059,9 @@ export {
|
||||
reference,
|
||||
setReference,
|
||||
getReference,
|
||||
safetySeal,
|
||||
setSafetySeal,
|
||||
getSafetySeal,
|
||||
washId,
|
||||
setWashId,
|
||||
getWashId,
|
||||
@@ -2068,4 +2104,4 @@ export {
|
||||
getCameraImageCaptureDelay as getCameraCaptureDelay,
|
||||
};
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -22,11 +22,23 @@ import PosDepartmentStepMobilePopupImage
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupImage.vue";
|
||||
import PosDepartmentStepMobilePopupAddCustomer
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupAddCustomer.vue";
|
||||
import PosDepartmentStepMobilePopupSelectOrderBooking
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupSelectOrderBooking.vue";
|
||||
|
||||
export type PopupComponentKey = 'select_customer' | 'completed_transaction' | 'complete_booking' | 'error' | 'add_product_note' | 'select_vehicle' | 'change_reference' | 'customer_notes' | 'image_viewer' | 'add_customer';
|
||||
export type PopupComponentKey = 'select_customer' | 'select_order_booking' | 'completed_transaction' | 'complete_booking' | 'error' | 'add_product_note' | 'select_vehicle' | 'change_reference' | 'customer_notes' | 'image_viewer' | 'add_customer';
|
||||
|
||||
export type SelectOrderBookingPopupProps = {
|
||||
bookings?: any[];
|
||||
onSelect?: (booking: any) => Promise<void> | void;
|
||||
onSkip?: () => Promise<void> | void;
|
||||
onRefreshBookings?: () => Promise<any[] | { bookings?: any[] } | void> | any[] | { bookings?: any[] } | void;
|
||||
departmentId?: number | null;
|
||||
matchedPlate?: string;
|
||||
};
|
||||
|
||||
export const PopupComponents = {
|
||||
select_customer: PosDepartmentStepMobilePopupSelectCustomer,
|
||||
select_order_booking: PosDepartmentStepMobilePopupSelectOrderBooking,
|
||||
completed_transaction: PosDepartmentStepMobilePopupCompletedTransaction,
|
||||
complete_booking: PosDepartmentStepMobilePopupCompleteBooking,
|
||||
error: PosDepartmentStepMobilePopupError,
|
||||
@@ -50,6 +62,9 @@ export type PosPopup = {
|
||||
status?: VehicleStatusKey;
|
||||
hideHeader?: boolean;
|
||||
hideFooter?: boolean;
|
||||
showHeaderClose?: boolean;
|
||||
onHeaderClose?: () => void;
|
||||
headerCloseTestId?: string;
|
||||
style?: Record<string, any>; // CSS styles
|
||||
/** Action buttons */
|
||||
actionButtons?: PosActionButton[];
|
||||
@@ -59,6 +74,7 @@ export default defineComponent({
|
||||
name: 'PosPopup',
|
||||
components: {
|
||||
PosDepartmentStepMobilePopupSelectCustomer,
|
||||
PosDepartmentStepMobilePopupSelectOrderBooking,
|
||||
PosDepartmentStepMobilePopupCompletedTransaction,
|
||||
PosDepartmentStepMobilePopupCompleteBooking,
|
||||
PosDepartmentStepMobilePopupError,
|
||||
@@ -81,4 +97,4 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { VehicleStatusKey } from './PosVehicleStatus.vue';
|
||||
import type { PosCustomerSuggestion } from './PosCustomerSuggestion.vue';
|
||||
export type OrderBookingSummary = {
|
||||
id: number;
|
||||
customer_name?: string;
|
||||
customer_number?: number;
|
||||
customer_id?: number;
|
||||
reg_1?: string;
|
||||
reg_2?: string;
|
||||
reference?: string;
|
||||
reference_number?: string;
|
||||
note?: string;
|
||||
notes?: string;
|
||||
po?: string;
|
||||
datetime?: string;
|
||||
created_at?: string;
|
||||
wash_type?: string;
|
||||
parsed_services?: {
|
||||
string?: string;
|
||||
array?: string[];
|
||||
};
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type PosSearchResult = {
|
||||
registrationNumber?: string;
|
||||
customerName: string;
|
||||
@@ -13,6 +35,7 @@ export type PosSearchResult = {
|
||||
lastOrderId?: number;
|
||||
washSubscription?: boolean;
|
||||
customerSuggestions?: PosCustomerSuggestion[]; // This is an array of previously billed customers.
|
||||
bookingId?: number; // If the search result is linked to a booking.
|
||||
bookingId?: number; // The explicitly selected booking id.
|
||||
bookingMatches?: OrderBookingSummary[];
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { VehicleStatusKey } from './PosVehicleStatus.vue';
|
||||
import type { OrderBookingSummary } from './PosSearchResult.vue';
|
||||
export type PosVehicle = {
|
||||
id?: number;
|
||||
customer_id?: number;
|
||||
@@ -19,7 +20,8 @@ export type PosVehicle = {
|
||||
last_order_id?: number;
|
||||
// Booking ID (if applicable)
|
||||
booking_id?: number;
|
||||
booking_matches?: OrderBookingSummary[];
|
||||
// additional data
|
||||
[key: string]: any;
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {
|
||||
order_id,
|
||||
order_notes,
|
||||
order_safety_seal,
|
||||
reference,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { metadata } from "./PosDepartmentStepMobileFlow.vue";
|
||||
|
||||
const normalizeTextValue = (value) => {
|
||||
return value === null || value === undefined ? "" : String(value).trim();
|
||||
};
|
||||
|
||||
const getNormalizedOrderId = () => {
|
||||
const parsed = Number.parseInt(String(order_id.value ?? ""), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const getNormalizedBookingId = () => {
|
||||
const parsed = Number.parseInt(String(metadata.getBookingId?.() ?? ""), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
export const getResolvedMobileSafetySeal = () => {
|
||||
const metadataSafetySeal = normalizeTextValue(metadata.getSafetySeal?.());
|
||||
if (metadataSafetySeal !== "") {
|
||||
return metadataSafetySeal;
|
||||
}
|
||||
|
||||
return normalizeTextValue(order_safety_seal.value);
|
||||
};
|
||||
|
||||
export const syncMobileSafetySealState = (value) => {
|
||||
const normalizedValue = normalizeTextValue(value);
|
||||
order_safety_seal.value = normalizedValue;
|
||||
metadata.setSafetySeal?.(normalizedValue);
|
||||
return normalizedValue;
|
||||
};
|
||||
|
||||
export const persistCurrentMobileOrderMetadata = async () => {
|
||||
const normalizedOrderId = getNormalizedOrderId();
|
||||
if (!normalizedOrderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedReference = normalizeTextValue(metadata.getReference?.() ?? reference.value);
|
||||
const normalizedNotes = normalizeTextValue(metadata.getNotes?.() ?? order_notes.value);
|
||||
const normalizedSafetySeal = syncMobileSafetySealState(getResolvedMobileSafetySeal());
|
||||
|
||||
reference.value = normalizedReference;
|
||||
order_notes.value = normalizedNotes;
|
||||
|
||||
await Promise.all([
|
||||
SessionUser.objects.orders.set.reference(normalizedOrderId, normalizedReference),
|
||||
SessionUser.objects.orders.set.notes(normalizedOrderId, normalizedNotes),
|
||||
SessionUser.objects.orders.set.safety_seal(normalizedOrderId, normalizedSafetySeal),
|
||||
]);
|
||||
|
||||
return normalizedOrderId;
|
||||
};
|
||||
|
||||
export const linkCurrentMobileOrderToBooking = async () => {
|
||||
const normalizedOrderId = getNormalizedOrderId();
|
||||
const normalizedBookingId = getNormalizedBookingId();
|
||||
if (!normalizedOrderId || !normalizedBookingId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await SessionUser.objects.orders.set.booking_id(normalizedOrderId, normalizedBookingId);
|
||||
await SessionUser.objects.order_bookings.set.order_id(normalizedBookingId, normalizedOrderId);
|
||||
return normalizedBookingId;
|
||||
};
|
||||
|
||||
export const completeLinkedBookingIfNeeded = async (bookingId = getNormalizedBookingId(), options = {}) => {
|
||||
const normalizedOptions = {
|
||||
safetySeal: null,
|
||||
...options,
|
||||
};
|
||||
const normalizedBookingId = Number.parseInt(String(bookingId ?? ""), 10);
|
||||
if (!Number.isInteger(normalizedBookingId) || normalizedBookingId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const booking = await SessionUser.objects.order_bookings.get.single(normalizedBookingId).catch(() => null);
|
||||
if (booking?.status === "completed") {
|
||||
return booking;
|
||||
}
|
||||
|
||||
const normalizedSafetySeal = normalizeTextValue(normalizedOptions.safetySeal);
|
||||
return SessionUser.objects.order_bookings.functions.complete(
|
||||
normalizedBookingId,
|
||||
normalizedSafetySeal === "" ? null : normalizedSafetySeal
|
||||
);
|
||||
};
|
||||
|
||||
export const finalizeCurrentMobileOrder = async (options = {}) => {
|
||||
const normalizedOptions = {
|
||||
completeLinkedBooking: true,
|
||||
bookingSafetySeal: null,
|
||||
markOrderCompleted: true,
|
||||
...options,
|
||||
};
|
||||
const normalizedOrderId = await persistCurrentMobileOrderMetadata();
|
||||
if (!normalizedOrderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedBookingId = await linkCurrentMobileOrderToBooking();
|
||||
if (normalizedBookingId && normalizedOptions.completeLinkedBooking) {
|
||||
await completeLinkedBookingIfNeeded(normalizedBookingId, {
|
||||
safetySeal: normalizedOptions.bookingSafetySeal,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedOptions.markOrderCompleted) {
|
||||
await SessionUser.objects.orders.functions.mark_as_completed(normalizedOrderId);
|
||||
}
|
||||
return normalizedOrderId;
|
||||
};
|
||||
+2
-1
@@ -38,6 +38,7 @@ const save = () => {
|
||||
<div class="control">
|
||||
<input
|
||||
class="custom-input"
|
||||
data-testid="pos-mobile-reference-input"
|
||||
id="reference_input"
|
||||
:class="{ 'is-searched': hasFocus }"
|
||||
type="text"
|
||||
@@ -107,4 +108,4 @@ const save = () => {
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+6
-3
@@ -147,7 +147,10 @@ watch(() => props.showInput, (newVal) => {
|
||||
<br />
|
||||
<small>{{ SessionUser.functions.ucFirst(SessionUser.functions.date.toWordsWithTime(note?.created_at || new Date())) }}</small>
|
||||
<br />
|
||||
<small v-if="note?.cashier_id">{{ t('pos.created_by') }} <strong>{{ cashierNamesMap.find(cashier => cashier.id === parseInt(note.cashier_id))?.display_name || t('pos.unknown') }}</strong></small>
|
||||
<small
|
||||
v-if="note?.cashier_id"
|
||||
:data-testid="`pos-mobile-customer-note-created-by-${note.id}`"
|
||||
>{{ t('admin.pos.created_by') }} <strong>{{ cashierNamesMap.find(cashier => cashier.id === parseInt(note.cashier_id))?.display_name || t('admin.pos.unknown') }}</strong></small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,7 +161,7 @@ watch(() => props.showInput, (newVal) => {
|
||||
<input
|
||||
class="input has-sharp-edges"
|
||||
type="text"
|
||||
:placeholder="t('pos.add_note_placeholder')"
|
||||
:placeholder="t('admin.pos.add_note_placeholder')"
|
||||
v-model="input"
|
||||
id="input_field"
|
||||
@keyup.enter="createNewNote(input); input=''"
|
||||
@@ -226,4 +229,4 @@ watch(() => props.showInput, (newVal) => {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+214
-75
@@ -1,19 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import {ref, defineEmits, onMounted, watch, defineProps} from "vue";
|
||||
import { vehicles } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { reg_1, reg_2, reg_3, vehicles_matching } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import {ref, defineEmits, onBeforeUnmount, onMounted, watch, defineProps} from "vue";
|
||||
import { order_id, reg_1, reg_2, reg_3, getOrderDetails } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumberInputField from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumberInputField.vue";
|
||||
import BarcodeScanner from "@/components/viewport/elements/icons/BarcodeScanner.vue";
|
||||
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
|
||||
import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue";
|
||||
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
|
||||
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { PosVehicle } from "@/components/displays/department/pos/steps/mobile/objects/PosVehicle.vue";
|
||||
import type { PosSearchResult } from "../objects/PosSearchResult.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
import PosDepartmentStepMobileFixedBottomControl
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||
import PosDepartmentStepMobileButtonNextStep
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
|
||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||
// Define the close event to emit when the component is closed
|
||||
const emit = defineEmits(["close"]);
|
||||
// Define the props
|
||||
@@ -42,61 +42,189 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const inputTestIds = {
|
||||
reg1: 'pos-mobile-reg-input-1',
|
||||
reg2: 'pos-mobile-reg-input-2',
|
||||
reg3: 'pos-mobile-reg-input-3',
|
||||
};
|
||||
|
||||
const normalizeRegistrationValue = (value: string | null | undefined) => String(value ?? '').toUpperCase();
|
||||
|
||||
const getNormalizedOrderId = () => {
|
||||
const parsedOrderId = Number.parseInt(String(order_id.value), 10);
|
||||
return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null;
|
||||
};
|
||||
|
||||
const getRegistrationSourceRef = (vehicleIndex: number) => {
|
||||
switch (vehicleIndex) {
|
||||
case 1:
|
||||
return reg_1;
|
||||
case 2:
|
||||
return reg_2;
|
||||
case 3:
|
||||
return reg_3;
|
||||
default:
|
||||
return reg_1;
|
||||
}
|
||||
};
|
||||
|
||||
const setPersistedRegistrationValue = (vehicleIndex: number, value: string) => {
|
||||
const normalizedValue = normalizeRegistrationValue(value);
|
||||
getRegistrationSourceRef(vehicleIndex).value = normalizedValue;
|
||||
};
|
||||
|
||||
const syncVehicleRegistration = (vehicleIndex: number, value: string, overrides: Record<string, any> = {}) => {
|
||||
const normalizedValue = normalizeRegistrationValue(value);
|
||||
|
||||
if (normalizedValue === "") {
|
||||
pos.vehicles.select(vehicleIndex, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentVehicle = pos.vehicles.get(vehicleIndex);
|
||||
pos.vehicles.select(vehicleIndex, {
|
||||
...(currentVehicle ?? {}),
|
||||
...overrides,
|
||||
reg: normalizedValue,
|
||||
});
|
||||
};
|
||||
|
||||
// Define a reactive variable to control the expanded state of the component
|
||||
const expanded = ref(props.forceShowApplicable);
|
||||
// Define reactive variables for registration numbers and labels
|
||||
const registrationNumber1 = ref("");
|
||||
const label1 = ref("Reg 1*");
|
||||
const registrationNumber2 = ref("");
|
||||
const label2 = ref("Reg 2*");
|
||||
const registrationNumber3 = ref("");
|
||||
const label3 = ref("Reg 3");
|
||||
|
||||
const createRegistrationAutosave = (vehicleIndex: number) => useOrderMetadataAutosave({
|
||||
source: getRegistrationSourceRef(vehicleIndex),
|
||||
normalizeValue: normalizeRegistrationValue,
|
||||
saveValue: async (value) => {
|
||||
const normalizedOrderId = getNormalizedOrderId();
|
||||
if (!normalizedOrderId) {
|
||||
return value;
|
||||
}
|
||||
|
||||
await SessionUser.objects.orders.set[`reg_${vehicleIndex}`](normalizedOrderId, value);
|
||||
const refreshedOrder = await getOrderDetails(normalizedOrderId);
|
||||
return refreshedOrder?.[`reg_${vehicleIndex}`] ?? value;
|
||||
},
|
||||
onSaved: async (value) => {
|
||||
setPersistedRegistrationValue(vehicleIndex, value);
|
||||
syncVehicleRegistration(vehicleIndex, value);
|
||||
},
|
||||
});
|
||||
|
||||
const registrationAutosaves = {
|
||||
1: createRegistrationAutosave(1),
|
||||
2: createRegistrationAutosave(2),
|
||||
3: createRegistrationAutosave(3),
|
||||
};
|
||||
|
||||
const reg1Draft = registrationAutosaves[1].draft;
|
||||
const reg2Draft = registrationAutosaves[2].draft;
|
||||
const reg3Draft = registrationAutosaves[3].draft;
|
||||
|
||||
const setRegistrationDraft = (vehicleIndex: number, value: string) => {
|
||||
const normalizedValue = normalizeRegistrationValue(value);
|
||||
|
||||
switch (vehicleIndex) {
|
||||
case 1:
|
||||
reg1Draft.value = normalizedValue;
|
||||
break;
|
||||
case 2:
|
||||
reg2Draft.value = normalizedValue;
|
||||
break;
|
||||
case 3:
|
||||
reg3Draft.value = normalizedValue;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
watch(reg1Draft, (value) => {
|
||||
syncVehicleRegistration(1, value);
|
||||
if (getNormalizedOrderId()) {
|
||||
registrationAutosaves[1].scheduleSave();
|
||||
return;
|
||||
}
|
||||
|
||||
setPersistedRegistrationValue(1, value);
|
||||
});
|
||||
|
||||
watch(reg2Draft, (value) => {
|
||||
syncVehicleRegistration(2, value);
|
||||
if (getNormalizedOrderId()) {
|
||||
registrationAutosaves[2].scheduleSave();
|
||||
return;
|
||||
}
|
||||
|
||||
setPersistedRegistrationValue(2, value);
|
||||
});
|
||||
|
||||
watch(reg3Draft, (value) => {
|
||||
syncVehicleRegistration(3, value);
|
||||
if (getNormalizedOrderId()) {
|
||||
registrationAutosaves[3].scheduleSave();
|
||||
return;
|
||||
}
|
||||
|
||||
setPersistedRegistrationValue(3, value);
|
||||
});
|
||||
|
||||
watch(() => getNormalizedOrderId(), (normalizedOrderId) => {
|
||||
if (!normalizedOrderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
[1, 2, 3].forEach((vehicleIndex) => {
|
||||
if (registrationAutosaves[vehicleIndex].isDirty.value) {
|
||||
registrationAutosaves[vehicleIndex].scheduleSave();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const flushRegistration = async (vehicleIndex: number) => {
|
||||
await registrationAutosaves[vehicleIndex].flush();
|
||||
};
|
||||
|
||||
const flushAllRegistrations = async () => {
|
||||
await Promise.all([1, 2, 3].map((vehicleIndex) => registrationAutosaves[vehicleIndex].flush()));
|
||||
};
|
||||
|
||||
// Function to set a value if it is not null
|
||||
function setIfNotNull(variable: string, value: { registrationNumber: string, customerStatus: any, customerId?: number, lastOrderId?: number } | null) {
|
||||
const createVehicleSelectionFromSearchResult = (value: PosSearchResult) => ({
|
||||
...value,
|
||||
reg: value.registrationNumber,
|
||||
status: value.customerStatus,
|
||||
customer_id: value.customerId,
|
||||
last_order_id: value.lastOrderId,
|
||||
booking_id: value.bookingId,
|
||||
booking_matches: value.bookingMatches || [],
|
||||
wash_subscription: value.washSubscription,
|
||||
});
|
||||
|
||||
function setIfNotNull(variable: string, value: PosSearchResult | null) {
|
||||
//console.warn("setIfNotNull", variable, value);
|
||||
if (value !== null) {
|
||||
const nextVehicleSelection = createVehicleSelectionFromSearchResult(value);
|
||||
|
||||
switch (variable) {
|
||||
case 'reg_1':
|
||||
//console.log("Setting reg_1 with value:", value);
|
||||
pos.vehicles.select(
|
||||
1,
|
||||
{
|
||||
reg: value.registrationNumber,
|
||||
status: value.customerStatus,
|
||||
customer_id: value.customerId,
|
||||
last_order_id: value.lastOrderId,
|
||||
}
|
||||
)
|
||||
registrationNumber1.value = value.registrationNumber;
|
||||
reg_1.value = value.registrationNumber;
|
||||
pos.vehicles.select(1, nextVehicleSelection);
|
||||
setRegistrationDraft(1, value.registrationNumber);
|
||||
//reg_1_status.value = value.customerStatus;
|
||||
break;
|
||||
case 'reg_2':
|
||||
pos.vehicles.select(
|
||||
2,
|
||||
{
|
||||
reg: value.registrationNumber,
|
||||
status: value.customerStatus,
|
||||
customer_id: value.customerId,
|
||||
}
|
||||
)
|
||||
registrationNumber2.value = value.registrationNumber;
|
||||
reg_2.value = value.registrationNumber;
|
||||
pos.vehicles.select(2, nextVehicleSelection);
|
||||
setRegistrationDraft(2, value.registrationNumber);
|
||||
//reg_2_status.value = value.customerStatus;
|
||||
|
||||
break;
|
||||
case 'reg_3':
|
||||
pos.vehicles.select(
|
||||
3,
|
||||
{
|
||||
reg: value.registrationNumber,
|
||||
status: value.customerStatus,
|
||||
customer_id: value.customerId,
|
||||
}
|
||||
)
|
||||
registrationNumber3.value = value.registrationNumber;
|
||||
reg_3.value = value.registrationNumber;
|
||||
pos.vehicles.select(3, nextVehicleSelection);
|
||||
setRegistrationDraft(3, value.registrationNumber);
|
||||
//reg_3_status.value = value.customerStatus;
|
||||
break;
|
||||
}
|
||||
@@ -112,95 +240,106 @@ function setIfNotNull(variable: string, value: { registrationNumber: string, cus
|
||||
// - Automatically select the first vehicle in the list, if the user input matches one of the vehicles exactly
|
||||
function setValueUserInput(vehicleIndex: number, value: string) {
|
||||
//console.log("setValueUserInput", vehicleIndex, value);
|
||||
let vehicle: PosVehicle | null = null; // Initialize vehicle as null
|
||||
const normalizedValue = normalizeRegistrationValue(value);
|
||||
/** Case: Empty input */
|
||||
// If the value is empty, clear the registration number
|
||||
if (value === "") {
|
||||
pos.vehicles.select(vehicleIndex, vehicle);
|
||||
if (normalizedValue === "") {
|
||||
pos.vehicles.select(vehicleIndex, null);
|
||||
setRegistrationDraft(vehicleIndex, '');
|
||||
void flushRegistration(vehicleIndex);
|
||||
return;
|
||||
}
|
||||
/** Case: Input matches a vehicle exactly */
|
||||
// If the value matches a result exactly, select that vehicle
|
||||
let matchingVehicle = vehicles_matching.value.filter(vehicle => vehicle.reg === value); // Filter the vehicles_matching array to find a vehicle with the same registration number
|
||||
if (matchingVehicle.length > 0) {
|
||||
//console.log("setValueUserInput: matchingVehicle", matchingVehicle);
|
||||
}
|
||||
|
||||
setRegistrationDraft(vehicleIndex, normalizedValue);
|
||||
void flushRegistration(vehicleIndex);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Initialize the registration numbers from the pos.vehicles object
|
||||
reg_1.value = pos.vehicles.get(1)?.reg ?? "";
|
||||
reg_2.value = pos.vehicles.get(2)?.reg ?? "";
|
||||
reg_3.value = pos.vehicles.get(3)?.reg ?? "";
|
||||
setRegistrationDraft(1, pos.vehicles.get(1)?.reg ?? reg_1.value ?? "");
|
||||
setRegistrationDraft(2, pos.vehicles.get(2)?.reg ?? reg_2.value ?? "");
|
||||
setRegistrationDraft(3, pos.vehicles.get(3)?.reg ?? reg_3.value ?? "");
|
||||
// If the reg_3 is not empty, set the expanded state to true
|
||||
expanded.value = reg_3.value !== "" || props.forceShowApplicable;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
void flushAllRegistrations();
|
||||
});
|
||||
|
||||
const closeManualInput = async () => {
|
||||
await flushAllRegistrations();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div data-testid="pos-mobile-manual-input">
|
||||
<template v-if="props.showReg1">
|
||||
<!-- First input field with a required label -->
|
||||
<PosDepartmentStepMobile1RegistrationNumberInputField
|
||||
v-model:modelValue="reg_1"
|
||||
v-model:modelValue="reg1Draft"
|
||||
v-model:label="label1"
|
||||
@focusout="setValueUserInput(1, reg_1)"
|
||||
@onInput="reg_1 = $event"
|
||||
:inputTestId="inputTestIds.reg1"
|
||||
@focusout="setValueUserInput(1, reg1Draft)"
|
||||
>
|
||||
<template #searchResults>
|
||||
<RegistrationNumberSearchResult @select="setIfNotNull('reg_1', $event)" :searchQuery="reg_1" :automaticallySelect="true"/>
|
||||
<RegistrationNumberSearchResult @select="setIfNotNull('reg_1', $event)" :searchQuery="reg1Draft" :automaticallySelect="true"/>
|
||||
</template>
|
||||
</PosDepartmentStepMobile1RegistrationNumberInputField>
|
||||
</template>
|
||||
<template v-if="props.showReg2">
|
||||
<!-- Second input field with a custom expander button -->
|
||||
<PosDepartmentStepMobile1RegistrationNumberInputField
|
||||
v-model:modelValue="reg_2"
|
||||
v-model:modelValue="reg2Draft"
|
||||
v-model:label="label2"
|
||||
@focusout="setValueUserInput(2, reg_2)"
|
||||
:inputTestId="inputTestIds.reg2"
|
||||
@focusout="setValueUserInput(2, reg2Draft)"
|
||||
>
|
||||
<template #default>
|
||||
<div class="custom-expander" @click="expanded = !expanded" v-if="!reg_3">
|
||||
<div class="custom-expander" @click="expanded = !expanded" v-if="!reg3Draft">
|
||||
<span class="custom-text">+</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #searchResults>
|
||||
<RegistrationNumberSearchResult @select="setIfNotNull('reg_2', $event)" :searchQuery="reg_2" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
|
||||
<RegistrationNumberSearchResult @select="setIfNotNull('reg_2', $event)" :searchQuery="reg2Draft" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
|
||||
</template>
|
||||
</PosDepartmentStepMobile1RegistrationNumberInputField>
|
||||
</template>
|
||||
<template v-if="props.showReg3">
|
||||
<!-- Third input field that is conditionally displayed based on the expanded state -->
|
||||
<PosDepartmentStepMobile1RegistrationNumberInputField
|
||||
v-show="expanded || reg_3 !== ''"
|
||||
v-model:modelValue="reg_3"
|
||||
v-show="expanded || reg3Draft !== ''"
|
||||
v-model:modelValue="reg3Draft"
|
||||
v-model:label="label3"
|
||||
@focusout="setValueUserInput(3, reg_3)"
|
||||
:inputTestId="inputTestIds.reg3"
|
||||
@focusout="setValueUserInput(3, reg3Draft)"
|
||||
>
|
||||
<template #searchResults>
|
||||
<RegistrationNumberSearchResult @select="setIfNotNull('reg_3', $event)" :searchQuery="reg_3" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
|
||||
<RegistrationNumberSearchResult @select="setIfNotNull('reg_3', $event)" :searchQuery="reg3Draft" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
|
||||
</template>
|
||||
</PosDepartmentStepMobile1RegistrationNumberInputField>
|
||||
</template>
|
||||
<template v-if="props.showButtons">
|
||||
<!-- Back to scan button -->
|
||||
<button class="button p-5 is-fullwidth mt-3 is-text" style="text-decoration: none" @click="$emit('close')">
|
||||
<button class="button p-5 is-fullwidth mt-3 is-text" data-testid="pos-mobile-manual-input-close" style="text-decoration: none" @click="closeManualInput()">
|
||||
<span class="m-2"><BarcodeScanner/></span>
|
||||
<span class="custom-label-button">{{ SessionUser.objects.global.language.or_scan_plates}}</span>
|
||||
</button>
|
||||
</template>
|
||||
<!-- Buttons -->
|
||||
<PosDepartmentStepMobileFixedBottomControl v-if="props.showButtons">
|
||||
<!-- Close button -->
|
||||
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => emit('close')" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
|
||||
<span class="is-float-left">{{ SessionUser.objects.global.language.close }}</span>
|
||||
<span class="is-float-right">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
<PosDepartmentStepMobileFixedBottomControl v-if="props.showButtons">
|
||||
<!-- Close button -->
|
||||
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => closeManualInput()" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
|
||||
<span class="pos-mobile-action-content">
|
||||
<span class="pos-mobile-action-label">{{ SessionUser.objects.global.language.close }}</span>
|
||||
<span class="pos-mobile-action-icon">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</span>
|
||||
</span>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
</PosDepartmentStepMobileFixedBottomControl>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
</PosDepartmentStepMobileFixedBottomControl>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -272,4 +411,4 @@ onMounted(() => {
|
||||
order: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+200
-99
@@ -1,14 +1,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {defineEmits, onMounted, onUnmounted, ref} from "vue";
|
||||
import { setTransparency, setBackgroundColor, backgroundColors, setOverflow } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
|
||||
import { defineEmits, onMounted, onUnmounted, ref } from "vue";
|
||||
import {
|
||||
setTransparency,
|
||||
setBackgroundColor,
|
||||
backgroundColors,
|
||||
setOverflow,
|
||||
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
|
||||
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import PosDepartmentStepMobileFixedBottomControl
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||
import PosDepartmentStepMobileButtonNextStep
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
|
||||
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||
import PosDepartmentStepMobileButtonNextStep from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
import {PosOrder} from "@/components/displays/department/pos/steps/mobile/objects/PosOrder.vue";
|
||||
import { PosOrder } from "@/components/displays/department/pos/steps/mobile/objects/PosOrder.vue";
|
||||
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
||||
// Define the close event to emit when the component is closed
|
||||
const emit = defineEmits(["close"]);
|
||||
@@ -17,14 +19,17 @@ const showError = ref(true);
|
||||
const showCompleted = ref(false);
|
||||
const showPending = ref(true);
|
||||
|
||||
const toValidOrderId = (value: unknown): number | null => {
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Set the header to be transparent
|
||||
setTransparency(!pos.views.isAnyActive.value); // Set transparency based on manualInput state
|
||||
setBackgroundColor(backgroundColors.default); // Set the default background color
|
||||
setOverflow(true); // Prevent scrolling
|
||||
// Get all customer names for the transactions
|
||||
getTransactionData(pos.transactionHistory.get());
|
||||
// Sync with server
|
||||
// Sync with server first, then enrich transactions
|
||||
syncListTransactionHistory();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
@@ -35,53 +40,69 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
const getTransactionCustomer = (transaction: PosOrder) => {
|
||||
SessionUser.objects.global.get.object('/users/customer', {customer_number: transaction.customer_id}).then((response) => {
|
||||
transaction.customer_name = response.customer_name;
|
||||
});
|
||||
}
|
||||
const customerNumber = toValidOrderId(transaction?.customer_id);
|
||||
if (!customerNumber) {
|
||||
return;
|
||||
}
|
||||
SessionUser.objects.global.get
|
||||
.object("/users/customer", { customer_number: transaction.customer_id })
|
||||
.then((response) => {
|
||||
if (response?.customer_name) {
|
||||
transaction.customer_name = response.customer_name;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getTransactionData = (transactions: PosOrder[]) => {
|
||||
// get all transactions by their ids, and update the transaction (total_net_amount, created_at, customer_id)
|
||||
SessionUser.objects.orders.get.multiple(transactions.map(t => t.id)).then((response) => {
|
||||
response.forEach((transaction: PosOrder) => {
|
||||
const index = transactions.findIndex(t => t.id === transaction.id);
|
||||
if (index !== -1) {
|
||||
// Validate the transaction data
|
||||
if (!transaction.id) {
|
||||
transactions[index].error = SessionUser.objects.global.language.error_loading_transaction;
|
||||
return; // Skip if no valid id
|
||||
}
|
||||
transactions[index] = transaction; // Update the transaction in the array
|
||||
// Get the customer name
|
||||
getTransactionCustomer(transactions[index]);
|
||||
}
|
||||
const safeTransactions = Array.isArray(transactions) ? transactions.filter((transaction) => !!transaction) : [];
|
||||
if (safeTransactions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validIds = [
|
||||
...new Set(
|
||||
safeTransactions.map((transaction) => toValidOrderId(transaction?.id)).filter((id): id is number => id !== null)
|
||||
),
|
||||
];
|
||||
if (validIds.length === 0) {
|
||||
safeTransactions.forEach((transaction) => {
|
||||
transaction.error = SessionUser.objects.global.language.error_loading_transaction;
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error("Error fetching transactions:", error);
|
||||
// Loop through each transaction and set the error message
|
||||
transactions.forEach((transaction) => {
|
||||
const index = transactions.findIndex(t => t.id === transaction.id);
|
||||
if (index !== -1) {
|
||||
// Try to fetch the order again individually
|
||||
SessionUser.objects.orders.get.single(transaction.id).then((response) => {
|
||||
if (!response.id) {
|
||||
transactions[index].error = SessionUser.objects.global.language.error_loading_transaction;
|
||||
return; // Skip if no valid id
|
||||
}
|
||||
// Get the customer name
|
||||
getTransactionCustomer(transactions[index]);
|
||||
transactions[index] = response; // Update the transaction in the array
|
||||
}).catch((err) => {
|
||||
console.error("Error fetching transaction:", err);
|
||||
transactions[index].error = SessionUser.objects.global.language.error_loading_transaction;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all transactions by their ids and update in-place while guarding malformed payloads
|
||||
SessionUser.objects.orders.get.multiple(validIds).then((response) => {
|
||||
const fetchedById = new Map<number, PosOrder>();
|
||||
(Array.isArray(response) ? response : []).forEach((fetchedTransaction: PosOrder | undefined) => {
|
||||
const fetchedId = toValidOrderId(fetchedTransaction?.id);
|
||||
if (!fetchedId || !fetchedTransaction) {
|
||||
return;
|
||||
}
|
||||
fetchedById.set(fetchedId, fetchedTransaction);
|
||||
});
|
||||
|
||||
safeTransactions.forEach((transaction) => {
|
||||
const transactionId = toValidOrderId(transaction?.id);
|
||||
if (!transactionId) {
|
||||
transaction.error = SessionUser.objects.global.language.error_loading_transaction;
|
||||
return;
|
||||
}
|
||||
|
||||
const fetched = fetchedById.get(transactionId);
|
||||
if (!fetched) {
|
||||
transaction.error = SessionUser.objects.global.language.error_loading_transaction;
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(transaction, fetched);
|
||||
getTransactionCustomer(transaction);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const refreshInterval = setInterval(() => {
|
||||
getTransactionData(pos.transactionHistory.get());
|
||||
syncListTransactionHistory();
|
||||
}, 60000); // Refresh every 60 seconds
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -91,43 +112,50 @@ onUnmounted(() => {
|
||||
const isPendingHandheld = (transaction: PosOrder) => {
|
||||
if (!transaction?.pending_handheld) return false;
|
||||
return !transaction?.completed_at;
|
||||
}
|
||||
};
|
||||
|
||||
const hasError = (transaction: PosOrder) => {
|
||||
return !!transaction?.error;
|
||||
}
|
||||
};
|
||||
|
||||
const getTransactionError = (transaction: PosOrder) => {
|
||||
return transaction?.error || SessionUser.objects.global.language.unknown_error;
|
||||
}
|
||||
};
|
||||
|
||||
const liveTransactions = ref(null)
|
||||
const liveTransactions = ref(null);
|
||||
const isLoading = ref(true);
|
||||
|
||||
const syncListTransactionHistory = async () => {
|
||||
let dateToday = new Date().toISOString().split('T')[0]; // Get today's date in YYYY-MM-DD format
|
||||
let dateToday = new Date().toISOString().split("T")[0]; // Get today's date in YYYY-MM-DD format
|
||||
isLoading.value = true;
|
||||
// Fetch the list of orders created today for the current department
|
||||
SessionUser.objects.orders.get.list({filters: {
|
||||
'created_at-date_from': dateToday,
|
||||
'created_at-date_to': dateToday,
|
||||
'department_id': SessionUser.functions.getDepartmentIdFromUrl(),
|
||||
}, pagination: {
|
||||
page: 1, limit: 1000
|
||||
}}).then((response) => {
|
||||
console.warn('Synced list of transactions', response);
|
||||
liveTransactions.value = response;
|
||||
// Clear the transactions
|
||||
pos.transactionHistory.clear();
|
||||
// Loop through the new transactions, and add them
|
||||
for (let i = 0; i < liveTransactions.value.length; i++) {
|
||||
pos.transactionHistory.add(liveTransactions.value[i]);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Error syncing list of transactions:', error);
|
||||
}).finally(() => {
|
||||
isLoading.value = false;
|
||||
});
|
||||
SessionUser.objects.orders.get
|
||||
.list({
|
||||
filters: {
|
||||
"created_at-date_from": dateToday,
|
||||
"created_at-date_to": dateToday,
|
||||
department_id: SessionUser.functions.getDepartmentIdFromUrl(),
|
||||
},
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
liveTransactions.value = Array.isArray(response) ? response : [];
|
||||
// Clear the transactions
|
||||
pos.transactionHistory.clear();
|
||||
// Loop through the new transactions, and add them
|
||||
for (let i = 0; i < liveTransactions.value.length; i++) {
|
||||
if (liveTransactions.value[i]) {
|
||||
pos.transactionHistory.add(liveTransactions.value[i]);
|
||||
}
|
||||
}
|
||||
getTransactionData(pos.transactionHistory.get());
|
||||
})
|
||||
.finally(() => {
|
||||
isLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: boolean) => {
|
||||
@@ -137,11 +165,20 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div data-testid="pos-mobile-transaction-history">
|
||||
<!-- Visibility toggles -->
|
||||
<div class="columns is-mobile is-vcentered is-multiline">
|
||||
<div class="column is-half" v-for="(visibility, index) in [showPending, showCompleted]" :key="index">
|
||||
<WhiteBoxCard :toggleable="false" :forceState="false" :defaultOpen="false" @click="() => { index === 0 ? showPending = !showPending : showCompleted = !showCompleted }">
|
||||
<WhiteBoxCard
|
||||
:toggleable="false"
|
||||
:forceState="false"
|
||||
:defaultOpen="false"
|
||||
@click="
|
||||
() => {
|
||||
index === 0 ? (showPending = !showPending) : (showCompleted = !showCompleted);
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #header>
|
||||
<div class="card-header-title">
|
||||
<span v-if="index === 0">{{ SessionUser.objects.global.language.pending }}</span>
|
||||
@@ -156,24 +193,36 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
</template>
|
||||
<template #content> </template>
|
||||
</WhiteBoxCard>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="scrollable" style="overflow-x: hidden;">
|
||||
<div class="scrollable" style="overflow-x: hidden">
|
||||
<!-- Transaction history list -->
|
||||
<div class="columns is-multiline is-mobile mb-6">
|
||||
<template v-for="(transaction, index) in pos.transactionHistory.get()" :key="index">
|
||||
<div class="column is-12" v-if="isVisible(transaction, showPending, showCompleted)">
|
||||
<div class="box" @click="SessionUser.functions.redirectTo.department(transaction.department_id, 'modules/pos/orders/' + transaction.id)">
|
||||
<div
|
||||
class="box transaction-history-card"
|
||||
:data-testid="`pos-mobile-transaction-history-card-${transaction.id}`"
|
||||
@click="
|
||||
SessionUser.functions.redirectTo.department(
|
||||
transaction.department_id,
|
||||
'modules/pos/orders/' + transaction.id
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="columns is-mobile is-vcentered is-multiline">
|
||||
<div class="column is-12 pb-0">
|
||||
<p class="is-size-7 has-text-grey">{{ SessionUser.functions.date.toWordsWithTime(new Date(transaction.created_at)) }}</p>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
{{ SessionUser.functions.date.toWordsWithTime(new Date(transaction.created_at)) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-8 pt-0">
|
||||
<p class="is-size-6 has-text-weight-semibold">{{ transaction?.customer_name || transaction?.customer_id }}</p>
|
||||
<p class="is-size-6 has-text-weight-semibold">
|
||||
{{ transaction?.customer_name || transaction?.customer_id }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-4 has-text-right">
|
||||
<p class="is-size-6 has-text-weight-semibold">
|
||||
@@ -183,15 +232,39 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
|
||||
<div class="column is-12">
|
||||
<div class="columns is-vcentered is-mobile">
|
||||
<div class="column">
|
||||
<span class="icon" :class="hasError(transaction) ? 'has-text-danger' : isPendingHandheld(transaction) ? 'has-text-warning' : 'has-text-success'">
|
||||
<!-- If the pending_handheld is true, show a clock icon, else show a check icon -->
|
||||
<i v-if="hasError(transaction)" class="fa-solid fa-triangle-exclamation"></i>
|
||||
<i v-else-if="isPendingHandheld(transaction)" class="fa-solid fa-clock"></i>
|
||||
<i v-else class="fa-solid fa-check"></i>
|
||||
<span
|
||||
class="icon"
|
||||
:class="
|
||||
hasError(transaction)
|
||||
? 'has-text-danger'
|
||||
: isPendingHandheld(transaction)
|
||||
? 'has-text-warning'
|
||||
: 'has-text-success'
|
||||
"
|
||||
>
|
||||
<!-- If the pending_handheld is true, show a clock icon, else show a check icon -->
|
||||
<i v-if="hasError(transaction)" class="fa-solid fa-triangle-exclamation"></i>
|
||||
<i v-else-if="isPendingHandheld(transaction)" class="fa-solid fa-clock"></i>
|
||||
<i v-else class="fa-solid fa-check"></i>
|
||||
</span>
|
||||
<span class="is-size-7" :class="hasError(transaction) ? 'has-text-danger' : isPendingHandheld(transaction) ? 'has-text-warning' : 'has-text-success'">
|
||||
<span
|
||||
class="is-size-7"
|
||||
:class="
|
||||
hasError(transaction)
|
||||
? 'has-text-danger'
|
||||
: isPendingHandheld(transaction)
|
||||
? 'has-text-warning'
|
||||
: 'has-text-success'
|
||||
"
|
||||
>
|
||||
<!-- If there is an error, show the error message, else if pending_handheld is true, show "Confirmation needed", else show "Completed" -->
|
||||
{{ hasError(transaction) ? getTransactionError(transaction) : isPendingHandheld(transaction) ? SessionUser.objects.global.language.confirmation_needed : SessionUser.objects.global.language.completed }}
|
||||
{{
|
||||
hasError(transaction)
|
||||
? getTransactionError(transaction)
|
||||
: isPendingHandheld(transaction)
|
||||
? SessionUser.objects.global.language.confirmation_needed
|
||||
: SessionUser.objects.global.language.completed
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="column is-narrow has-text-right">
|
||||
@@ -215,17 +288,41 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
|
||||
<!-- Buttons -->
|
||||
<PosDepartmentStepMobileFixedBottomControl>
|
||||
<!-- Refresh button -->
|
||||
<PosDepartmentStepMobileButtonNextStep class="mb-2" :isWhite="false" :customAction="() => syncListTransactionHistory()" :customDisabled="false" :buttonClasses="['has-background-primary-dark', 'has-text-black', ...isLoading ? ['is-loading'] : []]">
|
||||
<span class="is-float-left has-text-white">{{ SessionUser.objects.global.language.reload }}</span>
|
||||
<span class="is-float-right has-text-white is-loading">
|
||||
<i class="fa-solid fa-arrows-rotate"></i>
|
||||
<PosDepartmentStepMobileButtonNextStep
|
||||
class="mb-2"
|
||||
:isWhite="false"
|
||||
:customAction="() => syncListTransactionHistory()"
|
||||
:customDisabled="false"
|
||||
:buttonClasses="['has-background-primary-dark', 'has-text-black', ...(isLoading ? ['is-loading'] : [])]"
|
||||
>
|
||||
<span class="pos-mobile-action-content" data-testid="pos-mobile-transaction-history-reload-action">
|
||||
<span
|
||||
class="pos-mobile-action-label has-text-white"
|
||||
data-testid="pos-mobile-transaction-history-reload-label"
|
||||
>{{ SessionUser.objects.global.language.reload }}</span
|
||||
>
|
||||
<span
|
||||
class="pos-mobile-action-icon has-text-white"
|
||||
data-testid="pos-mobile-transaction-history-reload-icon"
|
||||
>
|
||||
<i class="fa-solid fa-arrows-rotate"></i>
|
||||
</span>
|
||||
</span>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
<!-- Close button -->
|
||||
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => emit('close')" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
|
||||
<span class="is-float-left">{{ SessionUser.objects.global.language.close }}</span>
|
||||
<span class="is-float-right">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
<PosDepartmentStepMobileButtonNextStep
|
||||
:isWhite="false"
|
||||
:customAction="() => emit('close')"
|
||||
:customDisabled="false"
|
||||
:buttonClasses="['has-background-primary', 'has-text-black']"
|
||||
>
|
||||
<span class="pos-mobile-action-content" data-testid="pos-mobile-transaction-history-close-action">
|
||||
<span class="pos-mobile-action-label" data-testid="pos-mobile-transaction-history-close-label">{{
|
||||
SessionUser.objects.global.language.close
|
||||
}}</span>
|
||||
<span class="pos-mobile-action-icon" data-testid="pos-mobile-transaction-history-close-icon">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</span>
|
||||
</span>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
</PosDepartmentStepMobileFixedBottomControl>
|
||||
@@ -238,4 +335,8 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
|
||||
max-height: calc(100vh - 250px); /* Adjust based on header/footer height */
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
.transaction-history-card {
|
||||
border-top: 1px solid #dbdbdb;
|
||||
}
|
||||
</style>
|
||||
|
||||
+2
-2
@@ -72,7 +72,7 @@ const canClose = () => Date.now() >= earliestCloseTime.value;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div data-testid="pos-mobile-vehicle-selection">
|
||||
<!-- Product & Search button -->
|
||||
<GenericLabelSearch @click="onSearchClick">
|
||||
{{ SessionUser.objects.products.meta.title }}
|
||||
@@ -99,4 +99,4 @@ const canClose = () => Date.now() >= earliestCloseTime.value;
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -228,6 +228,7 @@ const onClickAttachToOrder = (object) => {
|
||||
<!-- Actions -->
|
||||
<ActionSettingsWheelButton
|
||||
:order_id="object.order_id"
|
||||
:refreshFunction="loadList"
|
||||
>
|
||||
<template #actions>
|
||||
<!-- Label -->
|
||||
@@ -268,4 +269,4 @@ const onClickAttachToOrder = (object) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -23,7 +23,7 @@ const props = defineProps({
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
import {ref, watch} from 'vue';
|
||||
import {computed, ref, watch} from 'vue';
|
||||
import { departments, getDepartments, isLoading, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
|
||||
import {showPopper, removePopperIfOpen, popperBox} from "@/components/displays/PopperDefault.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
@@ -33,6 +33,7 @@ import OrderContentTable from "@/components/displays/superuser/tables/OrderConte
|
||||
import InvoiceMultipleCollectionsModal from "@/components/displays/modals/InvoiceMultipleCollectionsModal.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
|
||||
import { invoiceQueue } from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue";
|
||||
|
||||
|
||||
const redirectDepartmentOrderPage = (orderId, departmentId) => {
|
||||
@@ -51,16 +52,31 @@ if (departments.value.length === 0) {
|
||||
getDepartments();
|
||||
}
|
||||
|
||||
const getEconomicInvoiceModule = (order) => order?.economic_invoice_module ?? null;
|
||||
const getStripeInvoiceModule = (order) => order?.stripe_invoice_module ?? null;
|
||||
const getInvoiceCollection = (order) => order?.invoice_collection ?? null;
|
||||
const hasLongText = (value, maxLength = 7) => String(value ?? '').length > maxLength;
|
||||
|
||||
const isOrderInvoicedWithEconomic = (order) => {
|
||||
return order.economic_invoice_module.invoice_id !== null || order.economic_invoice_module.invoice_draft_id !== null;
|
||||
const economicInvoiceModule = getEconomicInvoiceModule(order);
|
||||
if (!economicInvoiceModule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return economicInvoiceModule.invoice_id !== null || economicInvoiceModule.invoice_draft_id !== null;
|
||||
};
|
||||
|
||||
const isOrderEconomicInvoiceBooked = (order) => {
|
||||
return order.economic_invoice_module.invoice_id !== null;
|
||||
const economicInvoiceModule = getEconomicInvoiceModule(order);
|
||||
if (!economicInvoiceModule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return economicInvoiceModule.invoice_id !== null;
|
||||
};
|
||||
|
||||
const isOrderInvoicedWithStripe = (order) => {
|
||||
return order.stripe_invoice_module !== undefined;
|
||||
return getStripeInvoiceModule(order) !== null;
|
||||
};
|
||||
|
||||
const isOrderInvoiced = (order) => {
|
||||
@@ -68,20 +84,31 @@ const isOrderInvoiced = (order) => {
|
||||
};
|
||||
|
||||
const doesOrderHaveInvoiceCollection = (order) => {
|
||||
return order.invoice_collection !== undefined && order.invoice_collection !== null;
|
||||
return getInvoiceCollection(order) !== null;
|
||||
};
|
||||
|
||||
const isOrderInvoiceCollectionClosed = (order) => {
|
||||
return order.invoice_collection.closed_at !== null;
|
||||
const invoiceCollection = getInvoiceCollection(order);
|
||||
if (!invoiceCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return invoiceCollection.closed_at !== null;
|
||||
};
|
||||
|
||||
const isOrderInvoiceCollectionBooked = (order) => {
|
||||
return order.invoice_collection.booked_invoice_id !== null;
|
||||
const invoiceCollection = getInvoiceCollection(order);
|
||||
if (!invoiceCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return invoiceCollection.booked_invoice_id !== null;
|
||||
};
|
||||
|
||||
const isOrderStripeInvoicePaid = (order) => {
|
||||
if (order.stripe_invoice_module) {
|
||||
return order.stripe_invoice_module.paid;
|
||||
const stripeInvoiceModule = getStripeInvoiceModule(order);
|
||||
if (stripeInvoiceModule) {
|
||||
return stripeInvoiceModule.paid;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -91,41 +118,41 @@ const doesOrderHaveErrorMessage = (order) => {
|
||||
};
|
||||
|
||||
const getOrderInvoiceStatusBarColor = (order) => {
|
||||
if (order.economic_invoice_module) {
|
||||
// Check the invoice status
|
||||
var color_class = 'has-text-danger';
|
||||
if (isOrderInvoicedWithStripe(order)) {
|
||||
isOrderStripeInvoicePaid(order)
|
||||
? color_class = 'has-text-success'
|
||||
: color_class = 'has-text-warning';
|
||||
}
|
||||
if (isOrderInvoicedWithEconomic(order)) {
|
||||
isOrderEconomicInvoiceBooked(order)
|
||||
? color_class = 'has-text-success'
|
||||
: color_class = 'has-text-warning';
|
||||
}
|
||||
if (doesOrderHaveInvoiceCollection(order)) {
|
||||
color_class = 'has-text-grey';
|
||||
// Check if the invoice collection is closed
|
||||
if (isOrderInvoiceCollectionClosed(order)) {
|
||||
color_class = 'has-text-warning';
|
||||
// Check if the invoice collection is booked
|
||||
if (isOrderInvoiceCollectionBooked(order)) {
|
||||
color_class = 'has-text-success';
|
||||
}
|
||||
// Check the invoice status
|
||||
let color_class = 'has-text-danger';
|
||||
if (isOrderInvoicedWithStripe(order)) {
|
||||
isOrderStripeInvoicePaid(order)
|
||||
? color_class = 'has-text-success'
|
||||
: color_class = 'has-text-warning';
|
||||
}
|
||||
if (isOrderInvoicedWithEconomic(order)) {
|
||||
isOrderEconomicInvoiceBooked(order)
|
||||
? color_class = 'has-text-success'
|
||||
: color_class = 'has-text-warning';
|
||||
}
|
||||
if (doesOrderHaveInvoiceCollection(order)) {
|
||||
color_class = 'has-text-grey';
|
||||
// Check if the invoice collection is closed
|
||||
if (isOrderInvoiceCollectionClosed(order)) {
|
||||
color_class = 'has-text-warning';
|
||||
// Check if the invoice collection is booked
|
||||
if (isOrderInvoiceCollectionBooked(order)) {
|
||||
color_class = 'has-text-success';
|
||||
}
|
||||
}
|
||||
if (doesOrderHaveErrorMessage(order)) {
|
||||
color_class = 'has-text-danger';
|
||||
}
|
||||
// Return the color class
|
||||
return color_class;
|
||||
}
|
||||
return '';
|
||||
if (doesOrderHaveErrorMessage(order)) {
|
||||
color_class = 'has-text-danger';
|
||||
}
|
||||
// Return the color class
|
||||
return color_class;
|
||||
};
|
||||
|
||||
const getOrderInvoiceStatusContent = (order) => {
|
||||
const content = [];
|
||||
const economicInvoiceModule = getEconomicInvoiceModule(order);
|
||||
const stripeInvoiceModule = getStripeInvoiceModule(order);
|
||||
const invoiceCollection = getInvoiceCollection(order);
|
||||
if (isOrderInvoiced(order)) {
|
||||
// If the order is invoiced with e-conomic, show the invoice id
|
||||
if (isOrderInvoicedWithEconomic(order)) {
|
||||
@@ -133,15 +160,15 @@ const getOrderInvoiceStatusContent = (order) => {
|
||||
? content.push('Bogført i e-conomic')
|
||||
: content.push('Gemt som kladde i e-conomic');
|
||||
isOrderEconomicInvoiceBooked(order)
|
||||
? content.push('Faktura ID: <strong>' + order.economic_invoice_module.invoice_id + '</strong>')
|
||||
: content.push('Faktura Kladde ID: <strong>' + order.economic_invoice_module.invoice_draft_id + '</strong>');
|
||||
? content.push('Faktura ID: <strong>' + (economicInvoiceModule?.invoice_id ?? '-') + '</strong>')
|
||||
: content.push('Faktura Kladde ID: <strong>' + (economicInvoiceModule?.invoice_draft_id ?? '-') + '</strong>');
|
||||
}
|
||||
// If the order is invoiced with stripe, show the invoice id
|
||||
if (isOrderInvoicedWithStripe(order)) {
|
||||
isOrderStripeInvoicePaid(order)
|
||||
? content.push('Fakturaen er <strong>betalt</strong>')
|
||||
: content.push('Fakturaen er <strong>ikke betalt</strong>');
|
||||
content.push('Faktura ID: <strong>' + order.stripe_invoice_module.id + '</strong>');
|
||||
content.push('Faktura ID: <strong>' + (stripeInvoiceModule?.id ?? '-') + '</strong>');
|
||||
}
|
||||
content.push(''); // Add a line break
|
||||
isOrderInvoicedWithEconomic(order)
|
||||
@@ -159,7 +186,7 @@ const getOrderInvoiceStatusContent = (order) => {
|
||||
}
|
||||
}
|
||||
content.push(''); // Add a line break
|
||||
content.push('Faktura samling ID: <strong>' + order.invoice_collection.id + '</strong>');
|
||||
content.push('Faktura samling ID: <strong>' + (invoiceCollection?.id ?? '-') + '</strong>');
|
||||
// Add the Error message if it exists
|
||||
if (doesOrderHaveErrorMessage(order)) {
|
||||
content.push('Fejl: <strong class="has-text-danger">' + order.error_message + '</strong>');
|
||||
@@ -344,159 +371,29 @@ const getUniqueInvoiceCollections = () => {
|
||||
return [...new Set(props.objects.map(order => order.invoice_collection_id))];
|
||||
};
|
||||
|
||||
const invoiceCollectionQueueInProgress = ref([]);
|
||||
const invoiceCollectionQueueFailed = ref([]);
|
||||
const invoiceCollectionQueueSuccess = ref([]);
|
||||
const invoiceCollectionQueue = ref([]);
|
||||
const invoiceCollectionMaxConnections = 2;
|
||||
const invoiceCollectionQueueInProgress = invoiceQueue.invoiceCollectionQueueInProgress;
|
||||
const invoiceCollectionQueueFailed = invoiceQueue.invoiceCollectionQueueFailed;
|
||||
const invoiceCollectionQueueSuccess = invoiceQueue.invoiceCollectionQueueSuccess;
|
||||
const invoiceCollectionQueue = invoiceQueue.invoiceCollectionQueue;
|
||||
const collectionQueueLog = invoiceQueue.collectionQueueLog;
|
||||
const isInvoiceMultipleCollectionsModalOpen = ref(false);
|
||||
const isInvoiceQueueBusy = computed(() => {
|
||||
return invoiceCollectionQueue.value.length > 0 || invoiceCollectionQueueInProgress.value.length > 0;
|
||||
});
|
||||
|
||||
const invoiceSelectedCollections = () => {
|
||||
// Open the invoice multiple collections modal
|
||||
isInvoiceMultipleCollectionsModalOpen.value = true;
|
||||
// Check if any invoice collections are selected
|
||||
if (selectedInvoiceCollections.value.length > 0) {
|
||||
// If they are, show a message
|
||||
console.log('Selected invoice collections: ' + selectedInvoiceCollections.value);
|
||||
for (const invoiceCollectionId of selectedInvoiceCollections.value) {
|
||||
// Log the invoice collection id
|
||||
console.log('Invoice collection id: ', invoiceCollectionId);
|
||||
// Add the invoice collection to the queue
|
||||
addInvoiceCollectionToQueue(invoiceCollectionId);
|
||||
}
|
||||
// Process the invoice collection queue
|
||||
processInvoiceCollectionQueue();
|
||||
} else {
|
||||
// If they are not, show a message
|
||||
console.log('No invoice collections selected');
|
||||
}
|
||||
};
|
||||
|
||||
const addInvoiceCollectionToQueue = (invoiceCollectionId) => {
|
||||
// Check if the invoice collection is already in the queue
|
||||
if (invoiceCollectionQueue.value.includes(invoiceCollectionId)) {
|
||||
// If it is, remove it from the queue
|
||||
invoiceCollectionQueue.value = invoiceCollectionQueue.value.filter(id => id !== invoiceCollectionId);
|
||||
} else {
|
||||
// If it is not, add it to the queue
|
||||
invoiceCollectionQueue.value.push(invoiceCollectionId);
|
||||
}
|
||||
};
|
||||
|
||||
const processInvoiceCollectionQueue = () => {
|
||||
// Check if the queue is empty
|
||||
if (invoiceCollectionQueue.value.length === 0) {
|
||||
// If it is, show a message
|
||||
console.log('Invoice collection queue is empty');
|
||||
if (selectedInvoiceCollections.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Check if the max connections are reached
|
||||
if (invoiceCollectionQueueInProgress.value.length >= invoiceCollectionMaxConnections) {
|
||||
// If they are, show a message
|
||||
console.log('Max connections reached');
|
||||
return;
|
||||
}
|
||||
while (invoiceCollectionQueueInProgress.value.length < invoiceCollectionMaxConnections && invoiceCollectionQueue.value.length > 0) {
|
||||
// Process the next invoice collection
|
||||
processNextInvoiceCollection();
|
||||
}
|
||||
invoiceQueue.addInvoiceCollectionsToQueue(selectedInvoiceCollections.value);
|
||||
invoiceQueue.processInvoiceCollectionQueue();
|
||||
};
|
||||
|
||||
const processNextInvoiceCollection = () => {
|
||||
// Check if the queue is empty
|
||||
if (invoiceCollectionQueue.value.length === 0) {
|
||||
// If it is, show a message
|
||||
console.log('Invoice collection queue is empty');
|
||||
return;
|
||||
}
|
||||
// Get the next invoice collection from the queue
|
||||
const invoiceCollectionId = invoiceCollectionQueue.value.shift();
|
||||
// Process the invoice collection
|
||||
processInvoiceCollection(invoiceCollectionId);
|
||||
};
|
||||
|
||||
const getFirstInvoiceWithCollectionId = (invoiceCollectionId) => {
|
||||
// Get the first invoice with the given collection id
|
||||
return props.objects.find(order => order.invoice_collection_id === invoiceCollectionId);
|
||||
};
|
||||
|
||||
const processInvoiceCollection = (invoiceCollectionId) => {
|
||||
// Check if the invoice collection is already in progress
|
||||
if (invoiceCollectionQueueInProgress.value.includes(invoiceCollectionId)) {
|
||||
// If it is, remove it from the in progress list
|
||||
invoiceCollectionQueueInProgress.value = invoiceCollectionQueueInProgress.value.filter(id => id !== invoiceCollectionId);
|
||||
} else {
|
||||
// If it is not, add it to the in progress list
|
||||
invoiceCollectionQueueInProgress.value.push(invoiceCollectionId);
|
||||
}
|
||||
// Process the invoice collection
|
||||
console.log('Processing invoice collection: ' + invoiceCollectionId);
|
||||
// Send the invoice collection request to the server
|
||||
// Send the request
|
||||
SessionUser.request(
|
||||
'/collected-invoices/economic',
|
||||
'POST',
|
||||
{
|
||||
id: parseInt(invoiceCollectionId)
|
||||
}
|
||||
).then((response) => {
|
||||
if (response.data && response.data.data && response.data.success) {
|
||||
// If the request was successful, add the invoice collection to the success list
|
||||
console.log('Invoice collection processed successfully: ' + invoiceCollectionId, response);
|
||||
addInvoiceCollectionToSuccess(invoiceCollectionId);
|
||||
addCollectionQueueLog(
|
||||
true, // Success state
|
||||
invoiceCollectionId, // Invoice collection id
|
||||
'Invoice collection processed successfully', // Error message
|
||||
);
|
||||
} else {
|
||||
// If the request failed, add the invoice collection to the failed list
|
||||
console.log('Invoice collection processing failed: ' + invoiceCollectionId, response);
|
||||
invoiceCollectionQueueFailed.value.push(invoiceCollectionId);
|
||||
addCollectionQueueLog(
|
||||
false, // Success state
|
||||
invoiceCollectionId, // Invoice collection id
|
||||
'Unknown error', // Error message
|
||||
);
|
||||
}
|
||||
}
|
||||
).catch((error) => {
|
||||
// If the request failed, add the invoice collection to the failed list
|
||||
console.log('Invoice collection processing failed 2: ' + invoiceCollectionId, error);
|
||||
invoiceCollectionQueueFailed.value.push(invoiceCollectionId);
|
||||
let errorMessage = error.response.data.data.message;
|
||||
addCollectionQueueLog(
|
||||
false, // Success state
|
||||
invoiceCollectionId, // Invoice collection id
|
||||
errorMessage, // Error message
|
||||
);
|
||||
}).finally(() => {
|
||||
// Remove the invoice collection from the in progress list
|
||||
invoiceCollectionQueueInProgress.value = invoiceCollectionQueueInProgress.value.filter(id => id !== invoiceCollectionId);
|
||||
// Process the next invoice collection in the queue
|
||||
processInvoiceCollectionQueue();
|
||||
});
|
||||
};
|
||||
|
||||
const collectionQueueLog = ref([]);
|
||||
const addCollectionQueueLog = (success, invoiceCollectionId, errorMessage) => {
|
||||
// Add the log to the collection queue log
|
||||
collectionQueueLog.value.push({
|
||||
success: success,
|
||||
invoiceCollectionId: invoiceCollectionId,
|
||||
errorMessage: errorMessage
|
||||
});
|
||||
};
|
||||
|
||||
const addInvoiceCollectionToSuccess = (invoiceCollectionId) => {
|
||||
// Check if the invoice collection is already in the success list
|
||||
if (invoiceCollectionQueueSuccess.value.includes(invoiceCollectionId)) {
|
||||
// If it is, remove it from the success list
|
||||
invoiceCollectionQueueSuccess.value = invoiceCollectionQueueSuccess.value.filter(id => id !== invoiceCollectionId);
|
||||
} else {
|
||||
// If it is not, add it to the success list
|
||||
invoiceCollectionQueueSuccess.value.push(invoiceCollectionId);
|
||||
}
|
||||
const retryInvoiceCollection = (invoiceCollectionId) => {
|
||||
invoiceQueue.retryInvoiceCollection(parseInt(invoiceCollectionId));
|
||||
};
|
||||
|
||||
const confirmCloseModal = () => {
|
||||
@@ -527,6 +424,7 @@ const confirmCloseModal = () => {
|
||||
<button
|
||||
class="button is-small"
|
||||
@click="invoiceSelectedCollections()"
|
||||
:disabled="selectedInvoiceCollections.length === 0 || isInvoiceQueueBusy"
|
||||
>
|
||||
{{SessionUser.objects.global.language.invoice}} {{SessionUser.objects.global.language.selected_multiple}} ({{selectedInvoiceCollections.length}})
|
||||
</button>
|
||||
@@ -729,7 +627,7 @@ const confirmCloseModal = () => {
|
||||
}"
|
||||
v-bind:visibility="{
|
||||
icon: false,
|
||||
dropdown: (order.reg_1.length > 7),
|
||||
dropdown: hasLongText(order.reg_1),
|
||||
}"
|
||||
v-bind:dropdown_content="{
|
||||
title: null,
|
||||
@@ -746,7 +644,7 @@ const confirmCloseModal = () => {
|
||||
</td>
|
||||
<td>
|
||||
<ColorIndicator
|
||||
v-show="order.reg_2 !== ''"
|
||||
v-show="(order.reg_2 || '') !== ''"
|
||||
v-bind:label="{
|
||||
text: order.reg_2,
|
||||
classes: [],
|
||||
@@ -754,7 +652,7 @@ const confirmCloseModal = () => {
|
||||
}"
|
||||
v-bind:visibility="{
|
||||
icon: false,
|
||||
dropdown: (order.reg_2.length > 7),
|
||||
dropdown: hasLongText(order.reg_2),
|
||||
}"
|
||||
v-bind:dropdown_content="{
|
||||
title: null,
|
||||
@@ -771,7 +669,7 @@ const confirmCloseModal = () => {
|
||||
</td>
|
||||
<td>
|
||||
<ColorIndicator
|
||||
v-show="order.reg_3 !== ''"
|
||||
v-show="(order.reg_3 || '') !== ''"
|
||||
v-bind:label="{
|
||||
text: order.reg_3,
|
||||
classes: [],
|
||||
@@ -779,7 +677,7 @@ const confirmCloseModal = () => {
|
||||
}"
|
||||
v-bind:visibility="{
|
||||
icon: false,
|
||||
dropdown: (order.reg_3.length > 7),
|
||||
dropdown: hasLongText(order.reg_3),
|
||||
}"
|
||||
v-bind:dropdown_content="{
|
||||
title: null,
|
||||
@@ -796,7 +694,7 @@ const confirmCloseModal = () => {
|
||||
</td>
|
||||
<td>
|
||||
<ColorIndicator
|
||||
v-show="order.reference !== ''"
|
||||
v-show="(order.reference || '') !== ''"
|
||||
v-bind:label="{
|
||||
text: order.reference,
|
||||
classes: [],
|
||||
@@ -804,7 +702,7 @@ const confirmCloseModal = () => {
|
||||
}"
|
||||
v-bind:visibility="{
|
||||
icon: false,
|
||||
dropdown: (order.reference.length > 7),
|
||||
dropdown: hasLongText(order.reference),
|
||||
}"
|
||||
v-bind:dropdown_content="{
|
||||
title: null,
|
||||
@@ -821,7 +719,7 @@ const confirmCloseModal = () => {
|
||||
</td>
|
||||
<td>
|
||||
<ColorIndicator
|
||||
v-show="order.notes !== ''"
|
||||
v-show="(order.notes || '') !== ''"
|
||||
v-bind:label="{
|
||||
text: order.notes,
|
||||
classes: [],
|
||||
@@ -829,7 +727,7 @@ const confirmCloseModal = () => {
|
||||
}"
|
||||
v-bind:visibility="{
|
||||
icon: false,
|
||||
dropdown: (order.notes.length > 7),
|
||||
dropdown: hasLongText(order.notes),
|
||||
}"
|
||||
v-bind:dropdown_content="{
|
||||
title: null,
|
||||
@@ -854,6 +752,7 @@ const confirmCloseModal = () => {
|
||||
v-bind:order_id="order.id"
|
||||
v-bind:invoice_collection_id="order.invoice_collection_id"
|
||||
v-bind:reg_1="order.reg_1"
|
||||
:refreshFunction="loadList"
|
||||
>
|
||||
<template v-slot:actions>
|
||||
<!--
|
||||
@@ -902,8 +801,10 @@ const confirmCloseModal = () => {
|
||||
v-bind:failed="invoiceCollectionQueueFailed"
|
||||
v-bind:succeeded="invoiceCollectionQueueSuccess"
|
||||
v-bind:queued="invoiceCollectionQueue"
|
||||
v-bind:jobs="invoiceQueue.invoiceCollectionQueueJobs"
|
||||
v-bind:log="collectionQueueLog"
|
||||
@closeModal="confirmCloseModal()"
|
||||
@retry="retryInvoiceCollection"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -929,4 +830,4 @@ const confirmCloseModal = () => {
|
||||
.status-bar.has-text-success {
|
||||
border-color: #48c774;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const isUsageOrderAttachedToOrder = (object) => {
|
||||
const duplicates = Array.isArray(object?.duplicates) ? object.duplicates : [];
|
||||
return duplicates.some((duplicate) => duplicate?.wash_id === object?.wash_id);
|
||||
};
|
||||
|
||||
export const filterUsageOrdersByAttachment = (objects, showOnlyUnattachedVehicle) => {
|
||||
const sourceObjects = Array.isArray(objects) ? objects : [];
|
||||
|
||||
if (!showOnlyUnattachedVehicle) {
|
||||
return sourceObjects;
|
||||
}
|
||||
|
||||
return sourceObjects.filter((object) => !isUsageOrderAttachedToOrder(object));
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user