Compare commits
277
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1a6fe302d | ||
|
|
eb2d119823 | ||
|
|
f7e845b3d2 | ||
|
|
88c65776ad | ||
|
|
17937200f0 | ||
|
|
b26e50cd1e | ||
|
|
20183b36e1 | ||
|
|
1a2218704d | ||
|
|
90eb67ba8c | ||
|
|
85f24bdb0c | ||
|
|
0f8b0273c1 | ||
|
|
7efa9f172c | ||
|
|
b576465f50 | ||
|
|
8094356488 | ||
|
|
0432564367 | ||
|
|
8391557f2d | ||
|
|
c3381aea39 | ||
|
|
c72da3879b | ||
|
|
f6c8e8123a | ||
|
|
03bb16309a | ||
|
|
b5f9210c42 | ||
|
|
bee7d699d3 | ||
|
|
ab1d52da0f | ||
|
|
40f1b0e208 | ||
|
|
04ee87b3ad | ||
|
|
ba1288ae10 | ||
|
|
7ebcac6fa0 | ||
|
|
1e21df7c68 | ||
|
|
dfea80034a | ||
|
|
c4af52ad39 | ||
|
|
ce2a18d203 | ||
|
|
bff949a1f0 | ||
|
|
f139cd1001 | ||
|
|
2380b64758 | ||
|
|
21c42fa443 | ||
|
|
c03603f3f3 | ||
|
|
94f56d9cd0 | ||
|
|
a24bb41df2 | ||
|
|
d7832910e0 | ||
|
|
aa1da3ec4d | ||
|
|
9cee2f70ba | ||
|
|
6e3506ff28 | ||
|
|
827d8a3c39 | ||
|
|
892471bd3a | ||
|
|
f13bff2ff5 | ||
|
|
75b8dff0f8 | ||
|
|
13528b07af | ||
|
|
7bdfb43635 | ||
|
|
9f9d6de03f | ||
|
|
83bfdf5f0a | ||
|
|
817c174e0e | ||
|
|
44f77eadc1 | ||
|
|
46bf518a0e | ||
|
|
84e3b0e982 | ||
|
|
1bd1b992a2 | ||
|
|
72fdc4c490 | ||
|
|
d7ff64bf4e | ||
|
|
1a784bbbfd | ||
|
|
5d30690dfd | ||
|
|
f3f6237c16 | ||
|
|
031b1e7563 | ||
|
|
56282a8093 | ||
|
|
e0d6f44d0b | ||
|
|
faa7ebae48 | ||
|
|
4071363454 | ||
|
|
bef9eaeb72 | ||
|
|
2d94322021 | ||
|
|
28bee86ce9 | ||
|
|
6c4b9cb599 | ||
|
|
55ec54fae6 | ||
|
|
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 |
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"version": 1,
|
||||
"snapshot_of": "front-end-vue",
|
||||
"projects": {
|
||||
"front-end-vue": {
|
||||
"display_name": "Truckwash Frontend",
|
||||
"relative_root": ".",
|
||||
"commands": {
|
||||
"setup": {
|
||||
"default": "npm ci\nnpx playwright install"
|
||||
},
|
||||
"run": {
|
||||
"default": "npm run dev"
|
||||
},
|
||||
"test_unit": {
|
||||
"default": "npm run test:unit"
|
||||
},
|
||||
"test_e2e": {
|
||||
"default": "npm run test:e2e:smoke"
|
||||
},
|
||||
"test_full": {
|
||||
"default": "npm run test:e2e:ci"
|
||||
},
|
||||
"debug": {
|
||||
"default": "npx playwright test --debug"
|
||||
}
|
||||
},
|
||||
"codex_environment": {
|
||||
"name": "pleno-vue",
|
||||
"actions": [
|
||||
{
|
||||
"name": "Run dev",
|
||||
"icon": "run",
|
||||
"command_id": "run"
|
||||
},
|
||||
{
|
||||
"name": "Vitest unit tests",
|
||||
"icon": "test",
|
||||
"command_id": "test_unit"
|
||||
},
|
||||
{
|
||||
"name": "Playwright smoke",
|
||||
"icon": "test",
|
||||
"command_id": "test_e2e"
|
||||
},
|
||||
{
|
||||
"name": "Playwright full suite",
|
||||
"icon": "test",
|
||||
"command_id": "test_full"
|
||||
},
|
||||
{
|
||||
"name": "Playwright debug",
|
||||
"icon": "debug",
|
||||
"command_id": "debug"
|
||||
},
|
||||
{
|
||||
"name": "AI workflow check",
|
||||
"icon": "debug",
|
||||
"literal_command": {
|
||||
"default": "node scripts/sync-ai-workflow.mjs --check"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_content": {
|
||||
"aiassistant_tests_lines": [
|
||||
"# Frontend Testing Rules",
|
||||
"",
|
||||
"These rules apply to Vue changes, browser flows, and frontend test updates.",
|
||||
"",
|
||||
"1. Add or update tests for every new feature, bug fix, API integration change, or auth, booking, POS, or i18n workflow change.",
|
||||
"2. Browser validation runs through Playwright. Legacy browser-driver tooling is outside the supported workflow.",
|
||||
"3. Use `npm run test:unit` for Vitest coverage of isolated logic and component behavior.",
|
||||
"4. Use `npm run test:e2e:smoke` for the narrow Playwright browser pass on changed user flows.",
|
||||
"5. Use `npm run test:e2e:ci` when a change affects shared navigation, authentication, release-critical flows, or multiple browser projects.",
|
||||
"6. Use `npx playwright test --debug` for interactive debugging and trace capture.",
|
||||
"7. Prefer stable selectors, explicit fixtures, and assertions about user-visible behavior.",
|
||||
"8. Avoid manual sleeps. Let Playwright waiting and expectations drive synchronization.",
|
||||
"9. If a bug fix needs a browser regression test, prove the failure first and then keep the test with the fix.",
|
||||
"",
|
||||
"Canonical workflow reference: `.ai-workflow/workflow.md`."
|
||||
],
|
||||
"junie_lines": [
|
||||
"# Truckwash Frontend Development Guidelines",
|
||||
"",
|
||||
"This file is generated from the canonical AI workflow and is the supported Junie-facing reference for the frontend repository.",
|
||||
"",
|
||||
"## Build And Run",
|
||||
"",
|
||||
"- Setup: `npm ci` and `npx playwright install`.",
|
||||
"- Start development server: `npm run dev`.",
|
||||
"- Production build sanity check: `npm run build`.",
|
||||
"",
|
||||
"## Testing",
|
||||
"",
|
||||
"- Vitest unit tests: `npm run test:unit`.",
|
||||
"- Playwright smoke: `npm run test:e2e:smoke`.",
|
||||
"- Playwright full suite: `npm run test:e2e:ci`.",
|
||||
"- Playwright debug: `npx playwright test --debug`.",
|
||||
"- Browser validation is Playwright-first. Legacy browser-driver tooling is not part of the supported workflow.",
|
||||
"",
|
||||
"## Workflow Notes",
|
||||
"",
|
||||
"- Generated assistant metadata is checked with `node scripts/sync-ai-workflow.mjs --check`.",
|
||||
"- Keep frontend browser verification aligned with the existing Playwright projects and runner scripts in `package.json`.",
|
||||
"- Use the narrowest relevant test command first, then expand to the broader suite when the change risk requires it.",
|
||||
"",
|
||||
"Canonical workflow reference: `.ai-workflow/workflow.md`."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"assistants": {
|
||||
"codex": {
|
||||
"description": "Codex environment files and actions."
|
||||
},
|
||||
"aiassistant": {
|
||||
"description": "Project-specific AI Assistant rules."
|
||||
},
|
||||
"junie": {
|
||||
"description": "Project-specific Junie guidance."
|
||||
},
|
||||
"copilot": {
|
||||
"description": "Standardized Copilot task dispatch workflow."
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"setup": {
|
||||
"description": "Install dependencies and prepare the supported local environment."
|
||||
},
|
||||
"run": {
|
||||
"description": "Start the primary local development entrypoint for the project."
|
||||
},
|
||||
"test_unit": {
|
||||
"description": "Run the project's narrow unit-style verification command."
|
||||
},
|
||||
"test_e2e": {
|
||||
"description": "Run the project's targeted browser or end-to-end verification command."
|
||||
},
|
||||
"test_full": {
|
||||
"description": "Run the broader high-confidence verification command when the project defines one."
|
||||
},
|
||||
"debug": {
|
||||
"description": "Run the supported debug entrypoint."
|
||||
}
|
||||
},
|
||||
"generated_outputs": [
|
||||
{
|
||||
"id": "frontend_codex_environment",
|
||||
"template": "codex_project_environment",
|
||||
"project": "front-end-vue",
|
||||
"path": ".codex/environments/environment.toml"
|
||||
},
|
||||
{
|
||||
"id": "frontend_aiassistant_tests",
|
||||
"template": "aiassistant_frontend_tests_rule",
|
||||
"project": "front-end-vue",
|
||||
"path": ".aiassistant/rules/Creating and maintaining tests.md"
|
||||
},
|
||||
{
|
||||
"id": "frontend_junie_guidelines",
|
||||
"template": "junie_frontend_guidelines",
|
||||
"project": "front-end-vue",
|
||||
"path": ".junie/guidelines.md"
|
||||
}
|
||||
],
|
||||
"sync_targets": {
|
||||
"front-end-vue": {
|
||||
"source_root": ".",
|
||||
"destination": "C:\\Users\\2jepp\\WebstormProjects\\pleno-vue",
|
||||
"supported_metadata_dirs": [
|
||||
".codex",
|
||||
".aiassistant",
|
||||
".junie",
|
||||
".github",
|
||||
".ai-workflow",
|
||||
"scripts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"unsupported": {
|
||||
"mcp": [
|
||||
"front-end-vue/.ai/mcp/mcp.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<!-- AUTOGENERATED SNAPSHOT for front-end-vue: refresh from the canonical workspace with `node scripts/sync-ai-workflow.mjs --write`. -->
|
||||
|
||||
# Developer AI Workflow
|
||||
|
||||
This directory is the canonical source of truth for the repository's developer-facing AI workflow.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep Codex, AI Assistant, Junie, and Copilot aligned from one maintained source.
|
||||
- Make assistant metadata deterministic so the generated files can be rewritten safely and checked in CI.
|
||||
- Preserve the current mirror-repo workflow for `backend-php` and `front-end-vue`.
|
||||
- Keep all changes in this workflow scoped to developer tooling and documentation. Runtime OpenAI features stay out of scope.
|
||||
|
||||
## Supported Assistants
|
||||
|
||||
- `Codex`: local environments and actions under `.codex/environments`.
|
||||
- `AI Assistant`: generated guidance under `.aiassistant/rules`.
|
||||
- `Junie`: generated project guidance under `.junie/guidelines.md`.
|
||||
- `Copilot`: standardized issue-dispatch workflow content under `.github/workflows/copilot.yml`.
|
||||
|
||||
## Global Rules
|
||||
|
||||
1. Change only `.ai-workflow/workflow.md` and `.ai-workflow/manifest.json` when updating the developer AI workflow.
|
||||
2. Regenerate all derived files with `node scripts/sync-ai-workflow.mjs --write`.
|
||||
3. Validate drift with `node scripts/sync-ai-workflow.mjs --check`.
|
||||
4. Generated assistant files are not hand-edited.
|
||||
5. Unsupported surfaces stay unsupported until they have a real owner and a real config.
|
||||
6. `front-end-vue/.ai/mcp/mcp.json` is intentionally unsupported and should not be recreated until there is an actual MCP integration to maintain.
|
||||
|
||||
## Command Matrix
|
||||
|
||||
### `backend-php`
|
||||
|
||||
- Setup: use the existing setup scripts in `backend-php/scripts`.
|
||||
- Run: start the API stack with `traefik`, `redis`, `mysql-debug`, `php1`, and `caddy`.
|
||||
- Debug: tail `php1` logs.
|
||||
- PHP verification: always run backend validation in the `php1` container.
|
||||
- Unit tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit"`.
|
||||
- Integration tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:integration"`.
|
||||
- API tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:api"`.
|
||||
|
||||
### `front-end-vue`
|
||||
|
||||
- Setup: `npm ci` and `npx playwright install`.
|
||||
- Run: `npm run dev`.
|
||||
- Unit tests: `npm run test:unit` with Vitest.
|
||||
- Browser smoke: `npm run test:e2e:smoke`.
|
||||
- Browser full suite: `npm run test:e2e:ci`.
|
||||
- Debug: `npx playwright test --debug`.
|
||||
- Browser validation is Playwright-first. WebdriverIO and Appium are not part of the supported workflow.
|
||||
|
||||
### `automation`
|
||||
|
||||
- Setup: `npm ci` and `npx playwright install`.
|
||||
- Run: `npx playwright test --ui`.
|
||||
- Browser tests: `npx playwright test`.
|
||||
- Debug: `npx playwright test --debug`.
|
||||
|
||||
## Mirror Repos And Sync
|
||||
|
||||
- `backend-php` mirrors to `C:\Users\2jepp\PhpstormProjects\api`.
|
||||
- `front-end-vue` mirrors to `C:\Users\2jepp\WebstormProjects\pleno-vue`.
|
||||
- Supported metadata directories that must stay mirrored are `.codex`, `.aiassistant`, `.junie`, `.github`, generated `.ai-workflow`, and `scripts/sync-ai-workflow.mjs`.
|
||||
- Cache and build directories remain excluded from the watcher.
|
||||
- The mirror repos receive generated snapshots of `.ai-workflow` and `scripts/sync-ai-workflow.mjs` so their local CI can run `--check` without depending on the combined workspace root.
|
||||
|
||||
## Generated Outputs
|
||||
|
||||
- Root Codex environment for combined backend and frontend entrypoints.
|
||||
- Backend and frontend Codex environments.
|
||||
- Backend and frontend AI Assistant guidance.
|
||||
- Backend and frontend Junie guidance.
|
||||
- Backend Copilot dispatcher workflow.
|
||||
- Backend and frontend snapshot copies of `.ai-workflow` plus `scripts/sync-ai-workflow.mjs` for mirrored repositories.
|
||||
@@ -2,256 +2,20 @@
|
||||
apply: always
|
||||
---
|
||||
|
||||
# Guidelines for Creating and Maintaining Tests
|
||||
<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->
|
||||
|
||||
## When to Create Tests
|
||||
# Frontend Testing Rules
|
||||
|
||||
Tests **MUST** be created when any of the following criteria are met:
|
||||
These rules apply to Vue changes, browser flows, and frontend test updates.
|
||||
|
||||
1. **New Feature Development**: Any new feature, component, or functionality requires accompanying tests
|
||||
2. **Bug Fixes**: When fixing a bug, write a test that reproduces the issue first, then verify the fix
|
||||
3. **API Changes**: Changes to APIs, services, or data-fetching logic require updated or new tests
|
||||
4. **Critical User Flows**: Any modification to authentication, payments, bookings, or POS flows
|
||||
5. **Component Behavior Changes**: When modifying Vue component logic, props, or events
|
||||
6. **i18n/Localization Changes**: When adding or modifying translation keys
|
||||
1. Add or update tests for every new feature, bug fix, API integration change, or auth, booking, POS, or i18n workflow change.
|
||||
2. Browser validation runs through Playwright. Legacy browser-driver tooling is outside the supported workflow.
|
||||
3. Use `npm run test:unit` for Vitest coverage of isolated logic and component behavior.
|
||||
4. Use `npm run test:e2e:smoke` for the narrow Playwright browser pass on changed user flows.
|
||||
5. Use `npm run test:e2e:ci` when a change affects shared navigation, authentication, release-critical flows, or multiple browser projects.
|
||||
6. Use `npx playwright test --debug` for interactive debugging and trace capture.
|
||||
7. Prefer stable selectors, explicit fixtures, and assertions about user-visible behavior.
|
||||
8. Avoid manual sleeps. Let Playwright waiting and expectations drive synchronization.
|
||||
9. If a bug fix needs a browser regression test, prove the failure first and then keep the test with the fix.
|
||||
|
||||
## Running Tests When Adding Tests
|
||||
|
||||
When creating or modifying tests, you **MUST**:
|
||||
|
||||
1. **Run the specific test file** to verify it passes:
|
||||
```bash
|
||||
npx playwright test tests/your-test-file.spec.ts
|
||||
```
|
||||
|
||||
2. **Run the full test suite** before committing to ensure no regressions:
|
||||
```bash
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
3. **Verify test failures before fixes**: If writing a test for a bug, confirm the test fails before applying the fix
|
||||
|
||||
## Testing Framework
|
||||
|
||||
This project uses **Playwright** as the primary testing framework. Playwright provides reliable end-to-end testing with auto-waiting, powerful selectors, and built-in assertions.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run full test suite
|
||||
npx playwright test
|
||||
|
||||
# Run tests in headed mode (visible browser)
|
||||
npx playwright test --headed
|
||||
|
||||
# Run tests in a specific browser
|
||||
npx playwright test --project=chromium
|
||||
npx playwright test --project=firefox
|
||||
npx playwright test --project=webkit
|
||||
|
||||
# Run tests with UI mode for debugging
|
||||
npx playwright test --ui
|
||||
|
||||
# Generate HTML report
|
||||
npx playwright show-report
|
||||
```
|
||||
|
||||
## Test File Location and Naming
|
||||
|
||||
- **Location**: All test files should be placed in `tests/` or `tests/specs/`
|
||||
- **Naming Convention**: Use kebab-case with `.spec.ts` suffix
|
||||
- ✅ `i18n-settings-wheel.spec.ts`
|
||||
- ✅ `user-authentication.spec.ts`
|
||||
- ❌ `UserAuth.test.js`
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Basic Template
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Brief description of what this test suite covers
|
||||
*/
|
||||
test.describe('Feature or Component Name', () => {
|
||||
test('should describe expected behavior', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Test implementation
|
||||
await expect(page).toHaveTitle(/Expected Text/);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Key Conventions
|
||||
|
||||
1. **Use descriptive test names**: Start with `should` to describe expected behavior
|
||||
2. **Always use `async/await`**: All Playwright interactions are asynchronous
|
||||
3. **Include JSDoc comments**: Add a comment block at the top describing the test purpose
|
||||
4. **Group related tests**: Use `test.describe` blocks to organize related test cases
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Navigation
|
||||
|
||||
```typescript
|
||||
// Navigate to a page
|
||||
await page.goto('/');
|
||||
await page.goto('/dashboard');
|
||||
|
||||
// Wait for navigation
|
||||
await page.goto('/dashboard', { waitUntil: 'networkidle' });
|
||||
```
|
||||
|
||||
### Page Interactions
|
||||
|
||||
```typescript
|
||||
// Fill input fields
|
||||
await page.fill('input[name="username"]', 'testuser');
|
||||
await page.fill('input[name="password"]', 'password123');
|
||||
|
||||
// Click elements
|
||||
await page.click('button[id="login-button"]');
|
||||
|
||||
// Wait for elements
|
||||
await page.waitForSelector('input[name="2fa_code"]');
|
||||
|
||||
// Get element text
|
||||
const text = await page.locator('.message').textContent();
|
||||
|
||||
// Get page title
|
||||
const title = await page.title();
|
||||
|
||||
// Execute JavaScript in browser context
|
||||
const result = await page.evaluate(() => {
|
||||
return document.querySelector('.data')?.textContent;
|
||||
});
|
||||
```
|
||||
|
||||
### Locators (Recommended)
|
||||
|
||||
```typescript
|
||||
// Use locators for better reliability
|
||||
const submitButton = page.locator('button[type="submit"]');
|
||||
await submitButton.click();
|
||||
|
||||
// Locator with text
|
||||
const loginButton = page.locator('button:has-text("Login")');
|
||||
|
||||
// Get by role (accessibility-friendly)
|
||||
const heading = page.getByRole('heading', { name: 'Welcome' });
|
||||
|
||||
// Get by test ID
|
||||
const element = page.getByTestId('submit-button');
|
||||
```
|
||||
|
||||
### Assertions
|
||||
|
||||
```typescript
|
||||
// Use Playwright's built-in expect assertions
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
await expect(page).toHaveTitle(/Dashboard/);
|
||||
await expect(page.locator('.message')).toBeVisible();
|
||||
await expect(page.locator('.message')).toHaveText('Success');
|
||||
await expect(page.locator('input')).toHaveValue('expected value');
|
||||
await expect(page.locator('.items')).toHaveCount(5);
|
||||
```
|
||||
|
||||
## Authentication Helpers
|
||||
|
||||
Create reusable authentication helpers in `tests/fixtures/`:
|
||||
|
||||
```typescript
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Login as a customer user
|
||||
* @param page - Playwright page object
|
||||
* @param credentials - User credentials
|
||||
*/
|
||||
export async function loginAsUser(
|
||||
page: Page,
|
||||
credentials: { customerNumber: string; password: string }
|
||||
) {
|
||||
await page.goto('/login');
|
||||
await page.fill('input[name="customer_number"]', credentials.customerNumber);
|
||||
await page.fill('input[name="password"]', credentials.password);
|
||||
await page.click('button[id="login-button"]');
|
||||
await expect(page).toHaveURL('/user');
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### DO:
|
||||
- ✅ Test one behavior per `test` block
|
||||
- ✅ Use meaningful variable names
|
||||
- ✅ Use Playwright's auto-waiting (avoid manual waits when possible)
|
||||
- ✅ Use locators for element interactions
|
||||
- ✅ Use `expect` assertions with auto-retry
|
||||
- ✅ Clean up test state when necessary
|
||||
- ✅ Use test fixtures for setup/teardown
|
||||
|
||||
### DON'T:
|
||||
- ❌ Write tests that depend on execution order
|
||||
- ❌ Hardcode sensitive data (use environment variables)
|
||||
- ❌ Use `page.waitForTimeout()` for synchronization (use proper waits)
|
||||
- ❌ Leave commented-out code in test files
|
||||
|
||||
## Testing i18n/Translations
|
||||
|
||||
When testing internationalization:
|
||||
|
||||
1. Define expected translation keys as an array
|
||||
2. Fetch locale files via page context
|
||||
3. Verify all required keys exist in each locale
|
||||
4. Check parity between locales (same keys in all languages)
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const requiredKeys = ['key1', 'key2', 'key3'];
|
||||
|
||||
test('should have all translations in English', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const result = await page.evaluate(async (keys) => {
|
||||
const response = await fetch('/src/i18n/locales/en.json');
|
||||
const data = await response.json();
|
||||
const missingKeys = keys.filter(key => !(key in data));
|
||||
return { success: missingKeys.length === 0, missingKeys };
|
||||
}, requiredKeys);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
## Mobile Testing
|
||||
|
||||
Playwright supports mobile emulation out of the box:
|
||||
|
||||
```typescript
|
||||
import { test, devices } from '@playwright/test';
|
||||
|
||||
// Use device presets
|
||||
test.use({ ...devices['iPhone 13'] });
|
||||
|
||||
test('should work on mobile', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Test mobile-specific behavior
|
||||
});
|
||||
```
|
||||
|
||||
Configure mobile projects in `playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
projects: [
|
||||
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
|
||||
{ name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
|
||||
]
|
||||
```
|
||||
|
||||
## Maintaining Tests
|
||||
|
||||
1. **Keep tests updated**: When modifying features, update corresponding tests
|
||||
2. **Review failing tests**: Investigate failures before marking as skipped
|
||||
3. **Remove obsolete tests**: Delete tests for removed features
|
||||
4. **Refactor shared logic**: Extract common test utilities to helper files in `tests/fixtures/`
|
||||
Canonical workflow reference: `.ai-workflow/workflow.md`.
|
||||
|
||||
@@ -11,14 +11,41 @@ npx playwright install
|
||||
[[actions]]
|
||||
name = "Run dev"
|
||||
icon = "run"
|
||||
command = "npm run dev"
|
||||
command = '''
|
||||
npm run dev
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "Run all tests"
|
||||
name = "Vitest unit tests"
|
||||
icon = "test"
|
||||
command = "npm run test:all"
|
||||
command = '''
|
||||
npm run test:unit
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "Run debug"
|
||||
name = "Playwright smoke"
|
||||
icon = "test"
|
||||
command = '''
|
||||
npm run test:e2e:smoke
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "Playwright full suite"
|
||||
icon = "test"
|
||||
command = '''
|
||||
npm run test:e2e:ci
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "Playwright debug"
|
||||
icon = "debug"
|
||||
command = "npx playwright test --debug"
|
||||
command = '''
|
||||
npx playwright test --debug
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "AI workflow check"
|
||||
icon = "debug"
|
||||
command = '''
|
||||
node scripts/sync-ai-workflow.mjs --check
|
||||
'''
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Run Qodana Configuration Uploader
|
||||
env:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Run Qodana Configuration Uploader
|
||||
env:
|
||||
|
||||
+114
-19
@@ -3,20 +3,49 @@ 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:
|
||||
runs-on: ubuntu-latest
|
||||
format-tests:
|
||||
# Match the labels exposed by the Coolify-managed GitHub runner.
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
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: Check AI workflow sync
|
||||
run: node scripts/sync-ai-workflow.mjs --check
|
||||
|
||||
- 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: [self-hosted, Linux, X64, default]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -29,21 +58,23 @@ jobs:
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
env:
|
||||
VITEST_BATCH_SIZE: 5
|
||||
|
||||
e2e-smoke:
|
||||
if: github.event_name != 'schedule'
|
||||
needs: build-and-unit
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
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 +89,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 }}
|
||||
@@ -68,16 +99,20 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
e2e-full:
|
||||
if: github.event_name == 'schedule' || github.ref == 'refs/heads/main'
|
||||
e2e-edge-gateway:
|
||||
if: github.event_name != 'schedule'
|
||||
needs: build-and-unit
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
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
|
||||
@@ -86,18 +121,78 @@ jobs:
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run full Playwright suite
|
||||
run: npm run test:e2e:ci
|
||||
- name: Run Edge Gateway route and visual tests (${{ matrix.project }})
|
||||
run: >-
|
||||
npx playwright test
|
||||
tests/e2e/edge-gateways.routes.spec.js
|
||||
tests/e2e/edge-gateways.visual.spec.js
|
||||
--project="${{ matrix.project }}"
|
||||
--workers=1
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-full
|
||||
name: playwright-report-edge-gateway-${{ matrix.project }}
|
||||
path: |
|
||||
output/playwright/report
|
||||
output/playwright/test-results
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
e2e-full:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
|
||||
needs: build-and-unit
|
||||
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
role: [customer, subuser, admin, superuser]
|
||||
browser: [chromium, firefox, webkit]
|
||||
device: [mobile, tablet, desktop]
|
||||
include:
|
||||
- browser: chromium
|
||||
browser_label: Chromium
|
||||
browser_install: chromium
|
||||
- browser: firefox
|
||||
browser_label: Firefox
|
||||
browser_install: firefox
|
||||
- browser: webkit
|
||||
browser_label: WebKit
|
||||
browser_install: webkit
|
||||
env:
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
|
||||
PLAYWRIGHT_REPORTER_MODE: line-html
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps ${{ matrix.browser_install }}
|
||||
|
||||
- name: Run full Playwright slice
|
||||
run: npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
|
||||
path: |
|
||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/report
|
||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
|
||||
output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
cd -- "$(dirname -- "$0")/.."
|
||||
npm run format:tests:commit
|
||||
+18
-68
@@ -1,77 +1,27 @@
|
||||
# Project Guidelines
|
||||
<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->
|
||||
|
||||
## Project Overview
|
||||
# Truckwash Frontend Development Guidelines
|
||||
|
||||
This repository contains a Vue 3 single-page application built with Vite. It powers dashboard-style pages and modules such as bookings and POS flows. The UI is primarily styled with Bulma. The app leverages libraries like FullCalendar and Chart.js for scheduling and analytics.
|
||||
This file is generated from the canonical AI workflow and is the supported Junie-facing reference for the frontend repository.
|
||||
|
||||
- Tech stack: Vue 3, Vite 5, Vue Router, Vuex, Bulma, FullCalendar, Chart.js
|
||||
- Notable integrations: toast notifications, QR/Barcode utilities, and PWA/devtools plugins
|
||||
- Target: modern browsers and mobile WebView contexts
|
||||
## Build And Run
|
||||
|
||||
Local development
|
||||
- Requirements: Node.js 18+ (recommended to match Vite 5), npm 9+
|
||||
- Install dependencies: `npm install`
|
||||
- Start dev server: `npm run dev` (default at http://localhost:5173)
|
||||
- Setup: `npm ci` and `npx playwright install`.
|
||||
- Start development server: `npm run dev`.
|
||||
- Production build sanity check: `npm run build`.
|
||||
|
||||
Build and preview
|
||||
- Production build: `npm run build` (output in `dist/`)
|
||||
- Preview build locally: `npm run preview`
|
||||
## Testing
|
||||
|
||||
Testing
|
||||
- Framework: WebdriverIO (wdio) with Vite service; Appium available for mobile testing
|
||||
- Common commands:
|
||||
- `npm run wdio` — run test suite per `wdio.conf.js`
|
||||
- `npm run test:mobile` — run tests excluding Safari
|
||||
- `npm run test:android` — run Android-targeted specs
|
||||
- Prerequisites for mobile tests may include Android SDK/emulator or iOS tooling and Appium drivers
|
||||
- Vitest unit tests: `npm run test:unit`.
|
||||
- Playwright smoke: `npm run test:e2e:smoke`.
|
||||
- Playwright full suite: `npm run test:e2e:ci`.
|
||||
- Playwright debug: `npx playwright test --debug`.
|
||||
- Browser validation is Playwright-first. Legacy browser-driver tooling is not part of the supported workflow.
|
||||
|
||||
Environment configuration
|
||||
- Place environment variables in `.env.*` files (Vite loads `VITE_`-prefixed vars into the client)
|
||||
## Workflow Notes
|
||||
|
||||
See the sections below for code style and detailed project structure.
|
||||
- Generated assistant metadata is checked with `node scripts/sync-ai-workflow.mjs --check`.
|
||||
- Keep frontend browser verification aligned with the existing Playwright projects and runner scripts in `package.json`.
|
||||
- Use the narrowest relevant test command first, then expand to the broader suite when the change risk requires it.
|
||||
|
||||
## Code Style and Conventions
|
||||
|
||||
### Vue Components
|
||||
|
||||
- Use PascalCase for component names (e.g., `UserProfile.vue`)
|
||||
- Implement Single File Components (SFC) pattern
|
||||
- Keep components focused and single-responsibility
|
||||
- Use composition API for new components
|
||||
- Document component props and events
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
- Use ES6+ features
|
||||
- Maintain consistent naming conventions (camelCase for variables/methods)
|
||||
- Document complex logic with clear comments
|
||||
- Use TypeScript for type safety when possible
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Root Directory Structure
|
||||
|
||||
- `/src` - Source code files
|
||||
- `/assets` - Static assets (images, fonts, etc.)
|
||||
- `/components` - Reusable Vue components
|
||||
- `/views` - Page components
|
||||
- `/router` - Vue Router configuration
|
||||
- `/store` - Vuex store modules
|
||||
- `/services` - API and external service integrations
|
||||
- `/utils` - Utility functions and helpers
|
||||
- `/styles` - Global styles and variables
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- `vite.config.js` - Vite configuration
|
||||
- `.env.*` - Environment variables
|
||||
- `package.json` - Project dependencies and scripts
|
||||
- `jsconfig.json` - JavaScript/TypeScript tooling and path aliases
|
||||
|
||||
### File Naming Conventions
|
||||
|
||||
- Components: PascalCase (e.g., `UserProfile.vue`)
|
||||
- Utilities: camelCase (e.g., `formatDate.js`)
|
||||
- Store modules: camelCase (e.g., `userStore.js`)
|
||||
- Style files: kebab-case (e.g., `main-styles.scss`)
|
||||
- Test files: `*.spec.js` or `*.test.js`
|
||||
Canonical workflow reference: `.ai-workflow/workflow.md`.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 120,
|
||||
"semi": true
|
||||
}
|
||||
@@ -28,6 +28,87 @@ 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`
|
||||
|
||||
## Playwright Full E2E
|
||||
|
||||
Run the permanent grouped full-suite entrypoint with a hard max of 5 total workers across:
|
||||
|
||||
- `chromium-(desktop|tablet|mobile)`
|
||||
- `firefox-(desktop|tablet|mobile)`
|
||||
- `webkit-(desktop|tablet|mobile)`
|
||||
|
||||
```sh
|
||||
npm run test:e2e:ci
|
||||
```
|
||||
|
||||
Run a single full-suite slice for one role and one Playwright project:
|
||||
|
||||
```sh
|
||||
npm run test:e2e:full:slice -- --role=admin --project=webkit-tablet
|
||||
```
|
||||
|
||||
Default worker allocation:
|
||||
|
||||
```sh
|
||||
PLAYWRIGHT_PARALLEL_WORKERS_CHROMIUM=2
|
||||
PLAYWRIGHT_PARALLEL_WORKERS_FIREFOX=1
|
||||
PLAYWRIGHT_PARALLEL_WORKERS_WEBKIT=2
|
||||
```
|
||||
|
||||
Optional overrides:
|
||||
|
||||
```sh
|
||||
PLAYWRIGHT_PARALLEL_BASE_PORT=5191
|
||||
PLAYWRIGHT_PARALLEL_WORKERS_CHROMIUM=2
|
||||
PLAYWRIGHT_PARALLEL_WORKERS_FIREFOX=1
|
||||
PLAYWRIGHT_PARALLEL_WORKERS_WEBKIT=2
|
||||
```
|
||||
|
||||
The runner fails fast if the combined worker count exceeds 5.
|
||||
|
||||
Artifacts and summaries:
|
||||
|
||||
- `output/playwright/ci-parallel-report/index.html`
|
||||
- `output/playwright/ci-parallel-chromium/report/index.html`
|
||||
- `output/playwright/ci-parallel-firefox/report/index.html`
|
||||
- `output/playwright/ci-parallel-webkit/report/index.html`
|
||||
- `output/playwright/test-lists/<role>-<project>.txt`
|
||||
|
||||
## Bubblewrap (TWA) Build and Install
|
||||
|
||||
To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands:
|
||||
|
||||
+7221
-204
File diff suppressed because it is too large
Load Diff
Generated
+4162
-2011
File diff suppressed because it is too large
Load Diff
+34
-6
@@ -7,11 +7,29 @@
|
||||
"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",
|
||||
"text:fix-encoding": "node scripts/text-encoding.mjs fix",
|
||||
"text:check-encoding": "node scripts/text-encoding.mjs check",
|
||||
"test:unit": "npm run text:check-encoding && 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:full:slice": "node scripts/run-playwright-full-slice.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 +51,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 +81,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,71 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
|
||||
const devHost = process.env.PLAYWRIGHT_DEV_HOST || "127.0.0.1";
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://${devHost}:${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 }]];
|
||||
|
||||
function buildProject(name: string, browserName: "chromium" | "firefox" | "webkit", deviceName: keyof typeof devices) {
|
||||
const { defaultBrowserType, ...device } = devices[deviceName];
|
||||
const use = {
|
||||
browserName,
|
||||
...device,
|
||||
};
|
||||
|
||||
if (browserName === "firefox") {
|
||||
delete use.isMobile;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
use,
|
||||
};
|
||||
}
|
||||
|
||||
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: [
|
||||
buildProject("chromium-desktop", "chromium", "Desktop Chrome"),
|
||||
buildProject("chromium-tablet", "chromium", "iPad Mini"),
|
||||
buildProject("chromium-mobile", "chromium", "Pixel 5"),
|
||||
buildProject("firefox-desktop", "firefox", "Desktop Firefox"),
|
||||
buildProject("firefox-tablet", "firefox", "iPad Mini"),
|
||||
buildProject("firefox-mobile", "firefox", "Pixel 5"),
|
||||
buildProject("webkit-desktop", "webkit", "Desktop Safari"),
|
||||
buildProject("webkit-tablet", "webkit", "iPad Mini"),
|
||||
buildProject("webkit-mobile", "webkit", "iPhone 12"),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
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 devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
|
||||
const devHost = process.env.PLAYWRIGHT_DEV_HOST || "127.0.0.1";
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://${devHost}:${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`);
|
||||
const serverOutputLimit = 80;
|
||||
|
||||
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 readUnixProcessDetails(pid, fallbackCommandLine = "") {
|
||||
const [rawCommandLine, currentWorkingDirectory] = await Promise.all([
|
||||
fs.readFile(`/proc/${pid}/cmdline`).catch(() => Buffer.alloc(0)),
|
||||
fs.readlink(`/proc/${pid}/cwd`).catch(() => ""),
|
||||
]);
|
||||
const commandLine = rawCommandLine.toString("utf8").split("\0").filter(Boolean).join(" ").trim();
|
||||
const commandContext = [commandLine || fallbackCommandLine, currentWorkingDirectory].filter(Boolean).join(" | ");
|
||||
|
||||
return {
|
||||
Id: pid,
|
||||
CommandLine: commandContext || fallbackCommandLine,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSsListeningProcess(stdout, port) {
|
||||
const line = stdout
|
||||
.split(/\r?\n/)
|
||||
.find((entry) => entry.includes("LISTEN") && entry.includes(`:${port}`) && entry.includes("pid="));
|
||||
if (!line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pidMatch = line.match(/pid=(\d+)/);
|
||||
if (!pidMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const commandMatch = line.match(/users:\(\("([^"]+)"/);
|
||||
return {
|
||||
pid: Number(pidMatch[1]),
|
||||
commandLine: commandMatch ? commandMatch[1] : "",
|
||||
};
|
||||
}
|
||||
|
||||
function parseNetstatListeningProcess(stdout, port) {
|
||||
const line = stdout
|
||||
.split(/\r?\n/)
|
||||
.find((entry) => entry.includes("LISTEN") && entry.match(new RegExp(`:${port}\\s`)));
|
||||
if (!line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pidMatch = line.match(/\s(\d+)\/([^\s]+)\s*$/);
|
||||
if (!pidMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
pid: Number(pidMatch[1]),
|
||||
commandLine: pidMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
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 readUnixProcessDetails(Number(pidMatch[1]), commandMatch ? commandMatch[1] : "");
|
||||
} catch {
|
||||
// Fall through to other tools that are commonly available on Linux runners.
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync("ss", ["-ltnp", `sport = :${port}`], {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const processMatch = parseSsListeningProcess(stdout, port);
|
||||
if (processMatch) {
|
||||
return readUnixProcessDetails(processMatch.pid, processMatch.commandLine);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to netstat.
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync("netstat", ["-ltnp"], {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const processMatch = parseNetstatListeningProcess(stdout, port);
|
||||
if (processMatch) {
|
||||
return readUnixProcessDetails(processMatch.pid, processMatch.commandLine);
|
||||
}
|
||||
} catch {
|
||||
// No more fallbacks available.
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function captureProcessOutput(stream, lines) {
|
||||
const reader = readline.createInterface({ input: stream });
|
||||
reader.on("line", (line) => {
|
||||
lines.push(line);
|
||||
if (lines.length > serverOutputLimit) {
|
||||
lines.splice(0, lines.length - serverOutputLimit);
|
||||
}
|
||||
});
|
||||
return reader;
|
||||
}
|
||||
|
||||
function formatProcessOutputSection(label, lines) {
|
||||
if (lines.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `\n${label}:\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
function formatServerExitError(url, exitInfo, stdoutLines, stderrLines) {
|
||||
const exitLabel = exitInfo.signal ? `signal ${exitInfo.signal}` : `code ${exitInfo.code ?? 0}`;
|
||||
return new Error(
|
||||
`The local Playwright dev server exited before ${url} became ready (${exitLabel}).${formatProcessOutputSection(
|
||||
"stdout",
|
||||
stdoutLines
|
||||
)}${formatProcessOutputSection("stderr", stderrLines)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForServerReady(url, serverProcess, stdoutLines, stderrLines, timeoutMs = 120_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let exitInfo = null;
|
||||
const handleExit = (code, signal) => {
|
||||
exitInfo = {
|
||||
code,
|
||||
signal,
|
||||
};
|
||||
};
|
||||
|
||||
serverProcess.on("exit", handleExit);
|
||||
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
if (exitInfo || serverProcess.exitCode !== null || serverProcess.signalCode !== null) {
|
||||
throw formatServerExitError(
|
||||
url,
|
||||
exitInfo || {
|
||||
code: serverProcess.exitCode,
|
||||
signal: serverProcess.signalCode,
|
||||
},
|
||||
stdoutLines,
|
||||
stderrLines
|
||||
);
|
||||
}
|
||||
|
||||
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}.${formatProcessOutputSection(
|
||||
"stdout",
|
||||
stdoutLines
|
||||
)}${formatProcessOutputSection("stderr", stderrLines)}`
|
||||
);
|
||||
} finally {
|
||||
serverProcess.off("exit", handleExit);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildWarmupTargets() {
|
||||
return [
|
||||
{ pathname: "/admin/12/modules/pos?step=1", expectedContentType: "text/html" },
|
||||
{ pathname: "/admin/12/modules/pos/orders/54518", expectedContentType: "text/html" },
|
||||
{ pathname: "/@vite/client", expectedContentType: "javascript" },
|
||||
{ pathname: "/src/main.js", expectedContentType: "javascript" },
|
||||
{ pathname: "/src/router.js", expectedContentType: "javascript" },
|
||||
];
|
||||
}
|
||||
|
||||
async function warmUpAsset(url, expectedContentType, timeoutMs = 120_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const perRequestTimeoutMs = 10_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(perRequestTimeoutMs),
|
||||
});
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
|
||||
if (response.ok && contentType.toLowerCase().includes(expectedContentType)) {
|
||||
await response.arrayBuffer();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// keep polling until the Vite transform is ready
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out warming Playwright dev asset ${url}.`);
|
||||
}
|
||||
|
||||
async function warmUpDevServer(url) {
|
||||
const warmupTargets = await buildWarmupTargets();
|
||||
|
||||
for (const target of warmupTargets) {
|
||||
await warmUpAsset(new URL(target.pathname, url).toString(), target.expectedContentType);
|
||||
}
|
||||
}
|
||||
|
||||
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 -- --force --host ${devHost} --port ${devPort} --strictPort`], {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
})
|
||||
: spawn("npm", ["run", "dev", "--", "--force", "--host", devHost, "--port", String(devPort), "--strictPort"], {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const stdoutLines = [];
|
||||
const stderrLines = [];
|
||||
const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines);
|
||||
const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines);
|
||||
serverProcess.unref();
|
||||
|
||||
await fs.writeFile(pidFile, JSON.stringify({ pid: serverProcess.pid, port: devPort }), "utf8");
|
||||
|
||||
try {
|
||||
await waitForServerReady(baseURL, serverProcess, stdoutLines, stderrLines);
|
||||
await warmUpDevServer(baseURL);
|
||||
} catch (error) {
|
||||
await killProcessTree(serverProcess.pid);
|
||||
throw error;
|
||||
} finally {
|
||||
stdoutReader.close();
|
||||
stderrReader.close();
|
||||
}
|
||||
}
|
||||
@@ -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,51 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = "http://127.0.0.1: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,284 @@
|
||||
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 legacyPerProcessWorkers = parsePositiveInt(process.env.PLAYWRIGHT_PARALLEL_WORKERS);
|
||||
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-tablet", "chromium-mobile"],
|
||||
defaultWorkers: 2,
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
projects: ["firefox-desktop", "firefox-tablet", "firefox-mobile"],
|
||||
defaultWorkers: 1,
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
projects: ["webkit-desktop", "webkit-tablet", "webkit-mobile"],
|
||||
defaultWorkers: 2,
|
||||
},
|
||||
];
|
||||
|
||||
function parsePositiveInt(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizePositiveInt(value, fallback) {
|
||||
const parsed = parsePositiveInt(value);
|
||||
return parsed ?? fallback;
|
||||
}
|
||||
|
||||
function resolveGroupWorkers(group) {
|
||||
const envName = `PLAYWRIGHT_PARALLEL_WORKERS_${group.name.toUpperCase()}`;
|
||||
const groupWorkers = parsePositiveInt(process.env[envName]);
|
||||
|
||||
if (groupWorkers !== null) {
|
||||
return groupWorkers;
|
||||
}
|
||||
|
||||
if (legacyPerProcessWorkers !== null) {
|
||||
return legacyPerProcessWorkers;
|
||||
}
|
||||
|
||||
return group.defaultWorkers;
|
||||
}
|
||||
|
||||
function resolveConfiguredGroups() {
|
||||
return groups.map((group) => ({
|
||||
...group,
|
||||
workers: resolveGroupWorkers(group),
|
||||
}));
|
||||
}
|
||||
|
||||
function validateWorkerCap(configuredGroups) {
|
||||
const totalWorkers = configuredGroups.reduce((sum, group) => sum + group.workers, 0);
|
||||
|
||||
if (totalWorkers > 5) {
|
||||
const groupSummary = configuredGroups.map((group) => `${group.name}=${group.workers}`).join(", ");
|
||||
throw new Error(
|
||||
`Parallel Playwright worker cap exceeded (${totalWorkers} > 5). Configure the group workers so their sum stays at or below 5. Current values: ${groupSummary}`
|
||||
);
|
||||
}
|
||||
|
||||
return totalWorkers;
|
||||
}
|
||||
|
||||
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", ...forwardedArgs, ...group.projects.flatMap((project) => ["--project", project])];
|
||||
|
||||
const child = spawn(process.execPath, [playwrightCliPath, ...args], {
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT_BASE_URL: "",
|
||||
PLAYWRIGHT_DEV_PORT: String(devPort),
|
||||
PLAYWRIGHT_WORKERS: String(group.workers),
|
||||
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,
|
||||
workers: group.workers,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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>${result.workers}</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>Workers</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() {
|
||||
const configuredGroups = resolveConfiguredGroups();
|
||||
const totalWorkers = validateWorkerCap(configuredGroups);
|
||||
|
||||
console.log(
|
||||
`Starting Playwright CI in parallel with ${
|
||||
configuredGroups.length
|
||||
} processes and ${totalWorkers} total worker(s): ${configuredGroups
|
||||
.map((group) => `${group.name}=${group.workers}`)
|
||||
.join(", ")}.`
|
||||
);
|
||||
|
||||
await Promise.all(configuredGroups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group))));
|
||||
const results = await Promise.all(configuredGroups.map((group, index) => spawnGroup(group, index)));
|
||||
await writeCombinedReportIndex(results);
|
||||
await Promise.all(configuredGroups.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,333 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const workingDirectory = process.cwd();
|
||||
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
|
||||
const execFileAsync = promisify(execFile);
|
||||
const roles = ["customer", "subuser", "admin", "superuser"];
|
||||
const listEntryPattern = /^\s+\[[^\]]+\]\s+›\s+(.+?):(\d+):(\d+)\s+›\s+(.+)\s*$/u;
|
||||
|
||||
const ownedFilesByRole = {
|
||||
customer: [
|
||||
"auth.smoke.spec.js",
|
||||
"booking-selfserve.smoke.spec.js",
|
||||
"connectivityIssue.spec.ts",
|
||||
"example.spec.ts",
|
||||
"i18n.smoke.spec.ts",
|
||||
"i18n.views.spec.ts",
|
||||
"navigation.smoke.spec.js",
|
||||
"self-serve-wash.spec.js",
|
||||
"user-orders.spec.ts",
|
||||
"userBookings.spec.ts",
|
||||
"userBookWash.spec.ts",
|
||||
"userHome.spec.ts",
|
||||
"userInvoices.spec.ts",
|
||||
"userMyWashStart.spec.ts",
|
||||
"userProfileInvoicing.spec.ts",
|
||||
"userProfileNotifications.spec.ts",
|
||||
"userProfileSecurity.spec.ts",
|
||||
"userVehicles.spec.ts",
|
||||
],
|
||||
subuser: [
|
||||
"subuserCompleteRegistration.spec.ts",
|
||||
"subuserProfileContact.spec.ts",
|
||||
"subuserProfileGrant.spec.ts",
|
||||
"subuserProfileInformation.spec.ts",
|
||||
"subuserProfileSecurity.spec.ts",
|
||||
"subuserProfileUsername.spec.ts",
|
||||
],
|
||||
admin: [
|
||||
"admin-bookings-mobile.spec.ts",
|
||||
"admin-daily-report.spec.ts",
|
||||
"admin-department-visibility.spec.ts",
|
||||
"admin-overview-mobile.spec.ts",
|
||||
"admin-overview-night-washes.spec.ts",
|
||||
"admin-pos-drafts.spec.ts",
|
||||
"admin-pos-orders.spec.ts",
|
||||
"adminModuleGoals.spec.ts",
|
||||
"adminModulePosMobileOrderFlow.spec.ts",
|
||||
"assign-draft-order-modal-create.spec.ts",
|
||||
"assign-draft-order-modal-layout.spec.ts",
|
||||
"change-invoice-collection.spec.ts",
|
||||
"change-customer.spec.ts",
|
||||
"economic-queue-workflow.spec.js",
|
||||
"pos-customer-rules.spec.js",
|
||||
"pos-desktop-card-payments.spec.js",
|
||||
"pos-flow.spec.js",
|
||||
"pos-mobile-card-payments.spec.js",
|
||||
"pos-mobile-order-flow.spec.js",
|
||||
"pos.visual.spec.js",
|
||||
],
|
||||
superuser: [
|
||||
"edge-gateways.routes.spec.js",
|
||||
"edge-gateways.smoke.spec.js",
|
||||
"edge-gateways.visual.spec.js",
|
||||
"invoice-distribution.smoke.spec.js",
|
||||
"invoice-transfer-queue-history.spec.js",
|
||||
"invoicing-period.smoke.spec.js",
|
||||
"superuser-bookings.spec.ts",
|
||||
"superuser-customer-complaints.spec.ts",
|
||||
"superuser-department-gates.spec.ts",
|
||||
"superuser-drafts.spec.ts",
|
||||
"superuser-system-status.smoke.spec.js",
|
||||
"superuser-vehicles.smoke.spec.js",
|
||||
"workfeed-config.smoke.spec.js",
|
||||
],
|
||||
};
|
||||
|
||||
const titleRules = [
|
||||
{ role: "customer", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[User\]/u] },
|
||||
{ role: "subuser", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Subuser\]/u] },
|
||||
{ role: "admin", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Operator\]/u] },
|
||||
{ role: "customer", file: "twoFactorAuth.spec.ts", patterns: [/\[AUTH\]\[2FA\]\[User\]/u] },
|
||||
{ role: "subuser", file: "twoFactorAuth.spec.ts", patterns: [/\[AUTH\]\[2FA\]\[Subuser\]/u] },
|
||||
{
|
||||
role: "customer",
|
||||
file: "passkeyAuth.spec.ts",
|
||||
patterns: [
|
||||
/\[AUTH\]\[Passkey\]\[User\]/u,
|
||||
/\[AUTH\]\[Passkey\]\[Browser Support\]/u,
|
||||
/\[AUTH\]\[Passkey\]\[Button State\]/u,
|
||||
],
|
||||
},
|
||||
{ role: "subuser", file: "passkeyAuth.spec.ts", patterns: [/\[AUTH\]\[Passkey\]\[Subuser\]/u] },
|
||||
{
|
||||
role: "customer",
|
||||
file: "subuser-management.spec.ts",
|
||||
patterns: [/^customer user can invite and manage grant access/i],
|
||||
},
|
||||
{ role: "customer", file: "userProfileVisibility.spec.ts", patterns: [/\[PROFILE\]\[User\]\[Visibility\]/u] },
|
||||
{
|
||||
role: "subuser",
|
||||
file: "subuser-management.spec.ts",
|
||||
patterns: [/^authorized subuser managers/i, /^subuser self-service/i, /^subusers without /i],
|
||||
},
|
||||
{ role: "subuser", file: "userProfileVisibility.spec.ts", patterns: [/\[PROFILE\]\[Subuser\]\[Visibility\]/u] },
|
||||
];
|
||||
|
||||
const ownedFileToRole = new Map();
|
||||
for (const role of roles) {
|
||||
for (const file of ownedFilesByRole[role]) {
|
||||
if (ownedFileToRole.has(file)) {
|
||||
throw new Error(`Duplicate role ownership for ${file}.`);
|
||||
}
|
||||
|
||||
ownedFileToRole.set(file, role);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCliArgs(argv) {
|
||||
const separatorIndex = argv.indexOf("--");
|
||||
const optionArgs = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
|
||||
const forwardedArgs = separatorIndex === -1 ? [] : argv.slice(separatorIndex + 1);
|
||||
const options = {
|
||||
role: "",
|
||||
project: "",
|
||||
listOnly: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < optionArgs.length; index += 1) {
|
||||
const arg = optionArgs[index];
|
||||
|
||||
if (arg === "--role") {
|
||||
options.role = optionArgs[index + 1] || "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--role=")) {
|
||||
options.role = arg.slice("--role=".length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--project") {
|
||||
options.project = optionArgs[index + 1] || "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--project=")) {
|
||||
options.project = arg.slice("--project=".length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--list-only") {
|
||||
options.listOnly = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return { options, forwardedArgs };
|
||||
}
|
||||
|
||||
function validateOptions(options, forwardedArgs) {
|
||||
if (!roles.includes(options.role)) {
|
||||
throw new Error(`--role must be one of: ${roles.join(", ")}`);
|
||||
}
|
||||
|
||||
if (!options.project) {
|
||||
throw new Error("--project is required.");
|
||||
}
|
||||
|
||||
for (const arg of forwardedArgs) {
|
||||
if (arg === "--list" || arg === "--test-list" || arg === "--project") {
|
||||
throw new Error(`Forwarded Playwright argument is not supported here: ${arg}`);
|
||||
}
|
||||
|
||||
if (arg.startsWith("--list=") || arg.startsWith("--test-list=") || arg.startsWith("--project=")) {
|
||||
throw new Error(`Forwarded Playwright argument is not supported here: ${arg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toBaseName(filePath) {
|
||||
return filePath.split(/[\\/]/u).pop() || filePath;
|
||||
}
|
||||
|
||||
function parseListedTests(listOutput) {
|
||||
return listOutput
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => {
|
||||
const match = line.match(listEntryPattern);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, relativeFile, lineNumber, columnNumber, title] = match;
|
||||
return {
|
||||
relativeFile,
|
||||
fileName: toBaseName(relativeFile),
|
||||
lineNumber: Number(lineNumber),
|
||||
columnNumber: Number(columnNumber),
|
||||
title,
|
||||
listLine: line.trim(),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function classifyTest(testEntry) {
|
||||
const matches = new Set();
|
||||
const directOwner = ownedFileToRole.get(testEntry.fileName);
|
||||
|
||||
if (directOwner) {
|
||||
matches.add(directOwner);
|
||||
}
|
||||
|
||||
for (const rule of titleRules) {
|
||||
if (rule.file !== testEntry.fileName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rule.patterns.some((pattern) => pattern.test(testEntry.title))) {
|
||||
matches.add(rule.role);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.size !== 1) {
|
||||
const location = `${testEntry.relativeFile}:${testEntry.lineNumber}:${testEntry.columnNumber}`;
|
||||
if (matches.size === 0) {
|
||||
throw new Error(`Unclassified test: ${location} › ${testEntry.title}`);
|
||||
}
|
||||
|
||||
throw new Error(`Ambiguous role ownership for test: ${location} › ${testEntry.title}`);
|
||||
}
|
||||
|
||||
return [...matches][0];
|
||||
}
|
||||
|
||||
async function listProjectTests(project, forwardedArgs) {
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
process.execPath,
|
||||
[playwrightCliPath, "test", "--list", `--project=${project}`, ...forwardedArgs],
|
||||
{
|
||||
cwd: workingDirectory,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
}
|
||||
);
|
||||
|
||||
if (stderr.trim()) {
|
||||
process.stderr.write(stderr);
|
||||
}
|
||||
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function writeTestList(role, project, matchingTests) {
|
||||
const outputDirectory = path.join(workingDirectory, "output", "playwright", "test-lists");
|
||||
await fs.mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
const testListPath = path.join(outputDirectory, `${role}-${project}.txt`);
|
||||
await fs.writeFile(testListPath, `${matchingTests.map((testEntry) => testEntry.listLine).join("\n")}\n`, "utf8");
|
||||
return testListPath;
|
||||
}
|
||||
|
||||
async function runPlaywright(project, testListPath, forwardedArgs) {
|
||||
const args = ["test", `--project=${project}`, `--test-list=${testListPath}`, ...forwardedArgs];
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [playwrightCliPath, ...args], {
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT: "1",
|
||||
},
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("close", (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`Playwright exited due to signal ${signal}.`));
|
||||
return;
|
||||
}
|
||||
|
||||
if ((code ?? 1) !== 0) {
|
||||
reject(new Error(`Playwright exited with code ${code ?? 1}.`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { options, forwardedArgs } = parseCliArgs(process.argv.slice(2));
|
||||
validateOptions(options, forwardedArgs);
|
||||
|
||||
const listOutput = await listProjectTests(options.project, forwardedArgs);
|
||||
const listedTests = parseListedTests(listOutput);
|
||||
const classifiedTests = listedTests.map((testEntry) => ({
|
||||
...testEntry,
|
||||
role: classifyTest(testEntry),
|
||||
}));
|
||||
const matchingTests = classifiedTests.filter((testEntry) => testEntry.role === options.role);
|
||||
|
||||
console.log(
|
||||
`Resolved ${matchingTests.length} ${options.role} test(s) out of ${classifiedTests.length} listed test(s) for ${options.project}.`
|
||||
);
|
||||
|
||||
if (matchingTests.length === 0) {
|
||||
console.log(`No ${options.role} tests matched for ${options.project}. Nothing to run.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.listOnly) {
|
||||
for (const testEntry of matchingTests) {
|
||||
console.log(testEntry.listLine);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const testListPath = await writeTestList(options.role, options.project, matchingTests);
|
||||
console.log(`Using generated test list: ${path.relative(workingDirectory, testListPath)}`);
|
||||
await runPlaywright(options.project, testListPath, forwardedArgs);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const scriptSource = await fs.readFile(__filename, "utf8");
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const workspaceRoot = path.resolve(args.root ?? path.join(__dirname, ".."));
|
||||
const workflowPath = path.join(workspaceRoot, ".ai-workflow", "workflow.md");
|
||||
const manifestPath = path.join(workspaceRoot, ".ai-workflow", "manifest.json");
|
||||
|
||||
const workflow = await fs.readFile(workflowPath, "utf8");
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||
|
||||
validateManifest(manifest);
|
||||
|
||||
const outputs = buildOutputs({
|
||||
manifest,
|
||||
workflow,
|
||||
scriptSource,
|
||||
});
|
||||
|
||||
if (args.mode === "check") {
|
||||
const drifted = await findDriftedOutputs(workspaceRoot, outputs);
|
||||
if (drifted.length > 0) {
|
||||
console.error("AI workflow outputs are out of sync:");
|
||||
for (const drift of drifted) {
|
||||
console.error(`- ${drift}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("AI workflow outputs are in sync.");
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = await writeOutputs(workspaceRoot, outputs);
|
||||
if (changed.length === 0) {
|
||||
console.log("AI workflow outputs are already up to date.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Updated AI workflow outputs:");
|
||||
for (const changedPath of changed) {
|
||||
console.log(`- ${changedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
mode: null,
|
||||
root: null,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (arg === "--write") {
|
||||
args.mode = "write";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--check") {
|
||||
args.mode = "check";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--root") {
|
||||
args.root = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
|
||||
if (!args.mode) {
|
||||
throw new Error("Expected --write or --check.");
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function validateManifest(manifest) {
|
||||
const requiredKeys = ["projects", "assistants", "commands", "generated_outputs", "sync_targets"];
|
||||
for (const key of requiredKeys) {
|
||||
if (!(key in manifest)) {
|
||||
throw new Error(`Manifest is missing required key: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
const seenPaths = new Set();
|
||||
for (const output of manifest.generated_outputs) {
|
||||
if (!output.path || !output.template) {
|
||||
throw new Error("Each generated output must define path and template.");
|
||||
}
|
||||
|
||||
if (seenPaths.has(output.path)) {
|
||||
throw new Error(`Duplicate generated output path: ${output.path}`);
|
||||
}
|
||||
|
||||
seenPaths.add(output.path);
|
||||
}
|
||||
}
|
||||
|
||||
function buildOutputs(context) {
|
||||
const outputs = [];
|
||||
for (const output of context.manifest.generated_outputs) {
|
||||
outputs.push({
|
||||
path: normalizeRelativePath(output.path),
|
||||
content: renderTemplate(output, context),
|
||||
});
|
||||
}
|
||||
return outputs;
|
||||
}
|
||||
|
||||
function renderTemplate(output, context) {
|
||||
const templateMap = {
|
||||
codex_workspace_environment: () => renderWorkspaceCodexEnvironment(context.manifest),
|
||||
codex_project_environment: () => renderProjectCodexEnvironment(context.manifest, output.project),
|
||||
aiassistant_backend_tests_rule: () =>
|
||||
renderAiAssistantRule(context.manifest.projects["backend-php"].generated_content.aiassistant_tests_lines),
|
||||
aiassistant_backend_routes_rule: () =>
|
||||
renderAiAssistantRule(context.manifest.projects["backend-php"].generated_content.aiassistant_routes_lines),
|
||||
aiassistant_frontend_tests_rule: () =>
|
||||
renderAiAssistantRule(context.manifest.projects["front-end-vue"].generated_content.aiassistant_tests_lines),
|
||||
junie_backend_guidelines: () =>
|
||||
renderJunieGuidelines(context.manifest.projects["backend-php"].generated_content.junie_lines),
|
||||
junie_frontend_guidelines: () =>
|
||||
renderJunieGuidelines(context.manifest.projects["front-end-vue"].generated_content.junie_lines),
|
||||
copilot_dispatcher_workflow: () => renderCopilotWorkflow(context.manifest.copilot),
|
||||
workflow_snapshot: () => renderWorkflowSnapshot(context.workflow, output.project),
|
||||
project_snapshot_manifest: () => renderProjectSnapshotManifest(context.manifest, output.project),
|
||||
project_sync_script: () => context.scriptSource,
|
||||
};
|
||||
|
||||
const renderer = templateMap[output.template];
|
||||
if (!renderer) {
|
||||
throw new Error(`Unsupported template: ${output.template}`);
|
||||
}
|
||||
|
||||
return ensureTrailingNewline(renderer());
|
||||
}
|
||||
|
||||
function renderWorkspaceCodexEnvironment(manifest) {
|
||||
return renderCodexEnvironment({
|
||||
name: manifest.workspace.name,
|
||||
setup: manifest.workspace.codex_environment.setup,
|
||||
actions: manifest.workspace.codex_environment.actions,
|
||||
manifest,
|
||||
workspaceScoped: true,
|
||||
});
|
||||
}
|
||||
|
||||
function renderProjectCodexEnvironment(manifest, projectKey) {
|
||||
const project = manifest.projects[projectKey];
|
||||
return renderCodexEnvironment({
|
||||
name: project.codex_environment.name,
|
||||
setup: project.commands.setup,
|
||||
actions: project.codex_environment.actions,
|
||||
manifest,
|
||||
projectKey,
|
||||
workspaceScoped: false,
|
||||
});
|
||||
}
|
||||
|
||||
function renderCodexEnvironment({ name, setup, actions, manifest, projectKey = null, workspaceScoped }) {
|
||||
const lines = [
|
||||
"# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY",
|
||||
"version = 1",
|
||||
`name = "${name}"`,
|
||||
"",
|
||||
];
|
||||
|
||||
appendTomlCommandBlock(lines, "setup", setup);
|
||||
|
||||
for (const action of actions) {
|
||||
lines.push("");
|
||||
lines.push("[[actions]]");
|
||||
lines.push(`name = "${escapeTomlString(action.name)}"`);
|
||||
lines.push(`icon = "${escapeTomlString(action.icon)}"`);
|
||||
lines.push(`command = ${renderTomlMultiline(resolveActionCommand(action, manifest, projectKey, workspaceScoped))}`);
|
||||
|
||||
if (action.platform) {
|
||||
lines.push(`platform = "${escapeTomlString(action.platform)}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function appendTomlCommandBlock(lines, key, commandSpec = {}) {
|
||||
lines.push(`[${key}]`);
|
||||
lines.push(`script = ${renderTomlMultiline(commandSpec.default ?? "")}`);
|
||||
|
||||
if (commandSpec.win32 !== undefined) {
|
||||
lines.push("");
|
||||
lines.push(`[${key}.win32]`);
|
||||
lines.push(`script = ${renderTomlMultiline(commandSpec.win32)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveActionCommand(action, manifest, projectKey, workspaceScoped) {
|
||||
if (action.literal_command) {
|
||||
return action.literal_command.default ?? "";
|
||||
}
|
||||
|
||||
if (!action.command_id) {
|
||||
throw new Error(`Action ${action.name} is missing command_id or literal_command.`);
|
||||
}
|
||||
|
||||
const resolvedProjectKey = action.project ?? projectKey;
|
||||
if (!resolvedProjectKey) {
|
||||
throw new Error(`Action ${action.name} does not resolve to a project.`);
|
||||
}
|
||||
|
||||
const project = manifest.projects[resolvedProjectKey];
|
||||
const command = project.commands[action.command_id]?.default ?? "";
|
||||
|
||||
if (!workspaceScoped) {
|
||||
return command;
|
||||
}
|
||||
|
||||
return joinCommands([`cd ${project.relative_root}`, command]);
|
||||
}
|
||||
|
||||
function renderAiAssistantRule(lines) {
|
||||
return [
|
||||
"---",
|
||||
"apply: always",
|
||||
"---",
|
||||
"",
|
||||
"<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->",
|
||||
"",
|
||||
...lines,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderJunieGuidelines(lines) {
|
||||
return [
|
||||
"<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->",
|
||||
"",
|
||||
...lines,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderCopilotWorkflow(copilot) {
|
||||
const body = copilot.body_lines
|
||||
.map((line) => line.replaceAll("{{ACTOR}}", "$ACTOR").replaceAll("{{TASK}}", "$TASK"))
|
||||
.join("\n");
|
||||
|
||||
return [
|
||||
`name: ${copilot.workflow_name}`,
|
||||
"on:",
|
||||
" workflow_dispatch:",
|
||||
" inputs:",
|
||||
" task:",
|
||||
` description: '${copilot.input_description}'`,
|
||||
" required: true",
|
||||
" type: string",
|
||||
"",
|
||||
"jobs:",
|
||||
" assign-task:",
|
||||
" runs-on: ubuntu-latest",
|
||||
" permissions:",
|
||||
" issues: write",
|
||||
" steps:",
|
||||
" - name: Checkout repository",
|
||||
" uses: actions/checkout@v4",
|
||||
"",
|
||||
" - name: Create GitHub Issue for Copilot",
|
||||
" env:",
|
||||
" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}",
|
||||
" TASK: ${{ github.event.inputs.task }}",
|
||||
" ACTOR: ${{ github.actor }}",
|
||||
" run: |",
|
||||
" BODY=\"$(cat <<EOF",
|
||||
...body.split("\n").map((line) => ` ${line}`),
|
||||
" EOF",
|
||||
" )\"",
|
||||
" gh issue create \\",
|
||||
` --title "${copilot.title_prefix} $TASK" \\`,
|
||||
" --body \"$BODY\" \\",
|
||||
` --label "${copilot.issue_label}"`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderWorkflowSnapshot(workflow, projectKey) {
|
||||
return [
|
||||
`<!-- AUTOGENERATED SNAPSHOT for ${projectKey}: refresh from the canonical workspace with \`node scripts/sync-ai-workflow.mjs --write\`. -->`,
|
||||
"",
|
||||
workflow.trimEnd(),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderProjectSnapshotManifest(manifest, projectKey) {
|
||||
const snapshotManifest = buildProjectSnapshotManifest(manifest, projectKey);
|
||||
return `${JSON.stringify(snapshotManifest, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function buildProjectSnapshotManifest(manifest, projectKey) {
|
||||
const project = JSON.parse(JSON.stringify(manifest.projects[projectKey]));
|
||||
const prefix = `${manifest.projects[projectKey].relative_root}/`;
|
||||
|
||||
project.relative_root = ".";
|
||||
|
||||
const snapshotOutputs = manifest.generated_outputs
|
||||
.filter((output) => output.project === projectKey)
|
||||
.filter((output) => output.template !== "workflow_snapshot")
|
||||
.filter((output) => output.template !== "project_snapshot_manifest")
|
||||
.filter((output) => output.template !== "project_sync_script")
|
||||
.map((output) => ({
|
||||
...output,
|
||||
path: normalizeRelativePath(output.path.slice(prefix.length)),
|
||||
}));
|
||||
|
||||
const snapshotManifest = {
|
||||
"version": manifest.version,
|
||||
"snapshot_of": projectKey,
|
||||
"projects": {
|
||||
[projectKey]: project,
|
||||
},
|
||||
"assistants": manifest.assistants,
|
||||
"commands": manifest.commands,
|
||||
"generated_outputs": snapshotOutputs,
|
||||
"sync_targets": {
|
||||
[projectKey]: {
|
||||
"source_root": ".",
|
||||
"destination": manifest.sync_targets[projectKey]?.destination ?? "",
|
||||
"supported_metadata_dirs": manifest.sync_targets[projectKey]?.supported_metadata_dirs ?? [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (projectKey === "backend-php" && manifest.copilot) {
|
||||
snapshotManifest.copilot = manifest.copilot;
|
||||
}
|
||||
|
||||
if (projectKey === "front-end-vue" && manifest.unsupported) {
|
||||
snapshotManifest.unsupported = manifest.unsupported;
|
||||
}
|
||||
|
||||
return snapshotManifest;
|
||||
}
|
||||
|
||||
async function findDriftedOutputs(workspaceRoot, outputs) {
|
||||
const drifted = [];
|
||||
for (const output of outputs) {
|
||||
const absolutePath = path.join(workspaceRoot, output.path);
|
||||
const currentContent = await readIfExists(absolutePath);
|
||||
if (currentContent !== output.content) {
|
||||
drifted.push(output.path);
|
||||
}
|
||||
}
|
||||
return drifted;
|
||||
}
|
||||
|
||||
async function writeOutputs(workspaceRoot, outputs) {
|
||||
const changed = [];
|
||||
for (const output of outputs) {
|
||||
const absolutePath = path.join(workspaceRoot, output.path);
|
||||
const currentContent = await readIfExists(absolutePath);
|
||||
if (currentContent === output.content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, output.content, "utf8");
|
||||
changed.push(output.path);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
async function readIfExists(filePath) {
|
||||
try {
|
||||
return await fs.readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function joinCommands(commands) {
|
||||
return commands.filter((command) => command && command.trim() !== "").join("\n");
|
||||
}
|
||||
|
||||
function renderTomlMultiline(value) {
|
||||
return `'''\n${value ?? ""}\n'''`;
|
||||
}
|
||||
|
||||
function escapeTomlString(value) {
|
||||
return String(value).replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
||||
}
|
||||
|
||||
function normalizeRelativePath(filePath) {
|
||||
return filePath.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function ensureTrailingNewline(value) {
|
||||
return value.endsWith("\n") ? value : `${value}\n`;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const SCAN_ROOTS = ["src", "tests"];
|
||||
const SCAN_EXTENSIONS = new Set([".vue", ".js", ".ts", ".json"]);
|
||||
const CP1252_EXTRA_BYTE_BY_CHAR = new Map([
|
||||
["€", 0x80],
|
||||
["‚", 0x82],
|
||||
["ƒ", 0x83],
|
||||
["„", 0x84],
|
||||
["…", 0x85],
|
||||
["†", 0x86],
|
||||
["‡", 0x87],
|
||||
["ˆ", 0x88],
|
||||
["‰", 0x89],
|
||||
["Š", 0x8a],
|
||||
["‹", 0x8b],
|
||||
["Œ", 0x8c],
|
||||
["Ž", 0x8e],
|
||||
["‘", 0x91],
|
||||
["’", 0x92],
|
||||
["“", 0x93],
|
||||
["”", 0x94],
|
||||
["•", 0x95],
|
||||
["–", 0x96],
|
||||
["—", 0x97],
|
||||
["˜", 0x98],
|
||||
["™", 0x99],
|
||||
["š", 0x9a],
|
||||
["›", 0x9b],
|
||||
["œ", 0x9c],
|
||||
["ž", 0x9e],
|
||||
["Ÿ", 0x9f],
|
||||
]);
|
||||
const SUSPICIOUS_PATTERNS = [
|
||||
/ÃÂ/g,
|
||||
/Ã./g,
|
||||
/Â(?=\S)/g,
|
||||
/â€(?:[™œžŸ"“”‘’•–—…])/g,
|
||||
/’/g,
|
||||
/“/g,
|
||||
/â€/g,
|
||||
/–/g,
|
||||
/—/g,
|
||||
/…/g,
|
||||
/�/g,
|
||||
];
|
||||
const REGIONAL_CHAR_REGEX = /[æøåÆØÅäöÄÖüÜßéÉèÈáÁàÀóÓúÚíÍñÑçÇ]/g;
|
||||
const LATIN_EXTENDED_CHAR_REGEX = /[\u00C0-\u024F]/g;
|
||||
const CONTROL_CHAR_REGEX = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
|
||||
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
function countMatches(text, regex) {
|
||||
const matches = text.match(regex);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
export function countSuspiciousMarkers(text) {
|
||||
return SUSPICIOUS_PATTERNS.reduce((count, pattern) => count + countMatches(text, pattern), 0);
|
||||
}
|
||||
|
||||
export function countRegionalCharacters(text) {
|
||||
return countMatches(text, REGIONAL_CHAR_REGEX);
|
||||
}
|
||||
|
||||
function countLatinExtendedCharacters(text) {
|
||||
return countMatches(text, LATIN_EXTENDED_CHAR_REGEX);
|
||||
}
|
||||
|
||||
function countControlCharacters(text) {
|
||||
return countMatches(text, CONTROL_CHAR_REGEX);
|
||||
}
|
||||
|
||||
function encodeLatin1(text) {
|
||||
const bytes = [];
|
||||
|
||||
for (const character of text) {
|
||||
const codePoint = character.codePointAt(0);
|
||||
if (codePoint > 0xff) {
|
||||
return null;
|
||||
}
|
||||
bytes.push(codePoint);
|
||||
}
|
||||
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
|
||||
function encodeWindows1252(text) {
|
||||
const bytes = [];
|
||||
|
||||
for (const character of text) {
|
||||
if (CP1252_EXTRA_BYTE_BY_CHAR.has(character)) {
|
||||
bytes.push(CP1252_EXTRA_BYTE_BY_CHAR.get(character));
|
||||
continue;
|
||||
}
|
||||
|
||||
const codePoint = character.codePointAt(0);
|
||||
if (codePoint > 0xff) {
|
||||
return null;
|
||||
}
|
||||
bytes.push(codePoint);
|
||||
}
|
||||
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
|
||||
function decodeUtf8(bytes) {
|
||||
try {
|
||||
return utf8Decoder.decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRepairCandidates(text) {
|
||||
const candidateSet = new Set();
|
||||
const encoders = [encodeLatin1, encodeWindows1252];
|
||||
|
||||
for (const encoder of encoders) {
|
||||
const encoded = encoder(text);
|
||||
if (!encoded) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const decoded = decodeUtf8(encoded);
|
||||
if (decoded && decoded !== text) {
|
||||
candidateSet.add(decoded);
|
||||
}
|
||||
}
|
||||
|
||||
return [...candidateSet];
|
||||
}
|
||||
|
||||
function isRepairImprovement(before, after) {
|
||||
if (!after || after === before) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const beforeSuspicious = countSuspiciousMarkers(before);
|
||||
const afterSuspicious = countSuspiciousMarkers(after);
|
||||
const beforeRegional = countRegionalCharacters(before);
|
||||
const afterRegional = countRegionalCharacters(after);
|
||||
const beforeLatinExtended = countLatinExtendedCharacters(before);
|
||||
const afterLatinExtended = countLatinExtendedCharacters(after);
|
||||
const beforeControls = countControlCharacters(before);
|
||||
const afterControls = countControlCharacters(after);
|
||||
|
||||
if (afterControls > beforeControls) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (afterSuspicious < beforeSuspicious) {
|
||||
return afterRegional >= beforeRegional || afterLatinExtended >= beforeLatinExtended || afterSuspicious === 0;
|
||||
}
|
||||
|
||||
return (
|
||||
afterSuspicious === beforeSuspicious && (afterRegional > beforeRegional || afterLatinExtended > beforeLatinExtended)
|
||||
);
|
||||
}
|
||||
|
||||
function scoreCandidate(text) {
|
||||
return (
|
||||
countSuspiciousMarkers(text) * 20 +
|
||||
countControlCharacters(text) * 30 -
|
||||
countLatinExtendedCharacters(text) * 2 -
|
||||
countRegionalCharacters(text) * 3
|
||||
);
|
||||
}
|
||||
|
||||
function repairOnce(text) {
|
||||
const candidates = buildRepairCandidates(text).filter((candidate) => isRepairImprovement(text, candidate));
|
||||
if (candidates.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return candidates.reduce((bestCandidate, candidate) => {
|
||||
return scoreCandidate(candidate) < scoreCandidate(bestCandidate) ? candidate : bestCandidate;
|
||||
});
|
||||
}
|
||||
|
||||
function repairSegment(text) {
|
||||
let current = text;
|
||||
|
||||
for (let pass = 0; pass < 4; pass += 1) {
|
||||
const repaired = repairOnce(current);
|
||||
if (repaired === current) {
|
||||
break;
|
||||
}
|
||||
current = repaired;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export function repairText(text) {
|
||||
const wholeTextRepair = repairSegment(text);
|
||||
const linewiseRepair = wholeTextRepair
|
||||
.split(/(\r?\n)/)
|
||||
.map((segment) => (segment.match(/\r?\n/) ? segment : repairSegment(segment)))
|
||||
.join("");
|
||||
|
||||
return repairSegment(linewiseRepair);
|
||||
}
|
||||
|
||||
export function containsSuspiciousEncoding(text) {
|
||||
return countSuspiciousMarkers(text) > 0;
|
||||
}
|
||||
|
||||
function getPreview(line) {
|
||||
return line.replace(/\s+/g, " ").trim().slice(0, 180);
|
||||
}
|
||||
|
||||
export function findSuspiciousLines(text) {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((line, index) => ({
|
||||
lineNumber: index + 1,
|
||||
preview: getPreview(line),
|
||||
suspiciousCount: countSuspiciousMarkers(line),
|
||||
}))
|
||||
.filter((entry) => entry.suspiciousCount > 0);
|
||||
}
|
||||
|
||||
async function collectScanFiles(rootDir) {
|
||||
const files = [];
|
||||
|
||||
async function walk(currentDir) {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await walk(entryPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile() || !SCAN_EXTENSIONS.has(path.extname(entry.name))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const scanRoot of SCAN_ROOTS) {
|
||||
await walk(path.join(rootDir, scanRoot));
|
||||
}
|
||||
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
async function run(mode) {
|
||||
const rootDir = process.cwd();
|
||||
const files = await collectScanFiles(rootDir);
|
||||
const suspiciousReports = [];
|
||||
let changedFiles = 0;
|
||||
|
||||
for (const filePath of files) {
|
||||
const originalText = await fs.readFile(filePath, "utf8");
|
||||
const repairedText = repairText(originalText);
|
||||
|
||||
if (mode === "fix" && repairedText !== originalText) {
|
||||
await fs.writeFile(filePath, repairedText, "utf8");
|
||||
changedFiles += 1;
|
||||
}
|
||||
|
||||
const inspectedText = mode === "fix" ? repairedText : originalText;
|
||||
const suspiciousLines = findSuspiciousLines(inspectedText);
|
||||
if (suspiciousLines.length > 0) {
|
||||
suspiciousReports.push({
|
||||
filePath: path.relative(rootDir, filePath),
|
||||
lines: suspiciousLines.slice(0, 12),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "fix") {
|
||||
console.log(`Updated ${changedFiles} file(s).`);
|
||||
}
|
||||
|
||||
if (suspiciousReports.length > 0) {
|
||||
console.error(`Detected suspicious encoding markers in ${suspiciousReports.length} file(s):`);
|
||||
suspiciousReports.forEach((report) => {
|
||||
console.error(`- ${report.filePath}`);
|
||||
report.lines.forEach((line) => {
|
||||
console.error(` ${line.lineNumber}: ${line.preview}`);
|
||||
});
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`No suspicious encoding markers found across ${files.length} file(s).`);
|
||||
}
|
||||
|
||||
const isDirectRun = typeof process.argv[1] === "string" && pathToFileURL(process.argv[1]).href === import.meta.url;
|
||||
|
||||
if (isDirectRun) {
|
||||
const mode = process.argv[2] || "check";
|
||||
if (!["check", "fix"].includes(mode)) {
|
||||
console.error("Usage: node scripts/text-encoding.mjs <check|fix>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await run(mode);
|
||||
}
|
||||
+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>
|
||||
|
||||
@@ -99,3 +99,30 @@ 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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { loadList } from "@/components/pagination/paginatedList.vue";
|
||||
import PageTitle from "@/components/global/PageTitle.vue";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ const toggleIsSelected = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="expandable-content-box">
|
||||
<div v-if="props.expanded">
|
||||
<slot name="expandedContent"></slot>
|
||||
</div>
|
||||
@@ -36,6 +37,7 @@ const toggleIsSelected = () => {
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -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);"
|
||||
>
|
||||
|
||||
@@ -28,6 +28,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
footerTestId: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
hasSelectedStyle: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
@@ -103,8 +107,8 @@ watch(() => props.forceState, (newVal) => {
|
||||
</div>
|
||||
</template>
|
||||
<!-- Slot for footer -->
|
||||
<template v-if="isOpen || props.forceStateFooter">
|
||||
<div class="card-footer">
|
||||
<template v-if="(isOpen || props.forceStateFooter) && $slots.footer">
|
||||
<div class="card-footer" :data-testid="props.footerTestId || undefined">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { IS_DEV } from '@/config.js';
|
||||
import { ref, computed, defineProps, defineEmits, watch } from 'vue';
|
||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||
import {BField, BMessage, BSelect} from "buefy";
|
||||
import { IS_DEV } from "@/config.js";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { BMessage } from "buefy";
|
||||
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
type DateRange = {
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
};
|
||||
|
||||
type Shortcut = {
|
||||
label: string,
|
||||
getRange: () => DateRange,
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
selection: {
|
||||
type: Object as () => ({
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
}),
|
||||
type: Object as () => DateRange,
|
||||
required: true,
|
||||
default: () => ({
|
||||
startDate: new Date(new Date().setHours(0, 0, 0, 0)), // Start of the day (Today at 00:00:00)
|
||||
endDate: new Date(new Date().setHours(23, 59, 59, 999)), // End of the day (Today at 23:59:59)
|
||||
startDate: new Date(new Date().setHours(0, 0, 0, 0)),
|
||||
endDate: new Date(new Date().setHours(23, 59, 59, 999)),
|
||||
}),
|
||||
},
|
||||
onSelectionChange: {
|
||||
@@ -24,7 +33,7 @@ const props = defineProps({
|
||||
endDate: Date
|
||||
) => {
|
||||
if (IS_DEV) {
|
||||
console.warn('onSelectionChange not provided', 'startDate:', startDate, 'endDate:', endDate);
|
||||
console.warn("onSelectionChange not provided", "startDate:", startDate, "endDate:", endDate);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -35,25 +44,25 @@ const props = defineProps({
|
||||
showToLabel?: boolean,
|
||||
showSelectionValidity?: boolean,
|
||||
showUpdateButton?: boolean,
|
||||
showDailySelector?: boolean, // Whether to show the daily selector
|
||||
showWeeklySelector?: boolean, // Whether to show the weekly selector
|
||||
showMonthSelector?: boolean, // Whether to show the month select
|
||||
showMultipleMonthWarning?: boolean, // Whether to show a warning if the selection spans multiple months
|
||||
showYearSelector?: boolean, // Whether to show the year selector
|
||||
showShortCuts?: boolean, // Whether to show the shortcuts
|
||||
showDailySelector?: boolean,
|
||||
showWeeklySelector?: boolean,
|
||||
showMonthSelector?: boolean,
|
||||
showMultipleMonthWarning?: boolean,
|
||||
showYearSelector?: boolean,
|
||||
showShortCuts?: boolean,
|
||||
},
|
||||
required: false,
|
||||
default: () => ({
|
||||
showStartDate: true,
|
||||
showEndDate: true,
|
||||
showToLabel: true, // Whether to show the "To" label between start and end date inputs
|
||||
showSelectionValidity: true, // Whether to show the validity of the selection
|
||||
showUpdateButton: true, // Whether to show the update button
|
||||
showToLabel: true,
|
||||
showSelectionValidity: true,
|
||||
showUpdateButton: true,
|
||||
}),
|
||||
},
|
||||
reverseLevelOrder: {
|
||||
type: Boolean,
|
||||
default: false, // Whether to reverse the order of left and right levels
|
||||
default: false,
|
||||
},
|
||||
isDisabled: {
|
||||
type: Boolean,
|
||||
@@ -66,19 +75,17 @@ const props = defineProps({
|
||||
restrictions: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
minDate: null, // No minimum date restriction
|
||||
maxDate: null, // No maximum date restriction
|
||||
minDate: null,
|
||||
maxDate: null,
|
||||
}),
|
||||
},
|
||||
autoEmitChange: {
|
||||
type: Boolean,
|
||||
default: true, // Automatically emit changes, set to false if you want the user to manually trigger updates
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const visibility = computed(() => {
|
||||
// Apply default visibility settings
|
||||
return {
|
||||
const visibility = computed(() => ({
|
||||
showStartDate: true,
|
||||
showEndDate: true,
|
||||
showToLabel: true,
|
||||
@@ -90,124 +97,162 @@ const visibility = computed(() => {
|
||||
showDailySelector: false,
|
||||
showWeeklySelector: false,
|
||||
showShortCuts: true,
|
||||
// Override with props visibility settings
|
||||
...props.visibility,
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
const emits = defineEmits(['update:selection']);
|
||||
const emits = defineEmits(["update:selection"]);
|
||||
const { width } = useWindowSize();
|
||||
const startDate = ref(props.selection.startDate);
|
||||
const endDate = ref(props.selection.endDate);
|
||||
const currentMonthSelection = ref(startDate.value.getMonth() + 1); // Months are 0-indexed in JavaScript
|
||||
const formattedStartDate = computed(() => SessionUser.functions.date.toLocal(startDate.value));
|
||||
const formattedEndDate = computed(() => SessionUser.functions.date.toLocal(endDate.value));
|
||||
const currentMonthSelection = ref(startDate.value.getMonth() + 1);
|
||||
const isPendingEmit = ref(false);
|
||||
const updateSelection = () => {
|
||||
emits('update:selection', { startDate: startDate.value, endDate: endDate.value });
|
||||
props.onSelectionChange(startDate.value, endDate.value);
|
||||
isPendingEmit.value = false; // Reset pending state after emitting
|
||||
const isMobileLayout = computed(() => width.value <= 768 || SessionUser.functions.device.isMobile());
|
||||
const showMobileAdvancedControls = computed(() => visibility.value.showMonthSelector || visibility.value.showYearSelector);
|
||||
const isMobileAdvancedExpanded = ref(false);
|
||||
|
||||
const availableYears = computed(() => (
|
||||
Array.from({ length: 21 }, (_, index) => new Date().getFullYear() - 10 + index)
|
||||
));
|
||||
|
||||
const formatDateInputValue = (date: Date) => {
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return date.toISOString().split("T")[0];
|
||||
};
|
||||
|
||||
const updateSelection = () => {
|
||||
emits("update:selection", { startDate: startDate.value, endDate: endDate.value });
|
||||
props.onSelectionChange(startDate.value, endDate.value);
|
||||
isPendingEmit.value = false;
|
||||
};
|
||||
|
||||
const applyDateRange = (newStartDate: Date, newEndDate: Date) => {
|
||||
startDate.value = newStartDate;
|
||||
endDate.value = newEndDate;
|
||||
currentMonthSelection.value = newStartDate.getMonth() + 1;
|
||||
isPendingEmit.value = true;
|
||||
|
||||
if (props.autoEmitChange) {
|
||||
updateSelection();
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.selection, (newSelection) => {
|
||||
startDate.value = newSelection.startDate;
|
||||
endDate.value = newSelection.endDate;
|
||||
currentMonthSelection.value = newSelection.startDate.getMonth() + 1;
|
||||
}, { immediate: true });
|
||||
|
||||
watch(isMobileLayout, (isMobile) => {
|
||||
if (!isMobile) {
|
||||
isMobileAdvancedExpanded.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isMobileAdvancedExpanded.value = false;
|
||||
}, { immediate: true });
|
||||
|
||||
const isStartDateValid = computed(() => {
|
||||
if (props.restrictions.minDate) {
|
||||
return startDate.value >= props.restrictions.minDate;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const isEndDateValid = computed(() => {
|
||||
if (props.restrictions.maxDate) {
|
||||
return endDate.value <= props.restrictions.maxDate;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const isSelectionValid = computed(() => {
|
||||
return isStartDateValid.value && isEndDateValid.value && startDate.value <= endDate.value;
|
||||
});
|
||||
|
||||
const isSelectionValid = computed(() => (
|
||||
isStartDateValid.value
|
||||
&& isEndDateValid.value
|
||||
&& startDate.value <= endDate.value
|
||||
));
|
||||
|
||||
const handleStartDateChange = (event) => {
|
||||
const newDate = new Date(event.target.value);
|
||||
if (isNaN(newDate.getTime())) {
|
||||
console.warn('Invalid start date:', event.target.value);
|
||||
if (Number.isNaN(newDate.getTime())) {
|
||||
console.warn("Invalid start date:", event.target.value);
|
||||
return;
|
||||
}
|
||||
startDate.value = newDate;
|
||||
isPendingEmit.value = true;
|
||||
if (props.autoEmitChange) {
|
||||
// Automatically update selection if autoEmitChange is true
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
applyDateRange(newDate, endDate.value);
|
||||
};
|
||||
|
||||
const handleEndDateChange = (event) => {
|
||||
const newDate = new Date(event.target.value);
|
||||
if (isNaN(newDate.getTime())) {
|
||||
console.warn('Invalid end date:', event.target.value);
|
||||
if (Number.isNaN(newDate.getTime())) {
|
||||
console.warn("Invalid end date:", event.target.value);
|
||||
return;
|
||||
}
|
||||
endDate.value = newDate;
|
||||
isPendingEmit.value = true;
|
||||
if (props.autoEmitChange) {
|
||||
// Automatically update selection if autoEmitChange is true
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
applyDateRange(startDate.value, newDate);
|
||||
};
|
||||
|
||||
const setMonth = (month: number) => {
|
||||
if (month < 1 || month > 12) {
|
||||
console.warn('Invalid month selection:', month);
|
||||
console.warn("Invalid month selection:", month);
|
||||
return;
|
||||
}
|
||||
currentMonthSelection.value = month;
|
||||
// Update start date to the first day of the selected month, and end date to the last day of the selected month
|
||||
|
||||
const year = startDate.value.getFullYear();
|
||||
const startOfMonth = new Date(year, month - 1, 2); // Months are 0-indexed
|
||||
const endOfMonth = new Date(year, month, 0); // Last day of the month
|
||||
startDate.value = startOfMonth;
|
||||
endDate.value = endOfMonth;
|
||||
isPendingEmit.value = true;
|
||||
if (props.autoEmitChange) {
|
||||
updateSelection();
|
||||
}
|
||||
const startOfMonth = new Date(year, month - 1, 1);
|
||||
const endOfMonth = new Date(year, month, 0);
|
||||
applyDateRange(startOfMonth, endOfMonth);
|
||||
};
|
||||
|
||||
const months = computed(() => {
|
||||
return Array.from({ length: 12 }, (_, i) => ({
|
||||
value: i + 1,
|
||||
label: new Date(0, i).toLocaleString('da-DK', { month: 'long' }),
|
||||
}));
|
||||
});
|
||||
const handleMonthChange = (event) => {
|
||||
const month = parseInt(event.target.value, 10);
|
||||
if (Number.isNaN(month)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMonth(month);
|
||||
};
|
||||
|
||||
const handleYearChange = (event) => {
|
||||
const year = parseInt(event.target.value, 10);
|
||||
if (Number.isNaN(year)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newStartDate = new Date(startDate.value);
|
||||
newStartDate.setFullYear(year);
|
||||
const newEndDate = new Date(endDate.value);
|
||||
newEndDate.setFullYear(year);
|
||||
applyDateRange(newStartDate, newEndDate);
|
||||
};
|
||||
|
||||
const months = computed(() => (
|
||||
Array.from({ length: 12 }, (_, index) => ({
|
||||
value: index + 1,
|
||||
label: new Date(0, index).toLocaleString("da-DK", { month: "long" }),
|
||||
}))
|
||||
));
|
||||
|
||||
const isCurrentSelectionEntireMonth = computed(() => {
|
||||
const isFirstDayOfMonth = new Date(startDate.value.getFullYear(), startDate.value.getMonth() - 1, 2).getDate() === startDate.value.getDate();
|
||||
const isLastDayOfMonth = endDate.value.getDate() === new Date(endDate.value.getFullYear(), endDate.value.getMonth() + 1, 0).getDate();
|
||||
// Debugging output to verify the logic
|
||||
/**
|
||||
* console.log('isCurrentSelectionEntireMonth:', {
|
||||
* isFirstDayOfMonth,
|
||||
* shouldMatch: new Date(startDate.value.getFullYear(), startDate.value.getMonth() - 1, 2).getDate(),
|
||||
* isLastDayOfMonth,
|
||||
* shouldMatchEnd: endDate.value.getDate(),
|
||||
* startDate: startDate.value,
|
||||
* endDate: endDate.value,
|
||||
* });
|
||||
*/
|
||||
const isFirstDayOfMonth = startDate.value.getDate() === 1;
|
||||
const isLastDayOfMonth = (
|
||||
endDate.value.getDate()
|
||||
=== new Date(endDate.value.getFullYear(), endDate.value.getMonth() + 1, 0).getDate()
|
||||
);
|
||||
return isFirstDayOfMonth && isLastDayOfMonth;
|
||||
});
|
||||
|
||||
const isMultiMonthSelection = computed(() => {
|
||||
return startDate.value.getMonth() !== endDate.value.getMonth() || startDate.value.getFullYear() !== endDate.value.getFullYear();
|
||||
});
|
||||
const isMultiMonthSelection = computed(() => (
|
||||
startDate.value.getMonth() !== endDate.value.getMonth()
|
||||
|| startDate.value.getFullYear() !== endDate.value.getFullYear()
|
||||
));
|
||||
|
||||
type shortcut = {
|
||||
label: string,
|
||||
getRange: () => { startDate: Date, endDate: Date },
|
||||
};
|
||||
|
||||
// Use computed to make shortcuts reactive to language changes
|
||||
const shortcuts = computed<shortcut[]>(() => {
|
||||
const dailyShortcuts: shortcut[] = [
|
||||
// Today
|
||||
const shortcuts = computed<Shortcut[]>(() => {
|
||||
const dailyShortcuts: Shortcut[] = [
|
||||
{
|
||||
label: SessionUser.objects.global.language.text.today,
|
||||
getRange: () => {
|
||||
@@ -218,7 +263,6 @@ const shortcuts = computed<shortcut[]>(() => {
|
||||
};
|
||||
},
|
||||
},
|
||||
// Yesterday
|
||||
{
|
||||
label: SessionUser.objects.global.language.text.yesterday,
|
||||
getRange: () => {
|
||||
@@ -231,13 +275,13 @@ const shortcuts = computed<shortcut[]>(() => {
|
||||
},
|
||||
},
|
||||
];
|
||||
const weeklyShortcuts: shortcut[] = [
|
||||
// This week
|
||||
|
||||
const weeklyShortcuts: Shortcut[] = [
|
||||
{
|
||||
label: SessionUser.objects.global.language.text.this_week,
|
||||
getRange: () => {
|
||||
const today = new Date();
|
||||
const firstDayOfWeek = new Date(today.setDate(today.getDate() - today.getDay() + 1)); // Assuming week starts on Monday
|
||||
const firstDayOfWeek = new Date(today.setDate(today.getDate() - today.getDay() + 1));
|
||||
const lastDayOfWeek = new Date(firstDayOfWeek);
|
||||
lastDayOfWeek.setDate(firstDayOfWeek.getDate() + 6);
|
||||
return {
|
||||
@@ -246,12 +290,11 @@ const shortcuts = computed<shortcut[]>(() => {
|
||||
};
|
||||
},
|
||||
},
|
||||
// Last week
|
||||
{
|
||||
label: SessionUser.objects.global.language.text.last_week,
|
||||
getRange: () => {
|
||||
const today = new Date();
|
||||
const firstDayOfLastWeek = new Date(today.setDate(today.getDate() - today.getDay() - 6)); // Assuming week starts on Monday
|
||||
const firstDayOfLastWeek = new Date(today.setDate(today.getDate() - today.getDay() - 6));
|
||||
const lastDayOfLastWeek = new Date(firstDayOfLastWeek);
|
||||
lastDayOfLastWeek.setDate(firstDayOfLastWeek.getDate() + 6);
|
||||
return {
|
||||
@@ -260,7 +303,6 @@ const shortcuts = computed<shortcut[]>(() => {
|
||||
};
|
||||
},
|
||||
},
|
||||
// This month
|
||||
{
|
||||
label: SessionUser.objects.global.language.text.this_month,
|
||||
getRange: () => {
|
||||
@@ -273,7 +315,6 @@ const shortcuts = computed<shortcut[]>(() => {
|
||||
};
|
||||
},
|
||||
},
|
||||
// Last month
|
||||
{
|
||||
label: SessionUser.objects.global.language.text.last_month,
|
||||
getRange: () => {
|
||||
@@ -286,12 +327,11 @@ const shortcuts = computed<shortcut[]>(() => {
|
||||
};
|
||||
},
|
||||
},
|
||||
// Same week last year
|
||||
{
|
||||
label: SessionUser.objects.global.language.text.same_week_last_year,
|
||||
getRange: () => {
|
||||
const today = new Date();
|
||||
const firstDayOfThisWeek = new Date(today.setDate(today.getDate() - today.getDay() + 1)); // Assuming week starts on Monday
|
||||
const firstDayOfThisWeek = new Date(today.setDate(today.getDate() - today.getDay() + 1));
|
||||
const firstDayOfLastYearSameWeek = new Date(firstDayOfThisWeek);
|
||||
firstDayOfLastYearSameWeek.setFullYear(firstDayOfThisWeek.getFullYear() - 1);
|
||||
const lastDayOfLastYearSameWeek = new Date(firstDayOfLastYearSameWeek);
|
||||
@@ -302,7 +342,6 @@ const shortcuts = computed<shortcut[]>(() => {
|
||||
};
|
||||
},
|
||||
},
|
||||
// Same month last year
|
||||
{
|
||||
label: SessionUser.objects.global.language.text.same_month_last_year,
|
||||
getRange: () => {
|
||||
@@ -320,119 +359,168 @@ const shortcuts = computed<shortcut[]>(() => {
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return [
|
||||
...((visibility.value.showDailySelector || visibility.value.showShortCuts) ? dailyShortcuts : []),
|
||||
...((visibility.value.showWeeklySelector || visibility.value.showShortCuts) ? weeklyShortcuts : []),
|
||||
];
|
||||
});
|
||||
|
||||
const selectedShortcutLabel = computed(() => (
|
||||
shortcuts.value.find((shortcut) => (
|
||||
formatDateInputValue(shortcut.getRange().startDate) === formatDateInputValue(startDate.value)
|
||||
&& formatDateInputValue(shortcut.getRange().endDate) === formatDateInputValue(endDate.value)
|
||||
))?.label || ""
|
||||
));
|
||||
|
||||
const applyShortcut = (shortcut: Shortcut) => {
|
||||
const range = shortcut.getRange();
|
||||
applyDateRange(range.startDate, range.endDate);
|
||||
};
|
||||
|
||||
const handleShortcutSelection = (event) => {
|
||||
const selectedLabel = String(event.target.value || "");
|
||||
const selectedShortcut = shortcuts.value.find((shortcut) => shortcut.label === selectedLabel);
|
||||
if (!selectedShortcut) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyShortcut(selectedShortcut);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Mobile date selector -->
|
||||
<div class="columns is-mobile is-multiline" v-show="SessionUser.functions.device.isMobile()">
|
||||
<!-- Date from & Date to -->
|
||||
<div class="column is-half">
|
||||
<label class="label is-small" v-if="visibility.showStartDate">{{ SessionUser.objects.global.language.text.date_from }}</label>
|
||||
<input
|
||||
type="date"
|
||||
class="input"
|
||||
v-model="props.selection.startDate.toISOString().split('T')[0]"
|
||||
@change="handleStartDateChange"
|
||||
v-bind:disabled="props.isDisabled || props.isReadonly"
|
||||
:class="{ 'is-danger': !isSelectionValid }"
|
||||
/>
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<label class="label is-small" v-if="visibility.showEndDate">{{ SessionUser.objects.global.language.text.date_to }}</label>
|
||||
<input
|
||||
type="date"
|
||||
class="input"
|
||||
v-model="props.selection.endDate.toISOString().split('T')[0]"
|
||||
@change="handleEndDateChange"
|
||||
v-bind:disabled="props.isDisabled || props.isReadonly"
|
||||
:class="{ 'is-danger': !isSelectionValid }"
|
||||
/>
|
||||
</div>
|
||||
<!-- Month selector -->
|
||||
<div class="column is-half" v-if="visibility.showMonthSelector">
|
||||
<div class="field has-addons is-centered">
|
||||
<p class="control is-expanded">
|
||||
<span class="select is-fullwidth">
|
||||
<div class="date-period-selector">
|
||||
<div v-if="isMobileLayout" class="date-period-selector__mobile" data-testid="date-period-mobile-layout">
|
||||
<div class="field" v-if="shortcuts.length > 0">
|
||||
<label class="label is-small">Periode</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
class="input"
|
||||
v-model="currentMonthSelection"
|
||||
@change="setMonth(currentMonthSelection)"
|
||||
v-bind:disabled="props.isDisabled || props.isReadonly"
|
||||
data-testid="date-period-shortcuts"
|
||||
:value="selectedShortcutLabel"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
@change="handleShortcutSelection"
|
||||
>
|
||||
<!-- Default option: Custom -->
|
||||
<option value="" disabled selected>Select Month</option>
|
||||
<option value="" disabled>Vaelg periode</option>
|
||||
<option v-for="shortcut in shortcuts" :key="shortcut.label" :value="shortcut.label">
|
||||
{{ shortcut.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" v-if="visibility.showStartDate">
|
||||
<label class="label is-small">{{ SessionUser.objects.global.language.text.date_from }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
type="date"
|
||||
class="input"
|
||||
data-testid="date-period-start"
|
||||
:value="formatDateInputValue(startDate)"
|
||||
@change="handleStartDateChange"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
:class="{ 'is-danger': !isSelectionValid }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" v-if="visibility.showEndDate">
|
||||
<label class="label is-small">{{ SessionUser.objects.global.language.text.date_to }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
type="date"
|
||||
class="input"
|
||||
data-testid="date-period-end"
|
||||
:value="formatDateInputValue(endDate)"
|
||||
@change="handleEndDateChange"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
:class="{ 'is-danger': !isSelectionValid }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" v-if="showMobileAdvancedControls">
|
||||
<div class="control">
|
||||
<button
|
||||
class="button is-light is-fullwidth date-period-selector__advanced-toggle"
|
||||
type="button"
|
||||
data-testid="date-period-advanced-toggle"
|
||||
@click="isMobileAdvancedExpanded = !isMobileAdvancedExpanded"
|
||||
>
|
||||
<span>Advanced period</span>
|
||||
<span class="icon">
|
||||
<i :class="isMobileAdvancedExpanded ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showMobileAdvancedControls && isMobileAdvancedExpanded" class="date-period-selector__advanced-panel">
|
||||
<div class="field" v-if="visibility.showMonthSelector">
|
||||
<label class="label is-small">{{ SessionUser.objects.global.language.text.month }}</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
:value="currentMonthSelection"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
@change="handleMonthChange"
|
||||
>
|
||||
<option value="" disabled>Select Month</option>
|
||||
<option v-for="month in months" :key="month.value" :value="month.value">
|
||||
{{ SessionUser.functions.ucFirst(month.label) }}
|
||||
</option>
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Year selector -->
|
||||
<div class="column is-half" v-if="visibility.showYearSelector">
|
||||
<div class="field has-addons is-centered">
|
||||
<p class="control is-expanded">
|
||||
<span class="select is-fullwidth">
|
||||
</div>
|
||||
|
||||
<div class="field" v-if="visibility.showYearSelector">
|
||||
<label class="label is-small">{{ SessionUser.objects.global.language.text.year }}</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
class="input"
|
||||
:value="startDate.getFullYear()"
|
||||
@change="(e) => { const year = parseInt(e.target.value); if (!isNaN(year)) { const newStartDate = new Date(startDate); newStartDate.setFullYear(year); startDate = newStartDate; const newEndDate = new Date(endDate); newEndDate.setFullYear(year); endDate = newEndDate; isPendingEmit = true; if (props.autoEmitChange) { updateSelection(); } } }"
|
||||
v-bind:disabled="props.isDisabled || props.isReadonly"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
@change="handleYearChange"
|
||||
>
|
||||
<!-- Generate a range of years from current year - 10 to current year + 10 -->
|
||||
<option v-for="year in Array.from({ length: 21 }, (_, i) => new Date().getFullYear() - 10 + i)" :key="year" :value="year">
|
||||
<option v-for="year in availableYears" :key="year" :value="year">
|
||||
{{ year }}
|
||||
</option>
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
<p class="control">
|
||||
<a class="button is-static">
|
||||
{{ SessionUser.objects.global.language.text.year }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Shortcuts -->
|
||||
<div class="column is-full">
|
||||
<b-field label="Periode">
|
||||
<b-select placeholder="Vælg periode"
|
||||
:expandable="true"
|
||||
:model-value="shortcuts.find(s => (s.getRange().startDate.toISOString().split('T')[0] === startDate.toISOString().split('T')[0]) && (s.getRange().endDate.toISOString().split('T')[0] === endDate.toISOString().split('T')[0]))?.label || null"
|
||||
:expanded="true"
|
||||
@update:modelValue="(value) => { const selectedShortcut = shortcuts.find(s => s.label === value); if (selectedShortcut) { const range = selectedShortcut.getRange(); startDate = range.startDate; endDate = range.endDate; isPendingEmit = true; if (props.autoEmitChange) { updateSelection(); } } }" v-bind:disabled="props.isDisabled || props.isReadonly">
|
||||
<option
|
||||
v-for="(option, index) in shortcuts"
|
||||
:key="index"
|
||||
:value="option.label"
|
||||
>{{ option.label }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Desktop date selector -->
|
||||
<div class="level is-align-items-center" v-show="!SessionUser.functions.device.isMobile()">
|
||||
|
||||
<div class="date-period-selector__mobile-actions" v-if="visibility.showUpdateButton">
|
||||
<slot name="right"></slot>
|
||||
<button
|
||||
class="button is-primary is-fullwidth"
|
||||
type="button"
|
||||
@click="updateSelection"
|
||||
:disabled="props.isDisabled || props.isReadonly || !isSelectionValid || !isPendingEmit"
|
||||
>
|
||||
Update Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="level is-align-items-center">
|
||||
<div class="level-left">
|
||||
<!-- Start Date Input -->
|
||||
<div class="level-item" v-if="visibility.showStartDate">
|
||||
<input
|
||||
type="date"
|
||||
class="input"
|
||||
v-model="props.selection.startDate.toISOString().split('T')[0]"
|
||||
data-testid="date-period-start"
|
||||
:value="formatDateInputValue(startDate)"
|
||||
@change="handleStartDateChange"
|
||||
v-bind:disabled="props.isDisabled || props.isReadonly"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
:class="{ 'is-danger': !isSelectionValid }"
|
||||
/>
|
||||
</div>
|
||||
<!-- "To" visual element -->
|
||||
<div class="level-item" v-if="visibility.showStartDate && visibility.showEndDate && visibility.showToLabel">
|
||||
<span class="label">To</span>
|
||||
</div>
|
||||
@@ -440,29 +528,26 @@ const shortcuts = computed<shortcut[]>(() => {
|
||||
<input
|
||||
type="date"
|
||||
class="input"
|
||||
v-model="props.selection.endDate.toISOString().split('T')[0]"
|
||||
data-testid="date-period-end"
|
||||
:value="formatDateInputValue(endDate)"
|
||||
@change="handleEndDateChange"
|
||||
v-bind:disabled="props.isDisabled || props.isReadonly"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
:class="{ 'is-danger': !isSelectionValid }"
|
||||
/>
|
||||
</div>
|
||||
<!-- Display a message if neither start nor end date is shown -->
|
||||
<div class="level-item" v-if="!visibility.showStartDate && !visibility.showEndDate && visibility.showToLabel">
|
||||
<span class="label">No date selection available</span>
|
||||
</div>
|
||||
<!-- Month and Year selectors -->
|
||||
<div class="level-item" v-if="visibility.showMonthSelector">
|
||||
<div class="field has-addons">
|
||||
<p class="control">
|
||||
<span class="select">
|
||||
<select
|
||||
class="input"
|
||||
v-model="currentMonthSelection"
|
||||
@change="setMonth(currentMonthSelection)"
|
||||
v-bind:disabled="props.isDisabled || props.isReadonly"
|
||||
:value="currentMonthSelection"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
@change="handleMonthChange"
|
||||
>
|
||||
<!-- Default option: Custom -->
|
||||
<option value="" disabled selected>Select Month</option>
|
||||
<option value="" disabled>Select Month</option>
|
||||
<option v-for="month in months" :key="month.value" :value="month.value">
|
||||
{{ SessionUser.functions.ucFirst(month.label) }}
|
||||
</option>
|
||||
@@ -481,13 +566,11 @@ const shortcuts = computed<shortcut[]>(() => {
|
||||
<p class="control">
|
||||
<span class="select">
|
||||
<select
|
||||
class="input"
|
||||
:value="startDate.getFullYear()"
|
||||
@change="(e) => { const year = parseInt(e.target.value); if (!isNaN(year)) { const newStartDate = new Date(startDate); newStartDate.setFullYear(year); startDate = newStartDate; const newEndDate = new Date(endDate); newEndDate.setFullYear(year); endDate = newEndDate; isPendingEmit = true; if (props.autoEmitChange) { updateSelection(); } } }"
|
||||
v-bind:disabled="props.isDisabled || props.isReadonly"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
@change="handleYearChange"
|
||||
>
|
||||
<!-- Generate a range of years from current year - 10 to current year + 10 -->
|
||||
<option v-for="year in Array.from({ length: 21 }, (_, i) => new Date().getFullYear() - 10 + i)" :key="year" :value="year">
|
||||
<option v-for="year in availableYears" :key="year" :value="year">
|
||||
{{ year }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -500,34 +583,34 @@ const shortcuts = computed<shortcut[]>(() => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Slot for left side content -->
|
||||
<slot name="left"></slot>
|
||||
</div>
|
||||
<div class="level-right"
|
||||
v-if="visibility.showSelectionValidity">
|
||||
<div class="level-right" v-if="visibility.showSelectionValidity">
|
||||
<slot name="right"></slot>
|
||||
<div class="level-item" v-if="visibility.showUpdateButton">
|
||||
<button
|
||||
class="button is-primary"
|
||||
@click="updateSelection"
|
||||
v-bind:disabled="props.isDisabled || props.isReadonly || !isSelectionValid || !isPendingEmit"
|
||||
:disabled="props.isDisabled || props.isReadonly || !isSelectionValid || !isPendingEmit"
|
||||
>
|
||||
Update Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar is-white" v-show="!SessionUser.functions.device.isMobile()">
|
||||
|
||||
<div class="navbar is-white">
|
||||
<div class="navbar-menu">
|
||||
<!-- Date shortcuts -->
|
||||
<div class="navbar-start">
|
||||
<div class="navbar-item">
|
||||
<div class="buttons">
|
||||
<button v-for="(shortcut, index) in shortcuts"
|
||||
:key="index"
|
||||
@click="() => { const range = shortcut.getRange(); startDate = range.startDate; endDate = range.endDate; isPendingEmit = true; if (props.autoEmitChange) { updateSelection(); } }"
|
||||
<button
|
||||
v-for="shortcut in shortcuts"
|
||||
:key="shortcut.label"
|
||||
:disabled="props.isDisabled || props.isReadonly"
|
||||
class="button is-light" :class="{ 'is-info': (startDate.toISOString().split('T')[0] === shortcut.getRange().startDate.toISOString().split('T')[0]) && (endDate.toISOString().split('T')[0] === shortcut.getRange().endDate.toISOString().split('T')[0]) }"
|
||||
class="button is-light"
|
||||
:class="{ 'is-info': selectedShortcutLabel === shortcut.label }"
|
||||
@click="applyShortcut(shortcut)"
|
||||
>
|
||||
{{ shortcut.label }}
|
||||
</button>
|
||||
@@ -536,10 +619,11 @@ const shortcuts = computed<shortcut[]>(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Warnings and messages -->
|
||||
<div class="level-item" v-if="visibility.showMonthSelector">
|
||||
<!-- Warning if the selection is not valid -->
|
||||
<b-message v-if="!isSelectionValid"
|
||||
</template>
|
||||
|
||||
<div class="date-period-selector__messages" v-if="visibility.showMonthSelector">
|
||||
<b-message
|
||||
v-if="!isSelectionValid"
|
||||
type="is-warning"
|
||||
iconPack="fas"
|
||||
has-icon
|
||||
@@ -547,7 +631,6 @@ const shortcuts = computed<shortcut[]>(() => {
|
||||
Invalid selection! Start date must be before end date. Please adjust your selection to ensure the start date is not after the end date, and that both dates are within any specified restrictions.
|
||||
</b-message>
|
||||
|
||||
<!-- Warning if the selection stretches across multiple months -->
|
||||
<b-message
|
||||
v-else-if="isMultiMonthSelection && visibility.showMultipleMonthWarning"
|
||||
style="width: 100%;"
|
||||
@@ -557,7 +640,7 @@ const shortcuts = computed<shortcut[]>(() => {
|
||||
>
|
||||
Selection spans multiple months! This may lead to unexpected results. Consider selecting an entire month or a shorter period for more accurate insights.
|
||||
</b-message>
|
||||
<!-- Warning if it's not an entire month -->
|
||||
|
||||
<b-message
|
||||
:closable="false"
|
||||
v-else-if="!isCurrentSelectionEntireMonth && visibility.showMultipleMonthWarning"
|
||||
@@ -569,8 +652,32 @@ const shortcuts = computed<shortcut[]>(() => {
|
||||
When a partial month selection is made, it may not accurately represent the intended time period. Consider selecting an entire month for more precise results.
|
||||
</b-message>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.date-period-selector__mobile {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.date-period-selector__advanced-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.date-period-selector__advanced-panel {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.date-period-selector__mobile-actions {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.date-period-selector__messages {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { watch } from 'vue';
|
||||
import { useRouter } from "vue-router";
|
||||
import { getCurrentStep, setDepartment, getOrderId, setStep, setOrderId, searchAndSelectCustomer, loadOrderItems } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import PosDepartmentStep1 from "@/components/displays/department/pos/steps/PosDepartmentStep1.vue";
|
||||
import PosDepartmentStep2 from "@/components/displays/department/pos/steps/PosDepartmentStep2.vue";
|
||||
@@ -8,12 +9,15 @@ 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 router = useRouter();
|
||||
const debugVehicles = () => {
|
||||
pos.vehicles.select(
|
||||
1,
|
||||
@@ -39,34 +43,18 @@ 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();
|
||||
watch(() => router.currentRoute.value.params.departmentId, (nextDepartmentId) => {
|
||||
if (nextDepartmentId) {
|
||||
setDepartment(nextDepartmentId);
|
||||
}
|
||||
});
|
||||
|
||||
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 +74,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>
|
||||
|
||||
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">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,206 +1,289 @@
|
||||
<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 { 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 {
|
||||
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 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="column">
|
||||
<div class="tabs is-small is-centered">
|
||||
<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="index" :class="{'is-active': tab.active}" @click="panel_tabs.forEach(tab => tab.active = false); tab.active = true">
|
||||
<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 }}
|
||||
@@ -213,45 +296,54 @@ const customer_data_has_empty_details = () => {
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
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
|
||||
)"
|
||||
<span
|
||||
class="pos-selected-customer__label"
|
||||
@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())">
|
||||
>{{ 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"
|
||||
<input
|
||||
:id="attribute.prop"
|
||||
type="checkbox"
|
||||
:name="attribute.prop"
|
||||
class="switch"
|
||||
checked="checked"
|
||||
:disabled="!SessionUser.hasPermission('delete_customer_attribute')"
|
||||
/>
|
||||
<label :for="attribute.prop"></label>
|
||||
@@ -259,9 +351,13 @@ const customer_data_has_empty_details = () => {
|
||||
</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">
|
||||
<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;
|
||||
}
|
||||
|
||||
.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,509 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { STRIPE_TERMINAL_STATUS_ORDER } from '@/components/displays/department/pos/displays/stripeTerminalReaders.js';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
readers: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'desktop',
|
||||
validator: (value) => ['desktop', 'mobile'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const stripeText = (key, params = {}) => t(`admin.pos.stripe.${key}`, params);
|
||||
|
||||
const dropdownRoot = ref(null);
|
||||
const dropdownMenu = ref(null);
|
||||
const isOpen = ref(false);
|
||||
const activeIndex = ref(-1);
|
||||
|
||||
const normalizedModelValue = computed(() => String(props.modelValue ?? ''));
|
||||
const hasReaders = computed(() => props.readers.length > 0);
|
||||
const hasSelectableReaders = computed(() => props.readers.some((reader) => reader.selectable));
|
||||
const selectedReader = computed(() => {
|
||||
return props.readers.find((reader) => reader.id === normalizedModelValue.value) ?? null;
|
||||
});
|
||||
const previewReader = computed(() => selectedReader.value ?? props.readers[0] ?? null);
|
||||
const triggerLabel = computed(() => {
|
||||
if (selectedReader.value) {
|
||||
return selectedReader.value.label;
|
||||
}
|
||||
|
||||
if (!hasReaders.value) {
|
||||
return stripeText('reader.none_available');
|
||||
}
|
||||
|
||||
if (!hasSelectableReaders.value) {
|
||||
return stripeText('reader.none_ready');
|
||||
}
|
||||
|
||||
return stripeText('reader.select');
|
||||
});
|
||||
const triggerStatusLabel = computed(() => {
|
||||
return previewReader.value?.statusLabel ?? stripeText('reader.none_available');
|
||||
});
|
||||
const triggerStatusKey = computed(() => previewReader.value?.statusKey ?? 'neutral');
|
||||
const triggerColorToken = computed(() => previewReader.value?.colorToken ?? 'neutral');
|
||||
|
||||
const groupedReaders = computed(() => {
|
||||
return STRIPE_TERMINAL_STATUS_ORDER
|
||||
.map((statusKey) => {
|
||||
const items = props.readers
|
||||
.map((reader, index) => ({ reader, index }))
|
||||
.filter((entry) => entry.reader.statusKey === statusKey);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: statusKey,
|
||||
label: items[0].reader.statusLabel,
|
||||
items,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const activeReader = computed(() => {
|
||||
if (activeIndex.value < 0 || activeIndex.value >= props.readers.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return props.readers[activeIndex.value] ?? null;
|
||||
});
|
||||
|
||||
const getReaderOptionId = (readerId) => `pos-stripe-terminal-option-${String(readerId).replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
||||
|
||||
const getInitialActiveIndex = (preferLast = false) => {
|
||||
if (!hasReaders.value) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const selectedIndex = props.readers.findIndex((reader) => reader.id === normalizedModelValue.value);
|
||||
if (selectedIndex >= 0) {
|
||||
return selectedIndex;
|
||||
}
|
||||
|
||||
return preferLast ? props.readers.length - 1 : 0;
|
||||
};
|
||||
|
||||
const closeDropdown = ({ restoreTriggerFocus = false } = {}) => {
|
||||
isOpen.value = false;
|
||||
activeIndex.value = -1;
|
||||
|
||||
if (restoreTriggerFocus) {
|
||||
nextTick(() => {
|
||||
dropdownRoot.value?.querySelector('.stripe-terminal-select__trigger')?.focus();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openDropdown = async ({ preferLast = false } = {}) => {
|
||||
if (isOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen.value = true;
|
||||
activeIndex.value = getInitialActiveIndex(preferLast);
|
||||
await nextTick();
|
||||
dropdownMenu.value?.focus();
|
||||
};
|
||||
|
||||
const toggleDropdown = async () => {
|
||||
if (isOpen.value) {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
await openDropdown();
|
||||
};
|
||||
|
||||
const selectReader = (reader) => {
|
||||
if (!reader?.selectable) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update:modelValue', reader.id);
|
||||
closeDropdown({ restoreTriggerFocus: true });
|
||||
};
|
||||
|
||||
const moveActiveIndex = (direction) => {
|
||||
if (!hasReaders.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeIndex.value < 0) {
|
||||
activeIndex.value = getInitialActiveIndex(direction < 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalReaders = props.readers.length;
|
||||
activeIndex.value = (activeIndex.value + direction + totalReaders) % totalReaders;
|
||||
};
|
||||
|
||||
const onTriggerKeydown = async (event) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
await openDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
await openDropdown({ preferLast: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
await toggleDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
const onMenuKeydown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeDropdown({ restoreTriggerFocus: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
moveActiveIndex(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
activeIndex.value = getInitialActiveIndex();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
activeIndex.value = getInitialActiveIndex(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
selectReader(activeReader.value);
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentClick = (event) => {
|
||||
if (!isOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dropdownRoot.value?.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
watch(() => props.readers, (nextReaders) => {
|
||||
if (!Array.isArray(nextReaders) || nextReaders.length === 0) {
|
||||
activeIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeIndex.value >= nextReaders.length) {
|
||||
activeIndex.value = nextReaders.length - 1;
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
watch(normalizedModelValue, () => {
|
||||
if (!isOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeIndex.value = getInitialActiveIndex();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="dropdownRoot"
|
||||
class="dropdown stripe-terminal-select"
|
||||
:class="{
|
||||
'is-active': isOpen,
|
||||
'stripe-terminal-select--mobile': props.variant === 'mobile',
|
||||
'stripe-terminal-select--desktop': props.variant === 'desktop',
|
||||
}"
|
||||
>
|
||||
<div class="dropdown-trigger stripe-terminal-select__trigger-wrap">
|
||||
<button
|
||||
class="button stripe-terminal-select__trigger"
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="isOpen ? 'true' : 'false'"
|
||||
data-testid="pos-stripe-terminal-trigger"
|
||||
@click.stop="toggleDropdown"
|
||||
@keydown="onTriggerKeydown"
|
||||
>
|
||||
<span
|
||||
class="stripe-terminal-select__status-dot"
|
||||
:class="`stripe-terminal-select__status-dot--${triggerColorToken}`"
|
||||
:data-status-key="triggerStatusKey"
|
||||
data-testid="pos-stripe-terminal-trigger-dot"
|
||||
></span>
|
||||
<span class="stripe-terminal-select__trigger-copy">
|
||||
<span class="stripe-terminal-select__trigger-label" data-testid="pos-stripe-terminal-trigger-label">
|
||||
{{ triggerLabel }}
|
||||
</span>
|
||||
<span class="stripe-terminal-select__trigger-status" data-testid="pos-stripe-terminal-trigger-status">
|
||||
{{ triggerStatusLabel }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="icon is-small stripe-terminal-select__trigger-icon" aria-hidden="true">
|
||||
<i class="fas" :class="isOpen ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="isOpen"
|
||||
ref="dropdownMenu"
|
||||
class="dropdown-menu stripe-terminal-select__menu"
|
||||
role="listbox"
|
||||
tabindex="0"
|
||||
:aria-activedescendant="activeReader ? getReaderOptionId(activeReader.id) : null"
|
||||
data-testid="pos-stripe-terminal-menu"
|
||||
@keydown="onMenuKeydown"
|
||||
>
|
||||
<div class="dropdown-content stripe-terminal-select__content">
|
||||
<template v-if="hasReaders">
|
||||
<section
|
||||
v-for="group in groupedReaders"
|
||||
:key="group.key"
|
||||
class="stripe-terminal-select__group"
|
||||
:data-testid="`pos-stripe-terminal-group-${group.key}`"
|
||||
>
|
||||
<p class="stripe-terminal-select__group-label">{{ group.label }}</p>
|
||||
|
||||
<button
|
||||
v-for="({ reader, index }) in group.items"
|
||||
:id="getReaderOptionId(reader.id)"
|
||||
:key="reader.id"
|
||||
class="dropdown-item stripe-terminal-select__option"
|
||||
:class="{
|
||||
'stripe-terminal-select__option--active': activeIndex === index,
|
||||
'stripe-terminal-select__option--selected': normalizedModelValue === reader.id,
|
||||
'stripe-terminal-select__option--disabled': !reader.selectable,
|
||||
}"
|
||||
type="button"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
:aria-disabled="reader.selectable ? 'false' : 'true'"
|
||||
:aria-selected="normalizedModelValue === reader.id ? 'true' : 'false'"
|
||||
:data-status-key="reader.statusKey"
|
||||
:data-testid="`pos-stripe-terminal-option-${reader.id}`"
|
||||
@mouseenter="activeIndex = index"
|
||||
@click="selectReader(reader)"
|
||||
>
|
||||
<span
|
||||
class="stripe-terminal-select__status-dot"
|
||||
:class="`stripe-terminal-select__status-dot--${reader.colorToken}`"
|
||||
:data-status-key="reader.statusKey"
|
||||
:data-testid="`pos-stripe-terminal-option-dot-${reader.id}`"
|
||||
></span>
|
||||
|
||||
<span class="stripe-terminal-select__option-copy">
|
||||
<span class="stripe-terminal-select__option-label">{{ reader.label }}</span>
|
||||
<span class="stripe-terminal-select__option-status">{{ reader.statusLabel }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="dropdown-item stripe-terminal-select__empty"
|
||||
data-testid="pos-stripe-terminal-empty"
|
||||
>
|
||||
{{ stripeText('reader.none_available') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stripe-terminal-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__trigger-wrap,
|
||||
.stripe-terminal-select__menu,
|
||||
.stripe-terminal-select__content,
|
||||
.stripe-terminal-select__trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__trigger {
|
||||
min-height: 2.75rem;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__trigger:focus-visible,
|
||||
.stripe-terminal-select__menu:focus-visible {
|
||||
outline: 2px solid rgba(59, 130, 246, 0.32);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__trigger-copy,
|
||||
.stripe-terminal-select__option-copy {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__trigger-label,
|
||||
.stripe-terminal-select__option-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__trigger-status,
|
||||
.stripe-terminal-select__option-status {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__trigger-icon {
|
||||
margin-left: auto;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__menu {
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__content {
|
||||
max-height: 18rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 18px 32px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
.stripe-terminal-select__group + .stripe-terminal-select__group {
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.9);
|
||||
}
|
||||
|
||||
.stripe-terminal-select__group-label {
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.9rem 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__option {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__option--active {
|
||||
background: rgba(241, 245, 249, 0.96);
|
||||
}
|
||||
|
||||
.stripe-terminal-select__option--selected {
|
||||
background: rgba(239, 246, 255, 0.98);
|
||||
}
|
||||
|
||||
.stripe-terminal-select__option--disabled {
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__option--disabled:hover {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__empty {
|
||||
color: #6b7280;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__status-dot {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
flex: 0 0 0.7rem;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.stripe-terminal-select__status-dot--green {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__status-dot--yellow {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__status-dot--red {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.stripe-terminal-select__status-dot--neutral {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.stripe-terminal-select--mobile .stripe-terminal-select__trigger {
|
||||
min-height: 3rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
export const STRIPE_TERMINAL_STATUS = Object.freeze({
|
||||
ready: 'ready',
|
||||
inUse: 'in_use',
|
||||
offline: 'offline',
|
||||
});
|
||||
|
||||
export const STRIPE_TERMINAL_STATUS_ORDER = Object.freeze([
|
||||
STRIPE_TERMINAL_STATUS.ready,
|
||||
STRIPE_TERMINAL_STATUS.inUse,
|
||||
STRIPE_TERMINAL_STATUS.offline,
|
||||
]);
|
||||
|
||||
const STRIPE_TERMINAL_STATUS_LABELS = Object.freeze({
|
||||
[STRIPE_TERMINAL_STATUS.ready]: 'Ready',
|
||||
[STRIPE_TERMINAL_STATUS.inUse]: 'In use',
|
||||
[STRIPE_TERMINAL_STATUS.offline]: 'Offline',
|
||||
});
|
||||
|
||||
const STRIPE_TERMINAL_STATUS_COLORS = Object.freeze({
|
||||
[STRIPE_TERMINAL_STATUS.ready]: 'green',
|
||||
[STRIPE_TERMINAL_STATUS.inUse]: 'yellow',
|
||||
[STRIPE_TERMINAL_STATUS.offline]: 'red',
|
||||
});
|
||||
|
||||
const STRIPE_TERMINAL_STATUS_SORT_INDEX = Object.freeze(
|
||||
STRIPE_TERMINAL_STATUS_ORDER.reduce((accumulator, statusKey, index) => {
|
||||
accumulator[statusKey] = index;
|
||||
return accumulator;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
export const getStripeTerminalStatusKey = (reader) => {
|
||||
if (reader?.status === 'online' && !reader?.action) {
|
||||
return STRIPE_TERMINAL_STATUS.ready;
|
||||
}
|
||||
|
||||
if (reader?.status === 'busy' || reader?.action) {
|
||||
return STRIPE_TERMINAL_STATUS.inUse;
|
||||
}
|
||||
|
||||
return STRIPE_TERMINAL_STATUS.offline;
|
||||
};
|
||||
|
||||
export const isStripeTerminalSelectable = (reader) => {
|
||||
return getStripeTerminalStatusKey(reader) === STRIPE_TERMINAL_STATUS.ready;
|
||||
};
|
||||
|
||||
export const normalizeStripeTerminalReaders = (readers, statusLabels = STRIPE_TERMINAL_STATUS_LABELS) => {
|
||||
const normalizedReaders = Array.isArray(readers) ? readers : [];
|
||||
|
||||
return normalizedReaders
|
||||
.map((reader, index) => {
|
||||
const statusKey = getStripeTerminalStatusKey(reader);
|
||||
|
||||
return {
|
||||
id: String(reader?.id ?? `stripe-terminal-${index}`),
|
||||
label: String(reader?.label ?? reader?.id ?? 'Unknown terminal'),
|
||||
statusKey,
|
||||
statusLabel: statusLabels[statusKey] ?? STRIPE_TERMINAL_STATUS_LABELS[statusKey],
|
||||
colorToken: STRIPE_TERMINAL_STATUS_COLORS[statusKey],
|
||||
selectable: isStripeTerminalSelectable(reader),
|
||||
reader,
|
||||
};
|
||||
})
|
||||
.sort((leftReader, rightReader) => {
|
||||
const statusDifference =
|
||||
(STRIPE_TERMINAL_STATUS_SORT_INDEX[leftReader.statusKey] ?? Number.MAX_SAFE_INTEGER)
|
||||
- (STRIPE_TERMINAL_STATUS_SORT_INDEX[rightReader.statusKey] ?? Number.MAX_SAFE_INTEGER);
|
||||
|
||||
if (statusDifference !== 0) {
|
||||
return statusDifference;
|
||||
}
|
||||
|
||||
return leftReader.label.localeCompare(rightReader.label, undefined, {
|
||||
sensitivity: 'base',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const getStripeTerminalReaderById = (readers, readerId) => {
|
||||
const normalizedReaderId = String(readerId ?? '');
|
||||
if (!normalizedReaderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (Array.isArray(readers) ? readers : []).find((reader) => reader.id === normalizedReaderId) ?? null;
|
||||
};
|
||||
|
||||
export const selectPreferredStripeTerminalReaderId = (readers, currentSelectedReaderId = '') => {
|
||||
const selectedReader = getStripeTerminalReaderById(readers, currentSelectedReaderId);
|
||||
if (selectedReader?.selectable) {
|
||||
return selectedReader.id;
|
||||
}
|
||||
|
||||
const firstReadyReader = (Array.isArray(readers) ? readers : []).find((reader) => reader.selectable);
|
||||
return firstReadyReader ? firstReadyReader.id : '';
|
||||
};
|
||||
@@ -0,0 +1,258 @@
|
||||
<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,
|
||||
},
|
||||
onFieldSaved: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const createCustomerWishField = ({
|
||||
key,
|
||||
label,
|
||||
source,
|
||||
saveValue,
|
||||
testIdBase,
|
||||
isRequired,
|
||||
warningStateWhenEmpty,
|
||||
warningIconClass,
|
||||
fillRow = false,
|
||||
onSaved = null,
|
||||
}) => {
|
||||
const inputId = `${testIdBase}-input`;
|
||||
const isEditing = ref(false);
|
||||
const autosave = useOrderMetadataAutosave({
|
||||
source,
|
||||
saveValue: async (value) => {
|
||||
await saveValue(value);
|
||||
return value;
|
||||
},
|
||||
onSaved: async (...args) => {
|
||||
if (typeof onSaved === "function") {
|
||||
await onSaved(...args);
|
||||
}
|
||||
},
|
||||
});
|
||||
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,
|
||||
onSaved: async () => {
|
||||
if (typeof props.onFieldSaved === "function") {
|
||||
await props.onFieldSaved("safety_seal");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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,147 +19,79 @@ 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);
|
||||
|
||||
// Define the input note, this is separate from the props note, because otherwise we risk loosing changes.
|
||||
const input = ref(props.note);
|
||||
|
||||
// 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 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 { t } = useI18n();
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
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 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()}">
|
||||
<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
|
||||
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"
|
||||
: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()"
|
||||
: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>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
<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 #customer>
|
||||
<PosSelectedCustomer />
|
||||
</template>
|
||||
<template #basket>
|
||||
<PosOrderItemsCurrent :orderItems="order_items" :orderId="order_id" :loadOrderItems="loadOrderItems" :editPossible="true" />
|
||||
</template>
|
||||
</ElementTabsBox>
|
||||
</WhiteBox>
|
||||
<!--<PosSelectedCustomer class="mt-3"/> -->
|
||||
<template #orderFooter>
|
||||
<div class="pos-step-2__actions" data-testid="pos-step-2-actions">
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<div class="buttons">
|
||||
<NextStep
|
||||
class="is-fullwidth"
|
||||
label="Næste"
|
||||
/>
|
||||
<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>
|
||||
.pos-step-2__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,80 +1,69 @@
|
||||
<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 -->
|
||||
<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>
|
||||
<PreviousStep :is-text="true" :label="t('pos.add_more_items')" />
|
||||
<PreviousStep :is-text="true" :label="t('admin.pos.add_more_items')" />
|
||||
</ButtonsBox>
|
||||
</template>
|
||||
</PosOrderItemsCurrent>
|
||||
</div>
|
||||
</template>
|
||||
</WhiteBox>
|
||||
|
||||
<template #rail-actions>
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<ButtonsBox>
|
||||
<Cancel
|
||||
style="width: 25%;"
|
||||
tabindex="2"
|
||||
class="is-fullwidth"
|
||||
:label="t('common.cancel')"
|
||||
/>
|
||||
<template v-if="customer_id !== 999">
|
||||
<ButtonsBox class="pos-actions pos-actions--rail">
|
||||
<template v-if="!isGuestStripeCustomer">
|
||||
<NextStep
|
||||
style="width: 25%;"
|
||||
class="is-fullwidth"
|
||||
:label="t('pos.complete')"
|
||||
:label="t('admin.pos.complete')"
|
||||
/>
|
||||
</template>
|
||||
<!-- Stripe customer -->
|
||||
<template v-else>
|
||||
<PayWithStripeButton
|
||||
style="width: 25%;"
|
||||
class="is-fullwidth"
|
||||
:label="t('pos.pay_with_stripe')"
|
||||
:label="t('admin.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>
|
||||
</div>
|
||||
<!-- Last provided orders -->
|
||||
<div class="column is-4 pl-3 pr-0">
|
||||
<WhiteBox>
|
||||
<template #default v-if="isCustomerSelected()">
|
||||
<PosSelectedCustomer />
|
||||
</template>
|
||||
</WhiteBox>
|
||||
</div>
|
||||
</div>
|
||||
</PosDesktopOrderWorkspace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,68 +1,49 @@
|
||||
<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 -->
|
||||
<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>
|
||||
<PreviousStep :is-text="true" :label="t('pos.add_more_items')" />
|
||||
<PreviousStep :is-text="true" :label="t('admin.pos.add_more_items')" />
|
||||
</ButtonsBox>
|
||||
</template>
|
||||
</PosOrderItemsCurrent>
|
||||
</div>
|
||||
</template>
|
||||
</WhiteBox>
|
||||
|
||||
<template #rail-actions>
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<ButtonsBox>
|
||||
<div class="buttons">
|
||||
<ButtonsBox class="pos-actions pos-actions--rail">
|
||||
<NextStep
|
||||
class="is-fullwidth"
|
||||
:label="t('admin.pos.complete')"
|
||||
/>
|
||||
<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>
|
||||
</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>
|
||||
</PosDesktopOrderWorkspace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+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>
|
||||
@@ -1,19 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import ScannerCamera from "@/components/viewport/page/templates/scanner/graphics/ScannerCamera.vue";
|
||||
import ScannerInstructions from "@/components/viewport/page/templates/scanner/graphics/ScannerInstructions.vue";
|
||||
import ScannerOutline from "@/components/viewport/page/templates/scanner/graphics/ScannerOutline.vue";
|
||||
import { setTransparency, setBackgroundColor, backgroundColors, setOverflow } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
|
||||
import {
|
||||
setTransparency,
|
||||
setBackgroundColor,
|
||||
backgroundColors,
|
||||
setOverflow,
|
||||
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { PosVehicle } from "@/components/displays/department/pos/steps/mobile/objects/PosVehicle.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumbers
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumbers.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumbers from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumbers.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import PosDepartmentStep1MobileManualInput
|
||||
from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileManualInput.vue";
|
||||
import PosDepartmentStepMobileButtonNextStep
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
|
||||
import { manualInput, vehicles, camera, sounds, transactionHistoryView, views } from "./objects/PosDepartmentStepMobileFlow.vue";
|
||||
import PosDepartmentStep1MobileManualInput from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileManualInput.vue";
|
||||
import PosDepartmentStepMobileButtonNextStep from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
|
||||
import {
|
||||
manualInput,
|
||||
vehicles,
|
||||
camera,
|
||||
sounds,
|
||||
transactionHistoryView,
|
||||
views,
|
||||
} from "./objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { PosSearchResult } from "./objects/PosSearchResult.vue";
|
||||
import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue";
|
||||
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
|
||||
@@ -21,18 +29,12 @@ import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCusto
|
||||
import BookedCustomer from "@/components/viewport/elements/icons/BookedCustomer.vue";
|
||||
import KnownCustomer from "@/components/viewport/elements/icons/KnownCustomer.vue";
|
||||
import CardPaymentCustomer from "@/components/viewport/elements/icons/CardPaymentCustomer.vue";
|
||||
import PosDepartmentStepMobileFixedBottomControl
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||
import PosDepartmentStepMobile1Location
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
|
||||
import PosDepartmentStepMobile1Debug
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue";
|
||||
import PosDepartmentStepMobileAttachments
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.vue";
|
||||
import PosDepartmentStep1MobileTransactionHistory
|
||||
from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue";
|
||||
import {
|
||||
attachments} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||
import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
|
||||
import PosDepartmentStepMobile1Debug from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue";
|
||||
import PosDepartmentStepMobileAttachments from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.vue";
|
||||
import PosDepartmentStep1MobileTransactionHistory from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue";
|
||||
import { attachments } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
// Debug mode flag
|
||||
const debug_mode = ref(false);
|
||||
// Debug array to store request results
|
||||
@@ -41,7 +43,7 @@ const debug_request_results = ref([]);
|
||||
const lastParsedImage = ref(null);
|
||||
const setLastCapturedImage = (image: string) => {
|
||||
camera.latestImage.value = image;
|
||||
}
|
||||
};
|
||||
|
||||
type LPRResponse = {
|
||||
success: boolean;
|
||||
@@ -64,11 +66,7 @@ const compressImageForLPR = (image: string): Promise<string> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = Math.min(
|
||||
1,
|
||||
LPR_IMAGE_MAX_WIDTH / sourceWidth,
|
||||
LPR_IMAGE_MAX_HEIGHT / sourceHeight
|
||||
);
|
||||
const scale = Math.min(1, LPR_IMAGE_MAX_WIDTH / sourceWidth, LPR_IMAGE_MAX_HEIGHT / sourceHeight);
|
||||
const targetWidth = Math.max(1, Math.round(sourceWidth * scale));
|
||||
const targetHeight = Math.max(1, Math.round(sourceHeight * scale));
|
||||
|
||||
@@ -93,7 +91,7 @@ const compressImageForLPR = (image: string): Promise<string> => {
|
||||
const activeVehicleIndexNext = () => {
|
||||
// Increment the active vehicle index, wrapping around if necessary
|
||||
if (vehicles.activeVehicleIndex.value < 3) {
|
||||
vehicles.setActiveVehicleIndex(vehicles.activeVehicleIndex.value + 1)
|
||||
vehicles.setActiveVehicleIndex(vehicles.activeVehicleIndex.value + 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,58 +101,51 @@ const handleLPRResult = () => {
|
||||
registrationNumber: latestLPRResponse.value?.license_plate_number || "",
|
||||
customerName: "",
|
||||
customerId: 0,
|
||||
customerStatus: 'unknown'
|
||||
customerStatus: "unknown",
|
||||
});
|
||||
if (searchResultIndex === vehicles.activeVehicleIndex.value) {
|
||||
// Only play the sound if the vehicle registration number is being set / changed.
|
||||
if (vehicles.getActiveVehicle()?.reg !== latestLPRResponse.value?.license_plate_number) {
|
||||
sounds.play(sounds.list.value.onAfterSuccessfulScan)
|
||||
sounds.play(sounds.list.value.onAfterSuccessfulScan);
|
||||
}
|
||||
vehicles.select(searchResultIndex, {
|
||||
reg: latestLPRResponse.value?.license_plate_number || "",
|
||||
status: 'unknown'
|
||||
status: "unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
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
|
||||
camera.setLatestImage(image); // Update the latest image in the camera object
|
||||
// Function to parse the image data
|
||||
const compressedImage = await compressImageForLPR(image);
|
||||
SessionUser.request(
|
||||
"/modules/scanner/lpr",
|
||||
"POST",
|
||||
{
|
||||
base64_image: compressedImage
|
||||
},
|
||||
).then((response) => {
|
||||
SessionUser.request("/modules/scanner/lpr", "POST", {
|
||||
base64_image: compressedImage,
|
||||
})
|
||||
.then((response) => {
|
||||
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.
|
||||
handleLPRResult()
|
||||
}).catch((error) => {
|
||||
handleLPRResult();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (debug_mode.value) {
|
||||
debug_request_results.value.push(error);
|
||||
}
|
||||
@@ -166,9 +157,12 @@ watch(manualInput, (newValue) => {
|
||||
// Update the header transparency when manualInput changes
|
||||
setTransparency(!newValue);
|
||||
});
|
||||
type statusIcon = typeof VerifiedCustomer | typeof KnownCustomer | typeof UnknownCustomer | typeof CardPaymentCustomer | typeof BookedCustomer;
|
||||
|
||||
|
||||
type statusIcon =
|
||||
| typeof VerifiedCustomer
|
||||
| typeof KnownCustomer
|
||||
| typeof UnknownCustomer
|
||||
| typeof CardPaymentCustomer
|
||||
| typeof BookedCustomer;
|
||||
|
||||
const registrationNumbers = computed(() => {
|
||||
// Return the registration numbers of all vehicles
|
||||
@@ -179,7 +173,6 @@ const registrationNumbers = computed(() => {
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
function getSearchResultIndex(object: PosSearchResult) {
|
||||
let targetIndex = vehicles.activeVehicleIndex.value; // Default to the current active vehicle index
|
||||
// Check if the registration number already exists in the vehicles (And update the vehicle if it does)
|
||||
@@ -195,10 +188,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 +203,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,9 +218,9 @@ const onAutomaticSelection = (object: PosSearchResult | null) => {
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.warn("Invalid active vehicle index:", targetIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const getQuery = computed(() => {
|
||||
// Return the registration number of the active vehicle
|
||||
@@ -252,7 +244,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,13 +263,17 @@ 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"/>
|
||||
<RegistrationNumberSearchResult
|
||||
:searchQuery="getQuery"
|
||||
@select="onAutomaticSelection"
|
||||
:isHidden="true"
|
||||
:automaticallySelect="true"
|
||||
:modifyCustomerOnChange="shouldCustomerBeModified"
|
||||
/>
|
||||
|
||||
<!-- Instructions for the user -->
|
||||
<ScannerInstructions
|
||||
@@ -286,6 +281,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 +292,10 @@ 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,39 +303,91 @@ 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" />
|
||||
</div>
|
||||
<!-- Attachment view button -->
|
||||
<div class="is-flex is-justify-content-center">
|
||||
<PosDepartmentStepMobileAttachments :showAttachments="false" :showTakePictureButton="false" :showReferenceButton="false" :showBackButton="false" class="mb-3"/>
|
||||
<PosDepartmentStepMobileAttachments
|
||||
:showAttachments="false"
|
||||
:showTakePictureButton="false"
|
||||
:showReferenceButton="false"
|
||||
:showBackButton="false"
|
||||
class="mb-3"
|
||||
/>
|
||||
</div>
|
||||
<!-- 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 -->
|
||||
<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']"
|
||||
action-key="pos-mobile-attachment-view-take-picture"
|
||||
copy-key="take_picture"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div class="is-flex is-justify-content-center">
|
||||
<!-- Next step button (If not in attachment view) -->
|
||||
<PosDepartmentStepMobileButtonNextStep style="width: 100%;" v-if="!views.attachmentView.value" :buttonClasses="['has-background-primary', 'has-text-black']" :isWhite="false"/>
|
||||
<PosDepartmentStepMobileButtonNextStep
|
||||
style="width: 100%"
|
||||
v-if="!views.attachmentView.value"
|
||||
:buttonClasses="['has-background-primary', 'has-text-black']"
|
||||
:isWhite="false"
|
||||
action-key="pos-mobile-next-step-primary"
|
||||
/>
|
||||
<!-- 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 -->
|
||||
<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"
|
||||
action-key="pos-mobile-attachment-view-close"
|
||||
copy-key="close"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</PosDepartmentStepMobileFixedBottomControl>
|
||||
@@ -356,24 +410,23 @@ 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 */
|
||||
}
|
||||
/** 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 */
|
||||
}
|
||||
gap: 1rem;
|
||||
padding: clamp(5rem, 12vh, 6.5rem) 1rem 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.custom-content > * {
|
||||
width: min(100%, 48rem);
|
||||
}
|
||||
</style>
|
||||
|
||||
+995
-316
File diff suppressed because it is too large
Load Diff
@@ -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 or send a Stripe payment link by email 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
@@ -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: {
|
||||
|
||||
+5
@@ -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"
|
||||
|
||||
+4
-9
@@ -1,11 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, ref } from 'vue';
|
||||
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
|
||||
import BookedCustomer from "@/components/viewport/elements/icons/BookedCustomer.vue";
|
||||
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
|
||||
import KnownCustomer from "@/components/viewport/elements/icons/KnownCustomer.vue";
|
||||
import CardPaymentCustomer from "@/components/viewport/elements/icons/CardPaymentCustomer.vue";
|
||||
import { VehicleStatusKey, determineStatusKey, VehicleStatusComponents, statusKeyToComponent } from '../objects/PosVehicleStatus.vue';
|
||||
import { defineProps, type PropType } from 'vue';
|
||||
import { VehicleStatusKey, statusKeyToComponent } from '../objects/PosVehicleStatus.vue';
|
||||
|
||||
const getBackgroundColor = (status: VehicleStatusKey) => {
|
||||
switch (status) {
|
||||
@@ -27,8 +22,8 @@ const getBackgroundColor = (status: VehicleStatusKey) => {
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String as () => VehicleStatusKey,
|
||||
required: true
|
||||
type: String as PropType<VehicleStatusKey>,
|
||||
default: 'unknown'
|
||||
},
|
||||
registrationNumber: {
|
||||
type: String,
|
||||
|
||||
+211
-42
@@ -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,23 +47,73 @@ 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) => {
|
||||
watch(
|
||||
() => metadata.getCustomerId(),
|
||||
(newCustomerId) => {
|
||||
if (newCustomerId) {
|
||||
getCustomerNotes();
|
||||
} else {
|
||||
customerNotes.value = [];
|
||||
}
|
||||
}, { immediate: true });
|
||||
},
|
||||
{ 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)" />
|
||||
@@ -74,43 +127,94 @@ watch(() => metadata.getCustomerId(), (newCustomerId) => {
|
||||
<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,6 +222,31 @@ 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;
|
||||
}
|
||||
@@ -153,4 +282,44 @@ watch(() => metadata.getCustomerId(), (newCustomerId) => {
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.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>
|
||||
+32
-8
@@ -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,14 +178,16 @@ 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">
|
||||
<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 -->
|
||||
<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 -->
|
||||
<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>
|
||||
.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>
|
||||
+2
@@ -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"
|
||||
|
||||
+1
-1
@@ -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)"
|
||||
|
||||
+5
-1
@@ -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>
|
||||
|
||||
+420
-125
@@ -1,179 +1,474 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, ref, computed } from "vue";
|
||||
import { computed, defineEmits, defineProps } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { PosOrder } from "../objects/PosOrder.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: "Ordered on Jun 16, 2025",
|
||||
required: true
|
||||
default: "Ordered on",
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: "Puller [1] + High gloss wax seal",
|
||||
required: true
|
||||
},
|
||||
defaultChecked: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
default: "",
|
||||
required: true,
|
||||
},
|
||||
lastOrder: {
|
||||
type: Object as () => PosOrder | null,
|
||||
default: null,
|
||||
required: false
|
||||
}
|
||||
required: false,
|
||||
},
|
||||
matchesCurrentSelection: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
const checked = ref(props.defaultChecked);
|
||||
// Function to generate a summary from the last order
|
||||
function generateSummary(order: PosOrder): string {
|
||||
if (!order || !order.items || order.items.length === 0) {
|
||||
return "Denne ordre har ingen varer.";
|
||||
}
|
||||
const itemNames = order.items.map(item => item?.product?.name || "Ukendt vare");
|
||||
const uniqueItems = Array.from(new Set(itemNames));
|
||||
return uniqueItems.length > 1
|
||||
? `${uniqueItems.length} varer: ${uniqueItems.slice(0, 2).join(", ")}${uniqueItems.length > 2 ? " og flere" : ""}`
|
||||
: uniqueItems[0];
|
||||
const emit = defineEmits<{
|
||||
(e: "copy"): void;
|
||||
}>();
|
||||
|
||||
const orderItems = computed(() => (Array.isArray(props.lastOrder?.items) ? props.lastOrder.items : []));
|
||||
const primaryItem = computed(() => {
|
||||
const standaloneItems = orderItems.value.filter(
|
||||
(item) => item?.related_item_id === null || item?.related_item_id === undefined
|
||||
);
|
||||
return standaloneItems[0] ?? orderItems.value[0] ?? null;
|
||||
});
|
||||
const addonItems = computed(() => {
|
||||
const primaryItemId = Number(primaryItem.value?.id ?? 0);
|
||||
return orderItems.value.filter((item) => {
|
||||
const itemId = Number(item?.id ?? 0);
|
||||
if (itemId && primaryItemId && itemId === primaryItemId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Functions to determine the display values
|
||||
const displayLabel = computed(() => {
|
||||
return props.lastOrder
|
||||
? `Vasket d. ${SessionUser.functions.date.toLocal(new Date(props?.lastOrder?.created_at))}`
|
||||
: props.label;
|
||||
return primaryItemId ? Number(item?.related_item_id ?? 0) === primaryItemId : false;
|
||||
});
|
||||
const displaySubtitle = computed(() => {
|
||||
return props.lastOrder
|
||||
? generateSummary(props.lastOrder)
|
||||
: props.subtitle;
|
||||
});
|
||||
const additionalItems = computed(() => {
|
||||
const primaryItemId = Number(primaryItem.value?.id ?? 0);
|
||||
return orderItems.value.filter((item) => {
|
||||
const itemId = Number(item?.id ?? 0);
|
||||
if (itemId && primaryItemId && itemId === primaryItemId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return item?.related_item_id === null || item?.related_item_id === undefined;
|
||||
});
|
||||
});
|
||||
const createdAtLabel = computed(() => {
|
||||
if (!props.lastOrder?.created_at) {
|
||||
return props.label;
|
||||
}
|
||||
|
||||
return SessionUser.functions.date.toLocal(new Date(props.lastOrder.created_at));
|
||||
});
|
||||
const referenceLabel = computed(() => String(props.lastOrder?.reference ?? "").trim());
|
||||
const lastWashLabel = computed(() => SessionUser.objects.global.language.last_wash || t("common.last_wash"));
|
||||
const referenceFieldLabel = computed(() => SessionUser.objects.global.language.reference || t("common.reference"));
|
||||
const copyButtonLabel = computed(
|
||||
() => `${SessionUser.objects.global.language.copy || t("common.copy")} ${lastWashLabel.value.toLowerCase()}`
|
||||
);
|
||||
const copyHint = computed(() => t("pos.copy_last_wash_hint"));
|
||||
const copyHoldLabel = computed(
|
||||
() => t("pos.hold_to_copy_last_wash") || `Hold inde for at ${copyButtonLabel.value.toLowerCase()}`
|
||||
);
|
||||
const copyTriggeredLabel = computed(() => t("pos.copying_last_wash") || `${copyButtonLabel.value}...`);
|
||||
const copyMatchedLabel = computed(() => t("pos.last_wash_matches_current_selection"));
|
||||
const totalPrice = computed(() => {
|
||||
return SessionUser.functions.currency.toLocal(props.lastOrder?.total_net_amount || 0);
|
||||
});
|
||||
|
||||
const {
|
||||
state: copyHoldState,
|
||||
progress: copyHoldProgress,
|
||||
remainingMs: copyHoldRemainingMs,
|
||||
onPointerDown: onCopyPointerDown,
|
||||
onPointerMove: onCopyPointerMove,
|
||||
onPointerUp: onCopyPointerUp,
|
||||
onPointerCancel: onCopyPointerCancel,
|
||||
} = useHoldToTrigger({
|
||||
onTrigger: () => {
|
||||
emit("copy");
|
||||
},
|
||||
});
|
||||
|
||||
const isCopyHoldArming = computed(() => copyHoldState.value === "arming");
|
||||
const isCopyHoldTriggered = computed(() => copyHoldState.value === "triggered");
|
||||
const isCopyMatched = computed(
|
||||
() => props.matchesCurrentSelection && !isCopyHoldArming.value && !isCopyHoldTriggered.value
|
||||
);
|
||||
const normalizedCopyHoldProgress = computed(() => Math.min(Math.max(Number(copyHoldProgress.value || 0), 0), 1));
|
||||
const copyHoldCountdownLabel = computed(
|
||||
() => `${(Math.max(Number(copyHoldRemainingMs.value || 0), 0) / 1000).toFixed(1)}s`
|
||||
);
|
||||
const copyActionHintLabel = computed(() =>
|
||||
isCopyHoldTriggered.value
|
||||
? copyTriggeredLabel.value
|
||||
: isCopyMatched.value
|
||||
? copyMatchedLabel.value
|
||||
: copyHoldLabel.value
|
||||
);
|
||||
const copyActionClasses = computed(() => ({
|
||||
"last-order-card__copy-action--arming": isCopyHoldArming.value,
|
||||
"last-order-card__copy-action--matched": isCopyMatched.value,
|
||||
"last-order-card__copy-action--triggered": isCopyHoldTriggered.value,
|
||||
}));
|
||||
const copyActionStyle = computed(() => ({
|
||||
"--last-order-card-hold-progress": `${normalizedCopyHoldProgress.value}`,
|
||||
}));
|
||||
|
||||
const noop = () => {};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Box -->
|
||||
<div class="box" style="width: inherit; height: inherit;">
|
||||
<!-- Last order date & radio button -->
|
||||
<div class="columns is-mobile is-vcentered is-multiline is-gapless">
|
||||
<!-- Last order date -->
|
||||
<div class="column has-text-left">
|
||||
<div class="is-flex is-align-items-center">
|
||||
<div class="last-order-card" data-testid="pos-mobile-last-order">
|
||||
<div class="last-order-card__header">
|
||||
<div>
|
||||
<p class="custom-title">{{ displayLabel }}</p>
|
||||
<p class="last-order-card__eyebrow">{{ lastWashLabel }}</p>
|
||||
<h3 class="last-order-card__title">{{ createdAtLabel }}</h3>
|
||||
</div>
|
||||
<span v-if="props.lastOrder?.total_net_amount" class="last-order-card__amount">
|
||||
{{ totalPrice }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="last-order-card__body">
|
||||
<div v-if="primaryItem" class="last-order-card__section">
|
||||
<span class="last-order-card__label">{{ t("common.service") }}</span>
|
||||
<p class="last-order-card__value">{{ primaryItem?.product?.name || primaryItem?.product_id }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="addonItems.length > 0" class="last-order-card__section">
|
||||
<span class="last-order-card__label">{{ t("common.addons") }}</span>
|
||||
<div class="last-order-card__tags">
|
||||
<span
|
||||
v-for="item in addonItems"
|
||||
:key="`${item?.id}-${item?.product_id}-${item?.related_item_id}`"
|
||||
class="last-order-card__tag"
|
||||
>
|
||||
{{ item?.product?.name || item?.product_id }}
|
||||
<template v-if="Number(item?.quantity ?? 1) > 1"> x{{ Number(item?.quantity ?? 1) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Radio button -->
|
||||
<div class="column is-narrow has-text-right">
|
||||
<!-- Align the radio button to the right -->
|
||||
<div class="is-pulled-right">
|
||||
<div class="radio-button-container is-flex is-align-items-center is-justify-content-center"
|
||||
@click="checked = !checked">
|
||||
<div class="radio-button-circle is-clickable"> <!-- The circle for the radio button -->
|
||||
<div class="radio-button-checkmark" v-if="checked"/> <!-- The checkmark inside the circle -->
|
||||
|
||||
<div v-if="additionalItems.length > 0" class="last-order-card__section">
|
||||
<span class="last-order-card__label">{{ SessionUser.objects.global.language.additional_items }}</span>
|
||||
<div class="last-order-card__tags">
|
||||
<span
|
||||
v-for="item in additionalItems"
|
||||
:key="`${item?.id}-${item?.product_id}-${item?.related_item_id}`"
|
||||
class="last-order-card__tag"
|
||||
>
|
||||
{{ item?.product?.name || item?.product_id }}
|
||||
<template v-if="Number(item?.quantity ?? 1) > 1"> x{{ Number(item?.quantity ?? 1) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="referenceLabel" class="last-order-card__section">
|
||||
<span class="last-order-card__label">{{ referenceFieldLabel }}</span>
|
||||
<p class="last-order-card__reference">{{ referenceLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Custom subtitle -->
|
||||
<div class="column is-12 has-text-left">
|
||||
<p class="custom-subtitle">{{ displaySubtitle }}</p>
|
||||
|
||||
<button
|
||||
class="last-order-card__copy-action"
|
||||
:class="copyActionClasses"
|
||||
:style="copyActionStyle"
|
||||
type="button"
|
||||
data-testid="pos-mobile-copy-last-wash"
|
||||
:data-state="copyHoldState"
|
||||
@click.stop.prevent="noop"
|
||||
@pointerdown.stop="onCopyPointerDown"
|
||||
@pointermove.stop="onCopyPointerMove"
|
||||
@pointerup.stop="onCopyPointerUp"
|
||||
@pointercancel.stop="onCopyPointerCancel"
|
||||
@pointerleave.stop="onCopyPointerCancel"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<span class="last-order-card__copy-action-progress" aria-hidden="true"></span>
|
||||
<div class="last-order-card__copy-action-copy">
|
||||
<span class="icon is-small last-order-card__copy-action-icon">
|
||||
<i :class="isCopyHoldTriggered ? 'fas fa-check' : 'fas fa-layer-group'"></i>
|
||||
</span>
|
||||
<div class="last-order-card__copy-action-text-group">
|
||||
<span class="last-order-card__copy-action-text">{{ copyHint }}</span>
|
||||
<span class="last-order-card__copy-action-hint">{{ copyActionHintLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="isCopyHoldArming"
|
||||
class="last-order-card__copy-action-timer"
|
||||
data-testid="pos-mobile-copy-last-wash-countdown"
|
||||
>
|
||||
{{ copyHoldCountdownLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isCopyHoldTriggered"
|
||||
class="icon last-order-card__copy-action-triggered"
|
||||
data-testid="pos-mobile-copy-last-wash-triggered"
|
||||
>
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isCopyMatched"
|
||||
class="icon last-order-card__copy-action-matched"
|
||||
data-testid="pos-mobile-copy-last-wash-matched"
|
||||
>
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
<span v-else class="icon last-order-card__copy-action-chevron">
|
||||
<i class="fas fa-angle-right"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-subtitle {
|
||||
/* Puller [1] + Rim flex unit [2] */
|
||||
margin: 0 auto;
|
||||
height: 12px;
|
||||
|
||||
/* sm-tx */
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
line-height: 100%;
|
||||
/* identical to box height, or 12px */
|
||||
|
||||
color: #929292;
|
||||
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 1;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
.last-order-card {
|
||||
background: radial-gradient(circle at top right, rgba(253, 185, 39, 0.18), transparent 42%),
|
||||
linear-gradient(180deg, #ffffff 0%, #fff8ee 100%);
|
||||
border: 1px solid #ecd7b8;
|
||||
border-radius: 1.15rem;
|
||||
box-shadow: 0 18px 34px rgba(72, 44, 4, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.95rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.radio-button-container {
|
||||
/* Button */
|
||||
|
||||
.last-order-card__header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.last-order-card__eyebrow {
|
||||
color: #8d6330;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 0.35rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.last-order-card__title {
|
||||
color: #4e3313;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
padding: 0px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
border-radius: 4px;
|
||||
}
|
||||
.radio-button-container:before {
|
||||
/* Rectangle 4 */
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
border: 1px solid #000000;
|
||||
border-radius: 10px;
|
||||
|
||||
.last-order-card__amount {
|
||||
background: rgba(143, 93, 25, 0.1);
|
||||
border-radius: 999px;
|
||||
color: #6c4312;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
padding: 0.45rem 0.7rem;
|
||||
}
|
||||
.radio-button-circle {
|
||||
/* Rectangle 4 */
|
||||
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
border: 1px solid #000000;
|
||||
border-radius: 10px;
|
||||
|
||||
.last-order-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.radio-button-checkmark {
|
||||
/* Rectangle 5 */
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
|
||||
background: #000000;
|
||||
border-radius: 10px;
|
||||
margin: 2px auto;
|
||||
|
||||
.last-order-card__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.box {
|
||||
/* Frame 88 */
|
||||
|
||||
box-sizing: border-box;
|
||||
.last-order-card__label {
|
||||
color: #9a6f3b;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Auto layout */
|
||||
padding: 0px 0px 8px 12px;
|
||||
.last-order-card__value,
|
||||
.last-order-card__reference {
|
||||
color: #4a2f11;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.last-order-card__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.last-order-card__tag {
|
||||
background: rgba(143, 93, 25, 0.08);
|
||||
border-radius: 999px;
|
||||
color: #6f4919;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0.7rem;
|
||||
}
|
||||
|
||||
.last-order-card__copy-action {
|
||||
-webkit-appearance: none;
|
||||
align-items: center;
|
||||
appearance: none;
|
||||
background: rgba(143, 93, 25, 0.08);
|
||||
border: 0;
|
||||
border-radius: 0.95rem;
|
||||
color: #6f4919;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
min-height: 4.2rem;
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
padding: 0.75rem 0.9rem;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
transition: background-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
|
||||
border: 1px solid #545353;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.custom-title {
|
||||
/* Ordered on Jun 16, 2025 */
|
||||
|
||||
margin: 0 auto;
|
||||
height: 19px;
|
||||
.last-order-card__copy-action:hover,
|
||||
.last-order-card__copy-action:focus-visible {
|
||||
background: rgba(143, 93, 25, 0.08);
|
||||
color: #6f4919;
|
||||
}
|
||||
|
||||
/* rg-tx */
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 19px;
|
||||
line-height: 120%;
|
||||
.last-order-card__copy-action:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
color: #000000;
|
||||
.last-order-card__copy-action::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.last-order-card__copy-action-progress {
|
||||
background: linear-gradient(90deg, rgba(143, 93, 25, 0.22) 0%, rgba(143, 93, 25, 0.1) 100%);
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
transform: scaleX(var(--last-order-card-hold-progress, 0));
|
||||
transform-origin: left center;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.last-order-card__copy-action--arming,
|
||||
.last-order-card__copy-action--matched,
|
||||
.last-order-card__copy-action--triggered {
|
||||
background: rgba(143, 93, 25, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(143, 93, 25, 0.14);
|
||||
}
|
||||
|
||||
.last-order-card__copy-action--arming .last-order-card__copy-action-progress,
|
||||
.last-order-card__copy-action--triggered .last-order-card__copy-action-progress {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.last-order-card__copy-action--arming .last-order-card__copy-action-icon {
|
||||
animation: last-order-card-hold-pulse 0.65s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.last-order-card__copy-action--triggered {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.last-order-card__copy-action-copy {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.last-order-card__copy-action-icon {
|
||||
color: #8f5d19;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.last-order-card__copy-action-text-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.last-order-card__copy-action-text {
|
||||
color: #6f4919;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.last-order-card__copy-action-hint {
|
||||
color: #8d6330;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.last-order-card__copy-action-chevron {
|
||||
color: #8f5d19;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.last-order-card__copy-action-timer,
|
||||
.last-order-card__copy-action-triggered,
|
||||
.last-order-card__copy-action-matched {
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
border: 1px solid rgba(143, 93, 25, 0.18);
|
||||
border-radius: 999px;
|
||||
color: #6f4919;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.74rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
min-width: 3.4rem;
|
||||
padding: 0.28rem 0.55rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.last-order-card__copy-action-triggered {
|
||||
min-width: 2.2rem;
|
||||
}
|
||||
|
||||
.last-order-card__copy-action-matched {
|
||||
min-width: 2.2rem;
|
||||
}
|
||||
|
||||
@keyframes last-order-card-hold-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+507
-110
@@ -1,148 +1,545 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from "vue";
|
||||
import { computed, defineEmits, defineProps } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { getPicture } from "@/components/displays/department/pos/displays/Piktogrammer.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { PosProduct } from "../objects/PosProduct.vue";
|
||||
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
product: {
|
||||
type: Object as () => PosProduct | null,
|
||||
default: null,
|
||||
required: false
|
||||
required: false,
|
||||
},
|
||||
additionalItems: {
|
||||
type: Array as () => PosProduct[],
|
||||
default: () => [],
|
||||
required: false,
|
||||
},
|
||||
additionalItemsLabel: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: false,
|
||||
},
|
||||
additionalItemsEmptyLabel: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: false,
|
||||
},
|
||||
holdState: {
|
||||
type: String as () => "idle" | "arming" | "triggered",
|
||||
default: "idle",
|
||||
required: false,
|
||||
},
|
||||
holdProgress: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
required: false,
|
||||
},
|
||||
holdRemainingMs: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "open-additional-items"): void;
|
||||
}>();
|
||||
|
||||
const productName = computed(() => props?.product?.name || "");
|
||||
const productPrice = computed(() => SessionUser.functions.currency.toLocal(props?.product?.price || 0));
|
||||
const productImage = computed(() => getPicture(props?.product?.piktogram || props?.product?.id || ""));
|
||||
const productBadge = computed(() => `${t("common.selected")} ${t("common.vehicle").toLowerCase()}`);
|
||||
const productChangeHint = computed(() => t("pos.hold_to_change_vehicle"));
|
||||
const additionalItemsLabel = computed(
|
||||
() => props.additionalItemsLabel || SessionUser.objects.global.language.additional_items
|
||||
);
|
||||
const additionalItemsEmptyLabel = computed(
|
||||
() => props.additionalItemsEmptyLabel || SessionUser.objects.global.language.no_additional_items
|
||||
);
|
||||
const additionalItems = computed(() =>
|
||||
Array.isArray(props.additionalItems)
|
||||
? props.additionalItems
|
||||
.filter((item): item is PosProduct => Boolean(item))
|
||||
.map((item) => ({
|
||||
id: Number(item.id ?? 0),
|
||||
name: String(item.name || item.id || ""),
|
||||
quantity: Number(item.quantity ?? 1),
|
||||
}))
|
||||
: []
|
||||
);
|
||||
const additionalItemsSummary = computed(() => {
|
||||
if (additionalItems.value.length === 0) {
|
||||
return additionalItemsEmptyLabel.value;
|
||||
}
|
||||
|
||||
return additionalItems.value.map((item) => `${item.name}${item.quantity > 1 ? ` x${item.quantity}` : ""}`).join(", ");
|
||||
});
|
||||
const normalizedHoldProgress = computed(() => Math.min(Math.max(Number(props.holdProgress || 0), 0), 1));
|
||||
const isHoldArming = computed(() => props.holdState === "arming");
|
||||
const isHoldTriggered = computed(() => props.holdState === "triggered");
|
||||
const additionalItemsActionHint = computed(() => t("pos.hold_to_choose_additional_items"));
|
||||
const footerClasses = computed(() => ({
|
||||
"vehicle-card__footer--arming": isHoldArming.value,
|
||||
"vehicle-card__footer--triggered": isHoldTriggered.value,
|
||||
}));
|
||||
const footerStyle = computed(() => ({
|
||||
"--vehicle-card-hold-progress": `${normalizedHoldProgress.value}`,
|
||||
}));
|
||||
const holdCountdownLabel = computed(() => `${(Math.max(Number(props.holdRemainingMs || 0), 0) / 1000).toFixed(1)}s`);
|
||||
const {
|
||||
state: additionalItemsHoldState,
|
||||
progress: additionalItemsHoldProgress,
|
||||
remainingMs: additionalItemsHoldRemainingMs,
|
||||
onPointerDown: onAdditionalItemsPointerDown,
|
||||
onPointerMove: onAdditionalItemsPointerMove,
|
||||
onPointerUp: onAdditionalItemsPointerUp,
|
||||
onPointerCancel: onAdditionalItemsPointerCancel,
|
||||
} = useHoldToTrigger({
|
||||
onTrigger: () => {
|
||||
emit("open-additional-items");
|
||||
},
|
||||
});
|
||||
const normalizedAdditionalItemsHoldProgress = computed(() =>
|
||||
Math.min(Math.max(Number(additionalItemsHoldProgress.value || 0), 0), 1)
|
||||
);
|
||||
const isAdditionalItemsHoldArming = computed(() => additionalItemsHoldState.value === "arming");
|
||||
const isAdditionalItemsHoldTriggered = computed(() => additionalItemsHoldState.value === "triggered");
|
||||
const additionalItemsClasses = computed(() => ({
|
||||
"vehicle-card__footer--arming": isAdditionalItemsHoldArming.value,
|
||||
"vehicle-card__footer--triggered": isAdditionalItemsHoldTriggered.value,
|
||||
}));
|
||||
const additionalItemsStyle = computed(() => ({
|
||||
"--vehicle-card-hold-progress": `${normalizedAdditionalItemsHoldProgress.value}`,
|
||||
}));
|
||||
const additionalItemsCountdownLabel = computed(
|
||||
() => `${(Math.max(Number(additionalItemsHoldRemainingMs.value || 0), 0) / 1000).toFixed(1)}s`
|
||||
);
|
||||
const additionalItemsDisplayHint = computed(() => {
|
||||
if (isAdditionalItemsHoldArming.value || isAdditionalItemsHoldTriggered.value || additionalItems.value.length === 0) {
|
||||
return additionalItemsActionHint.value;
|
||||
}
|
||||
|
||||
return additionalItemsSummary.value;
|
||||
});
|
||||
|
||||
const noop = () => {};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Box -->
|
||||
<div class="box" 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">
|
||||
<div class="vehicle-card" data-testid="pos-mobile-primary-product-card">
|
||||
<!--<div class="vehicle-card__header">
|
||||
<div>
|
||||
<p class="vehicle-card__eyebrow">{{ t("common.vehicle") }}</p>
|
||||
<h3 class="vehicle-card__title">{{ productName }}</h3>
|
||||
</div>
|
||||
<!-- Product id -->
|
||||
<div class="tag mb-2" v-show="false">
|
||||
<span>No. {{ props?.product?.piktogram || props?.product?.id }}</span>
|
||||
<span v-if="props.product" class="vehicle-card__badge">
|
||||
{{ productBadge }}
|
||||
</span>
|
||||
</div>-->
|
||||
|
||||
<div class="vehicle-card__content">
|
||||
<div class="vehicle-card__media">
|
||||
<img :src="productImage" :alt="productName || 'N/A'" class="vehicle-card__image" />
|
||||
</div>
|
||||
|
||||
<!--<div class="vehicle-card__details">
|
||||
<div class="vehicle-card__detail">
|
||||
<span class="vehicle-card__detail-label">{{ t("common.service") }}</span>
|
||||
<span class="vehicle-card__detail-value">{{ productName }}</span>
|
||||
</div>
|
||||
<div class="vehicle-card__detail">
|
||||
<span class="vehicle-card__detail-label">{{ t("common.price") }}</span>
|
||||
<span class="vehicle-card__detail-value">{{ productPrice }}</span>
|
||||
</div>
|
||||
</div>-->
|
||||
</div>
|
||||
<!-- Product name and price -->
|
||||
<div class="columns is-mobile is-vcentered">
|
||||
<!-- Product name -->
|
||||
<div class="column is-narrow">
|
||||
<p class="title is-5">{{ props?.product?.name || '' }}</p>
|
||||
</div>
|
||||
<!-- Product price -->
|
||||
<div class="column has-text-right" v-show="false">
|
||||
<p class="title is-5 is-grey">{{ SessionUser.functions.currency.toLocal(props?.product?.price || 0) }}</p>
|
||||
<div class="vehicle-card__details">
|
||||
<div class="vehicle-card__detail">
|
||||
<span class="vehicle-card__detail-label">{{ t("common.service") }}</span>
|
||||
<span class="vehicle-card__detail-value">{{ productName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="vehicle-card__footer"
|
||||
:class="footerClasses"
|
||||
:style="footerStyle"
|
||||
data-testid="pos-mobile-primary-product-hold-indicator"
|
||||
:data-state="props.holdState"
|
||||
>
|
||||
<span class="vehicle-card__footer-progress" aria-hidden="true"></span>
|
||||
<div class="vehicle-card__footer-copy">
|
||||
<span class="icon vehicle-card__footer-icon">
|
||||
<i :class="isHoldTriggered ? 'fas fa-check' : 'fas fa-hand-pointer'"></i>
|
||||
</span>
|
||||
<div class="vehicle-card__footer-text-group">
|
||||
<span class="vehicle-card__footer-text">{{ productBadge }}</span>
|
||||
<span class="vehicle-card__footer-hint">{{ productChangeHint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="isHoldArming"
|
||||
class="vehicle-card__footer-timer"
|
||||
data-testid="pos-mobile-primary-product-hold-countdown"
|
||||
>
|
||||
{{ holdCountdownLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isHoldTriggered"
|
||||
class="icon vehicle-card__footer-triggered"
|
||||
data-testid="pos-mobile-primary-product-hold-triggered"
|
||||
>
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
<span v-else class="icon vehicle-card__footer-chevron">
|
||||
<i class="fas fa-angle-right"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="vehicle-card__footer vehicle-card__additional-items"
|
||||
:class="additionalItemsClasses"
|
||||
:style="additionalItemsStyle"
|
||||
type="button"
|
||||
data-testid="pos-mobile-additional-items-open"
|
||||
:data-state="additionalItemsHoldState"
|
||||
@click.stop.prevent="noop"
|
||||
@pointerdown.stop="onAdditionalItemsPointerDown"
|
||||
@pointermove.stop="onAdditionalItemsPointerMove"
|
||||
@pointerup.stop="onAdditionalItemsPointerUp"
|
||||
@pointercancel.stop="onAdditionalItemsPointerCancel"
|
||||
@pointerleave.stop="onAdditionalItemsPointerCancel"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<span class="vehicle-card__footer-progress" aria-hidden="true"></span>
|
||||
<div class="vehicle-card__footer-copy vehicle-card__additional-items-copy">
|
||||
<span class="icon vehicle-card__footer-icon vehicle-card__additional-items-leading-icon">
|
||||
<i :class="isAdditionalItemsHoldTriggered ? 'fas fa-check' : 'fas fa-box-open'"></i>
|
||||
</span>
|
||||
<div class="vehicle-card__footer-text-group vehicle-card__additional-items-text-group">
|
||||
<span
|
||||
class="vehicle-card__footer-text vehicle-card__additional-items-title"
|
||||
data-testid="pos-mobile-additional-items-header-title"
|
||||
>
|
||||
{{ additionalItemsLabel }}
|
||||
</span>
|
||||
<span
|
||||
class="vehicle-card__footer-hint vehicle-card__additional-items-hint"
|
||||
data-testid="pos-mobile-additional-items-header-hint"
|
||||
>
|
||||
{{ additionalItemsDisplayHint }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vehicle-card__additional-items-status">
|
||||
<span
|
||||
v-if="isAdditionalItemsHoldArming"
|
||||
class="vehicle-card__footer-timer"
|
||||
data-testid="pos-mobile-additional-items-hold-countdown"
|
||||
>
|
||||
{{ additionalItemsCountdownLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isAdditionalItemsHoldTriggered"
|
||||
class="icon vehicle-card__footer-triggered"
|
||||
data-testid="pos-mobile-additional-items-hold-triggered"
|
||||
>
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
<span v-else class="icon vehicle-card__footer-chevron" data-testid="pos-mobile-additional-items-header-icon">
|
||||
<i class="fas fa-angle-right"></i>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-image {
|
||||
/* truck2 */
|
||||
height: 120px;
|
||||
width: max-content;
|
||||
object-fit: contain;
|
||||
}
|
||||
.box {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
padding: 16px;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
/* Tab */
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
/* Auto layout */
|
||||
.vehicle-card {
|
||||
background: radial-gradient(circle at top right, rgba(31, 91, 183, 0.16), transparent 42%),
|
||||
linear-gradient(180deg, #ffffff 0%, #f5f9ff 100%);
|
||||
border: 1px solid #d7e2f1;
|
||||
border-radius: 1.15rem;
|
||||
box-shadow: 0 18px 34px rgba(17, 46, 92, 0.08);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vehicle-card__header {
|
||||
align-items: flex-start;
|
||||
padding: 6px 4px;
|
||||
gap: 8px;
|
||||
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
|
||||
background: #F1F1F1;
|
||||
border: 1px solid #D9D9D9;
|
||||
border-radius: 2px;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 0;
|
||||
flex-grow: 0;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tag span {
|
||||
/* Category Tag */
|
||||
|
||||
width: 30px;
|
||||
height: 10px;
|
||||
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 10px;
|
||||
line-height: 100%;
|
||||
/* identical to box height, or 10px */
|
||||
|
||||
color: #000000;
|
||||
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 0;
|
||||
flex-grow: 0;
|
||||
.vehicle-card__eyebrow {
|
||||
color: #5a6f8f;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 0.35rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
/* Puller */
|
||||
|
||||
margin: 0 auto;
|
||||
height: 24px;
|
||||
|
||||
/* H3 */
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 24px;
|
||||
line-height: 100%;
|
||||
/* identical to box height, or 24px */
|
||||
|
||||
color: #000000;
|
||||
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 0;
|
||||
flex-grow: 0;
|
||||
.vehicle-card__title {
|
||||
color: #112e5c;
|
||||
font-size: 1.12rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title.is-grey {
|
||||
/* 579 kr */
|
||||
.vehicle-card__badge {
|
||||
background: rgba(17, 46, 92, 0.08);
|
||||
border-radius: 999px;
|
||||
color: #17325c;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
padding: 0.45rem 0.7rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
margin: 0 auto;
|
||||
height: 24px;
|
||||
.vehicle-card__content {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* H3 */
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 24px;
|
||||
line-height: 100%;
|
||||
/* identical to box height, or 24px */
|
||||
.vehicle-card__media {
|
||||
align-items: center;
|
||||
border-radius: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
color: #929292;
|
||||
.vehicle-card__image {
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vehicle-card__details {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 1;
|
||||
flex-grow: 0;
|
||||
.vehicle-card__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.vehicle-card__detail-label {
|
||||
color: #6c7d98;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.vehicle-card__detail-value {
|
||||
color: #16335f;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.vehicle-card__footer {
|
||||
align-items: center;
|
||||
background: rgba(17, 46, 92, 0.05);
|
||||
border-radius: 0.9rem;
|
||||
box-shadow: inset 0 0 0 1px transparent;
|
||||
color: #17325c;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
padding: 0.75rem 0.9rem;
|
||||
position: relative;
|
||||
transition: background-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.vehicle-card__footer-progress {
|
||||
background: linear-gradient(90deg, rgba(31, 91, 183, 0.28) 0%, rgba(31, 91, 183, 0.12) 100%);
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
transform: scaleX(var(--vehicle-card-hold-progress, 0));
|
||||
transform-origin: left center;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.vehicle-card__footer--arming,
|
||||
.vehicle-card__footer--triggered {
|
||||
background: rgba(31, 91, 183, 0.1);
|
||||
box-shadow: inset 0 0 0 1px rgba(31, 91, 183, 0.14);
|
||||
}
|
||||
|
||||
.vehicle-card__footer--arming .vehicle-card__footer-progress,
|
||||
.vehicle-card__footer--triggered .vehicle-card__footer-progress {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vehicle-card__footer--arming .vehicle-card__footer-icon {
|
||||
animation: vehicle-card-hold-pulse 0.65s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.vehicle-card__footer--triggered {
|
||||
background: rgba(31, 91, 183, 0.16);
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.vehicle-card__footer-copy {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vehicle-card__footer-icon {
|
||||
color: #1f5bb7;
|
||||
transition: color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.vehicle-card__footer-text-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vehicle-card__footer-text {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vehicle-card__footer-hint {
|
||||
color: #59708f;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.vehicle-card__footer-chevron {
|
||||
color: #1f5bb7;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vehicle-card__footer-timer,
|
||||
.vehicle-card__footer-triggered {
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
border: 1px solid rgba(31, 91, 183, 0.18);
|
||||
border-radius: 999px;
|
||||
color: #17325c;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.74rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
justify-content: center;
|
||||
min-width: 3.4rem;
|
||||
padding: 0.28rem 0.55rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vehicle-card__footer-triggered {
|
||||
min-width: 2.2rem;
|
||||
}
|
||||
|
||||
@keyframes vehicle-card-hold-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.vehicle-card__additional-items {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
justify-content: flex-start;
|
||||
min-height: 4.1rem;
|
||||
outline: none;
|
||||
text-align: left;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.vehicle-card__additional-items-copy {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.vehicle-card__additional-items-leading-icon {
|
||||
color: #1f5bb7;
|
||||
}
|
||||
|
||||
.vehicle-card__additional-items-text-group {
|
||||
align-items: flex-start;
|
||||
flex: 1 1 auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vehicle-card__additional-items-title {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vehicle-card__additional-items-hint {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vehicle-card__additional-items-status {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 0 0 3.4rem;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
min-width: 3.4rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vehicle-card__additional-items:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vehicle-card__additional-items::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
+1
-3
@@ -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"/>
|
||||
|
||||
+165
-39
@@ -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>
|
||||
@@ -227,3 +352,4 @@ const filterOutQuery = (url: string | null): string => {
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
+159
-77
@@ -1,82 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, defineProps, defineEmits } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { reference } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { metadata, camera, attachments, transactionHistory, views, popups, resetPos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import PosDepartmentStepMobileAttachmentsDisplay
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachmentsDisplay.vue";
|
||||
import {
|
||||
metadata,
|
||||
camera,
|
||||
attachments,
|
||||
transactionHistory,
|
||||
views,
|
||||
popups,
|
||||
resetPos,
|
||||
} from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import PosDepartmentStepMobileAttachmentsDisplay from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachmentsDisplay.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
import { reset_all_values, order_id } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { sounds } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
/** Props */
|
||||
const props = defineProps({
|
||||
showDefaultControls: {
|
||||
// This determines if the attach, reference, and history buttons should be shown
|
||||
type: Boolean,
|
||||
default: true
|
||||
default: true,
|
||||
},
|
||||
showClearAllButton: {
|
||||
// Determines if the clear all button should be shown
|
||||
type: Boolean,
|
||||
default: true
|
||||
default: true,
|
||||
},
|
||||
showAttachments: {
|
||||
// This determines if the attachments should be shown
|
||||
type: Boolean,
|
||||
default: true
|
||||
default: true,
|
||||
},
|
||||
showAttachButton: {
|
||||
// This determines if the attach button should be shown
|
||||
type: Boolean,
|
||||
default: true
|
||||
default: true,
|
||||
},
|
||||
showZoomButton: {
|
||||
// This determines if the zoom button should be shown
|
||||
type: Boolean,
|
||||
default: true
|
||||
default: true,
|
||||
},
|
||||
showBackButton: {
|
||||
// This determines if the back button in the attachment selector should be shown
|
||||
type: Boolean,
|
||||
default: true
|
||||
default: true,
|
||||
},
|
||||
showReferenceButton: {
|
||||
// This determines if the reference button should be shown
|
||||
type: Boolean,
|
||||
default: true
|
||||
default: true,
|
||||
},
|
||||
showHistoryButton: {
|
||||
// This determines if the history button should be shown
|
||||
type: Boolean,
|
||||
default: true
|
||||
default: true,
|
||||
},
|
||||
showTakePictureButton: {
|
||||
// This determines if the take picture button should be shown
|
||||
type: Boolean,
|
||||
default: true
|
||||
default: true,
|
||||
},
|
||||
showUploadFileButton: {
|
||||
// This determines if the upload file button should be shown
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
/** Emits */
|
||||
const emits = defineEmits([]); // No emits for now
|
||||
/** State */
|
||||
const isAttachmentSelectorVisible = ref(false); // When enabled, the attach and reference buttons are hidden, and the attachment selector is shown
|
||||
/** Reference */
|
||||
const onClickReferenceButton = () => {
|
||||
console.warn('End-user clicked reference button');
|
||||
popups.select('change_reference');
|
||||
}
|
||||
console.warn("End-user clicked reference button");
|
||||
popups.select("change_reference");
|
||||
};
|
||||
|
||||
const isReferenceEmpty = () => {
|
||||
return !reference.value || reference.value.trim() === '';
|
||||
}
|
||||
return !reference.value || reference.value.trim() === "";
|
||||
};
|
||||
/** Attachments */
|
||||
const onClickAttachmentButton = () => {
|
||||
isAttachmentSelectorVisible.value = !isAttachmentSelectorVisible.value;
|
||||
}
|
||||
};
|
||||
const previousZoomLevel = ref(camera.getZoom());
|
||||
// Watch the isAttachmentSelectorVisible to zoom out the camera when the selector is shown
|
||||
watch(isAttachmentSelectorVisible, (newValue) => {
|
||||
@@ -97,25 +108,25 @@ watch(views.attachmentView, (newValue) => {
|
||||
// Restore previous zoom level
|
||||
camera.setZoom(previousZoomLevel.value);
|
||||
}
|
||||
})
|
||||
});
|
||||
// Camera attachment
|
||||
const onClickAttachPicture = async () => {
|
||||
console.warn('End-user clicked attach picture button');
|
||||
console.warn("End-user clicked attach picture button");
|
||||
try {
|
||||
const image = await camera.get();
|
||||
console.warn('Camera image obtained:', image);
|
||||
console.warn("Camera image obtained:", image);
|
||||
if (image) {
|
||||
attachments.addBase64({ filename: `camera_${Date.now()}.jpg`, base64String: image });
|
||||
sounds.play(sounds.list.value.onAfterSuccessfulScan)
|
||||
console.warn('Image added to attachments');
|
||||
sounds.play(sounds.list.value.onAfterSuccessfulScan);
|
||||
console.warn("Image added to attachments");
|
||||
} else {
|
||||
// User cancelled or no image obtained
|
||||
console.warn('No image obtained from camera');
|
||||
console.warn("No image obtained from camera");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error accessing camera image:', error);
|
||||
}
|
||||
console.error("Error accessing camera image:", error);
|
||||
}
|
||||
};
|
||||
// File attachment (stored as base64)
|
||||
const uploadAttachment = (file: File) => {
|
||||
// Implement the upload logic here
|
||||
@@ -125,21 +136,21 @@ const uploadAttachment = (file: File) => {
|
||||
reader.onload = (e) => {
|
||||
const base64String = e.target?.result;
|
||||
const filename = file.name;
|
||||
if (typeof base64String === 'string') {
|
||||
if (typeof base64String === "string") {
|
||||
attachments.addBase64({ filename, base64String });
|
||||
console.warn('File added to attachments:', filename);
|
||||
console.warn("File added to attachments:", filename);
|
||||
} else {
|
||||
console.error('Failed to read file as base64 string');
|
||||
}
|
||||
console.error("Failed to read file as base64 string");
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const onClickAttachFile = () => {
|
||||
// Trigger file input click
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx'; // Accept images and common document types
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"; // Accept images and common document types
|
||||
fileInput.onchange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files && target.files[0]) {
|
||||
@@ -150,13 +161,13 @@ const onClickAttachFile = () => {
|
||||
};
|
||||
|
||||
const onClickAttachmentWashCertificate = () => {
|
||||
console.warn('End-user clicked attach wash certificate button');
|
||||
console.warn("End-user clicked attach wash certificate button");
|
||||
// Add the wash certificate to the additional items
|
||||
attachments.setWashCertificate(!attachments.hasWashCertificate());
|
||||
};
|
||||
|
||||
const onClickTransactionHistoryButton = () => {
|
||||
console.warn('End-user clicked transaction history button');
|
||||
console.warn("End-user clicked transaction history button");
|
||||
// Show the transaction history
|
||||
views.transactionHistoryView.value = true;
|
||||
};
|
||||
@@ -166,40 +177,52 @@ const resetAll = () => {
|
||||
resetPos();
|
||||
// 3. Reset all values
|
||||
reset_all_values();
|
||||
}
|
||||
};
|
||||
|
||||
const onClickClearAllButton = () => {
|
||||
// Check if the session storage contains the pos_order_id
|
||||
if (localStorage.getItem('pos_order_id')) {
|
||||
order_id.value = parseInt(localStorage.getItem('pos_order_id') || '0');
|
||||
if (localStorage.getItem("pos_order_id")) {
|
||||
order_id.value = parseInt(localStorage.getItem("pos_order_id") || "0");
|
||||
}
|
||||
// 1. Delete the order (If any)
|
||||
if (order_id.value) {
|
||||
SessionUser.objects.orders.delete.single(order_id.value).then(() => {
|
||||
SessionUser.objects.orders.delete
|
||||
.single(order_id.value)
|
||||
.then(() => {
|
||||
console.warn("Order deleted successfully");
|
||||
}).catch((error) => {
|
||||
console.error("Error deleting order:", error);
|
||||
}).finally(() => {
|
||||
resetAll();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error deleting order:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
resetAll();
|
||||
});
|
||||
} else {
|
||||
resetAll();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isDefaultControlsVisible = () => {
|
||||
return props.showDefaultControls && !isAttachmentSelectorVisible.value;
|
||||
}
|
||||
};
|
||||
const isAttachmentsVisible = () => {
|
||||
return props.showAttachments;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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"
|
||||
data-action-key="pos-mobile-clear-all-inline"
|
||||
data-copy-key="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,11 +237,19 @@ 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"
|
||||
data-action-key="pos-mobile-attachments-toggle"
|
||||
data-copy-key="admin.pos.attachments"
|
||||
v-if="isDefaultControlsVisible() && showAttachButton"
|
||||
@click="onClickAttachmentButton"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<div>
|
||||
<!-- Count Badge -->
|
||||
<div class="is-pulled-right" v-if="attachments.count() > 0">
|
||||
<span class="tag is-info is-rounded is-size-7 pl-2" style="position: absolute;">
|
||||
<span class="tag is-info is-rounded is-size-7 pl-2" style="position: absolute">
|
||||
{{ attachments.count() }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -231,21 +262,26 @@ const isAttachmentsVisible = () => {
|
||||
</div>
|
||||
<div class="column is-narrow pl-0">
|
||||
<p class="is-size-7 has-text-weight-semibold">
|
||||
<!-- Danish for Attach -->
|
||||
Vedhæft
|
||||
{{ t("admin.pos.attachments") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
data-action-key="pos-mobile-change-reference"
|
||||
data-copy-key="admin.pos.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;">
|
||||
<span class="tag is-rounded is-size-7 pl-2" style="position: absolute;">
|
||||
|
||||
</span>
|
||||
<div class="is-pulled-right" v-if="true" style="visibility: hidden">
|
||||
<span class="tag is-rounded is-size-7 pl-2" style="position: absolute"> </span>
|
||||
</div>
|
||||
<!-- Reference Display -->
|
||||
<div class="columns is-mobile is-vcentered">
|
||||
@@ -258,18 +294,26 @@ const isAttachmentsVisible = () => {
|
||||
</div>
|
||||
<div class="column is-narrow pl-0">
|
||||
<p class="is-size-7 has-text-weight-semibold">
|
||||
Reference
|
||||
{{ t("admin.pos.reference") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
data-action-key="pos-mobile-transaction-history"
|
||||
data-copy-key="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">
|
||||
<span class="tag is-warning is-rounded is-size-7 pl-2" style="position: absolute;">
|
||||
<span class="tag is-warning is-rounded is-size-7 pl-2" style="position: absolute">
|
||||
{{ transactionHistory.getPending().length }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -282,15 +326,20 @@ const isAttachmentsVisible = () => {
|
||||
</div>
|
||||
<div class="column is-narrow pl-0">
|
||||
<p class="is-size-7 has-text-weight-semibold">
|
||||
<!-- Danish for History -->
|
||||
{{ SessionUser.objects.orders.meta.labels.multiple}}
|
||||
{{ SessionUser.objects.global.language.history }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
@@ -299,15 +348,21 @@ const isAttachmentsVisible = () => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="column is-narrow pl-0">
|
||||
<p class="is-size-7 has-text-weight-semibold">
|
||||
Zoom
|
||||
</p>
|
||||
<p class="is-size-7 has-text-weight-semibold">Zoom</p>
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
data-action-key="pos-mobile-attachments-back"
|
||||
data-copy-key="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">
|
||||
@@ -316,13 +371,21 @@ const isAttachmentsVisible = () => {
|
||||
</div>
|
||||
<div class="column is-narrow pl-0">
|
||||
<p class="is-size-7 has-text-weight-semibold">
|
||||
Tilbage
|
||||
{{ SessionUser.objects.global.language.back }}
|
||||
</p>
|
||||
</div>
|
||||
</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"
|
||||
data-action-key="pos-mobile-attachments-take-picture"
|
||||
data-copy-key="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">
|
||||
@@ -331,13 +394,21 @@ const isAttachmentsVisible = () => {
|
||||
</div>
|
||||
<div class="column is-narrow pl-0">
|
||||
<p class="is-size-7 has-text-weight-semibold">
|
||||
Tag billede
|
||||
{{ SessionUser.objects.global.language.take_picture }}
|
||||
</p>
|
||||
</div>
|
||||
</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"
|
||||
data-action-key="pos-mobile-attachments-upload-file"
|
||||
data-copy-key="admin.pos.attachments_upload_action"
|
||||
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">
|
||||
@@ -346,13 +417,21 @@ const isAttachmentsVisible = () => {
|
||||
</div>
|
||||
<div class="column is-narrow pl-0">
|
||||
<p class="is-size-7 has-text-weight-semibold">
|
||||
Upload fil
|
||||
{{ t("admin.pos.attachments_upload_action") }}
|
||||
</p>
|
||||
</div>
|
||||
</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"
|
||||
data-action-key="pos-mobile-attachments-wash-certificate"
|
||||
data-copy-key="admin.pos.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">
|
||||
@@ -360,8 +439,11 @@ const isAttachmentsVisible = () => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="column is-narrow pl-0">
|
||||
<p class="is-size-7 has-text-weight-semibold" :class="{'has-text-success': attachments.hasWashCertificate()}">
|
||||
Vaskecertifikat
|
||||
<p
|
||||
class="is-size-7 has-text-weight-semibold"
|
||||
:class="{ 'has-text-success': attachments.hasWashCertificate() }"
|
||||
>
|
||||
{{ t("admin.pos.wash_certificate") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+34
-15
@@ -1,5 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { reset_all_values, customer_name, nextStep, searchAndSelectCustomer, isCustomerSelected, order_id, customer_id, step, reg_1, reg_2, reg_3, reference, order_notes, setDepartment, getDepartment } 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,
|
||||
setDepartment,
|
||||
getDepartment,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
|
||||
import { resetPos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
@@ -7,28 +23,35 @@ import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
const onClickClearAll = () => {
|
||||
// 1. Delete the order (If any)
|
||||
// Check localstorage for pos_order_id
|
||||
if (localStorage.getItem('pos_order_id')) {
|
||||
order_id.value = parseInt(localStorage.getItem('pos_order_id') || '0');
|
||||
if (localStorage.getItem("pos_order_id")) {
|
||||
order_id.value = parseInt(localStorage.getItem("pos_order_id") || "0");
|
||||
}
|
||||
if (order_id.value) {
|
||||
SessionUser.objects.orders.delete.single(order_id.value).then(() => {
|
||||
SessionUser.objects.orders.delete
|
||||
.single(order_id.value)
|
||||
.then(() => {
|
||||
// 2. Reset the POS
|
||||
resetPos();
|
||||
// 3. Reset all values
|
||||
reset_all_values();
|
||||
}).catch((error) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error deleting order:", error);
|
||||
resetPos();
|
||||
reset_all_values();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="button is-fullwidth is-white mt-2"
|
||||
@click="onClickClearAll()">
|
||||
<div
|
||||
class="button is-fullwidth is-white mt-2"
|
||||
data-testid="pos-mobile-clear-all-button"
|
||||
data-action-key="pos-mobile-clear-all"
|
||||
data-copy-key="clear_all"
|
||||
@click="onClickClearAll()"
|
||||
>
|
||||
<div class="columns is-mobile is-vcentered has-text-centered">
|
||||
<!-- Clear all -->
|
||||
<div class="column is-12 has-text-centered" @click="onClickClearAll()">
|
||||
@@ -39,9 +62,7 @@ const onClickClearAll = () => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="column is-narrow pl-0">
|
||||
<p class="is-size-7 has-text-weight-semibold">
|
||||
Slet alt
|
||||
</p>
|
||||
<p class="is-size-7 has-text-weight-semibold">Slet alt</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,6 +70,4 @@ const onClickClearAll = () => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
+318
-165
@@ -1,18 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
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 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.
|
||||
@@ -49,20 +86,31 @@ const props = defineProps({
|
||||
type: Array as () => string[],
|
||||
default: () => [],
|
||||
},
|
||||
actionKey: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
copyKey: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const updateReference = () => {
|
||||
if (!!order_id.value && order_id.value > 0)
|
||||
SessionUser.objects.orders.set.reference(order_id.value, reference.value);
|
||||
}
|
||||
if (!!order_id.value && order_id.value > 0) SessionUser.objects.orders.set.reference(order_id.value, reference.value);
|
||||
};
|
||||
|
||||
const updateNotes = () => {
|
||||
if (!!order_id.value && order_id.value > 0) 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.notes(order_id.value, order_notes.value);
|
||||
}
|
||||
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;
|
||||
const isNonEmptyString = (val) => typeof val === "string" && val.trim().length > 0;
|
||||
|
||||
const resolveReferenceFromSources = () => {
|
||||
// 1) Metadata reference
|
||||
@@ -79,22 +127,22 @@ const resolveReferenceFromSources = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const promptForReference = async (initialValue = '') => {
|
||||
const promptForReference = async (initialValue = "") => {
|
||||
const result = await Swal.fire({
|
||||
title: t('admin.pos.reference_required_title'),
|
||||
text: t('admin.pos.reference_required_text'),
|
||||
input: 'text',
|
||||
inputLabel: t('admin.pos.reference'),
|
||||
title: t("admin.pos.reference_required_title"),
|
||||
text: t("admin.pos.reference_required_text"),
|
||||
input: "text",
|
||||
inputLabel: t("admin.pos.reference"),
|
||||
inputValue: initialValue,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: t('admin.pos.confirm'),
|
||||
cancelButtonText: t('admin.pos.cancel'),
|
||||
confirmButtonText: t("admin.pos.confirm"),
|
||||
cancelButtonText: t("admin.pos.cancel"),
|
||||
preConfirm: (newReference) => {
|
||||
if (!isNonEmptyString(newReference)) {
|
||||
Swal.showValidationMessage(t('admin.pos.reference_cannot_be_empty'));
|
||||
Swal.showValidationMessage(t("admin.pos.reference_cannot_be_empty"));
|
||||
}
|
||||
return newReference;
|
||||
}
|
||||
},
|
||||
});
|
||||
if (result.isConfirmed && isNonEmptyString(result.value)) {
|
||||
return result.value.trim();
|
||||
@@ -106,7 +154,7 @@ const promptForReference = async (initialValue = '') => {
|
||||
const validateReferenceRequirements = async () => {
|
||||
await loadCustomerAttributes();
|
||||
|
||||
const customerRequiresReference = hasAttribute('requiresReferenceNumber');
|
||||
const customerRequiresReference = hasAttribute("requiresReferenceNumber");
|
||||
|
||||
// Try to resolve an existing reference from known sources
|
||||
const resolvedRef = resolveReferenceFromSources();
|
||||
@@ -124,23 +172,22 @@ const validateReferenceRequirements = async () => {
|
||||
|
||||
// Prompt user for a reference if required and none available
|
||||
try {
|
||||
const enteredRef = await promptForReference(reference.value ?? '');
|
||||
const enteredRef = await promptForReference(reference.value ?? "");
|
||||
if (isNonEmptyString(enteredRef)) {
|
||||
reference.value = enteredRef;
|
||||
metadata.setReference(enteredRef);
|
||||
console.warn('New reference set:', enteredRef);
|
||||
console.warn("New reference set:", enteredRef);
|
||||
onClick(); // Retry the click action
|
||||
return true;
|
||||
}
|
||||
console.warn('Reference number is required but was not provided. Aborting operation.');
|
||||
console.warn("Reference number is required but was not provided. Aborting operation.");
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.warn('An error occurred while setting the reference number:', error);
|
||||
console.warn("An error occurred while setting the reference number:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const step1 = () => {
|
||||
/** This function can be used to perform any specific actions for step 1 */
|
||||
// Set registration numbers
|
||||
@@ -152,6 +199,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) => {
|
||||
@@ -182,7 +230,7 @@ const mergeReferences = () => {
|
||||
metadata.setReference(reference.value);
|
||||
updateReference();
|
||||
}
|
||||
}
|
||||
};
|
||||
const mergeNotes = () => {
|
||||
if (metadata.getNotes() && metadata.getNotes().length >= 1) {
|
||||
order_notes.value = metadata.getNotes();
|
||||
@@ -191,147 +239,119 @@ const mergeNotes = () => {
|
||||
metadata.setNotes(order_notes.value);
|
||||
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()) {
|
||||
uploadAttachment(attachment).then(() => {
|
||||
console.warn('Attachment uploaded successfully');
|
||||
uploadAttachment(attachment)
|
||||
.then(() => {
|
||||
console.warn("Attachment uploaded successfully");
|
||||
// Remove the attachment from the list
|
||||
attachments.removeBase64(attachment);
|
||||
}).catch((error: any) => {
|
||||
})
|
||||
.catch((error: any) => {
|
||||
errors.value.push(error);
|
||||
console.warn('An error occurred while uploading the attachment:', error);
|
||||
console.warn("An error occurred while uploading the attachment:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addTransactionToHistory = async (orderId: number) => {
|
||||
SessionUser.objects.orders.get.single(orderId).then((transaction: any) => {
|
||||
SessionUser.objects.orders.get
|
||||
.single(orderId)
|
||||
.then((transaction: any) => {
|
||||
if (transaction) {
|
||||
transactionHistory.add(transaction);
|
||||
console.warn('Transaction added to history:', transaction);
|
||||
console.warn("Transaction added to history:", transaction);
|
||||
} else {
|
||||
console.warn('Unable to add transaction to history: Order not found.');
|
||||
console.warn("Unable to add transaction to history: Order not found.");
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
})
|
||||
.catch((error: any) => {
|
||||
errors.value.push(error);
|
||||
console.warn('An error occurred while fetching the transaction for history:', error);
|
||||
console.warn("An error occurred while fetching the transaction for history:", error);
|
||||
});
|
||||
};
|
||||
|
||||
const step2 = () => {
|
||||
/** This function can be used to perform any specific actions for step 2 */
|
||||
// Add all additional items to the order
|
||||
popups.select('completed_transaction', {
|
||||
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)
|
||||
|
||||
const selectedBookingId = getSelectedBookingId();
|
||||
const hasWashCertificateInBasket = Boolean(transactionItems.containsWashCertificate?.());
|
||||
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
|
||||
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
|
||||
|
||||
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();
|
||||
if (requiresBookingCompletionPopup) {
|
||||
const onCompleteBookingWithSafetySeal = async ({ safetySeal = "" } = {}) => {
|
||||
syncMobileSafetySealState(safetySeal);
|
||||
await completeStep2Order({
|
||||
bookingSafetySeal: String(safetySeal ?? ""),
|
||||
markOrderCompleted: false,
|
||||
});
|
||||
} 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
|
||||
popups.select("complete_booking", {
|
||||
props: {
|
||||
safetySeal: getResolvedMobileSafetySeal(),
|
||||
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);
|
||||
@@ -343,42 +363,69 @@ const completeOrder = () => {
|
||||
resetPos(); // Reset the POS state
|
||||
// Start the process again
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const step3 = () => {
|
||||
/** This function can be used to perform any specific actions for step 3 */
|
||||
// This step is not defined, but you can add your logic here if needed
|
||||
console.warn('Step 3: Card payment step is not defined.');
|
||||
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(() => {
|
||||
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();
|
||||
// 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});
|
||||
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();
|
||||
step2();
|
||||
await step2();
|
||||
break;
|
||||
case 3:
|
||||
// This step is not defined, but you can add your logic here if needed
|
||||
@@ -387,10 +434,10 @@ const onClick = () => {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Show the popup to select a customer
|
||||
popups.select('select_customer');
|
||||
} catch (error) {
|
||||
console.warn("Next-step action was interrupted:", error);
|
||||
} finally {
|
||||
isProcessingClick.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -409,7 +456,7 @@ const applyCustomerSelection = () => {
|
||||
if (shouldApplyCustomerSelection) {
|
||||
searchAndSelectCustomer(getCustomerId());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check customer selection states.
|
||||
onMounted(() => {
|
||||
@@ -418,7 +465,7 @@ onMounted(() => {
|
||||
|
||||
const isRegistrationNumberFilled = () => {
|
||||
return vehicles?.vehicle_1?.value?.reg && vehicles.vehicle_1.value?.reg.length >= 4;
|
||||
}
|
||||
};
|
||||
// Is the requirements for clicking the button met?
|
||||
const isRequirementsForClickMet = () => {
|
||||
// If a custom disabled state is provided, use that.
|
||||
@@ -436,8 +483,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()) {
|
||||
@@ -446,7 +493,7 @@ const isRequirementsForClickMet = () => {
|
||||
// The customer is selected, so check if the reg_1 is filled.
|
||||
return isRegistrationNumberFilled();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Should the button be visible?
|
||||
const isVisible = computed(() => {
|
||||
@@ -479,8 +526,8 @@ const onLongPress = () => {
|
||||
|
||||
const defaultLongPressBehavior = () => {
|
||||
// Select the customer
|
||||
popups.select('select_customer');
|
||||
}
|
||||
popups.select("select_customer");
|
||||
};
|
||||
|
||||
/**
|
||||
* Colors:
|
||||
@@ -489,23 +536,32 @@ const defaultLongPressBehavior = () => {
|
||||
* Secondary:
|
||||
* :buttonClasses="['has-background-primary-dark', 'has-text-black']"
|
||||
*/
|
||||
|
||||
</script>
|
||||
|
||||
<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"
|
||||
:action-key="props.actionKey"
|
||||
:copy-key="props.copyKey"
|
||||
class="is-size-6-mobile is-size-5-tablet is-size-4-desktop"
|
||||
:disabled="!isRequirementsForClickMet()" v-show="isVisible">
|
||||
: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;">
|
||||
<ContinueArrow style="width: 100%; height: 100%; padding: 6px 12px; gap: 5px;" />
|
||||
<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>
|
||||
</template>
|
||||
@@ -523,9 +579,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;
|
||||
@@ -559,6 +710,8 @@ const defaultLongPressBehavior = () => {
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+69
-9
@@ -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;
|
||||
}
|
||||
|
||||
.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>
|
||||
+251
-174
@@ -1,233 +1,310 @@
|
||||
<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}];
|
||||
};
|
||||
|
||||
const syncPopupActionButtons = () => {
|
||||
const popup = popups.get();
|
||||
if (!popup) {
|
||||
return;
|
||||
}
|
||||
popups.get().props.error = t('admin.pos.customer_creation_error_generic');
|
||||
|
||||
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);
|
||||
popups.get().props.error = t('admin.pos.customer_creation_error_generic');
|
||||
}
|
||||
},
|
||||
color: "primary",
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
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 }">
|
||||
<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')}"
|
||||
: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 }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Search results -->
|
||||
<!--{{ searchResult }}-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+88
-10
@@ -1,19 +1,86 @@
|
||||
<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 { getResolvedMobileSafetySeal } from "../objects/mobileOrderCompletion.js";
|
||||
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 resolveExistingSafetySeal = () => {
|
||||
const popupSafetySeal = popupProps.value?.safetySeal;
|
||||
if (popupSafetySeal !== null && popupSafetySeal !== undefined) {
|
||||
return String(popupSafetySeal).trim();
|
||||
}
|
||||
|
||||
const safetySealNumber = ref("");
|
||||
return getResolvedMobileSafetySeal();
|
||||
};
|
||||
const safetySealNumber = ref(resolveExistingSafetySeal());
|
||||
|
||||
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() || resolveExistingSafetySeal();
|
||||
const callback = popupProps.value?.onCompleteWithCertificate || popupProps.value?.onComplete;
|
||||
if (typeof callback === "function") {
|
||||
await runPopupCompletionCallback(callback, normalizedSafetySeal);
|
||||
return;
|
||||
}
|
||||
await submitBookingCompletionFallback(normalizedSafetySeal);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,6 +93,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 +101,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
-8
@@ -1,37 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
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();
|
||||
};
|
||||
|
||||
const popupShellStyle = computed(() => {
|
||||
const popupStyle = { ...(popups.get()?.style || {}) } as Record<string, string>;
|
||||
const viewportBound = "calc(100vh - var(--popup-top-clearance) - var(--popup-bottom-clearance))";
|
||||
|
||||
if (popupStyle.height) {
|
||||
popupStyle.height = `min(${popupStyle.height}, ${viewportBound})`;
|
||||
}
|
||||
|
||||
if (popupStyle.maxHeight) {
|
||||
popupStyle.maxHeight = `min(${popupStyle.maxHeight}, ${viewportBound})`;
|
||||
if (!popupStyle.height) {
|
||||
popupStyle.height = popupStyle.maxHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return popupStyle;
|
||||
});
|
||||
</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" data-testid="pos-mobile-popup-shell" :style="popupShellStyle">
|
||||
<!-- Icon? and title? -->
|
||||
<WhiteBoxCard :defaultOpen="true" :hideHeader="popups.get()?.hideHeader || false">
|
||||
<WhiteBoxCard
|
||||
:defaultOpen="true"
|
||||
:hideHeader="popups.get()?.hideHeader || false"
|
||||
footerTestId="pos-mobile-popup-footer"
|
||||
>
|
||||
<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 || {} }"/>
|
||||
<div class="popup-scroll-region" data-testid="pos-mobile-popup-scroll-region">
|
||||
<component :is="popupComponentKeyToComponent(popups.get()?.component)" @close="popups.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #footer v-if="!popups.get()?.hideFooter && (popups.get()?.actionButtons && popups.get()?.actionButtons.length > 0)">
|
||||
<div class="popup-footer-actions">
|
||||
<template
|
||||
v-for="(actionButton, index) in popups.get().actionButtons"
|
||||
:key="index"
|
||||
>
|
||||
<a class="card-footer-item"
|
||||
:class="'is-' + (actionButton?.color || 'primary')"
|
||||
:data-testid="actionButton.testId"
|
||||
@click="actionButton.onClick ? actionButton.onClick() : null">
|
||||
{{ actionButton.label }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</WhiteBoxCard>
|
||||
</div>
|
||||
@@ -40,6 +90,10 @@ import { popupComponentKeyToComponent } from "@/components/displays/department/p
|
||||
|
||||
<style scoped>
|
||||
.popup-container {
|
||||
--popup-top-clearance: max(4.5rem, env(safe-area-inset-top, 0px) + 1rem);
|
||||
--popup-bottom-clearance: calc(
|
||||
var(--pos-mobile-fixed-bottom-height, 0px) + max(1rem, env(safe-area-inset-bottom, 0px) + 0.75rem)
|
||||
);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -47,18 +101,101 @@ 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: var(--popup-top-clearance) 1rem var(--popup-bottom-clearance);
|
||||
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 - var(--popup-top-clearance) - var(--popup-bottom-clearance)));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
box-shadow: 0 20px 60px rgba(7, 18, 46, 0.32);
|
||||
}
|
||||
.popup-content h2 {
|
||||
}
|
||||
|
||||
.popup-content > :deep(.box.has-sharp-edges) {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.popup-content > :deep(.box.has-sharp-edges > .card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.popup-content > :deep(.box.has-sharp-edges > .card > .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(.box.has-sharp-edges > .card > .card-content) {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.popup-scroll-region {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.popup-content > :deep(.box.has-sharp-edges > .card > .card-footer) {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e5ebf3;
|
||||
}
|
||||
|
||||
.popup-footer-actions {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.popup-footer-actions .card-footer-item {
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
+507
-113
@@ -1,167 +1,561 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineEmits, watch, ref } from "vue";
|
||||
import { computed, defineEmits, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
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 { searchAndSelectCustomer } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import {
|
||||
searchAndSelectCustomer,
|
||||
customer_id,
|
||||
customer_name,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { metadata, vehicles } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import VehicleCustomerSuggestionsPos from "@/components/forms/department/pos/input/vehicleCustomerSuggestionsPos.vue";
|
||||
import {popups} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { useDraftTransactionCustomer } from "@/composables/useDraftTransactionCustomer.js";
|
||||
import { useStripeReaderAvailability } from "@/composables/useStripeReaderAvailability.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { draftTransactionCustomerNumber, hasDraftTransactionCustomer } = useDraftTransactionCustomer();
|
||||
const { isCardPaymentAvailable } = useStripeReaderAvailability();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: "close"): void;
|
||||
}>();
|
||||
// To prevent "blinking" of search results when typing, we store a local copy of the results, that's only updated when the search results change
|
||||
|
||||
const CUSTOMER_PICKER_MODE_INVOICE = "invoice";
|
||||
const CUSTOMER_PICKER_MODE_DRAFT = "draft";
|
||||
const CUSTOMER_PICKER_MODE_CARD = "card";
|
||||
|
||||
const activeQuickAction = ref(CUSTOMER_PICKER_MODE_INVOICE);
|
||||
const localSearchResults = ref<PosSearchResult[]>([]);
|
||||
|
||||
const toPositiveInteger = (value: unknown) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const resolveSelectedCustomerId = (value: unknown) => {
|
||||
if (value && typeof value === "object") {
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return toPositiveInteger(candidate.customerId ?? candidate.customerNumber ?? candidate.id ?? null);
|
||||
}
|
||||
|
||||
return toPositiveInteger(value);
|
||||
};
|
||||
|
||||
const getCurrentCustomerNumber = () => toPositiveInteger(customer_id.value);
|
||||
|
||||
const getResolvedQuickAction = () => {
|
||||
const currentCustomerNumber = getCurrentCustomerNumber();
|
||||
|
||||
if (currentCustomerNumber === 999) {
|
||||
return CUSTOMER_PICKER_MODE_CARD;
|
||||
}
|
||||
|
||||
if (
|
||||
hasDraftTransactionCustomer.value &&
|
||||
currentCustomerNumber === toPositiveInteger(draftTransactionCustomerNumber.value)
|
||||
) {
|
||||
return CUSTOMER_PICKER_MODE_DRAFT;
|
||||
}
|
||||
|
||||
return CUSTOMER_PICKER_MODE_INVOICE;
|
||||
};
|
||||
|
||||
const applySearchResults = (rawResults: any[]) => {
|
||||
const tmp = rawResults.map((result) => {
|
||||
localSearchResults.value = rawResults.map((result) => {
|
||||
return <PosSearchResult>{
|
||||
customerId: result.customerNumber,
|
||||
customerName: result.name,
|
||||
barred: result?.barred ?? false,
|
||||
customerStatus: result?.barred ? 'card' : 'verified',
|
||||
customerStatus: result?.barred ? "card" : "verified",
|
||||
};
|
||||
});
|
||||
localSearchResults.value = tmp;
|
||||
};
|
||||
watch(isSearching, (newVal) => {
|
||||
if (!newVal) {
|
||||
|
||||
watch(isSearching, (newValue) => {
|
||||
if (!newValue) {
|
||||
applySearchResults(searchCustomerResults.value);
|
||||
}
|
||||
});
|
||||
// Format the search results to match the PosSearchResult interface
|
||||
|
||||
const formattedSearchResults = computed(() => {
|
||||
return localSearchResults.value;
|
||||
watch(
|
||||
[() => customer_id.value, draftTransactionCustomerNumber],
|
||||
() => {
|
||||
activeQuickAction.value = getResolvedQuickAction();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const isCustomerSelected = computed(() => String(customer_name.value ?? "").trim() !== "");
|
||||
const isInvoiceMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_INVOICE);
|
||||
const isDraftMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_DRAFT);
|
||||
const isCardMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_CARD);
|
||||
const shouldShowSearchInput = computed(() => isInvoiceMode.value);
|
||||
const formattedSearchResults = computed(() => localSearchResults.value);
|
||||
const isCardPaymentDisabled = computed(() => !isCardPaymentAvailable.value);
|
||||
const selectedCustomerNumber = computed(() => getCurrentCustomerNumber());
|
||||
const selectedCustomerMode = computed(() => {
|
||||
if (!isCustomerSelected.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getResolvedQuickAction();
|
||||
});
|
||||
// Require particular fields from the search results
|
||||
const onClick = (result: Partial<PosSearchResult>) => {
|
||||
console.log("Selected customer:", result);
|
||||
searchAndSelectCustomer(result.customerId)
|
||||
metadata.setCustomerId(result.customerId);
|
||||
emit('close');
|
||||
const selectedCustomerModeLabel = computed(() => {
|
||||
if (selectedCustomerMode.value === CUSTOMER_PICKER_MODE_CARD) {
|
||||
return t("pos.customer_picker.select_card_payment");
|
||||
}
|
||||
|
||||
if (selectedCustomerMode.value === CUSTOMER_PICKER_MODE_DRAFT) {
|
||||
return t("pos.customer_picker.select_draft_customer");
|
||||
}
|
||||
|
||||
return t("pos.customer_picker.select_customer_invoice");
|
||||
});
|
||||
|
||||
const focusCustomerSearchInput = async () => {
|
||||
await nextTick();
|
||||
const input = document.querySelector('[data-testid="pos-mobile-customer-search-input"]');
|
||||
if (input instanceof HTMLElement) {
|
||||
input.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const onClickSuggestion = (customerId: number) => {
|
||||
console.log("Selected customer from suggestion:", customerId);
|
||||
onClick({ customerId });
|
||||
metadata.setCustomerId(customerId);
|
||||
emit('close');
|
||||
const onClick = async (result: Partial<PosSearchResult>) => {
|
||||
const selectedCustomerId = resolveSelectedCustomerId(result?.customerId);
|
||||
if (!selectedCustomerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeQuickAction.value = CUSTOMER_PICKER_MODE_INVOICE;
|
||||
await searchAndSelectCustomer(selectedCustomerId);
|
||||
metadata.setCustomerId(selectedCustomerId);
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const onClickSuggestion = async (payload: unknown) => {
|
||||
const selectedCustomerId = resolveSelectedCustomerId(payload);
|
||||
if (!selectedCustomerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onClick({ customerId: selectedCustomerId });
|
||||
};
|
||||
|
||||
const onSelectCustomerInvoice = async () => {
|
||||
activeQuickAction.value = CUSTOMER_PICKER_MODE_INVOICE;
|
||||
await focusCustomerSearchInput();
|
||||
};
|
||||
|
||||
const onClickDirectCardPayment = async () => {
|
||||
if (isCardPaymentDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeQuickAction.value = CUSTOMER_PICKER_MODE_CARD;
|
||||
const cardCustomerId = 999;
|
||||
await searchAndSelectCustomer(cardCustomerId);
|
||||
metadata.setCustomerId(cardCustomerId);
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const onClickDraftCustomer = async () => {
|
||||
if (!hasDraftTransactionCustomer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeQuickAction.value = CUSTOMER_PICKER_MODE_DRAFT;
|
||||
await searchAndSelectCustomer(draftTransactionCustomerNumber.value);
|
||||
metadata.setCustomerId(draftTransactionCustomerNumber.value);
|
||||
emit("close");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="overflow-y: auto; overflow-x: hidden; height: 100%; padding-bottom: 10px;">
|
||||
<!-- Search input field, with it's icon -->
|
||||
<div class="field underlined-input-field">
|
||||
<div class="control has-icons-right">
|
||||
<CustomerSearchField/>
|
||||
<div class="pos-mobile-customer-popup" data-testid="pos-mobile-customer-popup">
|
||||
<section
|
||||
v-if="isCustomerSelected"
|
||||
class="customer-selection-summary"
|
||||
data-testid="pos-mobile-selected-customer-summary"
|
||||
>
|
||||
<div class="customer-selection-summary__label">
|
||||
{{ t("pos.customer_picker.selected_customer") }}
|
||||
</div>
|
||||
<div class="customer-selection-summary__content">
|
||||
<span class="icon customer-selection-summary__icon">
|
||||
<i class="fas fa-user-check"></i>
|
||||
</span>
|
||||
<div class="customer-selection-summary__text">
|
||||
<p class="customer-selection-summary__name">{{ customer_name }}</p>
|
||||
<p v-if="selectedCustomerNumber" class="customer-selection-summary__number">
|
||||
#{{ selectedCustomerNumber }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="customer-selection-summary__mode">
|
||||
{{ selectedCustomerModeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="customer-action-divider" data-testid="pos-mobile-customer-action-divider">
|
||||
<span class="customer-action-divider__line"></span>
|
||||
<span class="customer-action-divider__label">
|
||||
{{ t("pos.customer_picker.select_payment_form") }}
|
||||
</span>
|
||||
<span class="customer-action-divider__line"></span>
|
||||
</div>
|
||||
|
||||
<div class="customer-quick-actions" data-testid="pos-mobile-customer-action-group">
|
||||
<button
|
||||
class="button is-light customer-quick-action customer-quick-action--invoice"
|
||||
:class="{ 'is-selected': isInvoiceMode }"
|
||||
type="button"
|
||||
data-testid="pos-mobile-customer-invoice"
|
||||
:aria-pressed="isInvoiceMode ? 'true' : 'false'"
|
||||
@click="onSelectCustomerInvoice"
|
||||
>
|
||||
<span class="icon is-small customer-quick-action__icon">
|
||||
<i class="fas fa-user"></i>
|
||||
</span>
|
||||
<span>{{ t("pos.customer_picker.select_customer_invoice") }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="hasDraftTransactionCustomer"
|
||||
class="button is-light customer-quick-action customer-quick-action--draft"
|
||||
:class="{ 'is-selected': isDraftMode }"
|
||||
type="button"
|
||||
data-testid="pos-mobile-draft-customer"
|
||||
:aria-pressed="isDraftMode ? 'true' : 'false'"
|
||||
@click="onClickDraftCustomer"
|
||||
>
|
||||
<span class="icon is-small customer-quick-action__icon">
|
||||
<i class="fas fa-file-invoice"></i>
|
||||
</span>
|
||||
<span>{{ t("pos.customer_picker.select_draft_customer") }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="button is-light customer-quick-action customer-quick-action--card"
|
||||
:class="{ 'is-selected': isCardMode, 'is-disabled': isCardPaymentDisabled }"
|
||||
type="button"
|
||||
data-testid="pos-mobile-direct-card-payment"
|
||||
:aria-pressed="isCardMode ? 'true' : 'false'"
|
||||
:disabled="isCardPaymentDisabled"
|
||||
@click="onClickDirectCardPayment"
|
||||
>
|
||||
<span class="icon is-small customer-quick-action__icon">
|
||||
<i class="fas fa-credit-card"></i>
|
||||
</span>
|
||||
<span>{{ t("pos.customer_picker.select_card_payment") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-if="shouldShowSearchInput" class="customer-search-panel">
|
||||
<label class="customer-search-panel__label">{{ t("common.customer") }}</label>
|
||||
<div class="field customer-search-panel__field">
|
||||
<div class="control has-icons-right" :class="{ 'is-loading': isSearching }">
|
||||
<CustomerSearchField testId="pos-mobile-customer-search-input" />
|
||||
<span class="icon is-right">
|
||||
<i class="fas fa-search"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="formattedSearchResults.length > 0">
|
||||
<!-- Search results (If any) -->
|
||||
<ControlFieldInputSearchResults v-bind:results="formattedSearchResults" @result-clicked="onClick"/>
|
||||
<div class="customer-search-results">
|
||||
<ControlFieldInputSearchResults
|
||||
v-bind:results="formattedSearchResults"
|
||||
testIdPrefix="pos-mobile-customer-search-result"
|
||||
@result-clicked="onClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Customer suggestions (If any) -->
|
||||
<VehicleCustomerSuggestionsPos v-if="vehicles?.vehicle_1?.value?.reg" :reg_1="vehicles.vehicle_1.value.reg" @customerSelected="onClickSuggestion"/>
|
||||
</section>
|
||||
|
||||
<VehicleCustomerSuggestionsPos
|
||||
v-if="shouldShowSearchInput && vehicles?.vehicle_1?.value?.reg"
|
||||
class="customer-suggestions"
|
||||
:reg_1="vehicles.vehicle_1.value.reg"
|
||||
@customerSelected="onClickSuggestion"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
input {
|
||||
/* input-fields */
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
/* Auto layout */
|
||||
.pos-mobile-customer-popup {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 0px;
|
||||
border-bottom: none;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
|
||||
background: #FFFFFF;
|
||||
border-color: transparent;
|
||||
border-radius: 4px;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 1;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
border-color: transparent;
|
||||
.customer-selection-summary {
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%);
|
||||
border: 1px solid #d7e3f5;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 14px 28px rgba(17, 46, 92, 0.08);
|
||||
padding: 0.95rem 1rem;
|
||||
}
|
||||
|
||||
.customer-selection-summary__label {
|
||||
color: #5a6f8f;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.customer-selection-summary__content {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.customer-selection-summary__icon {
|
||||
align-items: center;
|
||||
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
|
||||
border-radius: 999px;
|
||||
color: #ffffff;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
height: 2.4rem;
|
||||
justify-content: center;
|
||||
width: 2.4rem;
|
||||
}
|
||||
|
||||
.customer-selection-summary__text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.customer-selection-summary__name {
|
||||
color: #17325c;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.customer-selection-summary__number {
|
||||
color: #5d7090;
|
||||
font-size: 0.82rem;
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.customer-selection-summary__mode {
|
||||
background: rgba(23, 50, 92, 0.08);
|
||||
border-radius: 999px;
|
||||
color: #17325c;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
max-width: 10.5rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.customer-action-divider {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.customer-action-divider__line {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(170, 188, 214, 0) 0%,
|
||||
rgba(170, 188, 214, 0.95) 25%,
|
||||
rgba(170, 188, 214, 0.95) 75%,
|
||||
rgba(170, 188, 214, 0) 100%
|
||||
);
|
||||
flex: 1 1 auto;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.customer-action-divider__label {
|
||||
color: #6d7f99;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.customer-quick-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.customer-quick-actions :deep(.button) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.customer-quick-action {
|
||||
align-items: center;
|
||||
border: 1px solid #d2deee;
|
||||
border-radius: 0.95rem;
|
||||
box-shadow: 0 8px 16px rgba(21, 49, 93, 0.06);
|
||||
color: #17325c;
|
||||
display: inline-flex;
|
||||
flex: 1 1 10rem;
|
||||
font-weight: 600;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
min-height: 3.25rem;
|
||||
padding: 0.9rem 1rem;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.customer-quick-action.is-selected {
|
||||
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
|
||||
border-color: #10294b;
|
||||
box-shadow: 0 14px 24px rgba(15, 39, 72, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.customer-quick-action:hover,
|
||||
.customer-quick-action:focus-visible {
|
||||
background-color: #f1f6ff;
|
||||
border-color: #b6cae6;
|
||||
color: #0f2a55;
|
||||
}
|
||||
|
||||
.customer-quick-action.is-selected:hover,
|
||||
.customer-quick-action.is-selected:focus-visible {
|
||||
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
|
||||
border-color: #10294b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.customer-quick-action__icon {
|
||||
align-items: center;
|
||||
background: #e9f2ff;
|
||||
border-radius: 999px;
|
||||
color: #184b8a;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
height: 1.95rem;
|
||||
justify-content: center;
|
||||
width: 1.95rem;
|
||||
}
|
||||
|
||||
.customer-quick-action.is-selected .customer-quick-action__icon {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.customer-quick-action:disabled,
|
||||
.customer-quick-action.is-disabled {
|
||||
background: #f4f7fb;
|
||||
border-color: #d6dfec;
|
||||
box-shadow: none;
|
||||
color: #90a0b7;
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.customer-quick-action:disabled .customer-quick-action__icon,
|
||||
.customer-quick-action.is-disabled .customer-quick-action__icon {
|
||||
background: #edf2f8;
|
||||
color: #8ca0bc;
|
||||
}
|
||||
|
||||
.customer-quick-action:disabled:hover,
|
||||
.customer-quick-action:disabled:focus-visible,
|
||||
.customer-quick-action.is-disabled:hover,
|
||||
.customer-quick-action.is-disabled:focus-visible {
|
||||
background: #f4f7fb;
|
||||
border-color: #d6dfec;
|
||||
color: #90a0b7;
|
||||
}
|
||||
|
||||
.customer-quick-action--invoice .customer-quick-action__icon {
|
||||
background: #edf5ff;
|
||||
color: #1b4f92;
|
||||
}
|
||||
|
||||
.customer-quick-action--draft .customer-quick-action__icon {
|
||||
background: #eef0ff;
|
||||
color: #324cb1;
|
||||
}
|
||||
|
||||
.customer-quick-action--card .customer-quick-action__icon {
|
||||
background: #e4f7fa;
|
||||
color: #0f6a7b;
|
||||
}
|
||||
|
||||
.customer-search-panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe5f2;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 10px 18px rgba(21, 49, 93, 0.05);
|
||||
margin-top: 0.15rem;
|
||||
padding: 0.8rem 0.9rem 0.95rem;
|
||||
}
|
||||
|
||||
.customer-search-panel__label {
|
||||
color: #17325c;
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.customer-search-panel__field {
|
||||
border-bottom: 1px solid #c9d6e8;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.customer-search-panel :deep(input) {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
height: 42px;
|
||||
padding: 0.65rem 0.25rem 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.customer-search-panel :deep(input:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
.underlined-input-field {
|
||||
padding-bottom: 0;
|
||||
border-bottom: #929292 1px solid;
|
||||
|
||||
.customer-search-results {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.debug-input {
|
||||
/* input-fields */
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
/* Auto layout */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0px 8px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #A6A5A5;
|
||||
border-radius: 4px;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 1;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
.customer-suggestions {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
input.is-searched {
|
||||
/* input-fields */
|
||||
|
||||
box-sizing: border-box;
|
||||
@media (max-width: 380px) {
|
||||
.customer-quick-action {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
/* Auto layout */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 12px 8px;
|
||||
gap: 10px;
|
||||
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #000000;
|
||||
border-radius: 4px;
|
||||
border-bottom: none;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 1;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
.customer-selection-summary__content {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.customer-selection-summary__mode {
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user