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
|
||||
'''
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
name: Qodana Configuration Upload
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, dev ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
upload-qodana-config:
|
||||
runs-on: ubuntu-latest
|
||||
upload-qodana-config:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Run Qodana Configuration Uploader
|
||||
env:
|
||||
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v $(pwd):/workspace \
|
||||
-w /workspace \
|
||||
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
|
||||
jetbrains/qodana-configuration-uploader:latest \
|
||||
--global-configs-file qodana-global-configurations.yaml \
|
||||
--qodana-host https://qodana.cloud
|
||||
- name: Run Qodana Configuration Uploader
|
||||
env:
|
||||
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v $(pwd):/workspace \
|
||||
-w /workspace \
|
||||
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
|
||||
jetbrains/qodana-configuration-uploader:latest \
|
||||
--global-configs-file qodana-global-configurations.yaml \
|
||||
--qodana-host https://qodana.cloud
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
name: Qodana Configuration Upload
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, dev ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
upload-qodana-config:
|
||||
runs-on: ubuntu-latest
|
||||
upload-qodana-config:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Run Qodana Configuration Uploader
|
||||
env:
|
||||
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v $(pwd):/workspace \
|
||||
-w /workspace \
|
||||
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
|
||||
jetbrains/qodana-configuration-uploader:latest \
|
||||
--global-configs-file qodana-global-configurations.yaml \
|
||||
--qodana-host https://qodana.cloud
|
||||
- name: Run Qodana Configuration Uploader
|
||||
env:
|
||||
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v $(pwd):/workspace \
|
||||
-w /workspace \
|
||||
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
|
||||
jetbrains/qodana-configuration-uploader:latest \
|
||||
--global-configs-file qodana-global-configurations.yaml \
|
||||
--qodana-host https://qodana.cloud
|
||||
|
||||
+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:
|
||||
|
||||
+7225
-208
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;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import {defineProps, ref} from 'vue';
|
||||
import {computed, defineProps, ref, useSlots} from 'vue';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {BTooltip} from "buefy";
|
||||
|
||||
const slots = useSlots();
|
||||
const props = defineProps({
|
||||
object: Object,
|
||||
loadList: Function,
|
||||
@@ -44,10 +46,28 @@ const props = defineProps({
|
||||
default: () => {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
cellTestId: {
|
||||
type: String,
|
||||
default: () => {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
editButtonTestId: {
|
||||
type: String,
|
||||
default: () => {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const canEdit = computed(() => Boolean(props.editFunction) && props.permissionCheckFunction());
|
||||
|
||||
const clickEdit = () => {
|
||||
if (!canEdit.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the virtual column is not set, then show the edit form
|
||||
if (!props.virtualColumn) {
|
||||
isLoading.value = true;
|
||||
@@ -71,6 +91,8 @@ const clickEdit = () => {
|
||||
}
|
||||
}
|
||||
const isLoading = ref(false);
|
||||
const hasHoverHtmlSlot = computed(() => Boolean(slots.hoverHtml));
|
||||
const isTooltipMultiline = computed(() => Boolean(props.hoverHtml || hasHoverHtmlSlot.value));
|
||||
|
||||
//console.log(props);
|
||||
</script>
|
||||
@@ -79,7 +101,8 @@ const isLoading = ref(false);
|
||||
<component :is="props.componentWrapper ? props.componentWrapper : 'td'"
|
||||
@click="clickEdit"
|
||||
class="hover-illustration"
|
||||
:class="{'is-clickable': props.permissionCheckFunction() && props.editFunction}">
|
||||
:class="{'is-clickable': canEdit}"
|
||||
:data-testid="props.cellTestId || null">
|
||||
<div>
|
||||
|
||||
<!-- Divided display ( value | edit button ) -->
|
||||
@@ -93,8 +116,14 @@ const isLoading = ref(false);
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<span class="ml-2 is-float-right" v-if="props.permissionCheckFunction()">
|
||||
<button class="button is-small is-transparent is-text" style="text-decoration-line: none;" @click="clickEdit" v-if="props.editFunction" :class="{'is-loading': isLoading}" :disabled="isLoading">
|
||||
<span class="ml-2 is-float-right" v-if="canEdit">
|
||||
<button class="button is-small is-transparent is-text"
|
||||
style="text-decoration-line: none;"
|
||||
@click.stop="clickEdit"
|
||||
v-if="props.editFunction"
|
||||
:class="{'is-loading': isLoading}"
|
||||
:disabled="isLoading"
|
||||
:data-testid="props.editButtonTestId || null">
|
||||
<span class="icon">
|
||||
<i class="fas fa-edit"></i>
|
||||
</span>
|
||||
@@ -110,7 +139,7 @@ const isLoading = ref(false);
|
||||
<b-tooltip
|
||||
type="is-light"
|
||||
position="is-bottom"
|
||||
:multilined="$slots.hoverHtml"
|
||||
:multilined="isTooltipMultiline"
|
||||
square>
|
||||
<!-- Value Display -->
|
||||
<span class="" v-if="!props.virtualColumn">{{ parseFunction ? parseFunction(props.object[props.column]) : props.object[props.column] }}</span>
|
||||
|
||||
@@ -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));
|
||||
watch(() => router.currentRoute.value.params.departmentId, (nextDepartmentId) => {
|
||||
if (nextDepartmentId) {
|
||||
setDepartment(nextDepartmentId);
|
||||
}
|
||||
loadOrderItems();
|
||||
}
|
||||
});
|
||||
|
||||
if (window.location.search.includes("step=")) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const step = urlParams.get("step");
|
||||
if (step !== null) {
|
||||
setStep(parseInt(step));
|
||||
}
|
||||
}
|
||||
|
||||
if (window.location.search.includes("customer_id=")) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const customerId = urlParams.get("customer_id");
|
||||
if (customerId !== null) {
|
||||
searchAndSelectCustomer(parseInt(customerId));
|
||||
}
|
||||
}
|
||||
applyPosRouteSearch(window.location.search, {
|
||||
setOrderId,
|
||||
loadOrderItems,
|
||||
setStep,
|
||||
searchAndSelectCustomer,
|
||||
});
|
||||
|
||||
/** Define the createOrder function */
|
||||
</script>
|
||||
@@ -86,6 +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,267 +1,363 @@
|
||||
<script setup>
|
||||
import { customer_name, customer_id, customer_data, customer_attributes, loadCustomerAttributes, hideDiscountsCatalog, hidePricesCatalog, setHidePricesCatalog, setHideDiscountsCatalog, getHideDiscountsCatalog, getHidePricesCatalog, getUserDiscounts, user_discounts } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import {
|
||||
customer_name,
|
||||
customer_id,
|
||||
customer_data,
|
||||
customer_attributes,
|
||||
loadCustomerAttributes,
|
||||
hideDiscountsCatalog,
|
||||
hidePricesCatalog,
|
||||
setHidePricesCatalog,
|
||||
setHideDiscountsCatalog,
|
||||
getHideDiscountsCatalog,
|
||||
getHidePricesCatalog,
|
||||
getUserDiscounts,
|
||||
user_discounts,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { createAttribute, deleteAttribute } from "@/components/shop/CustomerAttributes.vue";
|
||||
import {ref, watch} from 'vue';
|
||||
import 'bulma-switch/dist/css/bulma-switch.min.css';
|
||||
import 'bulma-block-list/src/block-list.scss';
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import {popperBox, popper, removePopperIfOpen, showPopperWithContent, showPopper} from "@/components/displays/PopperDefault.vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import "bulma-switch/dist/css/bulma-switch.min.css";
|
||||
import "bulma-block-list/src/block-list.scss";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {
|
||||
popperBox,
|
||||
popper,
|
||||
removePopperIfOpen,
|
||||
showPopperWithContent,
|
||||
showPopper,
|
||||
} from "@/components/displays/PopperDefault.vue";
|
||||
import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue";
|
||||
import CustomerDiscountsDepartmentDisplay
|
||||
from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
|
||||
import CustomerDiscountsDepartmentDisplay from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
|
||||
import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentBox.vue";
|
||||
|
||||
const panel_tabs = ref([
|
||||
{
|
||||
name: 'Detaljer',
|
||||
key: "details",
|
||||
name: "Detaljer",
|
||||
active: true,
|
||||
permission: 'get_user'
|
||||
permission: "get_user",
|
||||
},
|
||||
{
|
||||
name: 'Regler',
|
||||
key: "rules",
|
||||
name: "Regler",
|
||||
active: false,
|
||||
permission: 'list_customer_attributes'
|
||||
permission: "list_customer_attributes",
|
||||
},
|
||||
{
|
||||
name: 'Genveje',
|
||||
key: "shortcuts",
|
||||
name: "Genveje",
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
name: 'Rabatter',
|
||||
key: "discounts",
|
||||
name: "Rabatter",
|
||||
active: false,
|
||||
permission: 'get_custom_prices_other'
|
||||
}
|
||||
permission: "get_custom_prices_other",
|
||||
},
|
||||
]);
|
||||
|
||||
const details = ref([
|
||||
{
|
||||
name: 'Kunde nummer',
|
||||
prop: 'customerNumber',
|
||||
icon: 'fas fa-user'
|
||||
name: "Kunde nummer",
|
||||
prop: "customerNumber",
|
||||
icon: "fas fa-user",
|
||||
},
|
||||
{
|
||||
name: 'Navn',
|
||||
prop: 'name',
|
||||
icon: 'fas fa-user'
|
||||
name: "Navn",
|
||||
prop: "name",
|
||||
icon: "fas fa-user",
|
||||
},
|
||||
{
|
||||
name: 'Adresse',
|
||||
prop: 'address',
|
||||
icon: 'fas fa-map-marker-alt'
|
||||
name: "Adresse",
|
||||
prop: "address",
|
||||
icon: "fas fa-map-marker-alt",
|
||||
},
|
||||
{
|
||||
name: 'Postnummer',
|
||||
prop: 'zip',
|
||||
icon: 'fas fa-map-marker-alt'
|
||||
name: "Postnummer",
|
||||
prop: "zip",
|
||||
icon: "fas fa-map-marker-alt",
|
||||
},
|
||||
{
|
||||
name: 'Kunde by',
|
||||
prop: 'city',
|
||||
icon: 'fas fa-map-marker-alt'
|
||||
name: "Kunde by",
|
||||
prop: "city",
|
||||
icon: "fas fa-map-marker-alt",
|
||||
},
|
||||
{
|
||||
name: 'Telefon',
|
||||
prop: 'mobilePhone',
|
||||
icon: 'fas fa-phone'
|
||||
name: "Telefon",
|
||||
prop: "mobilePhone",
|
||||
icon: "fas fa-phone",
|
||||
},
|
||||
{
|
||||
name: 'Email',
|
||||
prop: 'email',
|
||||
icon: 'fas fa-envelope'
|
||||
name: "Email",
|
||||
prop: "email",
|
||||
icon: "fas fa-envelope",
|
||||
},
|
||||
{
|
||||
name: 'CVR',
|
||||
prop: 'corporateIdentificationNumber',
|
||||
icon: 'fas fa-id-card'
|
||||
}
|
||||
name: "CVR",
|
||||
prop: "corporateIdentificationNumber",
|
||||
icon: "fas fa-id-card",
|
||||
},
|
||||
]);
|
||||
|
||||
const attributes = ref([
|
||||
{
|
||||
name: 'Kræver reference nr.',
|
||||
prop: 'requiresReferenceNumber',
|
||||
description: 'Når denne er sat, kræves der et reference nr. på ordren før den kan oprettes',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Kræver reference nr.",
|
||||
prop: "requiresReferenceNumber",
|
||||
description: "Når denne er sat, kræves der et reference nr. på ordren før den kan oprettes",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Må ikke ydes tillægsydelser',
|
||||
prop: 'restrictAdditionalServices',
|
||||
name: "Må ikke ydes tillægsydelser",
|
||||
prop: "restrictAdditionalServices",
|
||||
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "addons" til ordren',
|
||||
icon: 'fas fa-cogs'
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Registreringsnumre på faktura linjer',
|
||||
prop: 'requiresRegistrationNumbersInvoice',
|
||||
description: 'Når denne er sat, bliver der sendt registreringsnumre med på alle faktura linjer',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Registreringsnumre på faktura linjer",
|
||||
prop: "requiresRegistrationNumbersInvoice",
|
||||
description: "Når denne er sat, bliver der sendt registreringsnumre med på alle faktura linjer",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Fakturer alle ordrer individuelt',
|
||||
prop: 'invoiceAllOrdersIndividually',
|
||||
description: 'Når denne er sat, faktureres alle ordrer individuelt og ikke samlet',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Fakturer alle ordrer individuelt",
|
||||
prop: "invoiceAllOrdersIndividually",
|
||||
description: "Når denne er sat, faktureres alle ordrer individuelt og ikke samlet",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Må ikke ydes tank cleaning',
|
||||
prop: 'restrictTankCleaning',
|
||||
name: "Må ikke ydes tank cleaning",
|
||||
prop: "restrictTankCleaning",
|
||||
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "tank cleaning" til ordren',
|
||||
icon: 'fas fa-cogs'
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Må ikke ydes spot free',
|
||||
prop: 'restrictSpotFree',
|
||||
name: "Må ikke ydes spot free",
|
||||
prop: "restrictSpotFree",
|
||||
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "spot free" til ordren',
|
||||
icon: 'fas fa-cogs'
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Må ikke ydes indvendig vask',
|
||||
prop: 'restrictInteriorCleaning',
|
||||
name: "Må ikke ydes indvendig vask",
|
||||
prop: "restrictInteriorCleaning",
|
||||
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "interior cleaning" til ordren',
|
||||
icon: 'fas fa-cogs'
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Faktureres med Stripe',
|
||||
prop: 'invoiceWithStripe',
|
||||
description: 'Når denne er sat, faktureres ordren med Stripe',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Faktureres med Stripe",
|
||||
prop: "invoiceWithStripe",
|
||||
description: "Når denne er sat, faktureres ordren med Stripe",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Only tank cleaning',
|
||||
prop: 'onlyTankCleaning',
|
||||
name: "Only tank cleaning",
|
||||
prop: "onlyTankCleaning",
|
||||
description: 'Når denne er sat, bliver kunden kategoriseret som "Tank cleaning" kunde.',
|
||||
icon: 'fas fa-cogs'
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Vises priser på kunde og bookingside',
|
||||
prop: 'showPricesOnBookingPage',
|
||||
description: 'Når denne er sat, bliver kunden vist priser på kunde og bookingsiden.',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Vises priser på kunde og bookingside",
|
||||
prop: "showPricesOnBookingPage",
|
||||
description: "Når denne er sat, bliver kunden vist priser på kunde og bookingsiden.",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Bruger PO nummer',
|
||||
prop: 'usePONumbers',
|
||||
description: 'Når denne er sat, kan kunden angive et PO nummer på ordrer.',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Bruger PO nummer",
|
||||
prop: "usePONumbers",
|
||||
description: "Når denne er sat, kan kunden angive et PO nummer på ordrer.",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Undtaget fra månedligt administrations- og miljøgebyr',
|
||||
prop: 'exemptFromAdministrationFee',
|
||||
description: 'Når denne er sat, bliver kunden undtaget fra månedligt administrations- og miljøgebyr.',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Undtaget fra månedligt administrations- og miljøgebyr",
|
||||
prop: "exemptFromAdministrationFee",
|
||||
description: "Når denne er sat, bliver kunden undtaget fra månedligt administrations- og miljøgebyr.",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
]);
|
||||
|
||||
const shortcuts = ref([
|
||||
{
|
||||
name: 'Administrer kunde',
|
||||
icon: 'fas fa-user-edit',
|
||||
name: "Administrer kunde",
|
||||
icon: "fas fa-user-edit",
|
||||
// Open link in new tab
|
||||
action: () => {
|
||||
SessionUser.adminUser.customers.fromCustomerNumber.getUserId(customer_id.value).then(response => window.open('/superuser/users/' + response.data.data.user_id, '_blank'));
|
||||
SessionUser.adminUser.customers.fromCustomerNumber
|
||||
.getUserId(customer_id.value)
|
||||
.then((response) => window.open("/superuser/users/" + response.data.data.user_id, "_blank"));
|
||||
},
|
||||
visible: SessionUser.canAccessSuperUser()
|
||||
visible: SessionUser.canAccessSuperUser(),
|
||||
},
|
||||
{
|
||||
name: 'Vaskeabonnement & Specialaftaler',
|
||||
icon: 'fas fa-user-edit',
|
||||
name: "Vaskeabonnement & Specialaftaler",
|
||||
icon: "fas fa-user-edit",
|
||||
// Open link in new tab
|
||||
action: () => {
|
||||
SessionUser.adminUser.customers.fromCustomerNumber.getUserId(customer_id.value).then(response => window.open('/superuser/users/' + response.data.data.user_id + '/other', '_blank'));
|
||||
SessionUser.adminUser.customers.fromCustomerNumber
|
||||
.getUserId(customer_id.value)
|
||||
.then((response) => window.open("/superuser/users/" + response.data.data.user_id + "/other", "_blank"));
|
||||
},
|
||||
visible: SessionUser.canAccessSuperUser()
|
||||
visible: SessionUser.canAccessSuperUser(),
|
||||
},
|
||||
]);
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: "default",
|
||||
validator: (value) => ["default", "order-detail", "sidebar"].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const rootClasses = computed(() => ({
|
||||
"pos-selected-customer": true,
|
||||
[`pos-selected-customer--${props.variant}`]: true,
|
||||
}));
|
||||
const isSidebarVariant = computed(() => props.variant === "sidebar");
|
||||
const isRulesTabActive = computed(() => panel_tabs.value[1]?.active === true);
|
||||
|
||||
const setActiveTab = (selectedIndex) => {
|
||||
panel_tabs.value.forEach((tab, index) => {
|
||||
tab.active = index === selectedIndex;
|
||||
});
|
||||
};
|
||||
|
||||
const refreshCustomerAttributesForRules = async (selectedCustomerNumber = customer_id.value) => {
|
||||
if (!selectedCustomerNumber) {
|
||||
return customer_attributes.value;
|
||||
}
|
||||
|
||||
return loadCustomerAttributes(selectedCustomerNumber);
|
||||
};
|
||||
|
||||
/** Check if an attribute is set in the customer attributes */
|
||||
const hasAttribute = (prop) => {
|
||||
return customer_attributes.value.some(attribute => attribute.attribute === prop);
|
||||
return customer_attributes.value.some((attribute) => attribute.attribute === prop);
|
||||
};
|
||||
/** Fetch the customer attributes when the component is mounted */
|
||||
loadCustomerAttributes();
|
||||
|
||||
watch(
|
||||
isRulesTabActive,
|
||||
(isActive) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
void refreshCustomerAttributesForRules();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => customer_id.value,
|
||||
(nextCustomerId, previousCustomerId) => {
|
||||
if (!nextCustomerId || nextCustomerId === previousCustomerId) {
|
||||
return;
|
||||
}
|
||||
void refreshCustomerAttributesForRules(nextCustomerId);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const isCustomerDetailsExpanded = ref(false);
|
||||
|
||||
const customer_data_has_empty_details = () => {
|
||||
return details.value.some(detail => customer_data.value[detail.prop] === null || customer_data.value[detail.prop] === '');
|
||||
return details.value.some(
|
||||
(detail) => customer_data.value[detail.prop] === null || customer_data.value[detail.prop] === ""
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="customer_name">
|
||||
<article class="">
|
||||
<div class="panel-block">
|
||||
<div class="columns is-vcentered is-multiline">
|
||||
<div class="column is-12">
|
||||
<span class="is-size-6 title"
|
||||
>{{ customer_name }}</span>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="tabs is-small is-centered">
|
||||
<ul>
|
||||
<li v-for="(tab, index) in panel_tabs" :key="index" :class="{'is-active': tab.active}" @click="panel_tabs.forEach(tab => tab.active = false); tab.active = true">
|
||||
<RequiresPermission :permission="tab.permission" v-if="tab.permission">
|
||||
<a>
|
||||
{{ tab.name }}
|
||||
</a>
|
||||
</RequiresPermission>
|
||||
<a v-else>
|
||||
{{ tab.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="customer_name" :class="rootClasses">
|
||||
<article class="pos-selected-customer__panel">
|
||||
<div class="panel-block pos-selected-customer__header">
|
||||
<div class="pos-selected-customer__title-wrap">
|
||||
<span class="title is-6 pos-selected-customer__title">{{ customer_name }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="tabs is-small is-centered pos-selected-customer__tabs"
|
||||
:class="{ 'pos-sidebar-panel-tabs': isSidebarVariant }"
|
||||
:data-testid="isSidebarVariant ? 'pos-customer-sidebar-tabs' : undefined"
|
||||
>
|
||||
<ul>
|
||||
<li
|
||||
v-for="(tab, index) in panel_tabs"
|
||||
:key="tab.key"
|
||||
:class="{ 'is-active': tab.active }"
|
||||
:data-testid="`pos-customer-tab-${tab.key}`"
|
||||
@click="setActiveTab(index)"
|
||||
>
|
||||
<RequiresPermission :permission="tab.permission" v-if="tab.permission">
|
||||
<a>
|
||||
{{ tab.name }}
|
||||
</a>
|
||||
</RequiresPermission>
|
||||
<a v-else>
|
||||
{{ tab.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="panel_tabs[0].active" v-for="detail in details" :key="detail.name">
|
||||
<!-- Details that's not empty (unless isCustomerDetailsExpanded is true) -->
|
||||
<a class="panel-block" v-if="customer_data[detail.prop] || isCustomerDetailsExpanded">
|
||||
<span class="panel-icon">
|
||||
<a
|
||||
class="panel-block pos-selected-customer__row"
|
||||
v-if="customer_data[detail.prop] || isCustomerDetailsExpanded"
|
||||
>
|
||||
<span class="panel-icon pos-selected-customer__icon">
|
||||
<i :class="detail.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span style="width: 50%;">{{ detail.name }}</span><span class="has-text-right" style="width: 50%;">{{ customer_data[detail.prop] }}</span>
|
||||
<span class="pos-selected-customer__label">{{ detail.name }}</span>
|
||||
<span class="pos-selected-customer__value">{{ customer_data[detail.prop] }}</span>
|
||||
</a>
|
||||
</template>
|
||||
<!-- Details that's empty, (If there are any) -->
|
||||
<ExpandableContentBox
|
||||
v-if="panel_tabs[0].active && customer_data_has_empty_details"
|
||||
@update:expanded="isCustomerDetailsExpanded = !isCustomerDetailsExpanded"
|
||||
v-bind:expanded="isCustomerDetailsExpanded"
|
||||
v-if="panel_tabs[0].active && customer_data_has_empty_details()"
|
||||
@update:expanded="isCustomerDetailsExpanded = !isCustomerDetailsExpanded"
|
||||
v-bind:expanded="isCustomerDetailsExpanded"
|
||||
/>
|
||||
<!-- Attributes -->
|
||||
<RequiresPermission permission="list_customer_attributes">
|
||||
<div class="panel-block" v-if="panel_tabs[1].active" v-for="attribute in attributes" :key="attribute.name">
|
||||
<span class="panel-icon">
|
||||
<div
|
||||
class="panel-block pos-selected-customer__row"
|
||||
v-if="panel_tabs[1].active"
|
||||
v-for="attribute in attributes"
|
||||
:key="attribute.name"
|
||||
>
|
||||
<span class="panel-icon pos-selected-customer__icon">
|
||||
<i :class="attribute.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span style="width: 50%;"
|
||||
@mouseover="showPopper(
|
||||
popperBox(
|
||||
attribute.name,
|
||||
attribute.description
|
||||
),
|
||||
$event.target
|
||||
)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
>{{ attribute.name }}</span>
|
||||
<span class="has-text-right" style="width: 50%;">
|
||||
<span v-if="hasAttribute(attribute.prop)" @click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
|
||||
<span
|
||||
class="pos-selected-customer__label"
|
||||
@mouseover="showPopper(popperBox(attribute.name, attribute.description), $event.target)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
>{{ attribute.name }}</span
|
||||
>
|
||||
<span class="pos-selected-customer__value">
|
||||
<span
|
||||
v-if="hasAttribute(attribute.prop)"
|
||||
@click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())"
|
||||
>
|
||||
<span class="field">
|
||||
<input :id="attribute.prop"
|
||||
type="checkbox" :name="attribute.prop"
|
||||
class="switch" checked="checked"
|
||||
:disabled="!SessionUser.hasPermission('delete_customer_attribute')"
|
||||
<input
|
||||
:id="attribute.prop"
|
||||
type="checkbox"
|
||||
:name="attribute.prop"
|
||||
class="switch"
|
||||
checked="checked"
|
||||
:disabled="!SessionUser.hasPermission('delete_customer_attribute')"
|
||||
/>
|
||||
<label :for="attribute.prop"></label>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else @click="createAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
|
||||
<span class="field">
|
||||
<input :id="attribute.prop"
|
||||
type="checkbox" :name="attribute.prop"
|
||||
class="switch" :disabled="!SessionUser.hasPermission('add_customer_attribute')"/>
|
||||
<input
|
||||
:id="attribute.prop"
|
||||
type="checkbox"
|
||||
:name="attribute.prop"
|
||||
class="switch"
|
||||
:disabled="!SessionUser.hasPermission('add_customer_attribute')"
|
||||
/>
|
||||
<label :for="attribute.prop"></label>
|
||||
</span>
|
||||
</span>
|
||||
@@ -271,26 +367,158 @@ const customer_data_has_empty_details = () => {
|
||||
<!-- Shortcuts -->
|
||||
<template v-if="panel_tabs[2].active" v-for="shortcut in shortcuts" :key="shortcut.name">
|
||||
<RequiresPermission permission="access_super_user">
|
||||
<a class="panel-block">
|
||||
<span class="panel-icon">
|
||||
<a class="panel-block pos-selected-customer__row">
|
||||
<span class="panel-icon pos-selected-customer__icon">
|
||||
<i :class="shortcut.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span @click="shortcut.action">{{ shortcut.name }}</span>
|
||||
<span class="pos-selected-customer__shortcut" @click="shortcut.action">{{ shortcut.name }}</span>
|
||||
</a>
|
||||
</RequiresPermission>
|
||||
</template>
|
||||
<!-- Discounts -->
|
||||
<RequiresPermission permission="get_custom_prices_other">
|
||||
<div class="panel-block" v-if="panel_tabs[3].active">
|
||||
<CustomerDiscountsDepartmentDisplay/>
|
||||
<div class="panel-block pos-selected-customer__discounts" v-if="panel_tabs[3].active">
|
||||
<CustomerDiscountsDepartmentDisplay />
|
||||
</div>
|
||||
</RequiresPermission>
|
||||
</article>
|
||||
<div>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pos-selected-customer {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.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,146 +19,78 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
// Check if the required props are set
|
||||
if (props.order_id === null || props.note === null || props.loadOrder === null) {
|
||||
const missingProps = [];
|
||||
if (props.order_id === null) {
|
||||
missingProps.push("order_id");
|
||||
}
|
||||
if (props.note === null) {
|
||||
missingProps.push("note");
|
||||
}
|
||||
if (props.loadOrder === null) {
|
||||
missingProps.push("loadOrder");
|
||||
}
|
||||
throw new Error("The required props are not set: " + missingProps.join(", "));
|
||||
}
|
||||
// Random unique element id
|
||||
const uniqueId = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
const { t } = useI18n();
|
||||
|
||||
// Define the input note, this is separate from the props note, because otherwise we risk loosing changes.
|
||||
const input = ref(props.note);
|
||||
const uniqueId = Math.random().toString(36).slice(2);
|
||||
const isEditing = ref(false);
|
||||
const autosave = useOrderMetadataAutosave({
|
||||
source: () => props.note,
|
||||
saveValue: async (value) => {
|
||||
await SessionUser.objects.orders.set.notes(props.order_id, value);
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
// When the props note changes, then update the input note (If the input note is empty)
|
||||
watch(() => props.note, (value) => {
|
||||
if (input.value === "") {
|
||||
input.value = value;
|
||||
const previewValue = computed(() => autosave.draft.value);
|
||||
const rowCount = computed(() => Math.max((autosave.draft.value || '').split('\n').length, 5));
|
||||
|
||||
watch(() => autosave.draft.value, () => {
|
||||
if (isEditing.value) {
|
||||
autosave.scheduleSave();
|
||||
}
|
||||
});
|
||||
|
||||
const isSaved = () => {
|
||||
return input.value === props.note;
|
||||
}
|
||||
|
||||
// Define the input changed function
|
||||
const inputChanged = () => {}
|
||||
|
||||
// Define the lost focus function
|
||||
const lostFocus = () => {
|
||||
isEditing.value = false;
|
||||
saveChanges({force: true});
|
||||
}
|
||||
|
||||
// Save the last time the note was synced, this is used to prevent spamming the server with requests.
|
||||
const lastSync = ref(new Date().getTime());
|
||||
|
||||
// Define the save changes function
|
||||
const saveChanges = (options = {}) => {
|
||||
// If the input value is the same as the note, then return
|
||||
if (input.value === props.note && !options.force) {
|
||||
return;
|
||||
}
|
||||
// Set the note to the input value
|
||||
props.note = input.value;
|
||||
// Save the order
|
||||
SessionUser.request('/order', 'PUT', {
|
||||
id: props.order_id,
|
||||
field: 'notes',
|
||||
value: input.value
|
||||
}).then((response) => {
|
||||
console.log(response);
|
||||
// Load the order
|
||||
props.loadOrder();
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
// Sync the note with the server once every 2 seconds, if the note has changed.
|
||||
setInterval(() => {
|
||||
if (input.value !== props.note && new Date().getTime() - lastSync.value > 2000) {
|
||||
lastSync.value = new Date().getTime();
|
||||
saveChanges();
|
||||
} else {
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const isEditing = ref(false);
|
||||
const focusOnTextarea = () => {
|
||||
document.getElementById(`${uniqueId}-textarea`)?.focus();
|
||||
};
|
||||
|
||||
const onFieldClicked = () => {
|
||||
if (isEditing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isEditing.value = true;
|
||||
// Wait for the next tick, then focus on the textarea
|
||||
nextTick(() => {
|
||||
focusOnTextarea();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const focusOnTextarea = () => {
|
||||
const textarea = document.getElementById(uniqueId + '-textarea');
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const getRows = () => {
|
||||
return Math.max(input.value.split("\n").length, 1);
|
||||
}
|
||||
|
||||
const shortNote = ref('');
|
||||
|
||||
watch(() => props.note, (value) => {
|
||||
shortNote.value = generateShortNote();
|
||||
});
|
||||
|
||||
const generateShortNote = () => {
|
||||
let shownNote = '';
|
||||
if (input.value.length > 20 || input.value.split("\n").length > 1) {
|
||||
// Get the first line, and add ...
|
||||
shownNote = input.value.split("\n")[0].substring(0, 20) + '...';
|
||||
} else {
|
||||
shownNote = input.value;
|
||||
}
|
||||
shortNote.value = shownNote;
|
||||
return shownNote;
|
||||
}
|
||||
|
||||
generateShortNote();
|
||||
const lostFocus = async () => {
|
||||
isEditing.value = false;
|
||||
await autosave.flush();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="field" @click="onFieldClicked()">
|
||||
<div class="control" :class="{'is-loading': !isSaved()}">
|
||||
<textarea
|
||||
v-if="isEditing"
|
||||
class="textarea"
|
||||
:style="{'height': isEditing ? 'auto' : '2.25em', 'resize': isEditing ? 'vertical' : 'none', 'overflow': 'hidden', 'padding': '0.5em'}"
|
||||
:readonly="!isEditing"
|
||||
:rows="isEditing ? getRows() : 1"
|
||||
placeholder="Ordre note, denne bliver inkluderet i fakturaen"
|
||||
v-model="input"
|
||||
@input="inputChanged"
|
||||
@blur="lostFocus()"
|
||||
:id="uniqueId + '-textarea'"
|
||||
></textarea>
|
||||
<textarea
|
||||
class="textarea"
|
||||
:style="{'height': '2.25em', 'resize': 'none', 'overflow': 'hidden', 'padding': '0.5em'}"
|
||||
:readonly="true"
|
||||
:rows="1"
|
||||
v-if="!isEditing"
|
||||
v-model="shortNote"
|
||||
placeholder="Ordre note, denne bliver inkluderet i fakturaen"
|
||||
></textarea>
|
||||
<div class="pos-order-field pos-order-note-editor">
|
||||
<div class="control" :class="{ 'is-loading': autosave.isSaving.value }">
|
||||
<div v-if="isEditing" class="pos-order-field__control pos-order-field__control--editing">
|
||||
<textarea
|
||||
:id="`${uniqueId}-textarea`"
|
||||
v-model="autosave.draft.value"
|
||||
data-testid="pos-order-note-textarea"
|
||||
class="textarea pos-order-field__input"
|
||||
:rows="rowCount"
|
||||
:placeholder="t('pos.order.order_note')"
|
||||
@blur="lostFocus()"
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="pos-order-field__control pos-order-field__control--interactive"
|
||||
:class="{ 'pos-order-field__control--empty': !autosave.draft.value }"
|
||||
type="button"
|
||||
@click="onFieldClicked()"
|
||||
>
|
||||
<span v-if="previewValue" class="pos-order-field__preview">{{ previewValue }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="pos-order-field__empty-state pos-order-note-editor__empty"
|
||||
data-testid="pos-order-note-empty-state"
|
||||
>
|
||||
<span class="pos-order-field__empty-pill">+ {{ t('pos.add_note') }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<template #customer>
|
||||
<PosSelectedCustomer />
|
||||
</template>
|
||||
<template #basket>
|
||||
<PosOrderItemsCurrent :orderItems="order_items" :orderId="order_id" :loadOrderItems="loadOrderItems" :editPossible="true" />
|
||||
</template>
|
||||
</ElementTabsBox>
|
||||
</WhiteBox>
|
||||
<!--<PosSelectedCustomer class="mt-3"/> -->
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<div class="buttons">
|
||||
<NextStep
|
||||
class="is-fullwidth"
|
||||
label="Næste"
|
||||
/>
|
||||
<Cancel class="is-fullwidth" />
|
||||
<div data-testid="pos-step-2">
|
||||
<PosDesktopOrderWorkspace
|
||||
v-if="order_id"
|
||||
mode="catalog"
|
||||
:orderId="order_id"
|
||||
:orderItems="order_items"
|
||||
:loadOrderItems="loadOrderItems"
|
||||
:editPossible="true"
|
||||
:showCatalogPane="true"
|
||||
:showCustomerRail="false"
|
||||
>
|
||||
<template #orderFooter>
|
||||
<div class="pos-step-2__actions" data-testid="pos-step-2-actions">
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<ButtonsBox class="pos-actions pos-actions--desktop">
|
||||
<NextStep class="is-fullwidth" />
|
||||
<Cancel class="is-fullwidth" />
|
||||
</ButtonsBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #catalog>
|
||||
<SelectProductsFormPOS class="mt-3" :isCustomerBooking="false" />
|
||||
<PosNotes :notes="notes" class="mt-3" :isAddFormVisible="false" :isOldNotesVisible="true" />
|
||||
</template>
|
||||
</PosDesktopOrderWorkspace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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 -->
|
||||
<ButtonsBox>
|
||||
<PreviousStep :is-text="true" :label="t('pos.add_more_items')" />
|
||||
</ButtonsBox>
|
||||
</template>
|
||||
</PosOrderItemsCurrent>
|
||||
</div>
|
||||
</template>
|
||||
</WhiteBox>
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<div data-testid="pos-step-3">
|
||||
<PosDesktopOrderWorkspace
|
||||
v-if="order_id"
|
||||
mode="summary"
|
||||
:orderId="order_id"
|
||||
:orderItems="order_items"
|
||||
:loadOrderItems="loadOrderItems"
|
||||
:editPossible="true"
|
||||
:showCatalogPane="false"
|
||||
:showCustomerRail="true"
|
||||
>
|
||||
<template #bottom-order-items>
|
||||
<ButtonsBox>
|
||||
<Cancel
|
||||
style="width: 25%;"
|
||||
tabindex="2"
|
||||
class="is-fullwidth"
|
||||
:label="t('common.cancel')"
|
||||
/>
|
||||
<template v-if="customer_id !== 999">
|
||||
<NextStep
|
||||
style="width: 25%;"
|
||||
class="is-fullwidth"
|
||||
:label="t('pos.complete')"
|
||||
/>
|
||||
</template>
|
||||
<!-- Stripe customer -->
|
||||
<template v-else>
|
||||
<PayWithStripeButton
|
||||
style="width: 25%;"
|
||||
class="is-fullwidth"
|
||||
:label="t('pos.pay_with_stripe')"
|
||||
:departmentId="department_id"
|
||||
:order_id="order_id"
|
||||
v-bind:order_items="order_items"
|
||||
/>
|
||||
</template>
|
||||
<PreviousStep :is-text="true" :label="t('admin.pos.add_more_items')" />
|
||||
</ButtonsBox>
|
||||
</div>
|
||||
<!-- Last provided orders -->
|
||||
<div class="column is-4 pl-3 pr-0">
|
||||
<WhiteBox>
|
||||
<template #default v-if="isCustomerSelected()">
|
||||
<PosSelectedCustomer />
|
||||
</template>
|
||||
|
||||
<template #rail-actions>
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<ButtonsBox class="pos-actions pos-actions--rail">
|
||||
<template v-if="!isGuestStripeCustomer">
|
||||
<NextStep
|
||||
class="is-fullwidth"
|
||||
:label="t('admin.pos.complete')"
|
||||
/>
|
||||
</template>
|
||||
</WhiteBox>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<PayWithStripeButton
|
||||
class="is-fullwidth"
|
||||
: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>
|
||||
</template>
|
||||
</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 -->
|
||||
<ButtonsBox>
|
||||
<PreviousStep :is-text="true" :label="t('pos.add_more_items')" />
|
||||
</ButtonsBox>
|
||||
</template>
|
||||
</PosOrderItemsCurrent>
|
||||
</div>
|
||||
</template>
|
||||
</WhiteBox>
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<div data-testid="pos-step-4">
|
||||
<PosDesktopOrderWorkspace
|
||||
v-if="order_id"
|
||||
mode="summary"
|
||||
:orderId="order_id"
|
||||
:orderItems="order_items"
|
||||
:loadOrderItems="loadOrderItems"
|
||||
:editPossible="true"
|
||||
:showCatalogPane="false"
|
||||
:showCustomerRail="true"
|
||||
>
|
||||
<template #bottom-order-items>
|
||||
<ButtonsBox>
|
||||
<div class="buttons">
|
||||
<Cancel
|
||||
style="width: 25%;"
|
||||
tabindex="2"
|
||||
class="is-fullwidth"
|
||||
:label="t('common.cancel')"
|
||||
/>
|
||||
<NextStep
|
||||
style="width: 25%;"
|
||||
class="is-fullwidth"
|
||||
:label="t('pos.complete')"
|
||||
/>
|
||||
</div>
|
||||
<PreviousStep :is-text="true" :label="t('admin.pos.add_more_items')" />
|
||||
</ButtonsBox>
|
||||
</div>
|
||||
<!-- Last provided orders -->
|
||||
<div class="column is-4 pl-3 pr-0">
|
||||
<WhiteBox>
|
||||
<template #default v-if="isCustomerSelected()">
|
||||
<PosSelectedCustomer />
|
||||
</template>
|
||||
</WhiteBox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #rail-actions>
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<ButtonsBox class="pos-actions pos-actions--rail">
|
||||
<NextStep
|
||||
class="is-fullwidth"
|
||||
:label="t('admin.pos.complete')"
|
||||
/>
|
||||
<Cancel
|
||||
tabindex="2"
|
||||
class="is-fullwidth"
|
||||
:label="t('common.cancel')"
|
||||
/>
|
||||
</ButtonsBox>
|
||||
</template>
|
||||
</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>
|
||||
+170
-117
@@ -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 {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 {
|
||||
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 { 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,72 +101,68 @@ 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) => {
|
||||
if (debug_mode.value) {
|
||||
debug_request_results.value.push(response);
|
||||
}
|
||||
// If the response is not successful, log an error and return
|
||||
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) => {
|
||||
if (debug_mode.value) {
|
||||
debug_request_results.value.push(error);
|
||||
}
|
||||
//console.error("Error parsing image:", error);
|
||||
});
|
||||
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, stop here.
|
||||
if (!response.data.success) {
|
||||
return;
|
||||
}
|
||||
latestLPRResponse.value = response.data.data as LPRResponse;
|
||||
// Set the last successful capture time
|
||||
camera.setLastSuccess();
|
||||
// Handle parsed result.
|
||||
handleLPRResult();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (debug_mode.value) {
|
||||
debug_request_results.value.push(error);
|
||||
}
|
||||
//console.error("Error parsing image:", error);
|
||||
});
|
||||
};
|
||||
|
||||
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
|
||||
@@ -263,75 +254,138 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<!-- Manual input view -->
|
||||
<template v-if="manualInput">
|
||||
<PosDepartmentStep1MobileManualInput @close="manualInput = false"/>
|
||||
<PosDepartmentStep1MobileManualInput @close="manualInput = false" />
|
||||
</template>
|
||||
<!-- Transaction history view -->
|
||||
<template v-else-if="transactionHistoryView">
|
||||
<PosDepartmentStep1MobileTransactionHistory @close="transactionHistoryView = false"/>
|
||||
<PosDepartmentStep1MobileTransactionHistory @close="transactionHistoryView = false" />
|
||||
</template>
|
||||
<!-- 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
|
||||
v-if="!views.attachmentView.value"
|
||||
title="Scan nummerplader"
|
||||
subtitle="Hold kameraet op mod nummerpladerne."
|
||||
buttonText="Skriv registreringsnummer manuelt"
|
||||
@button-click="manualInput = true"
|
||||
v-if="!views.attachmentView.value"
|
||||
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 -->
|
||||
<div class="is-align-content-center is-flex is-justify-content-center">
|
||||
<ScannerOutline :loading="false" v-if="!views.attachmentView.value"/>
|
||||
<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">
|
||||
<PosDepartmentStepMobile1RegistrationNumbers v-if="!views.attachmentView.value"/>
|
||||
<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 -->
|
||||
<PosDepartmentStepMobile1Debug v-if="debug_mode" :results="debug_request_results"/>
|
||||
<PosDepartmentStepMobile1Debug v-if="debug_mode" :results="debug_request_results" />
|
||||
<!-- Location -->
|
||||
<PosDepartmentStepMobile1Location/>
|
||||
<PosDepartmentStepMobile1Location />
|
||||
<!-- Buttons -->
|
||||
<PosDepartmentStepMobileFixedBottomControl>
|
||||
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
|
||||
<!-- Attachments -->
|
||||
<div class="is-flex is-justify-content-center">
|
||||
<PosDepartmentStepMobileAttachments :showDefaultControls="false" v-show="views.attachmentView.value"/>
|
||||
<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 -->
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-camera"></i>
|
||||
<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 -->
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-times"></i>
|
||||
<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>
|
||||
@@ -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>
|
||||
|
||||
+1074
-395
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,
|
||||
|
||||
+221
-52
@@ -1,19 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import PosDepartmentStepMobile1RegistrationNumber1
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber1.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumber2
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber2.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumber3
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber3.vue";
|
||||
import { activeVehicleIndex, setActiveVehicleIndex, vehicles, popups, metadata, reference } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import PosDepartmentStepMobileButtonClearAll
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
|
||||
import PosDepartmentStepMobileReference
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileReference.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumber1 from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber1.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumber2 from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber2.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumber3 from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber3.vue";
|
||||
import {
|
||||
activeVehicleIndex,
|
||||
setActiveVehicleIndex,
|
||||
vehicles,
|
||||
popups,
|
||||
metadata,
|
||||
reference,
|
||||
} from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
|
||||
import LongPressListener from "@/components/viewport/elements/wrappers/LongPressListener.vue";
|
||||
import {ref, watch} from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { getNotes } from "@/components/shop/CustomerNotes.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
import { customerRequiresReferenceNumber, isBlankPosMetadataValue } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
const isActive = (number: number): boolean => {
|
||||
return activeVehicleIndex.value === number;
|
||||
};
|
||||
@@ -26,15 +28,16 @@ const setActive = (number: number): void => {
|
||||
setActiveVehicleIndex(number);
|
||||
};
|
||||
|
||||
|
||||
const onLongPress = (number: number) => {
|
||||
console.warn('Long press detected in parent for number', number);
|
||||
console.warn("Long press detected in parent for number", number);
|
||||
// Show the select vehicle popup
|
||||
popups.select('select_vehicle', { props: { vehicleNumber: number } });
|
||||
|
||||
}
|
||||
popups.select("select_vehicle", { props: { vehicleNumber: number } });
|
||||
};
|
||||
|
||||
const showNewDemo = ref(true);
|
||||
const registrationRowElement = ref<HTMLElement | null>(null);
|
||||
const referenceWidth = ref(174);
|
||||
let registrationRowResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// Get the customers notes (if any)
|
||||
const customerNotes = ref([]); // TODO: define type
|
||||
@@ -44,73 +47,174 @@ const getCustomerNotes = async () => {
|
||||
customerNotes.value = response.data.data;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching customer notes:', error);
|
||||
console.error("Error fetching customer notes:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for changes to the customer number, and fetch the notes
|
||||
watch(() => metadata.getCustomerId(), (newCustomerId) => {
|
||||
if (newCustomerId) {
|
||||
getCustomerNotes();
|
||||
} else {
|
||||
customerNotes.value = [];
|
||||
}
|
||||
}, { immediate: true });
|
||||
watch(
|
||||
() => metadata.getCustomerId(),
|
||||
(newCustomerId) => {
|
||||
if (newCustomerId) {
|
||||
getCustomerNotes();
|
||||
} else {
|
||||
customerNotes.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const updateReferenceWidth = () => {
|
||||
const rowWidth = registrationRowElement.value?.getBoundingClientRect().width ?? 0;
|
||||
referenceWidth.value = Math.max(174, Math.round(rowWidth));
|
||||
};
|
||||
const showReferenceWarning = computed(() => {
|
||||
return customerRequiresReferenceNumber() && isBlankPosMetadataValue(reference.value);
|
||||
});
|
||||
|
||||
const observeRegistrationRow = () => {
|
||||
registrationRowResizeObserver?.disconnect();
|
||||
registrationRowResizeObserver = null;
|
||||
|
||||
if (!registrationRowElement.value || typeof ResizeObserver === "undefined") {
|
||||
updateReferenceWidth();
|
||||
return;
|
||||
}
|
||||
|
||||
registrationRowResizeObserver = new ResizeObserver(() => {
|
||||
updateReferenceWidth();
|
||||
});
|
||||
registrationRowResizeObserver.observe(registrationRowElement.value);
|
||||
updateReferenceWidth();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
observeRegistrationRow();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
registrationRowResizeObserver?.disconnect();
|
||||
});
|
||||
|
||||
watch(registrationRowElement, async () => {
|
||||
await nextTick();
|
||||
observeRegistrationRow();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [vehicles.get(1)?.reg?.length ?? 0, vehicles.get(2)?.reg?.length ?? 0, vehicles.get(3)?.reg?.length ?? 0],
|
||||
async () => {
|
||||
await nextTick();
|
||||
updateReferenceWidth();
|
||||
},
|
||||
{ flush: "post" }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="max-height: 25vh; overflow-y: auto; padding-bottom: 10px;">
|
||||
<div class="registration-number-list">
|
||||
<template v-if="!showNewDemo">
|
||||
<LongPressListener @long-press="onLongPress(1)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber1 :active="isActive(1)" @click="setActive(1)"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber1 :active="isActive(1)" @click="setActive(1)" />
|
||||
</LongPressListener>
|
||||
<LongPressListener @long-press="onLongPress(2)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber2 :active="isActive(2)" @click="setActive(2)"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber2 :active="isActive(2)" @click="setActive(2)" />
|
||||
</LongPressListener>
|
||||
<LongPressListener @long-press="onLongPress(3)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber3 :active="isActive(3)" @click="setActive(3)"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber3 :active="isActive(3)" @click="setActive(3)" />
|
||||
</LongPressListener>
|
||||
<PosDepartmentStepMobileButtonClearAll/>
|
||||
<PosDepartmentStepMobileButtonClearAll />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<div class="columns is-mobile is-vcentered is-centered is-gapless">
|
||||
<div class="column is-narrow mr-1">
|
||||
<div class="registration-summary">
|
||||
<div ref="registrationRowElement" class="registration-row" data-testid="pos-mobile-step-1-registration-row">
|
||||
<div class="registration-slot">
|
||||
<LongPressListener @long-press="onLongPress(1)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber1 :active="isActive(1)" @click="setActive(1)" :showLabel="false" :isWider="!vehicles.get(1)?.reg?.length && !vehicles.get(2)?.reg?.length && !vehicles.get(3)?.reg?.length"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber1
|
||||
:active="isActive(1)"
|
||||
@click="setActive(1)"
|
||||
:showLabel="false"
|
||||
:isWider="
|
||||
!vehicles.get(1)?.reg?.length && !vehicles.get(2)?.reg?.length && !vehicles.get(3)?.reg?.length
|
||||
"
|
||||
/>
|
||||
</LongPressListener>
|
||||
</div>
|
||||
<div class="column is-narrow ml-1" v-if="vehicles.get(1)?.reg?.length || vehicles.get(2)?.reg?.length">
|
||||
<div class="registration-slot" v-if="vehicles.get(1)?.reg?.length || vehicles.get(2)?.reg?.length">
|
||||
<LongPressListener @long-press="onLongPress(2)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber2 :active="isActive(2)" @click="setActive(2)" :showLabel="false" :class="{'opacity-invisible': !vehicles.get(1)?.reg?.length}"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber2
|
||||
:active="isActive(2)"
|
||||
@click="setActive(2)"
|
||||
:showLabel="false"
|
||||
:class="{ 'opacity-invisible': !vehicles.get(1)?.reg?.length }"
|
||||
/>
|
||||
</LongPressListener>
|
||||
</div>
|
||||
<div class="column is-narrow ml-1" v-if="vehicles.get(3)?.reg?.length">
|
||||
<div class="registration-slot" v-if="vehicles.get(3)?.reg?.length">
|
||||
<LongPressListener @long-press="onLongPress(3)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber3 :active="isActive(3)" @click="setActive(3)" :showLabel="false" :class="{'opacity-invisible': !vehicles.get(2)?.reg?.length}"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber3
|
||||
:active="isActive(3)"
|
||||
@click="setActive(3)"
|
||||
:showLabel="false"
|
||||
:class="{ 'opacity-invisible': !vehicles.get(2)?.reg?.length }"
|
||||
/>
|
||||
</LongPressListener>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reference -->
|
||||
<div
|
||||
class="mt-2 mb-2 has-text-centered custom-button-secondary reference-trigger"
|
||||
:class="{
|
||||
'opacity-invisible': !vehicles.get(1)?.reg?.length,
|
||||
'reference-trigger--danger': showReferenceWarning,
|
||||
}"
|
||||
:style="{ width: `${referenceWidth}px` }"
|
||||
data-testid="pos-mobile-step-1-reference-trigger"
|
||||
:data-warning-state="showReferenceWarning ? 'danger' : undefined"
|
||||
@click="popups.select('change_reference')"
|
||||
>
|
||||
<span class="reference-trigger__content">
|
||||
<span
|
||||
v-if="showReferenceWarning"
|
||||
class="reference-trigger__icon"
|
||||
data-testid="pos-mobile-step-1-reference-warning-icon"
|
||||
>
|
||||
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span
|
||||
v-if="reference.length > 0"
|
||||
class="reference-trigger__text has-overflow-ellipsis has-text-weight-bold mx-3"
|
||||
:class="{ 'has-text-white': !showReferenceWarning }"
|
||||
>{{ reference }}</span
|
||||
>
|
||||
<span v-else class="reference-trigger__text is-italic">Indtast reference...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reference -->
|
||||
<div class="mt-2 mb-2 has-text-centered custom-button-secondary" style="min-width: 174px;" :class="{'opacity-invisible': !vehicles.get(1)?.reg?.length}" @click="popups.select('change_reference')">
|
||||
<p v-if="reference.length > 0"
|
||||
class="has-overflow-ellipsis has-text-weight-bold has-text-white mx-3"
|
||||
style="max-height: 20px; max-width: 90vw;"
|
||||
>{{ reference }}</p>
|
||||
<p v-else>
|
||||
<span class="is-italic">Indtast reference...</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Reference -->
|
||||
<div class="mt-2 mb-2 has-text-centered custom-button-secondary px-2" style="min-width: 174px;" :class="{'opacity-invisible': customerNotes.length === 0, 'has-background-warning': customerNotes.length > 0}" @click="popups.select('customer_notes')" v-if="customerNotes.length > 0">
|
||||
<div
|
||||
class="mt-2 mb-2 has-text-centered custom-button-secondary px-2 notes-trigger"
|
||||
:class="{ 'opacity-invisible': customerNotes.length === 0, 'has-background-warning': customerNotes.length > 0 }"
|
||||
:style="{ width: `${referenceWidth}px` }"
|
||||
@click="popups.select('customer_notes')"
|
||||
v-if="customerNotes.length > 0"
|
||||
data-testid="pos-mobile-customer-notes-trigger"
|
||||
>
|
||||
<p>
|
||||
<!-- If there are notes, show a warning icon -->
|
||||
<span v-if="customerNotes.length > 0" class="icon is-small has-text-dark pr-1">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</span>
|
||||
<span class="has-overflow-ellipsis" :class="{'has-text-dark': customerNotes.length > 0, 'has-text-weight-bold': customerNotes.length > 0, 'is-italic': customerNotes.length === 0}">{{ SessionUser.objects.global.language.customer_notes}}</span>
|
||||
<span
|
||||
class="has-overflow-ellipsis"
|
||||
:class="{
|
||||
'has-text-dark': customerNotes.length > 0,
|
||||
'has-text-weight-bold': customerNotes.length > 0,
|
||||
'is-italic': customerNotes.length === 0,
|
||||
}"
|
||||
>{{ SessionUser.objects.global.language.customer_notes }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -118,8 +222,33 @@ watch(() => metadata.getCustomerId(), (newCustomerId) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.registration-number-list {
|
||||
max-height: 25vh;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.registration-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.registration-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.registration-slot {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.is-wider {
|
||||
width: 200px!important;
|
||||
width: 200px !important;
|
||||
}
|
||||
.opacity-invisible {
|
||||
opacity: 0.3;
|
||||
@@ -153,4 +282,44 @@ watch(() => metadata.getCustomerId(), (newCustomerId) => {
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.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>
|
||||
+38
-14
@@ -169,7 +169,7 @@ const onClickAddProduct = async (product: PosProduct) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div data-testid="pos-mobile-additional-items">
|
||||
<!-- Minimal view, when not set as fullscreen view -->
|
||||
<WhiteBoxCard :toggleable="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length == 0"
|
||||
:defaultOpen="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0"
|
||||
@@ -178,13 +178,15 @@ const onClickAddProduct = async (product: PosProduct) => {
|
||||
v-if="!pos.views.additionalItemSelection.value">
|
||||
<!-- Header -->
|
||||
<template #header>
|
||||
<div class="card-header-title">{{ displayLabel }}</div>
|
||||
<div class="card-header-icon">
|
||||
<!-- Right arrow, if there's no items, down arrow if there are items -->
|
||||
<span class="icon">
|
||||
<i v-if="!pos.transactionItems.additionalItems.value || pos.transactionItems.additionalItems.value.length === 0" class="fas fa-angle-right"></i>
|
||||
<i v-else class="fas fa-angle-down"></i>
|
||||
</span>
|
||||
<div class="pos-mobile-additional-items-header" data-testid="pos-mobile-additional-items-open">
|
||||
<div class="card-header-title pos-mobile-additional-items-header__title" data-testid="pos-mobile-additional-items-header-title">{{ displayLabel }}</div>
|
||||
<div class="card-header-icon pos-mobile-additional-items-header__icon" data-testid="pos-mobile-additional-items-header-icon">
|
||||
<!-- Right arrow, if there's no items, down arrow if there are items -->
|
||||
<span class="icon">
|
||||
<i v-if="!pos.transactionItems.additionalItems.value || pos.transactionItems.additionalItems.value.length === 0" class="fas fa-angle-right"></i>
|
||||
<i v-else class="fas fa-angle-down"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Content -->
|
||||
@@ -201,7 +203,7 @@ const onClickAddProduct = async (product: PosProduct) => {
|
||||
<!-- Footer -->
|
||||
<template #footer>
|
||||
<!-- Select other product button -->
|
||||
<a class="card-footer-item" @click="onClickAddOtherProduct">
|
||||
<a class="card-footer-item" data-testid="pos-mobile-additional-items-open" @click="onClickAddOtherProduct">
|
||||
<span class="icon">
|
||||
<i class="fas fa-plus"></i>
|
||||
</span>
|
||||
@@ -211,23 +213,45 @@ const onClickAddProduct = async (product: PosProduct) => {
|
||||
</WhiteBoxCard>
|
||||
<!-- Fullscreen view, when selecting other products -->
|
||||
<template v-else>
|
||||
<div data-testid="pos-mobile-additional-items-selection">
|
||||
<!-- Categories of products -->
|
||||
<PosDepartmentStep2MobileVehicleSelection :onAddProduct="onClickAddProduct" :onSearchClick="() => console.warn('AdditionalItem Search Clicked')"/><!-- :asAddons="true" :addons="availableAdditionalItems" @update:addons="availableAdditionalItems = $event"/>-->
|
||||
<!-- Buttons -->
|
||||
<PosDepartmentStepMobileFixedBottomControl>
|
||||
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
|
||||
<!-- Floating Card -->
|
||||
<PosDepartmentStepMobile2FloatingCart/>
|
||||
<!-- Next button -->
|
||||
<PosDepartmentStepMobileButtonNextStep :isDark="true" :customAction="onClickAddOtherProduct">
|
||||
<span class="is-float-left has-text-white">{{ SessionUser.objects.global.language.next }}</span>
|
||||
<span class="is-float-right has-text-white">
|
||||
<!-- Arrow right icon -->
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
<span class="pos-mobile-cta-content">
|
||||
<span class="pos-mobile-cta-label has-text-white">{{ SessionUser.objects.global.language.next }}</span>
|
||||
<span class="pos-mobile-cta-value has-text-white">
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</span>
|
||||
</span>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
</PosDepartmentStepMobileFixedBottomControl>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.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>
|
||||
|
||||
+424
-129
@@ -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;
|
||||
}>();
|
||||
|
||||
// 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;
|
||||
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 displaySubtitle = computed(() => {
|
||||
return props.lastOrder
|
||||
? generateSummary(props.lastOrder)
|
||||
: props.subtitle;
|
||||
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;
|
||||
}
|
||||
|
||||
return primaryItemId ? Number(item?.related_item_id ?? 0) === primaryItemId : false;
|
||||
});
|
||||
});
|
||||
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>
|
||||
<p class="custom-title">{{ displayLabel }}</p>
|
||||
</div>
|
||||
<div class="last-order-card" data-testid="pos-mobile-last-order">
|
||||
<div class="last-order-card__header">
|
||||
<div>
|
||||
<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>
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<!-- Custom subtitle -->
|
||||
<div class="column is-12 has-text-left">
|
||||
<p class="custom-subtitle">{{ displaySubtitle }}</p>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
+510
-113
@@ -1,148 +1,545 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from "vue";
|
||||
import {getPicture} from "@/components/displays/department/pos/displays/Piktogrammer.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>
|
||||
<!-- Product id -->
|
||||
<div class="tag mb-2" v-show="false">
|
||||
<span>No. {{ props?.product?.piktogram || props?.product?.id }}</span>
|
||||
</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 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 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>
|
||||
<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 -->
|
||||
<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>
|
||||
|
||||
|
||||
+167
-85
@@ -1,82 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, defineProps, defineEmits} from "vue";
|
||||
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";
|
||||
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
|
||||
// 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');
|
||||
attachments.addBase64({ filename: `camera_${Date.now()}.jpg`, base64String: image });
|
||||
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(() => {
|
||||
console.warn("Order deleted successfully");
|
||||
}).catch((error) => {
|
||||
console.error("Error deleting order:", error);
|
||||
}).finally(() => {
|
||||
resetAll();
|
||||
})
|
||||
SessionUser.objects.orders.delete
|
||||
.single(order_id.value)
|
||||
.then(() => {
|
||||
console.warn("Order deleted successfully");
|
||||
})
|
||||
.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,29 +417,40 @@ 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">
|
||||
<i class="fas fa-file-alt" :class="{'has-text-success': attachments.hasWashCertificate()}"></i>
|
||||
<i class="fas fa-file-alt" :class="{ 'has-text-success': attachments.hasWashCertificate() }"></i>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Attachment Thumbnails -->
|
||||
<PosDepartmentStepMobileAttachmentsDisplay v-if="isAttachmentsVisible()"/>
|
||||
<PosDepartmentStepMobileAttachmentsDisplay v-if="isAttachmentsVisible()" />
|
||||
</div>
|
||||
|
||||
<!--
|
||||
|
||||
+45
-26
@@ -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,41 +23,46 @@ 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(() => {
|
||||
// 2. Reset the POS
|
||||
resetPos();
|
||||
// 3. Reset all values
|
||||
reset_all_values();
|
||||
}).catch((error) => {
|
||||
console.error("Error deleting order:", error);
|
||||
resetPos();
|
||||
reset_all_values();
|
||||
});
|
||||
SessionUser.objects.orders.delete
|
||||
.single(order_id.value)
|
||||
.then(() => {
|
||||
// 2. Reset the POS
|
||||
resetPos();
|
||||
// 3. Reset all values
|
||||
reset_all_values();
|
||||
})
|
||||
.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()">
|
||||
<div class="columns is-mobile is-vcentered is-centered">
|
||||
<div class="column is-narrow pr-1">
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</span>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</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>
|
||||
|
||||
+355
-202
@@ -1,18 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {defineProps, onMounted, computed, ref} from "vue";
|
||||
import { reset_all_values, customer_name, nextStep, searchAndSelectCustomer, isCustomerSelected, order_id, customer_id, step, reg_1, reg_2, reg_3, reference, order_notes, loadCustomerAttributes, hasAttribute, uploadAttachment } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { 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,
|
||||
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";
|
||||
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');
|
||||
// Remove the attachment from the list
|
||||
attachments.removeBase64(attachment);
|
||||
}).catch((error: any) => {
|
||||
errors.value.push(error);
|
||||
console.warn('An error occurred while uploading the attachment:', error);
|
||||
});
|
||||
uploadAttachment(attachment)
|
||||
.then(() => {
|
||||
console.warn("Attachment uploaded successfully");
|
||||
// Remove the attachment from the list
|
||||
attachments.removeBase64(attachment);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
errors.value.push(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) => {
|
||||
if (transaction) {
|
||||
transactionHistory.add(transaction);
|
||||
console.warn('Transaction added to history:', transaction);
|
||||
} else {
|
||||
console.warn('Unable to add transaction to history: Order not found.');
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
errors.value.push(error);
|
||||
console.warn('An error occurred while fetching the transaction for history:', error);
|
||||
});
|
||||
SessionUser.objects.orders.get
|
||||
.single(orderId)
|
||||
.then((transaction: any) => {
|
||||
if (transaction) {
|
||||
transactionHistory.add(transaction);
|
||||
console.warn("Transaction added to history:", transaction);
|
||||
} else {
|
||||
console.warn("Unable to add transaction to history: Order not found.");
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
errors.value.push(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.`,
|
||||
});
|
||||
// If the customer is paying with a card, go to the payment step
|
||||
if (isCustomerSelected() && getCustomerId() === 999) {
|
||||
nextStep({isMobile: true});
|
||||
return;
|
||||
}
|
||||
if (metadata.getBookingId() && metadata.getBookingId() > 0) {
|
||||
// If there is a booking ID, complete the booking
|
||||
SessionUser.objects.orders.set.booking_id(order_id.value, metadata.getBookingId());
|
||||
// Check if the booking has been completed
|
||||
SessionUser.objects.order_bookings.get.single(metadata.getBookingId()).then((booking: any) => {
|
||||
const isBookingCompleted = booking?.order_id && booking.order_id > 0;
|
||||
if (!isBookingCompleted) {
|
||||
// Set the order bookings id to the metadata
|
||||
if (metadata.getBookingId() && metadata.getBookingId() > 0) {
|
||||
SessionUser.objects.order_bookings.set.order_id(metadata.getBookingId(), order_id.value).then(() => {
|
||||
// Check if order requires safety seal popup (product id 41 present as item or addon)
|
||||
try {
|
||||
const posStr = localStorage.getItem('pos');
|
||||
let hasSeal = false;
|
||||
if (posStr) {
|
||||
try {
|
||||
const posData = JSON.parse(posStr);
|
||||
const primaryItem = posData?.transactionItems?.primaryItem;
|
||||
const additionalItems = posData?.transactionItems?.additionalItems || [];
|
||||
const allItems = [primaryItem, ...additionalItems].filter(Boolean);
|
||||
hasSeal = allItems.some((item: any) =>
|
||||
item?.product?.id === 41 ||
|
||||
(item?.addons && item.addons.some((addon: any) => addon?.product?.id === 41))
|
||||
);
|
||||
} catch (parseErr) {
|
||||
console.error('Failed to parse pos data:', parseErr);
|
||||
}
|
||||
}
|
||||
if (!hasSeal) {
|
||||
metadata.setLoadingState(true);
|
||||
metadata.setLoadingMessage(t('admin.pos.waiting_for_booking'));
|
||||
SessionUser.objects.order_bookings.functions.complete(metadata.getBookingId(), null, (response: any) => {
|
||||
console.log('Auto-completed booking without safety seal prompt (mobile POS - localStorage)');
|
||||
completeOrder();
|
||||
});
|
||||
} else {
|
||||
popups.select('complete_booking', {});
|
||||
awaitBookingCompletion(() => {
|
||||
completeOrder();
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking safety seal via localStorage:', err);
|
||||
popups.select('complete_booking', {});
|
||||
awaitBookingCompletion(() => {
|
||||
completeOrder();
|
||||
});
|
||||
}
|
||||
}).catch((err: any) => {
|
||||
console.error('Error setting order_id on booking:', err);
|
||||
errors.value.push(err);
|
||||
completeOrder();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// The booking has been completed, complete the order
|
||||
completeOrder();
|
||||
}
|
||||
}).catch((error: any) => {
|
||||
errors.value.push(error);
|
||||
// An error occurred, log it and complete the order anyway
|
||||
console.warn('An error occurred while checking if the booking has been completed:', error);
|
||||
completeOrder();
|
||||
});
|
||||
} else {
|
||||
completeOrder();
|
||||
}
|
||||
completeOrder();
|
||||
};
|
||||
|
||||
const awaitBookingCompletion = (callback: Function) => {
|
||||
// Lock the next step button to a loading state
|
||||
metadata.setLoadingState(true);
|
||||
metadata.setLoadingMessage(t('admin.pos.waiting_for_booking'));
|
||||
const interval = setInterval(() => {
|
||||
// If the popup is still open, we know the booking is not completed yet
|
||||
if (popups.isSet.value) {
|
||||
// The popup is still open, so the booking is not completed yet
|
||||
const 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;
|
||||
}
|
||||
|
||||
const selectedBookingId = getSelectedBookingId();
|
||||
const hasWashCertificateInBasket = Boolean(transactionItems.containsWashCertificate?.());
|
||||
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
|
||||
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
|
||||
|
||||
try {
|
||||
if (requiresBookingCompletionPopup) {
|
||||
const onCompleteBookingWithSafetySeal = async ({ safetySeal = "" } = {}) => {
|
||||
syncMobileSafetySealState(safetySeal);
|
||||
await completeStep2Order({
|
||||
bookingSafetySeal: String(safetySeal ?? ""),
|
||||
markOrderCompleted: false,
|
||||
});
|
||||
};
|
||||
|
||||
popups.select("complete_booking", {
|
||||
props: {
|
||||
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,54 +363,81 @@ 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(() => {
|
||||
// Proceed to the next step
|
||||
switch (step.value) {
|
||||
case 1:
|
||||
step1();
|
||||
// Set the order ID (If it's stored in pos_order_id in local storage)
|
||||
if (localStorage.getItem('pos_order_id')) {
|
||||
order_id.value = parseInt(localStorage.getItem('pos_order_id') || '0');
|
||||
console.warn('Order ID retrieved from local storage:', order_id.value);
|
||||
nextStep({isMobile: true, orderCreation: false});
|
||||
} else {
|
||||
nextStep({isMobile: true, orderCreation: true});
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
step1();
|
||||
step2();
|
||||
break;
|
||||
case 3:
|
||||
// This step is not defined, but you can add your logic here if needed
|
||||
step3();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (!(isCustomerSelected() && getCustomerId() > 0)) {
|
||||
// Show the popup to select a customer
|
||||
popups.select('select_customer');
|
||||
popups.select("select_customer");
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessingClick.value = true;
|
||||
try {
|
||||
await props.onBeforeStep();
|
||||
// Proceed to the next step
|
||||
switch (step.value) {
|
||||
case 1:
|
||||
step1();
|
||||
const restoredOrderId = await restoreStoredPosOrderId({
|
||||
validateOrder: true,
|
||||
customerId: getCustomerId(),
|
||||
departmentId: POSDepartmentProcess.getDepartment(),
|
||||
allowCompleted: false,
|
||||
});
|
||||
if (restoredOrderId) {
|
||||
console.warn("Order ID retrieved from local storage:", restoredOrderId);
|
||||
nextStep({
|
||||
isMobile: true,
|
||||
orderCreation: false,
|
||||
bookingId: metadata.getBookingId?.() ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
nextStep({
|
||||
isMobile: true,
|
||||
orderCreation: true,
|
||||
bookingId: metadata.getBookingId?.() ?? null,
|
||||
});
|
||||
break;
|
||||
case 2:
|
||||
step1();
|
||||
await step2();
|
||||
break;
|
||||
case 3:
|
||||
// This step is not defined, but you can add your logic here if needed
|
||||
step3();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Next-step action was interrupted:", error);
|
||||
} finally {
|
||||
isProcessingClick.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 }), {}))}"
|
||||
class="is-size-6-mobile is-size-5-tablet is-size-4-desktop"
|
||||
:disabled="!isRequirementsForClickMet()" v-show="isVisible">
|
||||
<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"
|
||||
>
|
||||
<!-- 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>
|
||||
.has-text-black >:not(.icon) {
|
||||
.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>
|
||||
+259
-182
@@ -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}];
|
||||
return;
|
||||
}
|
||||
popups.get().props.error = t('admin.pos.customer_creation_error_generic');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
popups.get().props.error = t('admin.pos.customer_creation_error_generic');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const syncPopupActionButtons = () => {
|
||||
const popup = popups.get();
|
||||
if (!popup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextButtons = [{ ...actionButtons.default.value.cancel }];
|
||||
|
||||
if (canCreateCustomer.value) {
|
||||
nextButtons.push({
|
||||
label: t("admin.pos.add_customer"),
|
||||
onClick: submitCustomer,
|
||||
color: "primary",
|
||||
testId: ADD_CUSTOMER_SUBMIT_TEST_ID,
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
popup.actionButtons = nextButtons;
|
||||
};
|
||||
|
||||
const applySearchResult = (nextSearchResult: any | null) => {
|
||||
searchResult.value = nextSearchResult;
|
||||
|
||||
if (!isValidPosAddCustomerSearchResult(nextSearchResult, t("admin.pos.not_found"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(
|
||||
form,
|
||||
applyPosAddCustomerSearchResult({
|
||||
fields: form,
|
||||
manualOverrides: manualOverrides.value,
|
||||
searchResult: nextSearchResult,
|
||||
notFoundLabel: t("admin.pos.not_found"),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const runSearch = async (query: string) => {
|
||||
const normalizedQuery = String(query ?? "").trim();
|
||||
|
||||
if (normalizedQuery === "") {
|
||||
searchResult.value = null;
|
||||
searching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRequest = nextPosAddCustomerRequestState(requestState.value);
|
||||
requestState.value = nextRequest.state;
|
||||
|
||||
try {
|
||||
const response = await SessionUser.request("/cvr/search", "GET", {
|
||||
query: normalizedQuery,
|
||||
});
|
||||
|
||||
const resolution = resolvePosAddCustomerRequestState(requestState.value, nextRequest.requestId);
|
||||
if (!resolution.shouldApply) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestState.value = resolution.state;
|
||||
applySearchResult(response?.data?.data ?? null);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
const resolution = resolvePosAddCustomerRequestState(requestState.value, nextRequest.requestId);
|
||||
if (!resolution.shouldApply) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestState.value = resolution.state;
|
||||
searchResult.value = null;
|
||||
} finally {
|
||||
if (nextRequest.requestId === requestState.value.latestRequestedId) {
|
||||
searching.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const queueSearch = (query: string) => {
|
||||
clearQueuedSearch();
|
||||
|
||||
const normalizedQuery = String(query ?? "").trim();
|
||||
popups.get().props.error = null;
|
||||
|
||||
if (normalizedQuery === "") {
|
||||
searchResult.value = null;
|
||||
searching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
searching.value = true;
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
searchDebounceTimer = null;
|
||||
runSearch(normalizedQuery);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const handleAutofillFieldInput = (field: keyof typeof manualOverrides.value) => {
|
||||
manualOverrides.value = setPosAddCustomerManualOverride(manualOverrides.value, field, form[field]);
|
||||
popups.get().props.error = null;
|
||||
};
|
||||
|
||||
const submitCustomer = async () => {
|
||||
const popup = popups.get();
|
||||
if (!popup) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup.actionButtons = [];
|
||||
|
||||
try {
|
||||
await SessionUser.request("/auth/register/cvr", "POST", {
|
||||
cvr: form.cvr,
|
||||
companyPhone: Number.parseInt(form.companyPhone, 10),
|
||||
invoiceEmail: form.invoiceEmail,
|
||||
contactEmail: form.contactEmail,
|
||||
contactPhone: Number.parseInt(form.contactPhone, 10),
|
||||
searchResult: searchResult.value,
|
||||
});
|
||||
|
||||
await searchAndSelectCustomer(form.companyPhone);
|
||||
metadata.setCustomerId(Number.parseInt(form.companyPhone, 10));
|
||||
emit("close");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const errorResponse = SessionUser.functions.parseErrorMessage(error);
|
||||
|
||||
if (errorResponse !== null) {
|
||||
popups.get().props.error = t("admin.pos.customer_creation_error", { error: errorResponse });
|
||||
} else {
|
||||
popups.get().props.error = t("admin.pos.customer_creation_error_generic");
|
||||
}
|
||||
} finally {
|
||||
syncPopupActionButtons();
|
||||
}
|
||||
};
|
||||
|
||||
watch(searchQuery, queueSearch);
|
||||
|
||||
watch(
|
||||
() => [
|
||||
form.cvr,
|
||||
form.companyPhone,
|
||||
form.invoiceEmail,
|
||||
form.contactEmail,
|
||||
form.contactPhone,
|
||||
canCreateCustomer.value,
|
||||
searchResult.value,
|
||||
],
|
||||
syncPopupActionButtons,
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearQueuedSearch();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="overflow-y: auto; overflow-x: hidden; height: 100%; padding-bottom: 10px;">
|
||||
<div
|
||||
style="overflow-y: auto; overflow-x: hidden; height: 100%; padding-bottom: 10px;"
|
||||
data-testid="pos-mobile-add-customer-popup"
|
||||
>
|
||||
<div class="mx-3">
|
||||
<div class="field">
|
||||
<label class="label"><small>CVR</small></label>
|
||||
<div class="control has-icons-right" :class="{'is-loading': searching}">
|
||||
<div class="control has-icons-right" :class="{ 'is-loading': searching }">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@input="searching = true; search(searchQuery);"
|
||||
class="input is-searched"
|
||||
:class="{'is-danger': searchResult === null, 'is-success': searchResult !== null && searchResult.name !== $t('admin.pos.not_found')}"
|
||||
type="text"
|
||||
:placeholder="$t('global.search_cvr')"
|
||||
v-model="searchQuery"
|
||||
class="input is-searched"
|
||||
:class="{
|
||||
'is-danger': searchResult === null || !isValidSearchResult,
|
||||
'is-success': isValidSearchResult,
|
||||
}"
|
||||
type="text"
|
||||
:placeholder="$t('global.search_cvr')"
|
||||
data-testid="pos-mobile-add-customer-search-input"
|
||||
/>
|
||||
<span class="icon is-right" :class="{'has-text-danger': (searchResult === null || searchResult.name === $t('admin.pos.not_found')) && !searching, 'has-text-success': (searchResult !== null && searchResult.name !== $t('admin.pos.not_found')) && !searching}">
|
||||
<i class="fas fa-check" v-if="(searchResult !== null && searchResult.name !== $t('admin.pos.not_found')) && !searching"></i>
|
||||
<i class="fas fa-exclamation-triangle" v-else-if="(searchResult === null || searchResult.name === $t('admin.pos.not_found')) && !searching"></i>
|
||||
<span
|
||||
class="icon is-right"
|
||||
:class="{
|
||||
'has-text-danger': !isValidSearchResult && !searching,
|
||||
'has-text-success': isValidSearchResult && !searching,
|
||||
}"
|
||||
>
|
||||
<i class="fas fa-check" v-if="isValidSearchResult && !searching"></i>
|
||||
<i class="fas fa-exclamation-triangle" v-else-if="!isValidSearchResult && !searching"></i>
|
||||
<i class="fas fa-search" v-else-if="!searching"></i>
|
||||
</span>
|
||||
</div>
|
||||
<p class="help" v-if="searchResult !== null && searchResult.name !== $t('admin.pos.not_found')">{{ searchResult.name }}</p>
|
||||
<p class="help" v-if="isValidSearchResult">{{ searchResult.name }}</p>
|
||||
</div>
|
||||
<!-- Company phone -->
|
||||
<div class="field">
|
||||
<label class="label"><small>{{ $t('admin.pos.company_phone') }}</small></label>
|
||||
<label class="label"><small>{{ $t("admin.pos.company_phone") }}</small></label>
|
||||
<div class="control">
|
||||
<input v-model="companyPhone" class="input" type="text" :placeholder="$t('admin.pos.company_phone')" :disabled="searchResult === null" :class="{'is-danger': searchResult !== null && companyPhone === ''}"/>
|
||||
<input
|
||||
v-model="form.companyPhone"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('admin.pos.company_phone')"
|
||||
:disabled="searchResult === null"
|
||||
:class="{ 'is-danger': searchResult !== null && form.companyPhone === '' }"
|
||||
data-testid="pos-mobile-add-customer-company-phone"
|
||||
@input="handleAutofillFieldInput('companyPhone')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Invoice email -->
|
||||
<div class="field">
|
||||
<label class="label"><small>{{ $t('admin.pos.invoice_email') }}</small></label>
|
||||
<label class="label"><small>{{ $t("admin.pos.invoice_email") }}</small></label>
|
||||
<div class="control">
|
||||
<input v-model="invoiceEmail" class="input" type="text" :placeholder="$t('admin.pos.invoice_email')" :disabled="searchResult === null"/>
|
||||
<input
|
||||
v-model="form.invoiceEmail"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('admin.pos.invoice_email')"
|
||||
:disabled="searchResult === null"
|
||||
data-testid="pos-mobile-add-customer-invoice-email"
|
||||
@input="handleAutofillFieldInput('invoiceEmail')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Contact email -->
|
||||
<div class="field">
|
||||
<label class="label"><small>{{ $t('admin.pos.contact_email') }}</small></label>
|
||||
<label class="label"><small>{{ $t("admin.pos.contact_email") }}</small></label>
|
||||
<div class="control">
|
||||
<input v-model="contactEmail" class="input" type="text" :placeholder="$t('admin.pos.contact_email')" :disabled="searchResult === null"/>
|
||||
<input
|
||||
v-model="form.contactEmail"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('admin.pos.contact_email')"
|
||||
:disabled="searchResult === null"
|
||||
data-testid="pos-mobile-add-customer-contact-email"
|
||||
@input="handleAutofillFieldInput('contactEmail')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Contact phone -->
|
||||
<div class="field">
|
||||
<label class="label"><small>{{ $t('admin.pos.contact_phone') }}</small></label>
|
||||
<label class="label"><small>{{ $t("admin.pos.contact_phone") }}</small></label>
|
||||
<div class="control">
|
||||
<input v-model="contactPhone" class="input" type="text" :placeholder="$t('admin.pos.contact_phone')" :disabled="searchResult === null" :class="{'is-danger': searchResult !== null && contactPhone === ''}"/>
|
||||
<input
|
||||
v-model="form.contactPhone"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('admin.pos.contact_phone')"
|
||||
:disabled="searchResult === null"
|
||||
:class="{ 'is-danger': searchResult !== null && form.contactPhone === '' }"
|
||||
data-testid="pos-mobile-add-customer-contact-phone"
|
||||
@input="handleAutofillFieldInput('contactPhone')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Error message -->
|
||||
<div class="notification is-danger" v-if="popups.get().props.error !== null">
|
||||
{{ popups.get().props.error}}
|
||||
{{ popups.get().props.error }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Search results -->
|
||||
<!--{{ searchResult }}-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+87
-9
@@ -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 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 onSuccessfulCertificateSubmission = (response: any) => {
|
||||
console.log("Certificate generated successfully:", response);
|
||||
return getResolvedMobileSafetySeal();
|
||||
};
|
||||
const safetySealNumber = ref(resolveExistingSafetySeal());
|
||||
|
||||
const closePopup = () => {
|
||||
emit('close');
|
||||
}
|
||||
};
|
||||
|
||||
const safetySealNumber = ref("");
|
||||
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>
|
||||
|
||||
+155
-18
@@ -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)">
|
||||
<template
|
||||
v-for="(actionButton, index) in popups.get().actionButtons"
|
||||
:key="index"
|
||||
>
|
||||
<a class="card-footer-item"
|
||||
:class="'is-' + (actionButton?.color || 'primary')"
|
||||
@click="actionButton.onClick ? actionButton.onClick() : null">
|
||||
{{ actionButton.label }}
|
||||
</a>
|
||||
</template>
|
||||
<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>
|
||||
+511
-117
@@ -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/>
|
||||
<span class="icon is-right">
|
||||
<i class="fas fa-search"></i>
|
||||
<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>
|
||||
<template v-if="formattedSearchResults.length > 0">
|
||||
<!-- Search results (If any) -->
|
||||
<ControlFieldInputSearchResults v-bind:results="formattedSearchResults" @result-clicked="onClick"/>
|
||||
</template>
|
||||
<!-- Customer suggestions (If any) -->
|
||||
<VehicleCustomerSuggestionsPos v-if="vehicles?.vehicle_1?.value?.reg" :reg_1="vehicles.vehicle_1.value.reg" @customerSelected="onClickSuggestion"/>
|
||||
|
||||
<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">
|
||||
<div class="customer-search-results">
|
||||
<ControlFieldInputSearchResults
|
||||
v-bind:results="formattedSearchResults"
|
||||
testIdPrefix="pos-mobile-customer-search-result"
|
||||
@result-clicked="onClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</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