Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 4fdbd22879 Stabilize remaining full e2e slices 2026-06-15 01:13:46 +02:00
Jeppe Bundgaard 0392672a2d Stabilize Firefox desktop and tablet e2e slices 2026-06-14 23:54:28 +02:00
Jeppe Bundgaard 3d229a2de2 Stabilize WebKit desktop POS visuals 2026-06-14 22:33:05 +02:00
Jeppe Bundgaard 7e090ab405 Stabilize admin mobile e2e slices 2026-06-14 21:29:22 +02:00
Jeppe Bundgaard b8c3758714 Separate PR and manual test concurrency 2026-06-14 20:37:05 +02:00
Jeppe Bundgaard 298fc0e04b Target Docker-capable e2e runners 2026-06-14 20:26:59 +02:00
Jeppe Bundgaard 1c1bffaee4 Install Node before e2e port probe 2026-06-14 19:49:02 +02:00
Jeppe Bundgaard 14506e545a Stabilize frontend e2e CI runners 2026-06-14 19:40:24 +02:00
Jeppe Bundgaard 89dff75416 Fix frontend e2e browser env and POS camera preview 2026-06-14 18:18:14 +02:00
Jeppe B 8a790582f4 Merge pull request #132 from copenhagentruckwash/codex/optimize-scanner-lpr-frontend
[codex] optimize scanner LPR frontend
2026-06-12 22:08:42 +02:00
Jeppe Bundgaard e6aff8f512 optimize scanner lpr frontend 2026-06-12 21:42:36 +02:00
Jeppe B 1c277d1944 Merge pull request #131 from copenhagentruckwash/fix/pwa-selfserve-speed
[codex] Improve PWA request queueing
2026-06-12 13:28:53 +02:00
Jeppe Bundgaard 26cc166282 Stop mobile camera scan queue growth 2026-06-12 13:09:34 +02:00
Jeppe Bundgaard 30e878979c Reduce POS request queue blocking 2026-06-12 12:27:05 +02:00
Jeppe Bundgaard 0f335e0984 Fix self-serve hardware request queueing 2026-06-12 11:48:51 +02:00
Jeppe B 4083c05b72 Merge pull request #130 from copenhagentruckwash/fix-issues-on-webkit-browsers
Fix WebKit async UI timing
2026-06-12 11:48:46 +02:00
Jeppe B 7b4d24a212 Fix WebKit async UI timing 2026-06-12 11:32:44 +02:00
Jeppe Bundgaard 02c31c8bb0 Fix i18n catalog compatibility 2026-06-11 21:45:48 +02:00
Jeppe Bundgaard 6c0278c6e9 Enhance local data reset dialog functionality and tests 2026-06-11 21:26:12 +02:00
Jeppe Bundgaard 41e0320ce1 Add local data reset dialog and enhance footer navigation with long press functionality 2026-06-11 21:05:36 +02:00
Jeppe Bundgaard d97b02bace Add indexedDB management and local data reset confirmation dialog 2026-06-11 21:02:26 +02:00
Jeppe Bundgaard 11bbe953f3 Add version display in viewport for mobile users 2026-06-11 19:25:58 +02:00
Jeppe Bundgaard ecdd895a5a Refactor notification display logic in SelfServeTasksStep component 2026-06-11 18:49:17 +02:00
Jeppe B 07bb815754 Merge pull request #129 from copenhagentruckwash/test/self-serve-logic-condition-coverage
Add self-serve condition evaluation coverage
2026-06-11 18:48:31 +02:00
Jeppe Bundgaard 2a64fcdf19 Stabilize self-serve mobile e2e checks 2026-06-11 18:21:35 +02:00
Jeppe Bundgaard f8b7bda74a Guard self-serve active wash restore after unmount 2026-06-11 18:04:42 +02:00
Jeppe Bundgaard 5a0fc5f36b Add self-serve condition evaluation coverage 2026-06-11 17:57:10 +02:00
Jeppe B 9e378b32ac Merge pull request #128 from copenhagentruckwash/fix/mobile-gps-department-selection
Fix mobile GPS department auto-selection
2026-06-11 17:53:38 +02:00
Jeppe Bundgaard 968587d847 Fix mobile GPS department auto-selection 2026-06-11 17:32:28 +02:00
Jeppe B fa809ccd0b Merge pull request #126 from copenhagentruckwash/fix/self-serve-path-editor-case-limit
Fix self-serve path editor case limit
2026-06-11 16:57:35 +02:00
Jeppe Bundgaard a8800a3be1 Avoid duplicate PR branch push workflows 2026-06-11 16:37:51 +02:00
Jeppe Bundgaard 1f23b58d1e Isolate frontend E2E workflow ports 2026-06-11 16:36:36 +02:00
Jeppe B c6b266b0de Merge pull request #127 from copenhagentruckwash/codex/slack-test-webhook
Add Slack test webhook button
2026-06-11 16:31:09 +02:00
Jeppe Bundgaard 3eba32c235 Avoid setup-node cache restore timeouts 2026-06-11 16:06:02 +02:00
Jeppe B ec76ae426e Merge pull request #125 from copenhagentruckwash/fix/mobile-pos-product-27-note
Fix mobile POS product 27 completion
2026-06-11 15:39:35 +02:00
Jeppe Bundgaard e35d59c652 Use frontend runners for CI 2026-06-11 15:24:12 +02:00
Jeppe Bundgaard 73fc89557b Add Slack test webhook button 2026-06-11 15:06:43 +02:00
Jeppe Bundgaard be98715045 Fix self-serve path editor case limit 2026-06-11 14:46:23 +02:00
Jeppe Bundgaard 4392b7915f Fix mobile POS product 27 completion 2026-06-11 14:34:53 +02:00
Jeppe B b1fb92549c Merge pull request #124 from copenhagentruckwash/fix/full-e2e-tests-20260611
Fix full e2e regressions
2026-06-11 14:22:32 +02:00
Jeppe Bundgaard 9bba666f3a Harden mobile e2e route readiness waits 2026-06-11 13:51:39 +02:00
Jeppe Bundgaard dcf0fd61ba Avoid sudo in Playwright browser fallback 2026-06-11 13:24:22 +02:00
Jeppe Bundgaard 3d02146911 Stabilize full e2e CI matrix 2026-06-11 13:13:06 +02:00
Jeppe Bundgaard 10bc822233 Fix full e2e regressions 2026-06-11 10:13:58 +02:00
Jeppe B baac1a243a Merge pull request #123 from copenhagentruckwash/fix/self-serve-machine-tasks-after-start
Keep machine tasks visible after start summary refresh
2026-06-10 22:11:00 +02:00
Jeppe Bundgaard 7c233d7c03 Keep machine tasks visible after start summary refresh 2026-06-10 20:53:02 +02:00
Jeppe B 7c19dbddf0 Merge pull request #122 from copenhagentruckwash/fix/self-serve-start-wash-type
Honor wash type in self-serve start command
2026-06-10 20:15:58 +02:00
Jeppe Bundgaard f4b248573a Honor wash type in self-serve start command 2026-06-10 19:18:24 +02:00
Jeppe B 14e454e9d9 Merge pull request #121 from copenhagentruckwash/fix-github-runner-test-failures-8jso54
Robust Playwright browser install, e2e/unit test reliability fixes, and session bootstrap improvements
2026-06-10 16:08:11 +02:00
Jeppe B ebab7d8804 Merge pull request #120 from copenhagentruckwash/fix-github-runner-test-failures
Stabilize flaky Playwright e2e tests: ensure session bootstrap, add test hooks and increase timeouts
2026-06-10 16:07:53 +02:00
Jeppe B 78a3fb1879 test: stabilize reference autocomplete unit cleanup 2026-06-10 16:06:36 +02:00
Jeppe B d42b58c4fe test: harden flaky chromium smoke specs 2026-06-10 15:27:14 +02:00
Jeppe B 724ad8e7a1 ci: stabilize Playwright runner installs and smoke tests 2026-06-10 02:57:02 +02:00
Jeppe B 2fe50729a5 ci: fall back for unsupported Playwright deps install 2026-06-10 00:52:14 +02:00
Jeppe B eb4eee1aef test: stabilize github runner e2e checks 2026-06-09 23:02:28 +02:00
Jeppe Bundgaard db972e2923 test: refresh invoicing flag counts from scoped flags 2026-06-09 16:03:23 +02:00
Jeppe Bundgaard 00817925e4 Add path case answer mapping and matching logic for improved path verification 2026-06-09 15:56:59 +02:00
Jeppe Bundgaard 347ab3c811 ci: allow e2e jobs on linux runner pool 2026-06-09 15:53:58 +02:00
Jeppe Bundgaard a3d05b15db test: stabilize edge gateway route readiness 2026-06-09 15:46:48 +02:00
Jeppe Bundgaard 9093d0d601 test: isolate request queue shortcut listeners 2026-06-09 15:20:15 +02:00
Jeppe Bundgaard 8c57b98627 Fix i18n CI runtime menu checks 2026-06-09 15:14:22 +02:00
Jeppe Bundgaard 4b99751c0e test: reset POS department route in unit setup 2026-06-09 15:13:06 +02:00
Jeppe Bundgaard 66f4331b4e Refactor machine allowance logic in self-serve studio and remove unused controls 2026-06-09 14:52:20 +02:00
Jeppe Bundgaard e1a824d565 Fix path editor answer row layout 2026-06-09 14:18:15 +02:00
Jeppe Bundgaard 883a88a551 ci: split playwright pr e2e matrix 2026-06-09 14:07:25 +02:00
Jeppe Bundgaard a328bc3767 Filter path editor to machine start cases 2026-06-09 13:57:33 +02:00
Jeppe Bundgaard 255447f788 Hide placeholder departments in admin navigation 2026-06-09 13:34:08 +02:00
Jeppe Bundgaard b3d57ca717 test: cover collected invoice move checks 2026-06-09 13:24:23 +02:00
Jeppe Bundgaard 45d21c4372 fix: restore self-serve lane step hook 2026-06-09 13:19:22 +02:00
Jeppe Bundgaard 7f7b5ae082 Add Swedish invoice collection move translation 2026-06-09 13:17:55 +02:00
Jeppe Bundgaard 028d8f1fc8 Add move to customer functionality for invoice collections in multiple languages 2026-06-09 13:16:26 +02:00
Jeppe Bundgaard 8d01ca1363 Implement move collected invoice to customer feature with API integration and tests 2026-06-09 13:16:05 +02:00
Jeppe Bundgaard a36403d279 Refactor self-serve fetch logic to use refs for state management; implement abort controller for active requests 2026-06-09 13:07:21 +02:00
Jeppe Bundgaard a78ad69c0c Add selfserve dynamic image size selector 2026-06-09 12:46:49 +02:00
Jeppe Bundgaard 22c856afb6 test: stabilize superuser firefox e2e 2026-06-09 12:34:11 +02:00
Jeppe Bundgaard 71a073ebf9 Add hidden frontend maintenance refresh 2026-06-09 12:32:58 +02:00
Jeppe Bundgaard f709cac95e Test wash type service sync 2026-06-09 12:13:35 +02:00
Jeppe Bundgaard 27867e48df test: update my wash start unit stubs 2026-06-09 11:50:21 +02:00
Jeppe Bundgaard f1a7e1dfcd test: stabilize POS draft full slice 2026-06-09 11:29:13 +02:00
Jeppe Bundgaard 31e9566f2d Refactor API route handling in tests to use centralized path patterns; enhance error handling in self-serve components 2026-06-09 10:52:20 +02:00
Jeppe Bundgaard f7f749e230 Fix PWA install scope 2026-06-08 20:56:32 +02:00
Jeppe Bundgaard f5b7da7b0a test: skip WebKit mobile invoice monitor header 2026-06-08 20:42:30 +02:00
Jeppe Bundgaard 2311188b13 test: align self-serve stop failure e2e 2026-06-08 20:25:01 +02:00
Jeppe Bundgaard 25637b52d9 test: update self-serve completion contract 2026-06-08 20:20:46 +02:00
Jeppe Bundgaard fddcc86cd6 fix: preserve self-serve stop failure banner 2026-06-08 20:17:44 +02:00
Jeppe Bundgaard c1b2be7d70 Enhance task ID handling in updateLaneAllowedServices; improve error handling in wash process 2026-06-08 19:56:02 +02:00
Jeppe Bundgaard 0a7b3a36aa test: stabilize release and department e2e mocks 2026-06-08 19:51:17 +02:00
Jeppe Bundgaard 57f102de71 test: skip desktop-only superuser specs on compact projects 2026-06-08 19:47:54 +02:00
Jeppe Bundgaard 6b22887a90 Remove guidance text for unavailable machine wash in self-serve flow 2026-06-08 18:58:33 +02:00
Jeppe Bundgaard 3b3f212ceb ci: use polling for Playwright Vite server 2026-06-08 18:54:25 +02:00
Jeppe Bundgaard 1a11e31836 ci: raise Playwright file descriptor limit 2026-06-08 18:46:56 +02:00
Jeppe Bundgaard d4b69c3990 test: align release runtime inspector privileges 2026-06-08 18:38:49 +02:00
Jeppe Bundgaard 4cbccd533a fix: stabilize POS route and action menu tests 2026-06-08 18:09:51 +02:00
Jeppe Bundgaard a9bc3ab0fc Fix POS booking match recursion 2026-06-08 17:29:06 +02:00
Jeppe Bundgaard d9f929df04 fix: restore Slack i18n compatibility 2026-06-08 17:25:56 +02:00
Jeppe Bundgaard bc154a4e6d Keep release wait after auto sync gateway timeout 2026-06-08 17:12:34 +02:00
Jeppe Bundgaard 7d48e40e05 Enhance release workflow with current commit check and enable concurrency cancellation 2026-06-08 17:02:42 +02:00
Jeppe Bundgaard 47af253bf8 Enhance self-serve logic to ignore stale answer sync responses and implement mutation request tracking 2026-06-08 16:53:03 +02:00
Jeppe Bundgaard e126dfccf9 Enhance accessibility and linting configuration
- Add tabindex attributes to input fields for better keyboard navigation
- Introduce ESLint configuration for improved code quality and consistency
- Update package.json to include linting scripts and dependencies
- Add tests for desktop tab navigation in POS flow
2026-06-08 16:35:56 +02:00
Jeppe Bundgaard be5191825d fix: distinguish Slack config permission errors 2026-06-08 16:33:52 +02:00
Jeppe Bundgaard 68cf9c4c06 Continue release wait after auto sync timeout 2026-06-08 16:25:24 +02:00
Jeppe Bundgaard e46b7ae58d Request release auto sync before artifact wait 2026-06-08 16:18:45 +02:00
Jeppe Bundgaard 3d81fb8dd7 Fix release verification for external build ids 2026-06-08 15:59:39 +02:00
Jeppe Bundgaard ff983dca60 Trigger frontend release 2026-06-08 15:48:21 +02:00
Jeppe Bundgaard fb0fceb6c4 Add booking count permission checks and handle API response failures gracefully 2026-06-08 15:26:57 +02:00
Jeppe Bundgaard 9591afb9a2 Add booking count permission checks and handle API response failures gracefully 2026-06-08 13:41:57 +02:00
Jeppe Bundgaard 0afde8ac8e Add support for webmanifest files in .htaccess and nginx configuration 2026-06-08 13:11:21 +02:00
Jeppe Bundgaard 4a83486b30 Update Danish translations for various UI elements and API keys 2026-06-08 12:51:10 +02:00
Jeppe Bundgaard a3ceac1636 Add Slack configuration module with customer registration webhook settings 2026-06-08 12:43:02 +02:00
Jeppe Bundgaard 8666d17a42 Add superuser new customer email notification settings 2026-06-08 12:19:32 +02:00
gold-dev 96d13b2400 Feat: Produktivitet overview 2026-06-08 11:32:30 +03:00
gold-dev 12fc1f80cc Feat: Rename ”Charts” til ”Grafer” 2026-06-08 10:50:37 +03:00
Jeppe B 2dfe4e9b37 Merge pull request #119 from copenhagentruckwash/fix-github-runner-test-failures
Fix self-serve wash progress actions, guided-wash emits and guest fallback UI; install Playwright deps for CI
2026-06-08 09:26:26 +02:00
Jeppe B 46c634e5a7 Fix self-serve wash test regressions 2026-06-08 09:13:24 +02:00
Jeppe Bundgaard 880680d7f8 Add reusable ErrorBanner component and integrate into MyWashStart 2026-06-07 22:07:56 +02:00
Jeppe Bundgaard 7d5ed772c2 Avoid empty MyWash lane service sync 2026-06-04 07:40:33 +02:00
Jeppe Bundgaard 83fbcdeaa8 Refresh MyWash eligibility before service sync 2026-06-04 07:31:41 +02:00
Jeppe Bundgaard 55a761d3a5 Publish MyWash tasks before hydration 2026-06-04 07:27:12 +02:00
Jeppe Bundgaard abe8e7750f Preserve MyWash machine services during sync 2026-06-04 07:21:07 +02:00
Jeppe Bundgaard aeef75b831 Clear stale MyWash summary after preview 2026-06-04 07:13:00 +02:00
Jeppe Bundgaard d6356d0244 Stop MyWash eligibility refresh after completion 2026-06-04 06:35:26 +02:00
Jeppe Bundgaard bbf2aeb95f Keep MyWash completion visible after stop 2026-06-04 06:05:41 +02:00
Jeppe Bundgaard 3a7d78ae05 Serve release-prefixed index shell 2026-06-04 04:08:33 +02:00
Jeppe Bundgaard 2a2f752692 Refresh recent completion marker during startup 2026-06-04 03:51:43 +02:00
Jeppe Bundgaard 8883bf8a07 Seed recently completed wash state before startup awaits 2026-06-04 03:44:29 +02:00
Jeppe Bundgaard 6ec80e2796 Recover recently completed self-serve screen 2026-06-04 03:29:55 +02:00
Jeppe Bundgaard 849a7b77cf Adopt resolved self-serve vehicle type 2026-06-04 03:16:59 +02:00
Jeppe Bundgaard 36ccd0c381 Keep completed wash fallback visible 2026-06-04 03:01:09 +02:00
Jeppe Bundgaard 58e7c8d6c9 Keep completed wash after lane refresh 2026-06-04 02:44:48 +02:00
Jeppe Bundgaard d04fc8cc0f Keep completed self-serve wash visible 2026-06-04 02:29:16 +02:00
Jeppe Bundgaard 339a1b1452 Recover completed wash after stop timeout 2026-06-04 02:15:15 +02:00
Jeppe Bundgaard 2d2d63d09e Merge remote-tracking branch 'origin/master' 2026-06-04 02:02:26 +02:00
Jeppe B 8241f7fd2f Treat unoccupied stop invoice errors as completed washes 2026-06-04 02:01:35 +02:00
Jeppe Bundgaard 985571af87 Treat unoccupied stop invoice errors as completed washes 2026-06-04 01:55:35 +02:00
Jeppe Bundgaard 82e1e4a466 Ignore preview-only active wash reservations 2026-06-04 01:21:25 +02:00
Jeppe Bundgaard 9a7f7bbb78 Prevent preview-only self-serve sessions from auto-restoring 2026-06-04 01:08:04 +02:00
Jeppe Bundgaard 359f29b75e Keep self-serve preview questions visible during summary load 2026-06-04 00:45:20 +02:00
Jeppe B ceced7cbaa Show MyWash completion when Buefy hides step panel 2026-06-04 00:21:33 +02:00
Jeppe Bundgaard 6b5cd00b10 Show completed wash outside step panels 2026-06-04 00:10:36 +02:00
Jeppe Bundgaard 02eae47d34 Refactor toggle-task event emission in SelfServeTaskList component 2026-06-04 00:00:28 +02:00
Jeppe Bundgaard 3f01d38676 Enhance self-serve image loading with preload and error handling 2026-06-03 23:53:54 +02:00
Jeppe Bundgaard 390a29ef55 Merge remote-tracking branch 'origin/master' 2026-06-03 23:34:05 +02:00
Jeppe Bundgaard b7f5786747 Add vehicle type selection logic for departments without product allow-list 2026-06-03 23:33:54 +02:00
Jeppe B 814c535757 Use relative release manifest asset paths 2026-06-03 23:13:26 +02:00
Jeppe Bundgaard ed0e27e4fd Use relative release manifest asset paths 2026-06-03 23:07:17 +02:00
Jeppe Bundgaard 659df480d7 Merge remote-tracking branch 'origin/master' 2026-06-03 23:04:43 +02:00
Jeppe B d0c45f6180 Fix release frontend origin routing 2026-06-03 22:45:32 +02:00
Jeppe Bundgaard 5a20461bd4 Fix release frontend origin routing 2026-06-03 22:40:49 +02:00
Jeppe Bundgaard 849f52fb8e Refactor error message display and update test descriptions for clarity 2026-06-03 22:16:27 +02:00
Jeppe Bundgaard da0c699b88 Add self-serve session handling and lane selection normalization 2026-06-03 22:07:47 +02:00
Jeppe Bundgaard af5514c982 Implement dynamic base path handling for frontend assets and update routing logic 2026-06-03 22:00:18 +02:00
Jeppe Bundgaard cc689e80a9 Use release base for frontend HTML assets 2026-06-03 21:45:25 +02:00
Jeppe Bundgaard 3b51374678 Add dynamic release frontend base path handling and update static asset references 2026-06-03 21:44:27 +02:00
Jeppe Bundgaard f07569db98 Allow active self-serve lane selection 2026-06-03 21:32:58 +02:00
Jeppe Bundgaard c5fcb90683 Include emitted chunks in release manifest 2026-06-03 21:12:08 +02:00
Jeppe Bundgaard 270f87dee1 Split release smoke static and app targets 2026-06-03 21:03:03 +02:00
Jeppe Bundgaard 765b8f3b35 Fix self-serve entry regressions 2026-06-03 20:51:06 +02:00
Jeppe Bundgaard 3ff695899c Restore self-serve task phase from active sessions 2026-06-03 20:32:40 +02:00
Jeppe Bundgaard be35baa838 Refactor self-serve registration field for improved layout and accessibility 2026-06-03 20:06:31 +02:00
Jeppe B 470d4d7314 Normalize release bootstrap cleanup 2026-06-03 19:38:54 +02:00
Jeppe B 0976608a25 Refresh release bootstrap asset hash 2026-06-03 19:34:48 +02:00
Jeppe B e7f4dfa07e Trigger frontend release after asset mismatch 2026-06-03 19:23:01 +02:00
Jeppe B 2c68f335c6 Trigger frontend release for self-serve vehicle fix 2026-06-03 18:08:25 +02:00
Jeppe Bundgaard cf29589e29 Fix self-serve vehicle step progression 2026-06-03 17:51:46 +02:00
Jeppe Bundgaard 05cabd8edc Add margin and padding adjustments to self-serve task checkbox 2026-06-02 20:09:18 +02:00
Jeppe Bundgaard 2f4ecba51a Merge remote-tracking branch 'origin/master' 2026-06-02 20:04:11 +02:00
Jeppe Bundgaard 74a7c9f23f Refactor self-serve task list to improve task title handling and remove unnecessary description checks 2026-06-02 20:03:55 +02:00
Jeppe B b075a717f7 Cover hidden customer number self-serve flow 2026-06-02 19:59:30 +02:00
Jeppe B a3dcb888d3 Cover self-serve task title presentation 2026-06-02 19:59:12 +02:00
Jeppe B 02706af508 Allow QR self-serve vehicle step without customer field 2026-06-02 19:58:50 +02:00
Jeppe B 1f2c007776 Tighten self-serve task presentation 2026-06-02 19:58:28 +02:00
Jeppe Bundgaard 42dfdfef13 Add mocks for department daily report components and update Vitest configuration for SCSS 2026-06-02 17:54:37 +02:00
Jeppe B b40bf0b9e1 Merge pull request #110 from copenhagentruckwash/create-playwright-test-suite-for-mywashstart
Add mocked Playwright tests for MyWashStart flow and improve answer-sync & department fallback
2026-06-02 13:52:00 +02:00
Jeppe B cc15a9c468 Merge branch 'master' into create-playwright-test-suite-for-mywashstart 2026-06-02 13:51:52 +02:00
Jeppe B 5d8b1eecb4 Merge pull request #111 from copenhagentruckwash/create-playwright-test-suite-for-mywashstart-6chtnh
Add mocked Playwright tests for MyWashStart flow and improve answer-sync & department fallback
2026-06-02 13:50:32 +02:00
Jeppe B 093d82d762 Merge branch 'master' into create-playwright-test-suite-for-mywashstart-6chtnh 2026-06-02 13:50:24 +02:00
Jeppe B 686577c209 Merge pull request #114 from copenhagentruckwash/update-mywashstart.vue-with-timeout-handling
Fix delayed active wash restore cleanup
2026-06-02 13:48:47 +02:00
Jeppe B c2b333a78b Merge branch 'master' into update-mywashstart.vue-with-timeout-handling 2026-06-02 13:48:33 +02:00
Jeppe B cef8b8bf0f Merge pull request #115 from copenhagentruckwash/update-userdashboard-to-inspect-lane-readiness
Guard wash entry self-serve CTA
2026-06-02 13:47:39 +02:00
Jeppe B 62a46710ba Merge branch 'master' into update-userdashboard-to-inspect-lane-readiness 2026-06-02 13:47:31 +02:00
Jeppe B a3e6feb07e Merge pull request #113 from copenhagentruckwash/refactor-self-serve-data-loading-with-fetch-scheduler
Debounce self-serve wash data fetches
2026-06-02 13:43:40 +02:00
Jeppe B 2ad093d4f1 Merge pull request #112 from copenhagentruckwash/update-mywashstart.vue-for-answer-sync-state
Block wash question flow on failed answer sync
2026-06-02 13:43:22 +02:00
Jeppe B 6017d576fc Merge pull request #116 from copenhagentruckwash/update-wash-department-handling-and-ui
Handle self-serve wash when geolocation is unavailable
2026-06-02 13:42:51 +02:00
Jeppe B 08ac38dc87 Merge pull request #117 from copenhagentruckwash/add-and-extend-unit-and-e2e-tests
Add production-ready tests for user self-serve wash start (unit + mocked e2e)
2026-06-02 13:42:36 +02:00
Jeppe B 3960daa556 Add production wash start test matrix 2026-06-02 13:42:22 +02:00
Jeppe B 395cbd87dd Handle self-serve wash without geolocation 2026-06-02 13:42:16 +02:00
Jeppe B 23429c047f Guard wash entry self-serve CTA 2026-06-02 13:42:09 +02:00
Jeppe B af68748c16 Fix active wash restore timeout cleanup 2026-06-02 13:42:02 +02:00
Jeppe B 18ab007f87 Debounce self-serve wash data fetches 2026-06-02 13:41:55 +02:00
Jeppe B 63942045be Block wash question flow on failed answer sync 2026-06-02 13:41:51 +02:00
Jeppe B a4cdc6e4f3 Add mocked MyWashStart flow coverage 2026-06-02 13:41:44 +02:00
Jeppe B 07c611bdbe Add mocked MyWashStart flow coverage 2026-06-02 13:41:32 +02:00
Jeppe Bundgaard 5170139a32 Switch CI to self-hosted runners
Updated all GitHub Actions workflows to use self-hosted runners instead of `ubuntu-latest`. This change ensures better control over the CI environment and aligns with internal infrastructure requirements.
2026-06-02 10:36:18 +02:00
Jeppe Bundgaard 90952a68b7 Refactor SubusersTable to use ActionSettingsWheelButton for row actions 2026-06-02 09:23:49 +02:00
Jeppe Bundgaard 1cc14d7b41 Merge remote-tracking branch 'origin/master' 2026-06-02 08:36:42 +02:00
Jeppe B aa4d6576f7 Merge pull request #108 from copenhagentruckwash/fix-page-crash-issues
Fix wash start recovery from API failures
2026-06-02 08:35:28 +02:00
Jeppe B 2a46984f9c Merge pull request #109 from copenhagentruckwash/fix-playwright-exit-code-error
Stabilize release sidebar E2E coverage
2026-06-02 08:34:47 +02:00
Jeppe Bundgaard 4a9c721c55 Remove debug message for beta frontend in build output 2026-06-02 08:12:50 +02:00
Jeppe B 7be9995c87 Stabilize release sidebar e2e coverage 2026-06-02 08:10:51 +02:00
Jeppe B 85529de3f8 Fix wash start recovery from API failures 2026-06-02 08:07:03 +02:00
Jeppe B 114ca9d956 Merge pull request #107 from copenhagentruckwash/investigate-and-fix-failing-tests
Resolve failing frontend CI: fix merge conflicts, restore edge cache & tests
2026-06-02 07:15:41 +02:00
Jeppe B 29f858685a Fix self-serve wash smoke restore fixture 2026-06-02 07:15:26 +02:00
Jeppe B 4112faf682 Fix edge gateway broker config e2e 2026-06-02 02:38:37 +02:00
Jeppe B a13b73c414 Harden edge gateway service parsing 2026-06-02 02:06:32 +02:00
Jeppe B 667e93af8f Resolve failing frontend checks 2026-06-02 01:13:28 +02:00
Jeppe B 54a888026e Merge pull request #104 from copenhagentruckwash/propose-fix-for-price-tampering-vulnerability
Fix trusted booking prices in POS hydration
2026-06-02 00:48:56 +02:00
Jeppe B a0fb02baa1 Merge pull request #106 from copenhagentruckwash/fix-booking-delete-access-permission
Fix booking delete action visibility
2026-06-02 00:47:38 +02:00
Jeppe B c445b932db Fix booking delete action visibility 2026-06-02 00:47:19 +02:00
copilot-swe-agent[bot] ffccb1683b Merge origin/master and resolve POS pricing conflict 2026-06-01 22:44:56 +00:00
Jeppe B 0d590fc9d6 Merge pull request #105 from copenhagentruckwash/fix-postinstall-script-vulnerability
Remove unsafe postinstall hook
2026-06-02 00:40:46 +02:00
Jeppe B 1d4910f947 Remove unsafe postinstall hook 2026-06-02 00:40:34 +02:00
Jeppe B 7e457ed961 Fix trusted booking prices in POS hydration 2026-06-02 00:39:55 +02:00
Jeppe B 34722c6985 Merge pull request #103 from copenhagentruckwash/fix-vulnerability-in-pos-order-pricing
Fix POS booking price hydration
2026-06-02 00:39:37 +02:00
Jeppe B c5a13b50ab Fix POS booking price hydration 2026-06-02 00:39:15 +02:00
Jeppe B 387bd50281 Merge pull request #102 from copenhagentruckwash/fix-unsandboxed-blob-attachment-previews
Sanitize and sandbox order attachment previews to prevent XSS
2026-06-02 00:38:15 +02:00
Jeppe B 81016fd898 Fix unsafe order attachment previews 2026-06-02 00:38:03 +02:00
Jeppe B 39fc1612ac Merge pull request #101 from copenhagentruckwash/fix-duplicate-stripe-invoice-submission-issue
Prevent duplicate Stripe invoice queue submissions
2026-06-02 00:30:18 +02:00
Jeppe B 7ab12a3fff Prevent duplicate Stripe invoice queue submissions 2026-06-02 00:29:53 +02:00
Jeppe B b4d869c469 Merge pull request #100 from copenhagentruckwash/fix-autosave-to-prevent-infinite-retry-loop
Fix autosave failed-save retry loop
2026-06-02 00:28:52 +02:00
Jeppe B 799b667828 Fix autosave failed-save retry loop 2026-06-02 00:28:39 +02:00
Jeppe B ae3513edf3 Merge pull request #99 from copenhagentruckwash/fix-repeated-booking-prefetch-flooding-api
Avoid duplicate booking prefetch by honoring exact-plate cache
2026-06-02 00:27:37 +02:00
Jeppe B 6db1861b78 Fix repeated booking prefetch requests 2026-06-02 00:27:25 +02:00
Jeppe B a8e839ba18 Merge pull request #98 from copenhagentruckwash/fix-mobile-pos-orders-completion-issue
Fix mobile POS order completion
2026-06-02 00:27:07 +02:00
Jeppe B b224cf67d1 Fix mobile POS order completion 2026-06-02 00:26:53 +02:00
Jeppe B 222237ab34 Merge pull request #96 from copenhagentruckwash/fix-edge-gateway-localstorage-vulnerability
Harden edge gateway workspace cache
2026-06-02 00:16:07 +02:00
copilot-swe-agent[bot] 433cfe521e Planning merge conflict resolution 2026-06-01 22:14:58 +00:00
Jeppe B 1e4a776fa5 Merge pull request #97 from copenhagentruckwash/fix-auto-selection-of-customer-suggestions
Fix stale POS customer suggestion auto-selection
2026-06-02 00:13:59 +02:00
Jeppe B e88a424a7d Fix stale POS customer suggestion auto-selection 2026-06-02 00:13:46 +02:00
Jeppe B 9450c7902d Harden edge gateway workspace cache 2026-06-02 00:08:20 +02:00
Jeppe B 75b5883afe Merge pull request #95 from copenhagentruckwash/fix-stripe-email-invoice-payment-misclassification
Fix Stripe email invoice paid validation
2026-06-02 00:07:49 +02:00
Jeppe B c26f0184d9 Fix Stripe email invoice paid validation 2026-06-02 00:07:35 +02:00
Jeppe B cb74b4bbe7 Merge pull request #93 from copenhagentruckwash/propose-fix-for-cached-gateway-details-leak
Scope edge gateway cache to session and prevent auth-blind cached fallbacks
2026-06-01 23:48:26 +02:00
Jeppe B 326eca656f Fix edge gateway cache authorization fallback 2026-06-01 23:48:17 +02:00
Jeppe B f0a5058b68 Merge pull request #92 from copenhagentruckwash/propose-fix-for-self-hosted-ci-vulnerability
Use GitHub-hosted runners for tests workflow
2026-06-01 23:46:15 +02:00
copilot-swe-agent[bot] d39cd36995 Merge remote-tracking branch 'origin/master' into propose-fix-for-self-hosted-ci-vulnerability
# Conflicts:
#	.github/workflows/tests.yml
2026-06-01 21:43:04 +00:00
Jeppe B 0548018b5c Use GitHub-hosted runners for tests workflow 2026-06-01 23:40:30 +02:00
Jeppe B 699e10856e Merge pull request #91 from copenhagentruckwash/propose-fix-for-self-hosted-runner-vulnerability
Fix CI runner exposure for pull requests
2026-06-01 23:40:09 +02:00
Jeppe B 36b399cb2f Fix CI runner exposure for pull requests 2026-06-01 23:40:00 +02:00
Jeppe B f613d58727 Merge pull request #90 from copenhagentruckwash/propose-fix-for-product-catalog-vulnerability
Restrict vehicle type product edits to superusers
2026-06-01 23:35:14 +02:00
Jeppe B feccfada77 Restrict vehicle type product edits to superusers 2026-06-01 23:35:04 +02:00
Jeppe B 4127e66114 Merge pull request #89 from copenhagentruckwash/fix-unvalidated-attachment-download-links
Validate self-serve attachment download links
2026-06-01 23:34:45 +02:00
Jeppe B b2a9b59399 Validate self-serve attachment download links 2026-06-01 23:34:34 +02:00
Jeppe B 6ffed9c2f2 Merge pull request #88 from copenhagentruckwash/fix-lane-toggle-guard-permission-issue
Fix lane toggle permission guards
2026-06-01 23:34:14 +02:00
Jeppe B 53e193d8d1 Fix lane toggle permission guards 2026-06-01 23:34:03 +02:00
Jeppe B 7835691bc9 Merge pull request #87 from copenhagentruckwash/fix-websocket-token-exposure-vulnerability
Secure edge gateway WebSocket session tokens
2026-06-01 23:33:44 +02:00
Jeppe B 91715541ec Secure edge gateway websocket sessions 2026-06-01 23:33:34 +02:00
Jeppe B e3f9e99129 Merge pull request #86 from copenhagentruckwash/fix-stored-xss-in-relay-options
Fix select option HTML escaping
2026-06-01 23:33:20 +02:00
Jeppe B 4634cbad89 Fix select option HTML escaping 2026-06-01 23:33:10 +02:00
Jeppe B 5db2a36da0 Merge pull request #85 from copenhagentruckwash/propose-fix-for-relay-failure-issue
Rollback wash start when relay activation fails
2026-06-01 22:53:44 +02:00
Jeppe B cd7b273da4 Rollback wash start when relay activation fails 2026-06-01 22:53:34 +02:00
Jeppe B cbbad1a6e2 Merge pull request #84 from copenhagentruckwash/fix-pos-url-order-reassignment-vulnerability
Prevent implicit POS order department changes
2026-06-01 22:44:24 +02:00
Jeppe B 9661d4efa7 Prevent implicit POS order department changes 2026-06-01 22:44:14 +02:00
Jeppe B 31762c23c5 Merge pull request #83 from copenhagentruckwash/fix-unescaped-order-delete-reasons
Fix protected order delete reason escaping
2026-06-01 22:43:55 +02:00
Jeppe B c71a6b4d14 Fix protected order delete reason escaping 2026-06-01 22:43:45 +02:00
Jeppe B e8ddb33f21 Merge pull request #82 from copenhagentruckwash/fix-vulnerability-in-edge-broker-config
Secure edge gateway broker configuration
2026-06-01 22:28:28 +02:00
Jeppe B ea368d19bb Secure edge gateway broker configuration 2026-06-01 22:28:18 +02:00
Jeppe B 7ab50b6290 Merge pull request #81 from copenhagentruckwash/fix-unredacted-broker-diagnostics-exposure
Redact edge gateway diagnostic secrets
2026-06-01 22:27:33 +02:00
Jeppe B fd7cfbd3c3 Redact edge gateway diagnostics 2026-06-01 22:27:19 +02:00
Jeppe B 90725af10a Merge pull request #80 from copenhagentruckwash/fix-ungated-destructive-actions-in-ui
Gate self-serve studio gateway actions and require confirmation for destructive operations
2026-06-01 22:26:58 +02:00
Jeppe B d63a62a921 Gate self-serve gateway actions 2026-06-01 22:26:44 +02:00
Jeppe B 4bc5481941 Merge pull request #79 from copenhagentruckwash/propose-fix-for-deferred-start-relay-issue
Require successful machine relay activation before marking wash in progress
2026-06-01 22:15:16 +02:00
Jeppe B 9feede3b0d Require machine relay activation before wash progress 2026-06-01 22:15:02 +02:00
Jeppe B 5aad9eda8d Merge pull request #78 from copenhagentruckwash/fix-raw-edge-log-context-exposure
Redact edge gateway log context secrets
2026-06-01 22:13:42 +02:00
Jeppe B 91b15faba6 Redact edge gateway log context secrets 2026-06-01 22:13:32 +02:00
Jeppe B 98c1c61cbd Merge pull request #77 from copenhagentruckwash/fix-active-wash-endpoint-information-leak
Scope active-wash restore to authenticated customer (my-active-wash)
2026-06-01 22:06:25 +02:00
Jeppe B 4463a5326a Fix scoped active wash restoration 2026-06-01 22:06:14 +02:00
Jeppe B bdb23513a4 Merge pull request #76 from copenhagentruckwash/propose-fix-for-security-vulnerability
Harden self-serve vehicle condition mutations
2026-06-01 22:05:37 +02:00
Jeppe B ee40f16232 Harden self-serve vehicle condition mutations 2026-06-01 22:05:27 +02:00
Jeppe B a76944b00c Merge pull request #75 from copenhagentruckwash/fix-sessionstorage-vulnerability-in-invoicing-period
Secure privileged session caches
2026-06-01 21:52:53 +02:00
Jeppe B cf074ff893 Secure privileged session caches 2026-06-01 21:52:42 +02:00
Jeppe B 712eb19db5 Merge pull request #74 from copenhagentruckwash/propose-fix-for-release-manager-token-leak
Restrict release manager control API origins
2026-06-01 21:52:20 +02:00
Jeppe B de9bc9b170 Restrict release manager control API origins 2026-06-01 21:52:09 +02:00
Jeppe B 95e2404307 Merge pull request #73 from copenhagentruckwash/fix-unredacted-deployment-failures-exposure
Redact release deployment failure diagnostics
2026-06-01 21:27:42 +02:00
Jeppe B 2c01feb3be Redact release deployment failure diagnostics 2026-06-01 21:27:28 +02:00
Jeppe B f416e0d462 Merge pull request #72 from copenhagentruckwash/fix-telemetry-that-leaks-passwords
Redact serialized request bodies and omit failed-request bodies when capture is disabled
2026-06-01 21:26:45 +02:00
Jeppe B 7f1308c8ce Redact release timeline failed request bodies 2026-06-01 21:26:32 +02:00
Jeppe B fd8d45e437 Merge pull request #71 from copenhagentruckwash/fix-vulnerabilities-in-release-runtime-urls
Harden release runtime URL trust boundaries
2026-06-01 21:25:51 +02:00
copilot-swe-agent[bot] 20390342d5 Merge origin/master and resolve release timeline spec conflict 2026-06-01 19:21:12 +00:00
Jeppe B c47bf229f0 Harden release runtime URL trust boundaries 2026-06-01 21:14:39 +02:00
Jeppe B 3616e197e1 Merge pull request #70 from copenhagentruckwash/propose-fix-for-isolated-stack-vulnerability
Prevent active isolated stack reuse during release deploys
2026-06-01 21:12:26 +02:00
Jeppe B 3d8544de21 Prevent isolated stack deployment reuse 2026-06-01 21:11:17 +02:00
Jeppe B 504abb7a15 Merge pull request #69 from copenhagentruckwash/fix-release-subject-autocomplete-exposure
Guard release assignment subject search
2026-06-01 21:11:02 +02:00
Jeppe B 37021533b9 Guard release assignment search by manage permission 2026-06-01 21:10:51 +02:00
Jeppe B 59eab84f03 Merge pull request #68 from copenhagentruckwash/fix-cross-origin-framing-vulnerability-in-nginx
Add anti-framing headers to Coolify Nginx frontend
2026-06-01 21:10:26 +02:00
Jeppe B e67033711c Add anti-framing headers to Coolify Nginx config 2026-06-01 21:10:14 +02:00
Jeppe B 85fd00f020 Merge pull request #67 from copenhagentruckwash/fix-metadata-exposure-for-low-privileged-users
Restrict release runtime metadata inspector
2026-06-01 21:09:54 +02:00
Jeppe B 4f064a1625 Restrict release runtime metadata inspector 2026-06-01 21:09:42 +02:00
Jeppe B 3bf7ada226 Merge pull request #66 from copenhagentruckwash/fix-tls-verification-in-dev-proxy
Secure Vite API proxy TLS by default
2026-06-01 21:09:15 +02:00
Jeppe B 3b90e17a1e Secure Vite API proxy TLS by default 2026-06-01 21:09:05 +02:00
Jeppe B c1fb5b72ee Merge pull request #65 from copenhagentruckwash/fix-privilege-escalation-in-daily-report
Guard department lane toggles by wash-lane permission
2026-06-01 21:08:43 +02:00
Jeppe B 29da26cc08 Guard department lane toggles by permission 2026-06-01 21:08:32 +02:00
Jeppe B 807fc09b1e Merge pull request #64 from copenhagentruckwash/fix-dgnvask-toggle-issue
Fix Døgnvask toggle state with configuration warnings
2026-06-01 21:08:03 +02:00
Jeppe B 528e4994db Fix Dognvask warning toggle state 2026-06-01 21:07:53 +02:00
Jeppe B c339122f4f Merge pull request #63 from copenhagentruckwash/fix-release-gate-binding-to-build
Bind frontend release gate to build id
2026-06-01 21:07:33 +02:00
Jeppe B feeef46354 Bind frontend release gate to build id 2026-06-01 21:07:23 +02:00
Jeppe B 066bd14554 Merge pull request #62 from copenhagentruckwash/fix-active-wash-refresh-vulnerability
Harden active-wash refresh ownership check
2026-06-01 21:06:53 +02:00
Jeppe B bffaa2ab88 Fix active wash refresh ownership check 2026-06-01 21:06:39 +02:00
Jeppe B 31495ea67b Merge pull request #61 from copenhagentruckwash/fix-ftp-deploy-password-exposure
Secure lftp release upload credentials
2026-06-01 21:02:13 +02:00
Jeppe B 8f2533f1b2 Secure lftp release upload credentials 2026-06-01 21:02:01 +02:00
Jeppe B 13b0742deb Merge pull request #60 from copenhagentruckwash/fix-cropping-issue-in-path-editor
Fix self-serve path editor answer switch layout
2026-06-01 20:45:04 +02:00
Jeppe B e2b2d50e5e Fix self-serve path editor answer switch layout 2026-06-01 20:19:35 +02:00
Jeppe B 55d61e37f3 Merge pull request #59 from copenhagentruckwash/fix-e2e-tests-in-i18n-v2-catalog
Fix i18n v2 catalog integrity and split Release Manager source fragments
2026-06-01 19:45:57 +02:00
Jeppe B fa19f5a704 Fix i18n v2 catalog integrity 2026-06-01 19:44:50 +02:00
Jeppe Bundgaard b995031255 Enable "Selv vask" button in MobileFooter.vue 2026-06-01 19:43:57 +02:00
Jeppe Bundgaard d096444ca0 Remove unused lane unavailable warning message from SelfServeLaneStep.vue 2026-06-01 19:41:16 +02:00
Jeppe B 10358d4f53 Merge pull request #58 from copenhagentruckwash/fix-self-serve-path-editor-for-all-departments
Fix self-serve path editor vehicle scope
2026-06-01 18:32:11 +02:00
Jeppe B 78f41b8380 Fix self-serve path editor vehicle scope 2026-06-01 18:31:53 +02:00
Jeppe B 90d4bc7c04 Merge pull request #57 from copenhagentruckwash/fix-bugs-in-start-wash-flow
Harden customer wash start flow
2026-06-01 18:01:29 +02:00
Jeppe B b59c17f120 Harden customer wash start flow 2026-06-01 17:54:42 +02:00
Jeppe Bundgaard c279e7fd38 Merge remote-tracking branch 'origin/master' 2026-06-01 17:23:06 +02:00
Jeppe Bundgaard 8a4fcb3e62 Remove unused return messages and cleanup peer dependency flags in package-lock.json 2026-06-01 17:22:33 +02:00
Jeppe B 680ef7088b Merge pull request #56 from copenhagentruckwash/investigate-playwright-exit-code-1-issue
Stabilize POS email notification E2E setup
2026-06-01 17:20:54 +02:00
Jeppe B 83935af973 Stabilize POS email notification E2E setup 2026-06-01 16:45:40 +02:00
Jeppe B 37973f8fcb Add x-api-coverage happy path flag to listOrders operation 2026-06-01 13:31:48 +02:00
Jeppe B acf19c281a Add email notification actions for booking confirmations and wash certificates 2026-06-01 13:24:29 +02:00
Jeppe B 683fce32da Fix POS step 1 customer lookup loop 2026-06-01 12:41:08 +02:00
Jeppe B 4c9ba7c30d Add unit tests for Playwright full-slice ownership 2026-06-01 12:28:42 +02:00
Jeppe B ca1f3ab477 Merge pull request #55 from copenhagentruckwash/codex/frontend-release-gate-coolify-20260530
Fix frontend release gate for Coolify deploys
2026-05-31 10:22:29 +02:00
Jeppe B 2ab09076f5 Stabilize edge terminal smoke navigation 2026-05-30 21:51:07 +02:00
Jeppe B 97e17fadd4 Respect mobile self-serve studio layout in e2e 2026-05-30 20:33:27 +02:00
Jeppe B ef780e5542 Keep completed self-serve wash step visible 2026-05-30 19:44:52 +02:00
Jeppe B 931c31c7be Stabilize self-serve question loading layout 2026-05-30 18:37:37 +02:00
Jeppe B 25f2be875c Wait for self-serve completion command response 2026-05-30 17:45:06 +02:00
Jeppe B ff70326665 Stabilize frontend PR smoke gates 2026-05-30 16:58:04 +02:00
Jeppe B 52ed49211a Fix frontend release gate for Coolify deploys 2026-05-30 15:39:03 +02:00
Jeppe B 0565fc2a7c Merge pull request #54 from copenhagentruckwash/codex/master-tests-pass-frontend-20260528
[codex] Keep user vehicle actions in wheel menu
2026-05-29 16:33:25 +02:00
Jeppe B d30273cda8 Map user vehicle changes to PR Playwright coverage 2026-05-29 14:46:52 +02:00
Jeppe B b893e69b54 Keep user vehicle actions in wheel menu 2026-05-28 23:35:14 +02:00
Jeppe B eabb12ef97 Merge pull request #53 from copenhagentruckwash/fix-issues-and-verify-with-tests
Format DepartmentSelfServeStudio.vue and update vehicle actions test to reflect wheel menu behavior
2026-05-28 19:52:53 +02:00
Jeppe Bundgaard 225741cf7b Refactor unit tests and clean up Vue component formatting
- Update `self-serve-studio-managed-inspector.spec.js` to improve node kind checks.
- Tidy up and standardize `SessionUser.request` calls in `vehiclesTable.vue`.
- Format code consistently in tables, conditions, and task specs for clarity and maintenance.
2026-05-28 19:47:02 +02:00
Jeppe B dad42da6d9 Keep vehicle actions in wheel menu 2026-05-28 19:01:27 +02:00
Jeppe Bundgaard 0fa2284e89 Add connectivity issue handling logic and tests
- Add `ConnectivityIssue.vue` component for displaying server connection issues.
- Implement polling logic with retry intervals to check server health.
- Update i18n with new connectivity issue messages.
- Add unit tests to cover connectivity failure and recovery scenarios.
- Update test suite for breadth of coverage, including router contract and timer cleanup.
2026-05-28 18:11:21 +02:00
Jeppe Bundgaard cc241e5e19 Add subuser support and require notes for specific products 2026-05-28 16:36:42 +02:00
Jeppe Bundgaard c5a536fbea Add subuser support and require notes for specific products 2026-05-28 16:12:41 +02:00
Jeppe Bundgaard 3a7fef11ef Implement subuser password policy validation and update related components 2026-05-27 19:16:26 +02:00
Jeppe Bundgaard 625757fafc Fix Danish translations and enhance mobile department auto-selection logic 2026-05-27 17:34:51 +02:00
Jeppe Bundgaard e1a8af5a72 Extend i18n with new self-serve prompts and lane availability messages, and add E2E test for lane confirmation logic. 2026-05-27 13:25:31 +02:00
Jeppe Bundgaard c437c89cfa Add release management enhancements, runtime-aware frontend updates, and extensive test coverage
- Implement release update detection, asset preloading, and frontend version management.
- Add unit and E2E tests for release update workflows, widget behavior, and failure scenarios.
- Introduce new services for handling release ping, error reporting, and update installation workflows.
- Extend i18n for release-related components and error report localization.
- Add `ReleaseFrontendVersionBadge.vue` and related styles to display frontend update statuses.
2026-05-27 13:23:22 +02:00
Jeppe Bundgaard 184ef5670c Enhance session management by normalizing session payload and resetting session state on logout. Update login redirect logic and improve runtime API URL handling for selected release channels. 2026-05-26 17:28:21 +02:00
Jeppe Bundgaard 8ab8915429 Add release management components and update routing logic. Introduce keyboard shortcuts, enhance release data grid, and improve asset handling in Nginx configuration. 2026-05-26 14:14:13 +02:00
Jeppe B a952dd2c61 Merge pull request #52
Add endpoint mode options and enhance runtime refresh handling
2026-05-21 14:48:44 +02:00
Jeppe Bundgaard ebcd52a019 Add endpoint mode options, release channel rollback handling, and runtime refresh improvements
- Implement `endpoint_mode` (manual/auto) and related configuration in i18n across multiple locales.
- Add new utility methods for channel rollback and runtime confirmation workflows.
- Update unit and E2E tests to cover runtime refresh behavior, channel switching mechanics, and release references.
- Enhance `SessionUser` and related services to improve error handling during release runtime refresh.
- Add data-test attributes for frontend components to support improved test coverage.
2026-05-21 14:47:46 +02:00
Jeppe Bundgaard b2ddf0db5c Add beta frontend debug display in build info 2026-05-21 11:45:20 +02:00
Jeppe Bundgaard eaf4b965d5 Add runtime support for service sets, release management enhancements, and new unit tests
- Extend `releaseTimeline.js` and related modules for runtime-aware service sets and URLs.
- Update the release session builder to include `service_set` handling.
- Introduce release service definitions, status tones, and enhanced runtime display text.
- Add new dataset mode options, preview rows, and warnings for release configurations in `ConfigurationReleaseManager.vue`.
- Implement extensive utility methods for data service handling, target context, and deployment endpoints.
- Add `vite-api-proxy.spec.js` and extend `release-bootstrap.spec.js` to ensure coverage for new functionality.
2026-05-21 11:41:50 +02:00
Jeppe Bundgaard 6ba1c754a1 Serve frontend releases under channel prefixes 2026-05-20 19:27:01 +02:00
Jeppe Bundgaard 468613ab68 Add Coolify frontend release Docker image 2026-05-20 18:53:17 +02:00
Jeppe Bundgaard 261d84bd01 Route selected release runtime through gateway 2026-05-20 18:32:21 +02:00
Jeppe Bundgaard ea8db593c5 Add release headers service, runtime-aware API header handling, and i18n updates for release configuration
- Implement `releaseHeaders.js` to manage release-related HTTP headers.
- Update requests to include runtime-based release headers.
- Extend i18n phrases across multiple locales for release runtime and configuration labels.
- Enhance unit tests for header building and API availability error handling.
2026-05-20 17:52:54 +02:00
Jeppe Bundgaard 130cc2fc10 Add release runtime bootstrap implementation with unit and E2E tests
- Create `releaseBootstrap.js` service to handle runtime configuration and remote frontend loading.
- Integrate runtime-aware API URL resolution across components and services.
- Update i18n phrases to clarify new domains for `ssl_domain_message`.
- Enhance unit and E2E tests to validate runtime-based frontend loading and fallback behavior.
- Refactor session-related API calls and incorporate runtime-configured base URLs.
2026-05-20 11:26:21 +02:00
Jeppe Bundgaard 3715da8fd3 Add release channel selector components, subject autocomplete, and related tests
- Implement components for `ReleaseChannelSelector`, including panel and sidebar variants.
- Add `ReleaseAssignmentSubjectAutocomplete` for user, subuser, and customer search functionality.
- Update i18n phrases for channel selector and subject assignment.
- Enhance unit and E2E tests for release channel availability, switching, and runtime configuration.
- Modify modules for channel-related state handling and runtime refresh mechanics.
2026-05-20 10:31:49 +02:00
Jeppe Bundgaard 100e477a95 Add "Delete" functionality for release service sets in isolated stacks
- Implement deletion of inactive isolated stacks via API and UI.
- Normalize build pack handling, defaulting frontend to "static".
- Update E2E tests to verify deletion and adjusted UI behavior.
- Refactor related release manager methods and validation logic.
2026-05-19 16:55:20 +02:00
Jeppe Bundgaard 1132c8c256 Merge remote-tracking branch 'origin/master' 2026-05-19 14:47:01 +02:00
Jeppe Bundgaard 42237fcc70 Add unit tests for release manager i18n validation and implement ReleaseReplayInspector Vue component for timeline inspection and replay functionality. 2026-05-19 14:46:42 +02:00
gold-dev ae8b3b6b64 Feat: Hvidovre only one toggler 2026-05-19 14:26:23 +03:00
Jeppe B 8378491b0b Merge pull request #51
Fix chromium mobile runner flakes
2026-05-19 13:12:11 +02:00
Jeppe Bundgaard 20274cf8eb Update i18n phrases for ssl_domain fields to clarify load balancer routing details and add new methods for release service sets and bundles in SuperUser Release Manager. 2026-05-19 13:11:17 +02:00
Jeppe Bundgaard d25726fafd Add unit and E2E tests for release channel availability and switched-notice functionality. Configure runtime mocks and implement Vue components for channel-specific handling. 2026-05-19 12:05:25 +02:00
Jeppe Bundgaard 4f1973d048 Add SuperUser Release Manager view with channel management, deployments, replay, and integrations 2026-05-19 10:04:00 +02:00
Jeppe Bundgaard d69c474655 Add multilingual glossary and phrases for i18n, including terms, attributes, and compatibility across da, de, en, no, and sv locales. 2026-05-19 08:03:39 +02:00
Jeppe Bundgaard 56078307da Update vehicles table action visibility and adjust E2E test timeouts and MinIO configuration
- Change `displayActionsDirectly` prop to `false` in vehicles table for compact mode consistency.
- Refactor E2E tests with reusable `PAGE_READY_TIMEOUT` constant to prevent flaky behavior.
- Enhance MinIO configuration in replication management and system status tests to include additional environment variables and form fields.
2026-05-18 15:07:25 +02:00
Jeppe Bundgaard e6291cdb8a Add response handling for MinIO provisioning in replication E2E test 2026-05-18 13:52:01 +02:00
Jeppe Bundgaard bf0b2152c5 Enhance replication management to support MinIO alongside database and Redis, update UI labels, and improve status reporting 2026-05-18 13:48:16 +02:00
Jeppe Bundgaard 9771723992 Fix chromium mobile runner flakes 2026-05-18 12:28:23 +02:00
Jeppe Bundgaard 156e50a864 Merge remote-tracking branch 'origin/master' 2026-05-18 11:01:12 +02:00
Jeppe Bundgaard 5081fe0776 Add Playwright PR runner and Vitest unit test fast runner scripts; integrate with test commands and update specs/tests for PR-based selection. 2026-05-18 10:59:56 +02:00
Jeppe Bundgaard 656e43d449 Add SuperUser replication management view, including UI for database and Redis hosts, replication status, provisioning, and Compose template generation 2026-05-18 09:55:41 +02:00
Jeppe Bundgaard d135a3f0af Add database and Redis replication support to System Status Dashboard, including UI updates, new routes, localization, and unit/E2E tests. 2026-05-17 15:32:45 +02:00
gold-dev 7bd880497f Feat: Disable click redirect, setup checkbox counts 2026-05-13 22:15:28 +03:00
Jeppe Bundgaard d148c0d6b9 Enhance ActionSettingsWheelButton functionality with fixed positioning logic, ResizeObserver integration, and dropdown layout syncing; refactor unit tests for improved mock structure. 2026-05-13 17:36:15 +02:00
Jeppe Bundgaard 75bab7b425 Update UI styles and logic for ActionSettingsWheelButton, invoicing period components, and desktop section layout, adding flyout positioning logic and syncing. 2026-05-13 16:47:31 +02:00
Jeppe Bundgaard 8dfaebba00 Add unit test for user vehicle actions visibility and enhancements for department 75 loading UI, default department handling, and translations. 2026-05-13 15:35:21 +02:00
Jeppe Bundgaard f7f01dcd2f Add E2E and unit tests for invoicing period flag handling, enhance duplicate groups filtering by date, and implement Excel export for department 75 distribution 2026-05-13 13:58:00 +02:00
Jeppe Bundgaard fba85a72c1 Refactor pagination components to use provide and usePaginatedList, optimize fast link handling with queuing and concurrency limits, and update XLVask content generation logic and caching. 2026-05-12 15:03:03 +02:00
Jeppe Bundgaard c657ec57ad Refactor DatePeriodSelector and invoicing period tests: adjust visibility condition for mobile update button, remove redundant test skips, and enhance logic for date shortcut handling across layouts. 2026-05-12 05:38:50 +02:00
Jeppe Bundgaard f24df440d0 Enhance ActionSettingsWheelButton and invoicing period tests with improved structure and visibility checks 2026-05-12 04:52:05 +02:00
Jeppe Bundgaard b73f83a4ab Clean up debug logs in invoicing statistics, add isDateLoading state for period paging, update date change handling with refresh logic, and enhance locale translations and tests. 2026-05-12 04:03:36 +02:00
Jeppe Bundgaard 2700ff3526 Add caching, component updates, and unit tests for invoicing period and XLVask functionality 2026-05-12 03:41:40 +02:00
Jeppe Bundgaard a261f2f3bd Add unit tests and components for invoicing period flag handling and self-wash view rendering 2026-05-11 21:40:51 +02:00
Jeppe Bundgaard 81c4ba0800 Refine safety seal normalization, enhance booking completion flow, and update E2E tests for improved validation and popup handling. 2026-05-11 04:37:34 +02:00
Jeppe Bundgaard 31e72af9bf Warm smoke route chunks before Playwright runs 2026-05-11 03:47:05 +02:00
Jeppe Bundgaard 10a52521d5 Fix invoicing period invoice action 2026-05-11 03:35:36 +02:00
Jeppe Bundgaard c9b34ba36c Fix frontend smoke test races 2026-05-11 03:15:56 +02:00
Jeppe Bundgaard 87ad8f9d31 Merge remote-tracking branch 'origin/master' 2026-05-11 01:57:06 +02:00
Jeppe Bundgaard a754cfc7bb Add delay support for booking completion flow, refine safety seal handling, and enhance popup submission logic with better action management and validation. Update related E2E tests. 2026-05-11 01:56:15 +02:00
gold-dev 29bc0e548b Feat: Header & Daily report block switcher 2026-05-07 16:39:11 +03:00
Jeppe Bundgaard 378598b161 Restrict unassigned department visibility test to desktop, refactor test selectors, and simplify department loading logic. 2026-05-07 14:12:17 +02:00
Jeppe Bundgaard e8c1dda3cc Add E2E test for unassigned department visibility in the overview list. Refactor department sorting and UI logic with priority-based filtering for improved accessibility. 2026-05-07 13:54:53 +02:00
Jeppe Bundgaard fabc4ad54a Refactor department access logic to use canAccessAssignedDepartment. Update related components, tests, and UI for consistency. Add tests for unassigned department handling and align E2E layouts. 2026-05-07 13:50:12 +02:00
Jeppe Bundgaard b89ae2bb9e Sort departments by priority order in UI and API logic. Add support for "order_priority" and normalize fields consistently throughout components and tests. 2026-05-07 13:22:07 +02:00
Jeppe Bundgaard fb162eef6c Add archived department filtering feature for superusers, including E2E tests, UI updates, and OpenAPI enhancements. Extend department table with archived toggle switch and update translations. 2026-05-07 12:54:31 +02:00
Jeppe Bundgaard dc2840e023 Merge remote-tracking branch 'origin/master' 2026-05-07 10:43:02 +02:00
Jeppe Bundgaard fe6d9e8d16 Add E2E tests for department branding management and refine supporting API mocks. Implement branding creation, editing, assignment, and clearing workflows. Extend DepartmentProfile with branding UI and logic updates. 2026-05-07 10:42:45 +02:00
Jeppe Bundgaard 7ab7d83fb5 Align invoicing period specs with formatted date contract 2026-05-07 10:15:49 +02:00
gold-dev 028dad2183 Feat: Time ago to local time hours 2026-05-06 17:08:57 +03:00
gold-dev 6b0fe60958 Feat: Change texts 2026-05-06 16:49:06 +03:00
Jeppe Bundgaard a7789cb631 Merge remote-tracking branch 'origin/master' 2026-05-06 14:15:01 +02:00
Jeppe Bundgaard da65876a64 Add responsive tests for "/qr/new-customer" layout, extend API with split_by_month logic, and update E2E/admin tests with session mocking and department sync fixes. Normalize POS department handling, improve invoicing period split logic, and enhance receipt export safety. 2026-05-06 14:14:34 +02:00
Jeppe Bundgaard f245161bfa Align stale unit specs with current self-serve and invoices views 2026-05-06 09:08:23 +02:00
Jeppe Bundgaard bdffdca420 Handle "STOP" command failures by interpreting "lane not occupied" as a safe-to-clear state. Refactor error handling, add clearActiveWashState, and extend unit/E2E tests with this scenario. 2026-04-30 14:52:40 +02:00
Jeppe Bundgaard 5d1199a133 Update unit test to include lane click event trigger 2026-04-29 17:43:29 +02:00
Jeppe Bundgaard 4752b3d096 Add self-serve feature logic, including lane filtering, normalization, and OpenAPI updates. Enhance Self-Serve Studio with progress indicators, UI controls, path projection streaming, and abort handling. Update E2E and unit tests for enhanced self-serve behaviors. 2026-04-29 17:27:53 +02:00
Jeppe Bundgaard fc9c3700a1 Add Danish self-serve labels and guided wash flow steps. Update UI with guided step actions and bottom control integration. Fix translations and refine E2E/unit test coverage for self-serve flow behavior. 2026-04-29 16:01:34 +02:00
Jeppe Bundgaard a15d6841c5 Add task attachment management to Self-Serve Studio. Extend graph, path outcomes, and flow logic. Update E2E/unit tests with attachment creation, upload, download, deletion flows, and related validations. 2026-04-29 14:41:29 +02:00
Jeppe Bundgaard 9641f67e3a Extend Self-Serve Studio with "action" nodes, supporting creation, focus, and editing workflows. Add unit tests, normalize forms, and enhance virtual binding/gateway logic. Update E2E tests and node visualization settings. 2026-04-29 10:56:40 +02:00
Jeppe Bundgaard de2fa57c63 Add audit issue navigation and task-button normalization to Self-Serve Studio. Implement node ID resolution, audit visualization logic, and reusable task button helpers. Extend E2E and unit tests. 2026-04-29 09:51:09 +02:00
Jeppe Bundgaard a658fe7863 Resume active self-serve washes for authenticated customers across devices. Update OpenAPI, UI components, and extend E2E/unit tests. 2026-04-28 18:00:09 +02:00
Jeppe Bundgaard 503fb84f10 Extend Self-Serve Studio with deferred relay logic and Bookinger-style loading states. Update OpenAPI, UI components, and related E2E/unit tests. 2026-04-28 17:09:50 +02:00
Jeppe Bundgaard 11bb8f824d Extend Self-Serve Studio with vehicle type management, mutation options, and enhanced simulation/debug tools. Update OpenAPI, UI components, and related E2E/unit tests. 2026-04-28 16:54:27 +02:00
Jeppe Bundgaard 8615cc6678 Introduce task-service logic and relay coverage to Self-Serve Studio. Add service normalization, binding service visualizations, and task-service coverage mapping. Extend E2E tests for tasks and bindings. 2026-04-28 15:42:06 +02:00
Jeppe Bundgaard 65e4c27f45 Enhance Self-Serve Studio with dynamic filters, scoped layouts, and optimized auto-layout logic. Update filtering behavior, node visibility, and edge handling. Extend E2E tests for vehicle-specific scenarios. 2026-04-28 15:01:30 +02:00
Jeppe Bundgaard f9d8b87768 Add new table and enhance studio layout logic
Introduce `department_selfserve_studio_layouts` table for department-specific layouts and implement advanced auto-layout functionality in the DepartmentSelfServeStudio module. Added custom node definitions, updated styling, and integrated new logics for sorting and visualizing nodes in the Vue Flow interface.
2026-04-28 14:21:26 +02:00
Jeppe Bundgaard 586a0cc86d Add fullscreen mode to Self-Serve Studio with improved canvas controls and responsive layout adjustments. Extend related E2E tests. 2026-04-28 13:46:47 +02:00
Jeppe Bundgaard e9cbb40559 Add all-in-one self-serve studio with flow editor, simulator, versioning, and enhanced E2E/OpenAPI support. Extend legacy edge gateway workflows. 2026-04-28 13:36:16 +02:00
Jeppe Bundgaard f2c3962749 Improve error handling for lane service updates and add comprehensive tests for edge gateway failures. Refactor route handling and enhance lane/session binding logic. 2026-04-28 12:29:56 +02:00
Jeppe Bundgaard e63e113628 Load EdgeGateway components asynchronously in SuperUserDashboard self-serve view to optimize performance. 2026-04-28 11:37:35 +02:00
Jeppe Bundgaard 4b02de7358 Add self-serve Edge Agents management with navigation, routes, and tabs. Update legacy redirects and extend unit and E2E tests. 2026-04-28 11:32:23 +02:00
Jeppe Bundgaard 61aab76e63 Add self-serve sessions management with filtering, force-stop functionality, and enhanced E2E test coverage. Extend edge gateway module configuration tests. 2026-04-28 10:03:36 +02:00
Jeppe Bundgaard e26e37d9d7 Add enhanced admin access checks and error handling for draft transaction customers and self-serve processes. Extend edge gateway diagnostics, refine terminal readiness detection, and improve live session test coverage. 2026-04-28 09:01:52 +02:00
Jeppe Bundgaard d411da2a12 Enhance edge gateway live sessions, improve WebSocket and error diagnostics, optimize lane relay testing, and extend terminal and command handling features. 2026-04-27 17:39:24 +02:00
Jeppe Bundgaard 15b3c726dc Add relay test modes (local/cloud), enhance lane and self-serve relay testing, and refine UI/UX for department hardware workspace. 2026-04-27 16:25:18 +02:00
Jeppe Bundgaard ee47c801ea Enhance edge gateway live session handling, extend telemetry updates, refine department lane bindings, and replace legacy invoice components. 2026-04-27 14:14:52 +02:00
Jeppe B d0993d2963 Merge pull request #50 from copenhagentruckwash/codex/github-actions-green-20260424-local
Stabilize frontend unit test environments
2026-04-24 21:09:18 +02:00
Jeppe Bundgaard 85a6506bfd Stabilize frontend unit test environments 2026-04-24 19:22:24 +02:00
Jeppe B 7a489e371f Merge pull request #49 from copenhagentruckwash/run-tests-on-mobile-and-desktop
Stabilize mobile/desktop Playwright smoke tests and JSON warmup
2026-04-24 18:59:29 +02:00
Jeppe B 17b973d77c Stabilize cross-device Playwright smoke coverage 2026-04-24 15:13:18 +02:00
1268 changed files with 189837 additions and 10454 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(find /c/Users/2jepp/WebstormProjects/pleno-vue -type f \\\\\\(-name *.ts -o -name *.js \\\\\\) -not -path */node_modules* -not -path */dist/* -not -path */.git/* -not -path */dev-dist/*)",
"Bash(find /c/Users/2jepp/WebstormProjects/pleno-vue/src -type f \\\\\\(-name *.ts -o -name *.js -o -name *.vue \\\\\\))",
"Bash(find /c/Users/2jepp/WebstormProjects/pleno-vue/app -type f \\\\\\(-name *.ts -o -name *.js -o -name *.java -o -name *.kt \\\\\\))",
"Bash(grep -r \"getAutomaticFlags\\\\|AutomaticFlags\\\\|automaticFlags\\\\|Automatic.*Flags\" /c/Users/2jepp/WebstormProjects/pleno-vue --include=*.ts --include=*.js --include=*.java --include=*.kt --exclude-dir=node_modules --exclude-dir=dist --exclude-dir=dev-dist --exclude-dir=output --exclude-dir=.git)",
"Bash(xargs grep:*)"
]
}
}
+17
View File
@@ -0,0 +1,17 @@
.github
.idea
.vscode
coverage
dev-dist
dist
node_modules
node_modules.*
output
playwright-report
test-results
.ai
.ai-workflow
.claude
.codex
.gradle
gradle
+15
View File
@@ -0,0 +1,15 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.pdf binary
*.zip binary
*.webm binary
-28
View File
@@ -1,28 +0,0 @@
name: Qodana Configuration Upload
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
upload-qodana-config:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Run Qodana Configuration Uploader
env:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: |
docker run --rm \
-v $(pwd):/workspace \
-w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader:latest \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
+8 -4
View File
@@ -7,9 +7,13 @@ on:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
upload-qodana-config:
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 10
steps:
- name: Checkout repository
@@ -20,9 +24,9 @@ jobs:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: |
docker run --rm \
-v $(pwd):/workspace \
-v "$(pwd):/workspace" \
-w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader:latest \
-e QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
+192
View File
@@ -0,0 +1,192 @@
name: Frontend Release
on:
workflow_run:
workflows:
- Automated Tests
types:
- completed
branches:
- master
permissions:
contents: read
actions: read
concurrency:
group: frontend-release-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
build-upload-and-verify:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
runs-on: [self-hosted, Linux, X64, default]
env:
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }}
RELEASE_WAIT_INITIAL_SECONDS: 45
RELEASE_WAIT_TIMEOUT_SECONDS: 600
RELEASE_POLL_INTERVAL_SECONDS: 10
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}
- name: Check release commit is current
id: branch-head
run: |
latest_sha="$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; then
echo "Could not resolve origin/$RELEASE_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$RELEASE_EXPECTED_COMMIT" ]]; then
echo "current=false" >> "$GITHUB_OUTPUT"
echo "Skipping stale release for $RELEASE_EXPECTED_COMMIT; origin/$RELEASE_BRANCH is $latest_sha."
exit 0
fi
echo "current=true" >> "$GITHUB_OUTPUT"
echo "Release commit is current for $RELEASE_BRANCH."
env:
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
- name: Setup Node.js
if: steps.branch-head.outputs.current == 'true'
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
if: steps.branch-head.outputs.current == 'true'
run: npm ci --legacy-peer-deps
- name: Check AI workflow sync
if: steps.branch-head.outputs.current == 'true'
run: node scripts/sync-ai-workflow.mjs --check
- name: Source and i18n checks
if: steps.branch-head.outputs.current == 'true'
run: |
npm run text:check-encoding
npm run i18n:v2:source-check
- name: Unit tests
if: steps.branch-head.outputs.current == 'true'
run: npm run test:unit
env:
VITEST_BATCH_SIZE: 5
- name: Build release artifact
if: steps.branch-head.outputs.current == 'true'
run: npm run build
- name: Install Playwright Chromium
if: steps.branch-head.outputs.current == 'true'
run: node scripts/install-playwright-browsers.mjs chromium
- name: Production Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:prod
- name: Upload dist artifact
if: steps.branch-head.outputs.current == 'true'
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
path: dist
retention-days: 3
- name: Request Release Manager auto sync
if: steps.branch-head.outputs.current == 'true'
run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
response_file="$(mktemp)"
status_code="$(curl --show-error --silent \
--output "$response_file" \
--write-out "%{http_code}" \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
if [[ "$status_code" =~ ^2 ]]; then
cat "$response_file"
elif [[ "$status_code" == "504" ]]; then
echo "Release Manager auto sync request reached the gateway timeout; continuing to artifact wait."
else
cat "$response_file" >&2
echo "Release Manager auto sync request failed with HTTP $status_code." >&2
exit 1
fi
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Wait for Coolify release artifact
if: steps.branch-head.outputs.current == 'true'
run: npm run release:verify-upload
- name: Public live Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:live:public
env:
NODE_OPTIONS: --use-system-ca
- name: Credentialed live Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:live:roles
env:
NODE_OPTIONS: --use-system-ca
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
PLAYWRIGHT_OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
- name: Record Release Manager gate
if: steps.branch-head.outputs.current == 'true'
run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}"
curl --fail --show-error --silent \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Update server version after verification
if: steps.branch-head.outputs.current == 'true'
run: npm run release:update-server-version
env:
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
- name: Upload Playwright report
if: failure() && steps.branch-head.outputs.current == 'true'
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
path: output/playwright
if-no-files-found: ignore
retention-days: 3
+282 -36
View File
@@ -3,6 +3,8 @@ name: Automated Tests
on:
pull_request:
push:
branches:
- master
workflow_dispatch:
schedule:
- cron: "0 2 * * *"
@@ -11,14 +13,29 @@ permissions:
contents: read
concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.ref }}
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
format-tests:
# Match the labels exposed by the Coolify-managed GitHub runner.
runs-on: [self-hosted, Linux, X64, default]
# CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 15
steps:
- name: Repair self-hosted workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository
uses: actions/checkout@v5
@@ -26,7 +43,6 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Check AI workflow sync
run: node scripts/sync-ai-workflow.mjs --check
@@ -39,8 +55,23 @@ jobs:
build-and-unit:
needs: format-tests
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 30
steps:
- name: Repair self-hosted workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository
uses: actions/checkout@v5
@@ -48,11 +79,16 @@ jobs:
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Lint
run: npm run lint
- name: Check i18n source consistency
run: npm run i18n:v2:check
- name: Build sanity check
run: npm run build
@@ -61,51 +97,174 @@ jobs:
env:
VITEST_BATCH_SIZE: 5
e2e-smoke:
e2e-pr:
if: github.event_name != 'schedule'
needs: build-and-unit
runs-on: [self-hosted, Linux, X64, default]
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 4
matrix:
suite: [core, changed]
project: [chromium-desktop, chromium-mobile]
env:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
PLAYWRIGHT_REPORTER_MODE: line-html
steps:
- name: Repair self-hosted workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Resolve Playwright diff refs
id: playwright-diff
shell: bash
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
EVENT_NAME: ${{ github.event_name }}
HEAD_SHA: ${{ github.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
zero_sha="0000000000000000000000000000000000000000"
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_BASE_SHA" ]]; then
base_ref="$PR_BASE_SHA"
elif [[ -z "$PUSH_BEFORE_SHA" || "$PUSH_BEFORE_SHA" == "$zero_sha" ]]; then
git fetch --no-tags --prune origin "$DEFAULT_BRANCH"
base_ref="origin/$DEFAULT_BRANCH"
else
base_ref="$PUSH_BEFORE_SHA"
fi
echo "base=$base_ref" >> "$GITHUB_OUTPUT"
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
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 chromium
- name: Run Playwright smoke tests (${{ matrix.project }})
run: npx playwright test --grep @smoke --project="${{ matrix.project }}"
- name: Run Playwright PR suite in container
shell: bash
env:
DIFF_BASE_REF: ${{ steps.playwright-diff.outputs.base }}
DIFF_HEAD_REF: ${{ steps.playwright-diff.outputs.head }}
MATRIX_SUITE: ${{ matrix.suite }}
MATRIX_PROJECT: ${{ matrix.project }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
case "$MATRIX_SUITE" in
core) suite_offset=0 ;;
changed) suite_offset=10 ;;
*) echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2; exit 1 ;;
esac
case "$MATRIX_PROJECT" in
chromium-desktop) project_offset=1 ;;
chromium-mobile) project_offset=2 ;;
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
playwright_dev_port="$(
PORT_SEED="$port_seed" node - <<'NODE'
const net = require("node:net");
const start = Number(process.env.PORT_SEED || 20000);
const candidates = Array.from({ length: 200 }, (_, index) => start + index);
function tryPort(index) {
if (index >= candidates.length) {
console.error("Unable to find a free Playwright dev-server port.");
process.exit(1);
}
const port = candidates[index];
const server = net.createServer();
server.once("error", () => tryPort(index + 1));
server.listen(port, "127.0.0.1", () => {
server.close(() => {
console.log(port);
});
});
}
tryPort(0);
NODE
)"
if docker info >/dev/null 2>&1; then
docker_cmd=(docker)
elif sudo -n docker info >/dev/null 2>&1; then
docker_cmd=(sudo docker)
else
echo "Docker is not available to the runner user, and sudo docker is not available." >&2
exit 1
fi
mkdir -p output/playwright
"${docker_cmd[@]}" run --rm --ipc=host --network host \
--volume "$PWD:/source:ro" \
--volume "$PWD/output/playwright:/work/output/playwright" \
--workdir /work \
--env HOME=/tmp \
--env CI="${CI:-}" \
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
--env MATRIX_SUITE="$MATRIX_SUITE" \
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
--env DIFF_BASE_REF="$DIFF_BASE_REF" \
--env DIFF_HEAD_REF="$DIFF_HEAD_REF" \
mcr.microsoft.com/playwright:v1.58.2-noble \
bash -lc '
set -euo pipefail
tar --exclude=./output/playwright -C /source -cf - . | tar -C /work -xf -
git config --global --add safe.directory /work
npm ci --legacy-peer-deps
ulimit -n 16384 || true
if [[ "$MATRIX_SUITE" == "core" ]]; then
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
else
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF"
fi
'
- name: Upload Playwright report
if: failure()
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: playwright-report-smoke-${{ matrix.project }}
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
path: |
output/playwright/report
output/playwright/test-results
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
if-no-files-found: ignore
retention-days: 7
retention-days: 1
e2e-full:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
needs: build-and-unit
if: >
always() &&
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
needs.build-and-unit.result == 'success' &&
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
needs: [build-and-unit, e2e-pr]
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 4
matrix:
role: [customer, subuser, admin, superuser]
browser: [chromium, firefox, webkit]
@@ -124,6 +283,20 @@ jobs:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
PLAYWRIGHT_REPORTER_MODE: line-html
steps:
- name: Repair self-hosted workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository
uses: actions/checkout@v5
@@ -131,19 +304,92 @@ jobs:
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: Run full Playwright slice in container
shell: bash
env:
MATRIX_ROLE: ${{ matrix.role }}
MATRIX_BROWSER: ${{ matrix.browser }}
MATRIX_DEVICE: ${{ matrix.device }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
case "$MATRIX_ROLE" in
customer) role_offset=0 ;;
subuser) role_offset=100 ;;
admin) role_offset=200 ;;
superuser) role_offset=300 ;;
*) echo "Unsupported Playwright role: $MATRIX_ROLE" >&2; exit 1 ;;
esac
case "$MATRIX_BROWSER" in
chromium) browser_offset=0 ;;
firefox) browser_offset=30 ;;
webkit) browser_offset=60 ;;
*) echo "Unsupported Playwright browser: $MATRIX_BROWSER" >&2; exit 1 ;;
esac
case "$MATRIX_DEVICE" in
mobile) device_offset=1 ;;
tablet) device_offset=2 ;;
desktop) device_offset=3 ;;
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
playwright_dev_port="$(
PORT_SEED="$port_seed" node - <<'NODE'
const net = require("node:net");
const start = Number(process.env.PORT_SEED || 20000);
const candidates = Array.from({ length: 200 }, (_, index) => start + index);
function tryPort(index) {
if (index >= candidates.length) {
console.error("Unable to find a free Playwright dev-server port.");
process.exit(1);
}
const port = candidates[index];
const server = net.createServer();
server.once("error", () => tryPort(index + 1));
server.listen(port, "127.0.0.1", () => {
server.close(() => {
console.log(port);
});
});
}
tryPort(0);
NODE
)"
if docker info >/dev/null 2>&1; then
docker_cmd=(docker)
elif sudo -n docker info >/dev/null 2>&1; then
docker_cmd=(sudo docker)
else
echo "Docker is not available to the runner user, and sudo docker is not available." >&2
exit 1
fi
mkdir -p output/playwright
"${docker_cmd[@]}" run --rm --ipc=host --network host \
--volume "$PWD:/source:ro" \
--volume "$PWD/output/playwright:/work/output/playwright" \
--workdir /work \
--env HOME=/tmp \
--env CI="${CI:-}" \
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
--env MATRIX_ROLE="$MATRIX_ROLE" \
--env MATRIX_BROWSER="$MATRIX_BROWSER" \
--env MATRIX_DEVICE="$MATRIX_DEVICE" \
mcr.microsoft.com/playwright:v1.58.2-noble \
bash -lc '
set -euo pipefail
tar --exclude=./output/playwright -C /source -cf - . | tar -C /work -xf -
git config --global --add safe.directory /work
npm ci --legacy-peer-deps
ulimit -n 16384 || true
npm run test:e2e:full:slice -- --role="$MATRIX_ROLE" --project="$MATRIX_BROWSER-$MATRIX_DEVICE"
'
- name: Upload Playwright report
if: failure()
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
@@ -152,4 +398,4 @@ jobs:
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
retention-days: 1
View File
View File
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
3.0
View File
+4
View File
@@ -0,0 +1,4 @@
{
"version": 1,
"setupCompletedAt": "2026-06-07T11:37:04.444Z"
}
+26
View File
@@ -0,0 +1,26 @@
FROM node:24-alpine AS build
WORKDIR /app
RUN apk add --no-cache git
COPY package*.json ./
RUN npm ci --ignore-scripts
COPY . .
ARG SOURCE_COMMIT
ARG RELEASE_COMMIT_SHA
ARG COMMIT_SHA
ARG GITHUB_SHA
ENV SOURCE_COMMIT=$SOURCE_COMMIT
ENV RELEASE_COMMIT_SHA=$RELEASE_COMMIT_SHA
ENV COMMIT_SHA=$COMMIT_SHA
ENV GITHUB_SHA=$GITHUB_SHA
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.coolify-frontend.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+30
View File
@@ -22,6 +22,36 @@ npm install
npm run dev
```
By default, the Vite dev server proxies `/api/*` to the remote stable API at
`https://api-v2.truckwash.io/master/api`. This lets the Vue app run locally
without a local PHP API container.
To develop against a local PHP API instead:
```powershell
$env:VITE_API_PROXY_TARGET="http://localhost"; npm run dev
```
To use another remote API route:
```powershell
$env:VITE_API_PROXY_BASE_PATH="/canary/api"; npm run dev
```
TLS certificate validation is enabled for proxied HTTPS APIs by default. If you
are using a trusted local HTTPS API with a self-signed certificate, you can opt
out explicitly:
```powershell
$env:VITE_API_PROXY_TARGET="https://local-api.test"; $env:VITE_API_PROXY_SECURE="false"; npm run dev
```
For compatible local gateways that expect the `/api` prefix to be preserved:
```powershell
$env:VITE_API_PROXY_TARGET="http://localhost"; $env:VITE_API_PROXY_STRIP_PREFIX="false"; npm run dev
```
### Compile and Minify for Production
```sh
+3 -3
View File
@@ -56,9 +56,9 @@ android {
defaultConfig {
applicationId "io.truckwash.twa"
minSdkVersion 21
targetSdkVersion 35
versionCode 6
versionName "6"
targetSdkVersion 36
versionCode 7
versionName "7"
// The name for the application
resValue "string", "appName", twaManifest.name
+1 -1
View File
@@ -1 +1 @@
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"pleno-pwa-test"}
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"/"}
@@ -0,0 +1,33 @@
# User wash start production-readiness QA matrix
This note documents deterministic coverage for the self-serve user wash start flow. The `@dynamic-image` Playwright case remains separated because it validates rendered image behavior in addition to deterministic state and API transitions.
## Unit matrix
| Area | Required scenario | Coverage |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useWashFlowState` | Step transitions for vehicle, questions, lane, tasks, in-progress, completed | `tests/unit/use-wash-flow-state-production.spec.js` validates step clickability, next-button gating, questions-to-lane updates, lane-to-start target selection, task completion transition, in-progress navigation, and completed-step non-clickability contract. |
| `useWashSessionActions` | START success and failure | `tests/unit/use-wash-session-actions-production.spec.js` covers successful manual START state mutation and failed START retryability without active-wash mutation. |
| `useWashSessionActions` | Relay enable success and relay enable failure with STOP rollback | `tests/unit/use-wash-session-actions-production.spec.js` covers machine relay success and relay failure rollback via STOP. |
| `useWashSessionActions` | STOP failure and already-stopped STOP recovery | `tests/unit/use-wash-session-actions-production.spec.js` covers failed STOP preserving active state and already-not-occupied STOP clearing local state. |
| `useSelfServeLogic` | Preview/summary merge | `tests/unit/use-self-serve-logic-production.spec.js` covers preview questions merging with summary questions, answer maps, visible question order, tasks, and allowed services. |
| `useSelfServeLogic` | Request race handling | `tests/unit/use-self-serve-logic-production.spec.js` covers stale preview/summary responses being ignored when a newer request wins. |
| `useSelfServeLogic` | Allowed services updates | `tests/unit/use-self-serve-logic-production.spec.js` covers lane allowed-service endpoint updates and first-failure fallback behavior. |
| `useSelfServeLogic` | Answer sync failures | `tests/unit/use-self-serve-logic-production.spec.js` covers error reporting while preserving the caller's optimistic local answer. |
| `MyWashStart.vue` | Local progress restore, server active-wash restore, recent-completion suppression, unmount cleanup, duplicate-fetch prevention | `tests/unit/my-wash-start-production.spec.js` locks the component contracts for restore ordering, authenticated active-wash application, recent-completion suppression across restore/polling, unmount cleanup, and duplicate-fetch/sync guards. |
## E2E mocked matrix
| Required scenario | Coverage |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Full manual flow | `tests/e2e/self-serve-wash.spec.js` covers direct manual wash start, guided completion, STOP, exit-gate open, completed state, and close/reset. |
| Full machine flow | `tests/e2e/self-serve-wash.spec.js` covers machine task rendering, dynamic image progress, required task completion, machine start path, and reload persistence. |
| Reload/resume active wash | `tests/e2e/self-serve-wash.spec.js` covers local progress reload and authenticated server active-wash resume from another device. |
| Backend says wash completed during polling | `tests/e2e/self-serve-wash.spec.js` covers server polling of active wash and local transition to completed when the backend no longer reports the matching in-progress wash. |
| Network failures for preview, answer sync, START, relay enable, STOP | `tests/e2e/self-serve-wash.spec.js` covers retryable START failure, STOP failure preservation, allowed-services gateway timeout/retry, answer sync background resilience, and unit-level relay rollback. Add mocked network route overrides when expanding browser-level failure assertions. |
## Optional live smoke
| Optional scenario | Coverage |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dynamic image smoke | The `@dynamic-image` Playwright test in `tests/e2e/self-serve-wash.spec.js` is tagged separately from deterministic CI coverage so it can be included or excluded explicitly with Playwright grep controls. |
Vendored
+1
View File
@@ -5,6 +5,7 @@ interface ImportMetaEnv {
readonly VITE_APP_VERSION: string,
readonly VITE_COMMIT_HASH: string,
readonly VITE_IS_DEV: string,
readonly VITE_RELEASE_SOURCE: 'local' | 'deployment' | 'auto',
}
interface ImportMeta {
readonly env: ImportMetaEnv
+139
View File
@@ -0,0 +1,139 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import vue from "eslint-plugin-vue";
const commonGlobals = {
...globals.browser,
...globals.node,
...globals.es2024,
grecaptcha: "readonly",
};
const vitestGlobals = {
...globals.vitest,
};
const lintTargets = ["**/*.{js,mjs,cjs,ts,tsx,vue}"];
const tsTargets = ["**/*.{ts,tsx,mts,cts}"];
function warningRules(rules = {}) {
return Object.fromEntries(
Object.entries(rules).map(([name, value]) => {
if (value === "off" || value === 0) {
return [name, value];
}
if (Array.isArray(value)) {
return [name, ["warn", ...value.slice(1)]];
}
return [name, "warn"];
}),
);
}
function warningConfig(config, files) {
return {
...config,
files: config.files ?? files,
rules: warningRules(config.rules),
};
}
const jsRecommended = warningConfig(js.configs.recommended, lintTargets);
const tsRecommended = tseslint.configs.recommended.map((config) =>
warningConfig(config, tsTargets),
);
const vueEssential = vue.configs["flat/essential"].map((config) =>
warningConfig(config, ["**/*.vue"]),
);
const ignoredPaths = [
"app/build/**",
"coverage/**",
"dist/**",
"node_modules/**",
"node_modules.codex-backup/**",
"output/**",
"public/build/**",
"src/assets/test/**",
"test/**",
"vendor/**",
"*.timestamp-*.mjs",
"**/*.timestamp-*.mjs",
];
const commonUnusedOptions = {
argsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
varsIgnorePattern: "^_",
};
export default [
{
ignores: ignoredPaths,
},
jsRecommended,
...tsRecommended,
...vueEssential,
{
files: ["**/*.vue"],
languageOptions: {
parserOptions: {
parser: tseslint.parser,
extraFileExtensions: [".vue"],
ecmaVersion: "latest",
sourceType: "module",
},
},
},
{
files: lintTargets,
plugins: {
vue,
},
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: commonGlobals,
},
rules: {
"no-console": "off",
"no-debugger": "warn",
"no-empty": "warn",
"no-undef": "warn",
"no-unused-vars": ["warn", commonUnusedOptions],
"no-useless-assignment": "warn",
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": "warn",
"vue/no-unused-components": "warn",
"vue/no-unused-vars": "warn",
"vue/no-v-html": "off",
},
},
{
files: ["**/*.{ts,tsx}"],
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["warn", commonUnusedOptions],
},
},
{
files: ["tests/**/*.{js,ts}", "playwright*.ts", "scripts/**/*.{js,mjs}"],
languageOptions: {
globals: {
...commonGlobals,
...globals.node,
...vitestGlobals,
},
},
rules: {
"no-empty": "warn",
},
},
];
+14 -6
View File
@@ -2,11 +2,19 @@
<html lang="" class="theme-light" data-theme="light">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/png" href="/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicons/favicon.svg" />
<link rel="shortcut icon" href="/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<script>
(function () {
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/);
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/';
var base = document.createElement('base');
base.href = href;
document.currentScript.after(base);
})();
</script>
<link rel="icon" type="image/png" href="%BASE_URL%assets/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="%BASE_URL%assets/favicons/favicon.svg" />
<link rel="shortcut icon" href="%BASE_URL%assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="%BASE_URL%assets/favicons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
@@ -16,6 +24,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="/src/releaseBootstrap.js"></script>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
location ~ ^/(?:.+/)?(?<webmanifest_path>(?:assets/)?manifest\.webmanifest)$ {
types { application/manifest+json webmanifest; }
default_type application/manifest+json;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$webmanifest_path =404;
}
location ~ ^/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$2.json =404;
}
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$static_asset_path =404;
}
location ~ ^/(?:.+/)?(?<static_file_path>index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
try_files /$static_file_path =404;
}
location ~ \.[^/]+$ {
try_files $uri =404;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend$ {
try_files /index.html =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/ {
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
try_files $uri $uri/ /index.html;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+872 -5
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -56,9 +56,9 @@ android {
defaultConfig {
applicationId "io.truckwash.twa.staging"
minSdkVersion 21
targetSdkVersion 35
versionCode 5
versionName "5"
targetSdkVersion 36
versionCode 6
versionName "6"
// The name for the application
resValue "string", "appName", twaManifest.name
@@ -1 +1 @@
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"pleno-pwa-test"}
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"/"}
+1173 -45
View File
File diff suppressed because it is too large Load Diff
+32 -4
View File
@@ -7,18 +7,34 @@
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint eslint.config.js src tests scripts vite.config.js vitest.config.js playwright*.ts --quiet",
"lint:strict": "npm run lint:report -- --max-warnings=0",
"lint:report": "eslint eslint.config.js src tests scripts vite.config.js vitest.config.js playwright*.ts",
"format:tests": "prettier --write \"tests/**/*.{js,ts}\"",
"format:tests: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",
"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",
"i18n:v2:compile": "node scripts/i18n-v2-compile-source.mjs",
"i18n:v2:source-check": "node scripts/i18n-v2-compile-source.mjs --check",
"i18n:v2:check": "npm run i18n:v2:source-check && npm run i18n:v2:global-template-audit && npm run i18n:v2:template-dedupe-audit && npm run i18n:v2:word-audit",
"i18n:v2:word-audit": "node scripts/i18n-v2-word-dedupe.mjs",
"i18n:v2:word-dedupe": "node scripts/i18n-v2-word-dedupe.mjs --apply",
"i18n:v2:global-template-audit": "node scripts/i18n-v2-globalize-templates.mjs",
"i18n:v2:globalize-templates": "node scripts/i18n-v2-globalize-templates.mjs --apply",
"i18n:v2:template-dedupe-audit": "node scripts/i18n-v2-template-dedupe.mjs",
"i18n:v2:template-dedupe": "node scripts/i18n-v2-template-dedupe.mjs --apply",
"test:unit": "npm run text:check-encoding && npm run test:unit:fast && npm run test:unit:serial",
"test:unit:fast": "node scripts/run-vitest-unit-fast.mjs",
"test:unit:serial": "node scripts/run-vitest-unit-batches.mjs --from-list tests/unit/serial-tests.txt",
"test:unit:single": "vitest run",
"test:e2e": "playwright test",
"test:e2e": "npm run test:e2e:matrix --",
"test:e2e:matrix": "playwright test",
"test:e2e:pr": "node scripts/run-playwright-pr.mjs",
"test:e2e:changed": "node scripts/run-playwright-pr.mjs --changed-only",
"test:e2e:i18n:views": "playwright test tests/e2e/i18n.views.spec.ts --project=chromium-desktop",
"test:e2e:ci": "node scripts/run-playwright-ci-parallel.mjs",
"test:e2e:full:slice": "node scripts/run-playwright-full-slice.mjs",
@@ -28,9 +44,14 @@
"test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile",
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
"test:e2e:live:public": "playwright test --config=playwright.live.config.ts --grep @public-live",
"test:e2e:live:roles": "playwright test --config=playwright.live.config.ts --grep @role-live",
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
"release:verify-upload": "node scripts/release/verify-upload.mjs",
"release:update-server-version": "node scripts/release/update-server-version.mjs",
"release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh",
"test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"",
"test:all": "npm run test:unit && npm run test:e2e:smoke",
"test:all": "npm run test:unit && npm run test:e2e:pr",
"twa:build": "bubblewrap build",
"twa:update": "bubblewrap update"
},
@@ -67,6 +88,7 @@
"chart.js": "^4.4.7",
"dayspan-vuetify": "^0.4.0",
"flow": "^0.2.3",
"html2canvas": "^1.4.1",
"qrcode": "^1.5.4",
"sass": "^1.81.1",
"sweetalert2": "11.22.4",
@@ -89,17 +111,23 @@
},
"devDependencies": {
"@creativebulma/bulma-divider": "^1.1.0",
"@eslint/js": "^10.0.1",
"@event-calendar/core": "^4.1.0",
"@playwright/test": "^1.58.2",
"@types/event-calendar__core": "^3.7.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/test-utils": "^2.4.6",
"eslint": "^10.4.1",
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.6.0",
"husky": "^9.1.7",
"jsdom": "^29.0.0",
"otpauth": "^9.5.0",
"prettier": "2.8.8",
"sass-embedded": "^1.81.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vite": "7.1.11",
"vite-plugin-pwa": "^1.0.2",
"vite-plugin-vue-devtools": "^7.5.4",
+4
View File
@@ -38,10 +38,14 @@ function buildProject(name: string, browserName: "chromium" | "firefox" | "webki
export default defineConfig({
testDir: "./tests/e2e",
testIgnore: ["**/release/**"],
snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}-win32{ext}",
timeout: 60_000,
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
expect: {
timeout: 15_000,
},
workers,
...(process.env.PLAYWRIGHT_BASE_URL
? {}
+67 -30
View File
@@ -1,4 +1,5 @@
import { execFile, spawn } from "node:child_process";
import { createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
@@ -11,15 +12,26 @@ const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://${devHost}:${devPort}
const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`)
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, "-");
const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`);
const serverLogDir = path.resolve(process.cwd(), "output/playwright");
const pidFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.json`);
const stdoutLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.stdout.log`);
const stderrLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.stderr.log`);
const viteCliPath = path.resolve(process.cwd(), "node_modules/vite/bin/vite.js");
const serverOutputLimit = 80;
const activeOutputReaders = [];
const viteServerEnv = {
...process.env,
PLAYWRIGHT: "1",
...(process.env.CI
? {
CHOKIDAR_INTERVAL: process.env.CHOKIDAR_INTERVAL || "300",
CHOKIDAR_USEPOLLING: process.env.CHOKIDAR_USEPOLLING || "true",
}
: {}),
};
// Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot.
const viteDevArgs = [
"run",
"dev",
"--",
"--force",
...(process.env.PLAYWRIGHT_VITE_FORCE === "1" ? ["--force"] : []),
...(process.platform === "win32" ? ["--configLoader", "runner"] : []),
"--host",
devHost,
@@ -176,14 +188,18 @@ async function killProcessTree(pid) {
}
}
function captureProcessOutput(stream, lines) {
function captureProcessOutput(stream, lines, logStream) {
const reader = readline.createInterface({ input: stream });
reader.on("line", (line) => {
lines.push(line);
logStream.write(`${line}\n`);
if (lines.length > serverOutputLimit) {
lines.splice(0, lines.length - serverOutputLimit);
}
});
reader.on("close", () => {
logStream.end();
});
return reader;
}
@@ -342,7 +358,9 @@ function resolveExpectedContentType(specifier) {
}
if (cleanSpecifier.endsWith(".json")) {
return "json";
// Vite serves imported JSON modules as JavaScript ("?import" transform),
// so warming should verify a JavaScript content type.
return "javascript";
}
return "javascript";
@@ -366,6 +384,10 @@ async function warmModuleGraph(entryUrl, { depth = 2, timeoutMs = 120_000 } = {}
}
for (const specifier of extractModuleImports(source)) {
if (specifier.startsWith("/node_modules/.vite/deps/")) {
continue;
}
const expectedContentType = resolveExpectedContentType(specifier);
const importUrl = new URL(specifier, current.url).toString();
@@ -384,12 +406,36 @@ async function warmModuleGraph(entryUrl, { depth = 2, timeoutMs = 120_000 } = {}
async function warmUpDevServer(url) {
const warmupTargets = await buildWarmupTargets();
const selfServeEntryModules = [
"/src/views/dashboards/userDashboard/wash/MyWash.vue",
"/src/views/dashboards/userDashboard/wash/MyWashStart.vue",
"/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue",
"/src/views/guest/book/GuestBookExteriorWash.vue",
];
const smokeRouteEntryModules = [
{ path: "/src/components/page/wrappers/LayoutV2.vue", depth: 2 },
{ path: "/src/views/pages/LandingPage.vue", depth: 2 },
{ path: "/src/views/dashboards/superUserDashboard/selfserve/EdgeAgents.vue", depth: 1 },
{ path: "/src/features/edgeGateways/EdgeGatewayManager.vue", depth: 2 },
{ path: "/src/features/edgeGateways/EdgeGatewayTerminalPage.vue", depth: 2 },
{ path: "/src/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue", depth: 1 },
{ path: "/src/features/edgeGateways/EdgeGatewayDepartmentWorkspace.vue", depth: 2 },
{ path: "/src/views/dashboards/superUserDashboard/CollectedOrderInvoices.vue", depth: 1 },
{ path: "/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionOverview.vue", depth: 2 },
{ path: "/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionMonthView.vue", depth: 2 },
];
for (const target of warmupTargets) {
await warmUpAsset(new URL(target.pathname, url).toString(), target.expectedContentType);
}
await warmModuleGraph(new URL("/src/main.js", url).toString());
for (const entryModule of selfServeEntryModules) {
await warmModuleGraph(new URL(entryModule, url).toString(), { depth: 2 });
}
for (const { path: entryModule, depth } of smokeRouteEntryModules) {
await warmModuleGraph(new URL(entryModule, url).toString(), { depth });
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
@@ -410,31 +456,22 @@ export default async function globalSetup() {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const serverProcess =
process.platform === "win32"
? spawn("cmd.exe", ["/d", "/s", "/c", `npm.cmd ${viteDevArgs.join(" ")}`], {
cwd: process.cwd(),
detached: true,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
})
: spawn("npm", viteDevArgs, {
cwd: process.cwd(),
detached: true,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: ["ignore", "pipe", "pipe"],
});
const serverProcess = spawn(process.execPath, [viteCliPath, ...viteDevArgs], {
cwd: process.cwd(),
detached: true,
env: viteServerEnv,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
const stdoutLines = [];
const stderrLines = [];
const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines);
const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines);
const stdoutLogStream = createWriteStream(stdoutLogFile, { flags: "w" });
const stderrLogStream = createWriteStream(stderrLogFile, { flags: "w" });
serverProcess.on("exit", (code, signal) => {
stderrLogStream.write(`[playwright-global-setup] vite exited with code ${code ?? "null"} signal ${signal ?? "null"}\n`);
});
const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines, stdoutLogStream);
const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines, stderrLogStream);
activeOutputReaders.push(stdoutReader, stderrReader);
serverProcess.unref();
+51 -20
View File
@@ -1,3 +1,4 @@
import fs from "node:fs";
import { defineConfig, devices } from "@playwright/test";
const baseURL = "http://127.0.0.1:4173";
@@ -5,6 +6,55 @@ const isCI = !!process.env.CI;
process.env.PLAYWRIGHT_BASE_URL = baseURL;
const osReleaseValue = (key: string) => {
try {
const body = fs.readFileSync("/etc/os-release", "utf8");
const match = body.match(new RegExp(`^${key}=(.*)$`, "m"));
return String(match?.[1] || "").replace(/^"|"$/g, "");
} catch {
return "";
}
};
const isUnsupportedWebKitHost = () => {
if (process.platform !== "linux") {
return false;
}
const id = osReleaseValue("ID").toLowerCase();
const version = Number.parseFloat(osReleaseValue("VERSION_ID"));
return id === "ubuntu" && Number.isFinite(version) && version >= 26.04;
};
const webKitOverride = String(process.env.PLAYWRIGHT_PROD_WEBKIT || "")
.trim()
.toLowerCase();
const includeWebKit =
webKitOverride === "1" || (webKitOverride !== "0" && webKitOverride !== "false" && !isUnsupportedWebKitHost());
const projects = [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 5"],
},
},
];
if (includeWebKit) {
projects.push({
name: "webkit-desktop",
use: {
...devices["Desktop Safari"],
},
});
}
export default defineConfig({
testDir: "./tests/e2e/release",
testMatch: /.*\.local-prod\.spec\.ts/,
@@ -28,24 +78,5 @@ export default defineConfig({
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"],
},
},
],
projects,
});
+57
View File
@@ -0,0 +1,57 @@
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_mime.c>
AddType application/manifest+json .webmanifest
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:assets|resources|favicons|icons|img|sounds|\.well-known)/ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule \.[^/]+$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.html [L]
</IfModule>
+3 -2
View File
@@ -2,15 +2,16 @@
"name": "Truck Wash Kundeportal",
"short_name": "Truck Wash",
"description": "Access your Truck Wash accounts and transactions from anywhere.",
"id": "/",
"icons": [
{
"src": "/favicons/web-app-manifest-192x192.png",
"src": "assets/favicons/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/favicons/web-app-manifest-512x512.png",
"src": "assets/favicons/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
+185
View File
@@ -0,0 +1,185 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const sourceDirectory = path.join(projectRoot, "src", "i18n", "source");
const generatedDirectory = path.join(projectRoot, "src", "i18n", "generated");
const activeLocales = ["da", "en", "sv", "de", "no"];
const mode = process.argv.includes("--check") ? "check" : "write";
const linkTokenPattern = /@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))/gu;
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
const stableJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
const writeJson = (filePath, value) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, stableJson(value), "utf8");
};
const listJsonFiles = (directory) => {
if (!fs.existsSync(directory)) {
return [];
}
const entries = fs.readdirSync(directory, { withFileTypes: true });
return entries
.flatMap((entry) => {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
return listJsonFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith(".json") ? [entryPath] : [];
})
.sort((left, right) => left.localeCompare(right, "en", { sensitivity: "base" }));
};
const mergeMessages = (left = {}, right = {}) => {
const merged = { ...left };
for (const [key, value] of Object.entries(right ?? {})) {
if (isPlainObject(value) && isPlainObject(merged[key])) {
merged[key] = mergeMessages(merged[key], value);
} else {
merged[key] = value;
}
}
return merged;
};
const readJsonFragments = (directory) => {
let merged = {};
for (const filePath of listJsonFiles(directory)) {
merged = mergeMessages(merged, readJson(filePath));
}
return merged;
};
const mapSourcePathToRuntimePath = (keyPath) => {
if (keyPath.startsWith("terms.glossary.")) {
return `words.generated.${keyPath.slice("terms.glossary.".length)}`;
}
if (keyPath.startsWith("terms.")) {
return `words.${keyPath.slice("terms.".length)}`;
}
if (keyPath.startsWith("phrases.compat.")) {
return `templates.generated.compat.${keyPath.slice("phrases.compat.".length)}`;
}
if (keyPath.startsWith("phrases.")) {
return `templates.${keyPath.slice("phrases.".length)}`;
}
return keyPath;
};
const rewriteLinks = (value) => {
if (typeof value === "string") {
return value.replace(linkTokenPattern, (token, modifier = "", literalTarget, pathTarget) => {
const target = literalTarget ?? pathTarget;
const mappedTarget = mapSourcePathToRuntimePath(target);
const quoteTarget = literalTarget || /[^\p{L}\p{N}_.-]/u.test(mappedTarget);
return quoteTarget ? `@${modifier}:{'${mappedTarget}'}` : `@${modifier}:${mappedTarget}`;
});
}
if (Array.isArray(value)) {
return value.map((entry) => rewriteLinks(entry));
}
if (!isPlainObject(value)) {
return value;
}
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, rewriteLinks(entry)]));
};
const compileWords = (sourceTerms) => {
const { glossary, ...terms } = sourceTerms;
return {
grammar: terms.grammar ?? {},
actions: terms.actions ?? {},
entities: terms.entities ?? {},
statuses: terms.statuses ?? {},
domain: terms.domain ?? {},
services: terms.services ?? {},
units: terms.units ?? {},
replication: terms.replication ?? {},
generated: glossary ?? {},
};
};
const compileTemplates = (sourcePhrases) => {
const { compat, ...phrases } = sourcePhrases;
return {
...phrases,
generated: {
compat: compat ?? {},
},
};
};
const compileLocale = (locale) => {
const localeSourceDirectory = path.join(sourceDirectory, locale);
const sourceTerms = readJsonFragments(path.join(localeSourceDirectory, "terms"));
const sourcePhrases = readJsonFragments(path.join(localeSourceDirectory, "phrases"));
return rewriteLinks({
words: compileWords(sourceTerms),
templates: compileTemplates(sourcePhrases),
});
};
const compileGlobal = () => {
const shared = readJsonFragments(path.join(sourceDirectory, "global", "shared"));
const locales = {};
for (const locale of activeLocales) {
locales[locale] = readJsonFragments(path.join(sourceDirectory, "global", "locales", locale));
}
return rewriteLinks({
shared,
locales,
});
};
const compiledFiles = new Map([
[path.join(generatedDirectory, "global-v2.json"), compileGlobal()],
...activeLocales.map((locale) => [path.join(generatedDirectory, `${locale}-v2.json`), compileLocale(locale)]),
]);
if (mode === "check") {
const mismatches = [];
for (const [filePath, value] of compiledFiles.entries()) {
const expected = stableJson(value);
const actual = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "") : "";
if (actual !== expected) {
mismatches.push(path.relative(projectRoot, filePath));
}
}
if (mismatches.length > 0) {
console.error("Generated i18n v2 runtime files are out of date:");
for (const mismatch of mismatches) {
console.error(` ${mismatch}`);
}
console.error("Run npm run i18n:v2:compile.");
process.exit(1);
}
console.log("Generated i18n v2 runtime files are up to date.");
} else {
for (const [filePath, value] of compiledFiles.entries()) {
writeJson(filePath, value);
}
console.log(`Compiled i18n v2 runtime files to ${path.relative(projectRoot, generatedDirectory)}.`);
}
+389
View File
@@ -0,0 +1,389 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
const activeLocales = ["da", "en", "sv", "de", "no"];
const mode = process.argv.includes("--apply") ? "apply" : "check";
const templatePrefix = "templates.generated.compat";
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)/gu;
const exactLinkPattern = /^@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))$/u;
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
};
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
const flattenStringEntries = (value, prefix = "") => {
if (typeof value === "string") {
return [{ key: prefix, value }];
}
if (!isPlainObject(value)) {
return [];
}
return Object.entries(value).flatMap(([key, entry]) =>
flattenStringEntries(entry, prefix ? `${prefix}.${key}` : key)
);
};
const getValueAtPath = (value, keyPath) => {
let current = value;
for (const segment of keyPath.split(".")) {
if (!isPlainObject(current) || !(segment in current)) {
return undefined;
}
current = current[segment];
}
return current;
};
const ensureObjectAtPath = (value, keyPath) => {
let current = value;
for (const segment of keyPath.split(".")) {
if (!isPlainObject(current[segment])) {
current[segment] = {};
}
current = current[segment];
}
return current;
};
const setValueAtPath = (value, keyPath, nextValue) => {
const segments = keyPath.split(".");
const parent = ensureObjectAtPath(value, segments.slice(0, -1).join("."));
parent[segments.at(-1)] = nextValue;
};
const deleteValueAtPath = (value, keyPath) => {
const segments = keyPath.split(".");
const parents = [];
let current = value;
for (const segment of segments.slice(0, -1)) {
if (!isPlainObject(current) || !(segment in current)) {
return false;
}
parents.push([current, segment]);
current = current[segment];
}
if (!isPlainObject(current) || !(segments.at(-1) in current)) {
return false;
}
delete current[segments.at(-1)];
for (let index = parents.length - 1; index >= 0; index -= 1) {
const [parent, segment] = parents[index];
const child = parent[segment];
if (isPlainObject(child) && Object.keys(child).length === 0) {
delete parent[segment];
} else {
break;
}
}
return true;
};
const mergeMessages = (sharedMessages = {}, localeMessages = {}) => {
const mergedMessages = { ...sharedMessages };
for (const [key, value] of Object.entries(localeMessages ?? {})) {
if (isPlainObject(value) && isPlainObject(mergedMessages[key])) {
mergedMessages[key] = mergeMessages(mergedMessages[key], value);
} else {
mergedMessages[key] = value;
}
}
return mergedMessages;
};
const applyTextModifier = (value, modifier) => {
switch (modifier) {
case ".capitalize":
return `${value.charAt(0).toLocaleUpperCase()}${value.slice(1)}`;
case ".upper":
return value.toLocaleUpperCase();
case ".lower":
return value.toLocaleLowerCase();
default:
return value;
}
};
const resolveLinkedMessage = (messages, keyPath, seen = new Set()) => {
if (seen.has(keyPath)) {
return undefined;
}
seen.add(keyPath);
const value = getValueAtPath(messages, keyPath);
if (typeof value !== "string") {
return undefined;
}
const exactLink = value.trim().match(exactLinkPattern);
if (exactLink) {
const target = exactLink.groups.literal ?? exactLink.groups.path;
const resolved = resolveLinkedMessage(messages, target, seen);
return resolved === undefined ? undefined : applyTextModifier(resolved, exactLink.groups.modifier);
}
return value.replace(linkTokenPattern, (token) => {
const match = token.match(exactLinkPattern);
if (!match?.groups) {
return token;
}
const target = match.groups.literal ?? match.groups.path;
const resolved = resolveLinkedMessage(messages, target, new Set(seen));
return resolved === undefined ? token : applyTextModifier(resolved, match.groups.modifier);
});
};
const readRawLocales = () =>
Object.fromEntries(
activeLocales.map((locale) => [locale, readJson(path.join(localesDirectory, `${locale}-v2.json`))])
);
const buildMergedLocales = (globalMessages, rawLocales) =>
Object.fromEntries(
activeLocales.map((locale) => [
locale,
mergeMessages(
mergeMessages(globalMessages.shared ?? {}, globalMessages.locales?.[locale] ?? {}),
rawLocales[locale]
),
])
);
const isCatalogPhraseKey = (keyPath) => !keyPath.startsWith("words.") && !keyPath.startsWith("templates.");
const exactLinkTarget = (value) => {
const match = value.trim().match(exactLinkPattern);
return match?.groups?.literal ?? match?.groups?.path;
};
const templateKeyFor = (keyPath) => `${templatePrefix}.${keyPath}`;
const linkedTemplateFor = (keyPath) => `@:{'${templateKeyFor(keyPath)}'}`;
const sourceValueFor = (globalMessages, rawLocales, locale, keyPath) => {
const rawValue = getValueAtPath(rawLocales[locale], keyPath);
if (typeof rawValue === "string") {
return rawValue;
}
const localeValue = getValueAtPath(globalMessages.locales?.[locale] ?? {}, keyPath);
if (typeof localeValue === "string") {
return localeValue;
}
const sharedValue = getValueAtPath(globalMessages.shared ?? {}, keyPath);
return typeof sharedValue === "string" ? sharedValue : undefined;
};
const collectCandidateKeys = (globalMessages, rawLocales) => {
const keys = new Set();
for (const locale of activeLocales) {
for (const entry of flattenStringEntries(rawLocales[locale]).filter((entry) => isCatalogPhraseKey(entry.key))) {
keys.add(entry.key);
}
for (const entry of flattenStringEntries(globalMessages.locales?.[locale] ?? {}).filter((entry) =>
isCatalogPhraseKey(entry.key)
)) {
const target = exactLinkTarget(entry.value);
if (target !== templateKeyFor(entry.key)) {
keys.add(entry.key);
}
}
}
return [...keys].sort((left, right) => left.localeCompare(right, "en", { sensitivity: "base" }));
};
const reorderLocaleRoot = (localeMessages) => {
const { words, templates, ...rest } = localeMessages;
return { words, templates, ...rest };
};
const pruneLocaleMap = (locales) => {
const nextLocales = {};
for (const locale of activeLocales) {
nextLocales[locale] = isPlainObject(locales?.[locale]) ? locales[locale] : {};
}
return nextLocales;
};
const transformCatalogs = (originalGlobalMessages, originalRawLocales) => {
const globalMessages = cloneJson(originalGlobalMessages);
const rawLocales = cloneJson(originalRawLocales);
globalMessages.shared = globalMessages.shared ?? {};
globalMessages.locales = pruneLocaleMap(globalMessages.locales ?? {});
const originalMergedLocales = buildMergedLocales(originalGlobalMessages, originalRawLocales);
const candidateKeys = collectCandidateKeys(originalGlobalMessages, originalRawLocales);
let directSharedAliasCount = 0;
let generatedTemplateCount = 0;
let removedRawEntryCount = 0;
let removedLocaleEntryCount = 0;
for (const keyPath of candidateKeys) {
const valuesByLocale = Object.fromEntries(
activeLocales.map((locale) => {
const sourceValue = sourceValueFor(originalGlobalMessages, originalRawLocales, locale, keyPath);
if (typeof sourceValue === "string") {
return [locale, sourceValue];
}
const mergedValue = getValueAtPath(originalMergedLocales[locale], keyPath);
return [locale, typeof mergedValue === "string" ? mergedValue : undefined];
})
);
const values = activeLocales.map((locale) => valuesByLocale[locale]);
if (values.some((value) => typeof value !== "string")) {
continue;
}
const firstValue = values[0];
const allValuesIdentical = values.every((value) => value === firstValue);
const canUseDirectSharedAlias =
allValuesIdentical &&
!firstValue.includes("words.generated.") &&
!firstValue.includes(templatePrefix) &&
exactLinkTarget(firstValue) !== keyPath;
if (canUseDirectSharedAlias) {
setValueAtPath(globalMessages.shared, keyPath, firstValue);
directSharedAliasCount += 1;
} else {
setValueAtPath(globalMessages.shared, keyPath, linkedTemplateFor(keyPath));
for (const locale of activeLocales) {
setValueAtPath(rawLocales[locale], templateKeyFor(keyPath), valuesByLocale[locale]);
}
generatedTemplateCount += 1;
}
for (const locale of activeLocales) {
if (deleteValueAtPath(rawLocales[locale], keyPath)) {
removedRawEntryCount += 1;
}
if (deleteValueAtPath(globalMessages.locales[locale], keyPath)) {
removedLocaleEntryCount += 1;
}
}
}
for (const locale of activeLocales) {
rawLocales[locale] = reorderLocaleRoot(rawLocales[locale]);
globalMessages.locales[locale] = isPlainObject(globalMessages.locales[locale])
? globalMessages.locales[locale]
: {};
}
const nextMergedLocales = buildMergedLocales(globalMessages, rawLocales);
const mismatches = [];
for (const locale of activeLocales) {
for (const entry of flattenStringEntries(originalMergedLocales[locale])) {
const expected = resolveLinkedMessage(originalMergedLocales[locale], entry.key);
const actual = resolveLinkedMessage(nextMergedLocales[locale], entry.key);
if (actual !== expected) {
mismatches.push(
`${locale}.${entry.key}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
);
}
}
}
const remainingRawPhraseEntries = activeLocales.flatMap((locale) =>
flattenStringEntries(rawLocales[locale])
.filter((entry) => isCatalogPhraseKey(entry.key))
.map((entry) => `${locale}.${entry.key}`)
);
const remainingLocalePhraseEntries = activeLocales.flatMap((locale) =>
flattenStringEntries(globalMessages.locales?.[locale] ?? {})
.filter((entry) => isCatalogPhraseKey(entry.key))
.map((entry) => `${locale}.${entry.key}`)
);
return {
globalMessages,
rawLocales,
candidateCount: candidateKeys.length,
directSharedAliasCount,
generatedTemplateCount,
removedRawEntryCount,
removedLocaleEntryCount,
mismatches,
remainingRawPhraseEntries,
remainingLocalePhraseEntries,
};
};
const globalPath = path.join(localesDirectory, "global-v2.json");
const originalGlobalMessages = readJson(globalPath);
const originalRawLocales = readRawLocales();
const result = transformCatalogs(originalGlobalMessages, originalRawLocales);
console.log(
[
`candidate phrase keys: ${result.candidateCount}`,
`direct shared aliases: ${result.directSharedAliasCount}`,
`generated shared template aliases: ${result.generatedTemplateCount}`,
`removed raw locale entries: ${result.removedRawEntryCount}`,
`removed global locale entries: ${result.removedLocaleEntryCount}`,
].join("\n")
);
let hasErrors = false;
if (result.mismatches.length > 0) {
hasErrors = true;
console.error(`transformed output changed ${result.mismatches.length} strings`);
for (const mismatch of result.mismatches.slice(0, 30)) {
console.error(` ${mismatch}`);
}
}
if (result.remainingRawPhraseEntries.length > 0) {
hasErrors = true;
console.error(`raw locale phrase entries remain outside words/templates: ${result.remainingRawPhraseEntries.length}`);
for (const entry of result.remainingRawPhraseEntries.slice(0, 30)) {
console.error(` ${entry}`);
}
}
if (result.remainingLocalePhraseEntries.length > 0) {
hasErrors = true;
console.error(`global-v2 locale phrase entries remain: ${result.remainingLocalePhraseEntries.length}`);
for (const entry of result.remainingLocalePhraseEntries.slice(0, 30)) {
console.error(` ${entry}`);
}
}
if (hasErrors) {
process.exit(1);
}
if (mode === "apply") {
writeJson(globalPath, result.globalMessages);
for (const locale of activeLocales) {
writeJson(path.join(localesDirectory, `${locale}-v2.json`), result.rawLocales[locale]);
}
}
+341
View File
@@ -0,0 +1,341 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
const activeLocales = ["da", "en", "sv", "de", "no"];
const mode = process.argv.includes("--apply") ? "apply" : "check";
const templatePrefix = "templates.generated.compat";
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*\}/g;
const exactLinkPattern = /^@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)$/u;
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
};
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
const flattenStringEntries = (value, prefix = "") => {
if (typeof value === "string") {
return [{ key: prefix, value }];
}
if (!isPlainObject(value)) {
return [];
}
return Object.entries(value).flatMap(([key, entry]) =>
flattenStringEntries(entry, prefix ? `${prefix}.${key}` : key)
);
};
const getValueAtPath = (value, keyPath) => {
let current = value;
for (const segment of keyPath.split(".")) {
if (!isPlainObject(current) || !(segment in current)) {
return undefined;
}
current = current[segment];
}
return current;
};
const ensureObjectAtPath = (value, keyPath) => {
let current = value;
if (!keyPath) {
return current;
}
for (const segment of keyPath.split(".")) {
if (!isPlainObject(current[segment])) {
current[segment] = {};
}
current = current[segment];
}
return current;
};
const setValueAtPath = (value, keyPath, nextValue) => {
const segments = keyPath.split(".");
const parent = ensureObjectAtPath(value, segments.slice(0, -1).join("."));
parent[segments.at(-1)] = nextValue;
};
const deleteValueAtPath = (value, keyPath) => {
const segments = keyPath.split(".");
const parents = [];
let current = value;
for (const segment of segments.slice(0, -1)) {
if (!isPlainObject(current) || !(segment in current)) {
return false;
}
parents.push([current, segment]);
current = current[segment];
}
if (!isPlainObject(current) || !(segments.at(-1) in current)) {
return false;
}
delete current[segments.at(-1)];
for (let index = parents.length - 1; index >= 0; index -= 1) {
const [parent, segment] = parents[index];
const child = parent[segment];
if (isPlainObject(child) && Object.keys(child).length === 0) {
delete parent[segment];
} else {
break;
}
}
return true;
};
const normalizePhrase = (value) =>
value
.normalize("NFKC")
.toLocaleLowerCase()
.replace(placeholderPattern, "{}")
.replace(/[\p{P}\p{S}]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
const linkToTemplate = (suffix) => `@:{'${templatePrefix}.${suffix}'}`;
const canonicalPriority = (suffix) => {
if (suffix.startsWith("common.")) return 0;
if (suffix.startsWith("global.")) return 1;
if (suffix.startsWith("nav.")) return 2;
if (suffix.startsWith("tables.common.")) return 3;
if (suffix.startsWith("objects.columns.")) return 4;
if (suffix.startsWith("auth.")) return 5;
if (suffix.startsWith("products.")) return 6;
return 10;
};
const chooseCanonicalSuffix = (suffixes) =>
[...suffixes].sort(
(left, right) =>
canonicalPriority(left) - canonicalPriority(right) ||
left.split(".").length - right.split(".").length ||
left.length - right.length ||
left.localeCompare(right, "en", { sensitivity: "base" })
)[0];
const createDisjointSet = (items) => {
const parent = new Map(items.map((item) => [item, item]));
const find = (item) => {
const currentParent = parent.get(item);
if (currentParent === item) {
return item;
}
const root = find(currentParent);
parent.set(item, root);
return root;
};
const union = (left, right) => {
const leftRoot = find(left);
const rightRoot = find(right);
if (leftRoot !== rightRoot) {
parent.set(rightRoot, leftRoot);
}
};
const groups = () => {
const grouped = new Map();
for (const item of items) {
const root = find(item);
const group = grouped.get(root) ?? [];
group.push(item);
grouped.set(root, group);
}
return [...grouped.values()];
};
return { union, groups };
};
const replaceTemplatePathReferences = (value, replacements) => {
if (typeof value === "string") {
let nextValue = value;
for (const [fromSuffix, toSuffix] of replacements.entries()) {
nextValue = nextValue.replaceAll(`${templatePrefix}.${fromSuffix}`, `${templatePrefix}.${toSuffix}`);
}
return nextValue;
}
if (Array.isArray(value)) {
return value.map((entry) => replaceTemplatePathReferences(entry, replacements));
}
if (!isPlainObject(value)) {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, replaceTemplatePathReferences(entry, replacements)])
);
};
const readRawLocales = () =>
Object.fromEntries(
activeLocales.map((locale) => [locale, readJson(path.join(localesDirectory, `${locale}-v2.json`))])
);
const collectTemplateSuffixes = (rawLocales) => {
const suffixes = new Set();
for (const locale of activeLocales) {
for (const entry of flattenStringEntries(getValueAtPath(rawLocales[locale], templatePrefix) ?? {})) {
suffixes.add(entry.key);
}
}
return [...suffixes].sort((left, right) => left.localeCompare(right, "en", { sensitivity: "base" }));
};
const transformCatalogs = (originalGlobalMessages, originalRawLocales) => {
const rawLocales = cloneJson(originalRawLocales);
let globalMessages = cloneJson(originalGlobalMessages);
const suffixes = collectTemplateSuffixes(rawLocales);
const disjointSet = createDisjointSet(suffixes);
for (const locale of activeLocales) {
const groupedByLocalePhrase = new Map();
for (const suffix of suffixes) {
const value = getValueAtPath(rawLocales[locale], `${templatePrefix}.${suffix}`);
if (typeof value !== "string") {
continue;
}
const signature = normalizePhrase(value);
if (signature.length < 4) {
continue;
}
const group = groupedByLocalePhrase.get(signature) ?? [];
group.push(suffix);
groupedByLocalePhrase.set(signature, group);
}
for (const group of groupedByLocalePhrase.values()) {
if (group.length <= 1) {
continue;
}
const [first, ...rest] = group;
for (const suffix of rest) {
disjointSet.union(first, suffix);
}
}
}
const duplicateGroups = disjointSet.groups().filter((group) => group.length > 1);
const replacements = new Map();
for (const group of duplicateGroups) {
const canonicalSuffix = chooseCanonicalSuffix(group);
for (const suffix of group) {
if (suffix !== canonicalSuffix) {
replacements.set(suffix, canonicalSuffix);
}
}
}
for (const [fromSuffix, toSuffix] of [...replacements.entries()]) {
const seen = new Set([fromSuffix]);
let finalSuffix = toSuffix;
while (replacements.has(finalSuffix) && !seen.has(finalSuffix)) {
seen.add(finalSuffix);
finalSuffix = replacements.get(finalSuffix);
}
replacements.set(fromSuffix, finalSuffix);
}
if (replacements.size > 0) {
globalMessages = replaceTemplatePathReferences(globalMessages, replacements);
for (const locale of activeLocales) {
rawLocales[locale] = replaceTemplatePathReferences(rawLocales[locale], replacements);
}
}
let removedTemplateCount = 0;
let repointedAliasCount = 0;
for (const [fromSuffix, toSuffix] of replacements.entries()) {
setValueAtPath(globalMessages.shared, fromSuffix, linkToTemplate(toSuffix));
repointedAliasCount += 1;
for (const locale of activeLocales) {
if (deleteValueAtPath(rawLocales[locale], `${templatePrefix}.${fromSuffix}`)) {
removedTemplateCount += 1;
}
}
}
const remainingDuplicateGroups = [];
for (const locale of activeLocales) {
const nonLinkedTemplates = flattenStringEntries(getValueAtPath(rawLocales[locale], templatePrefix) ?? {}).filter(
(entry) => !exactLinkPattern.test(entry.value.trim())
);
const byPhrase = new Map();
for (const entry of nonLinkedTemplates) {
const signature = normalizePhrase(entry.value);
const group = byPhrase.get(signature) ?? [];
group.push(entry.key);
byPhrase.set(signature, group);
}
for (const [signature, group] of byPhrase.entries()) {
if (signature.length >= 4 && group.length > 1) {
remainingDuplicateGroups.push(`${locale}: ${signature} <- ${group.join(", ")}`);
}
}
}
return {
globalMessages,
rawLocales,
duplicateGroupCount: duplicateGroups.length,
replacementCount: replacements.size,
repointedAliasCount,
removedTemplateCount,
remainingDuplicateGroups,
};
};
const globalPath = path.join(localesDirectory, "global-v2.json");
const originalGlobalMessages = readJson(globalPath);
const originalRawLocales = readRawLocales();
const result = transformCatalogs(originalGlobalMessages, originalRawLocales);
console.log(
[
`duplicate generated template groups: ${result.duplicateGroupCount}`,
`template replacements: ${result.replacementCount}`,
`repointed shared aliases: ${result.repointedAliasCount}`,
`removed locale template entries: ${result.removedTemplateCount}`,
].join("\n")
);
if (result.remainingDuplicateGroups.length > 0) {
console.error(`near-duplicate generated templates remain: ${result.remainingDuplicateGroups.length}`);
for (const group of result.remainingDuplicateGroups.slice(0, 30)) {
console.error(` ${group}`);
}
process.exit(1);
}
if (mode === "apply") {
writeJson(globalPath, result.globalMessages);
for (const locale of activeLocales) {
writeJson(path.join(localesDirectory, `${locale}-v2.json`), result.rawLocales[locale]);
}
}
+390
View File
@@ -0,0 +1,390 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
const activeLocales = ["da", "en", "sv", "de", "no"];
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)/gu;
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*\}/g;
const wordTokenPattern = /[\p{L}\p{N}]+/gu;
const linkedMessagePattern = /^@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))$/u;
const mode = process.argv.includes("--apply") ? "apply" : "check";
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
};
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
const flattenStringEntries = (value, prefix = "") => {
if (typeof value === "string") {
return [{ key: prefix, value }];
}
if (!isPlainObject(value)) {
return [];
}
return Object.entries(value).flatMap(([key, entry]) =>
flattenStringEntries(entry, prefix ? `${prefix}.${key}` : key)
);
};
const getValueAtPath = (value, keyPath) => {
let current = value;
for (const segment of keyPath.split(".")) {
if (!isPlainObject(current) || !(segment in current)) {
return undefined;
}
current = current[segment];
}
return current;
};
const setValueAtPath = (value, keyPath, nextValue) => {
const segments = keyPath.split(".");
let current = value;
for (const segment of segments.slice(0, -1)) {
current = current[segment];
}
current[segments.at(-1)] = nextValue;
};
const compareStringValues = (left, right) => left.localeCompare(right, "en", { sensitivity: "base" });
const protectSegments = (value) => {
const protectedRanges = [];
for (const pattern of [linkTokenPattern, placeholderPattern]) {
pattern.lastIndex = 0;
for (const match of value.matchAll(pattern)) {
protectedRanges.push({ start: match.index ?? 0, end: (match.index ?? 0) + match[0].length });
}
}
protectedRanges.sort((a, b) => a.start - b.start || b.end - a.end);
const mergedRanges = [];
for (const range of protectedRanges) {
const previous = mergedRanges.at(-1);
if (previous && range.start <= previous.end) {
previous.end = Math.max(previous.end, range.end);
} else {
mergedRanges.push({ ...range });
}
}
const segments = [];
let offset = 0;
for (const range of mergedRanges) {
if (offset < range.start) {
segments.push({ protected: false, value: value.slice(offset, range.start) });
}
segments.push({ protected: true, value: value.slice(range.start, range.end) });
offset = range.end;
}
if (offset < value.length) {
segments.push({ protected: false, value: value.slice(offset) });
}
return segments;
};
const normalizeToken = (token) => token.normalize("NFKC").toLocaleLowerCase();
const isAuditedToken = (token) => /\p{L}/u.test(token);
const collectTokens = (value) => {
const tokens = [];
for (const segment of protectSegments(value)) {
if (segment.protected) {
continue;
}
wordTokenPattern.lastIndex = 0;
for (const match of segment.value.matchAll(wordTokenPattern)) {
const token = match[0];
if (isAuditedToken(token)) {
tokens.push(token);
}
}
}
return tokens;
};
const stripMarks = (value) => value.normalize("NFKD").replace(/\p{M}/gu, "");
const slugify = (token, usedKeys) => {
const base =
stripMarks(token)
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "_")
.replace(/^_+|_+$/g, "")
.replace(/_{2,}/g, "_") || "word";
const prefixed = /^\p{N}/u.test(base) ? `word_${base}` : base;
let key = prefixed;
let suffix = 2;
while (usedKeys.has(key)) {
key = `${prefixed}_${suffix}`;
suffix += 1;
}
usedKeys.add(key);
return key;
};
const isCapitalizedToken = (token) => {
const lower = token.toLocaleLowerCase();
return token === `${lower.charAt(0).toLocaleUpperCase()}${lower.slice(1)}` && token !== lower;
};
const isUpperToken = (token) => token.length > 1 && token === token.toLocaleUpperCase() && token !== token.toLocaleLowerCase();
const isLowerToken = (token) => token === token.toLocaleLowerCase() && token !== token.toLocaleUpperCase();
const linkedWord = (keyPath, modifier = "") => `@${modifier}:{'${keyPath}'}`;
const applyTextModifier = (value, modifier) => {
switch (modifier) {
case ".capitalize":
return `${value.charAt(0).toLocaleUpperCase()}${value.slice(1)}`;
case ".upper":
return value.toLocaleUpperCase();
case ".lower":
return value.toLocaleLowerCase();
default:
return value;
}
};
const modifierForToken = (token, word) => {
if (normalizeToken(token) !== normalizeToken(word.value)) {
return null;
}
const candidateModifiers = ["", ".upper", ".capitalize", ".lower"];
for (const modifier of candidateModifiers) {
if (applyTextModifier(word.value, modifier) === token) {
return modifier;
}
}
return null;
};
const replaceRepeatedTokens = (value, tokenToWord) =>
protectSegments(value)
.map((segment) => {
if (segment.protected) {
return segment.value;
}
return segment.value.replace(wordTokenPattern, (token) => {
const normalized = normalizeToken(token);
const word = tokenToWord.get(normalized);
if (!word || !isAuditedToken(token)) {
return token;
}
const exactVariant = word.variants?.get(token);
if (exactVariant) {
return linkedWord(exactVariant.path);
}
const modifier = modifierForToken(token, word);
if (modifier !== null) {
return linkedWord(word.path, modifier);
}
return token;
});
})
.join("");
const applyLinkedModifier = (value, modifier) => {
return applyTextModifier(value, modifier);
};
const resolveLinkedMessage = (messages, keyPath, seen = new Set()) => {
if (seen.has(keyPath)) {
return undefined;
}
seen.add(keyPath);
const value = getValueAtPath(messages, keyPath);
if (typeof value !== "string") {
return undefined;
}
const exactLink = value.trim().match(linkedMessagePattern);
if (exactLink) {
const resolved = resolveLinkedMessage(messages, exactLink.groups.literal ?? exactLink.groups.path, seen);
return resolved === undefined ? undefined : applyLinkedModifier(resolved, exactLink.groups.modifier);
}
return value.replace(linkTokenPattern, (token) => {
const match = token.match(linkedMessagePattern);
if (!match?.groups) {
return token;
}
const resolved = resolveLinkedMessage(messages, match.groups.literal ?? match.groups.path, new Set(seen));
if (resolved === undefined) {
return token;
}
return applyLinkedModifier(resolved, match.groups.modifier);
});
};
const reorderLocaleRoot = (localeMessages) => {
const { words, templates, ...rest } = localeMessages;
return { words, templates, ...rest };
};
const transformLocale = (locale, originalMessages) => {
const messages = cloneJson(originalMessages);
const entries = flattenStringEntries(messages).filter((entry) => !entry.key.startsWith("words."));
const tokenStats = new Map();
for (const entry of entries) {
for (const token of collectTokens(entry.value)) {
const normalized = normalizeToken(token);
const stat = tokenStats.get(normalized) ?? { count: 0, casings: new Map() };
stat.count += 1;
stat.casings.set(token, (stat.casings.get(token) ?? 0) + 1);
tokenStats.set(normalized, stat);
}
}
const repeatedTokens = [...tokenStats.entries()]
.filter(([, stat]) => stat.count > 1)
.sort(([left], [right]) => compareStringValues(left, right));
const existingWords = messages.words ?? {};
const generatedWords = {};
const tokenToWord = new Map();
const usedKeys = new Set(Object.keys(existingWords.generated ?? {}));
for (const [normalized, stat] of repeatedTokens) {
const existingWordEntry = flattenStringEntries(existingWords).find(
(entry) => normalizeToken(entry.value) === normalized
);
if (existingWordEntry) {
tokenToWord.set(normalized, { path: `words.${existingWordEntry.key}`, value: existingWordEntry.value });
continue;
}
const casings = [...stat.casings.entries()].sort(
([leftToken, leftCount], [rightToken, rightCount]) =>
rightCount - leftCount || compareStringValues(leftToken, rightToken)
);
const lowerCaseToken = casings.find(([token]) => token === normalized)?.[0];
const canonical = lowerCaseToken ?? casings[0][0];
const key = slugify(normalized, usedKeys);
generatedWords[key] = canonical;
const word = { path: `words.generated.${key}`, value: canonical, variants: new Map() };
for (const [casing] of casings) {
if (modifierForToken(casing, word) !== null) {
continue;
}
const variantKey = slugify(`${normalized}_${casing}`, usedKeys);
generatedWords[variantKey] = casing;
word.variants.set(casing, { path: `words.generated.${variantKey}`, value: casing });
}
tokenToWord.set(normalized, word);
}
messages.words = {
grammar: messages.words?.grammar ?? {},
actions: messages.words?.actions ?? {},
entities: messages.words?.entities ?? {},
statuses: messages.words?.statuses ?? {},
domain: messages.words?.domain ?? {},
services: messages.words?.services ?? {},
units: messages.words?.units ?? {},
...(messages.words ?? {}),
generated: {
...(messages.words?.generated ?? {}),
...Object.fromEntries(Object.entries(generatedWords).sort(([left], [right]) => compareStringValues(left, right))),
},
};
for (const entry of entries) {
const nextValue = replaceRepeatedTokens(entry.value, tokenToWord);
if (nextValue !== entry.value) {
setValueAtPath(messages, entry.key, nextValue);
}
}
const mismatches = [];
for (const entry of entries) {
const previousResolved = resolveLinkedMessage(originalMessages, entry.key);
const resolved = resolveLinkedMessage(messages, entry.key);
if (resolved !== previousResolved) {
mismatches.push(`${entry.key}: expected ${JSON.stringify(previousResolved)}, got ${JSON.stringify(resolved)}`);
}
}
return {
messages: reorderLocaleRoot(messages),
generatedWordCount: Object.keys(generatedWords).length,
repeatedTokenCount: repeatedTokens.length,
mismatches,
};
};
const countRepeatedTokensOutsideWords = (messages) => {
const counts = new Map();
for (const entry of flattenStringEntries(messages).filter((entry) => !entry.key.startsWith("words."))) {
for (const token of collectTokens(entry.value)) {
const normalized = normalizeToken(token);
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
}
}
return [...counts.entries()].filter(([, count]) => count > 1);
};
let hasErrors = false;
for (const locale of activeLocales) {
const filePath = path.join(localesDirectory, `${locale}-v2.json`);
const originalMessages = readJson(filePath);
const result = transformLocale(locale, originalMessages);
const repeatedAfter = countRepeatedTokensOutsideWords(result.messages);
if (result.mismatches.length > 0) {
hasErrors = true;
console.error(`${locale}: transformed output changed ${result.mismatches.length} strings`);
for (const mismatch of result.mismatches.slice(0, 20)) {
console.error(` ${mismatch}`);
}
}
if (repeatedAfter.length > 0) {
hasErrors = true;
console.error(`${locale}: ${repeatedAfter.length} repeated token groups remain outside words`);
for (const [token, count] of repeatedAfter.slice(0, 20)) {
console.error(` ${token}: ${count}`);
}
}
console.log(
`${locale}: ${result.repeatedTokenCount} repeated token groups, ${result.generatedWordCount} new generated word entries`
);
if (mode === "apply" && !hasErrors) {
writeJson(filePath, result.messages);
}
}
if (hasErrors) {
process.exit(1);
}
+90
View File
@@ -0,0 +1,90 @@
import { spawnSync } from "node:child_process";
const browsers = process.argv.slice(2);
const requestedBrowsers = browsers.length > 0 ? browsers : ["chromium"];
const fallbackHostPlatformByUnsupportedPlatform = (platform) => {
const ubuntuMatch = platform.match(/^ubuntu(\d+\.\d+)-(x64|arm64)$/);
if (ubuntuMatch && Number.parseInt(ubuntuMatch[1], 10) >= 26) {
return `ubuntu24.04-${ubuntuMatch[2]}`;
}
return null;
};
const outputText = (result) => `${result.stdout || ""}\n${result.stderr || ""}`;
const unsupportedHostPlatform = (result) => {
const output = outputText(result);
return (
output.match(/Cannot install dependencies for (?<platform>\S+) with Playwright/i)?.groups?.platform ||
output.match(/Playwright does not support \S+ on (?<platform>\S+)/i)?.groups?.platform ||
null
);
};
const runPlaywrightInstall = (args, env = {}) =>
spawnSync("npx", ["playwright", "install", ...args], {
cwd: process.cwd(),
env: {
...process.env,
...env,
},
encoding: "utf8",
});
const writeOutput = (result) => {
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
};
const hasUnsupportedHostPlatformFailure = (result) => {
const output = outputText(result);
return (
result.status !== 0 &&
/Playwright does not support .* on /i.test(output)
);
};
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
writeOutput(withDepsResult);
if (withDepsResult.status === 0) {
process.exit(0);
}
if (!hasUnsupportedHostPlatformFailure(withDepsResult)) {
process.exit(withDepsResult.status ?? 1);
}
const unsupportedPlatform = unsupportedHostPlatform(withDepsResult);
const fallbackHostPlatform = unsupportedPlatform
? fallbackHostPlatformByUnsupportedPlatform(unsupportedPlatform)
: null;
if (!fallbackHostPlatform) {
console.error(
`Playwright dependency install is unsupported for ${unsupportedPlatform || "this platform"}, ` +
"and this script does not have a safe browser archive fallback for it."
);
process.exit(withDepsResult.status ?? 1);
}
console.warn(
[
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
`Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
"The self-hosted runner image must provide the required browser system libraries.",
].join("\n")
);
const fallbackResult = runPlaywrightInstall(requestedBrowsers, {
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE: fallbackHostPlatform,
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: "1",
});
writeOutput(fallbackResult);
process.exit(fallbackResult.status ?? 1);
+89
View File
@@ -0,0 +1,89 @@
export const chromiumProjects = ["chromium-desktop", "chromium-mobile"];
export const prGrep = "@pr";
export const smokeGrep = "@smoke";
export const fallbackChangePatterns = [
/^package(?:-lock)?\.json$/u,
/^vite\.config\.js$/u,
/^playwright(?:\..+)?\.config\.(?:js|ts)$/u,
/^playwright\.global-(?:setup|teardown)\.mjs$/u,
/^scripts\/run-playwright/u,
/^tests\/e2e\/(?:support|fixtures)\//u,
];
export const sourceMappings = [
{
name: "auth",
patterns: [/^src\/(?:views|components|middleware)\/.*auth/iu, /^src\/views\/auth\//u, /^src\/components\/session\//u],
specs: ["tests/e2e/auth.smoke.spec.js", "tests/e2e/userAuth.spec.ts"],
projects: chromiumProjects,
},
{
name: "public-routes",
patterns: [/^src\/views\/pages\//u, /^src\/components\/page\//u, /^src\/router\.js$/u],
specs: ["tests/e2e/navigation.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "booking",
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
specs: [
"tests/e2e/booking-selfserve.smoke.spec.js",
"tests/e2e/userBookWash.spec.ts",
"tests/e2e/userBookings.spec.ts",
],
projects: chromiumProjects,
},
{
name: "user-vehicles",
patterns: [/^src\/components\/displays\/user\/vehicles\//u, /^src\/views\/dashboards\/userDashboard\/vehicles\//u],
specs: ["tests/e2e/userVehicles.spec.ts"],
projects: chromiumProjects,
},
{
name: "pos",
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u],
specs: ["tests/e2e/pos-flow.spec.js", "tests/e2e/pos-mobile-order-flow.spec.js", "tests/e2e/admin-pos-orders.spec.ts"],
projects: chromiumProjects,
},
{
name: "edge-gateways",
patterns: [/edge[-/]?gateways?/iu, /edgeGateway/iu],
specs: ["tests/e2e/edge-gateways.smoke.spec.js", "tests/e2e/edge-gateways.routes.spec.js"],
projects: chromiumProjects,
},
{
name: "invoicing",
patterns: [/invoic/iu, /economic[-/]?queue/iu, /collected-order/iu],
specs: [
"tests/e2e/invoicing-period.smoke.spec.js",
"tests/e2e/invoice-distribution.smoke.spec.js",
"tests/e2e/invoice-transfer-queue-history.spec.js",
],
projects: chromiumProjects,
},
{
name: "workfeed",
patterns: [/workfeed/iu],
specs: ["tests/e2e/workfeed-config.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "system-status",
patterns: [/system[-/]?status/iu, /systemDatabase/iu, /replication/iu],
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "self-serve",
patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu],
specs: ["tests/e2e/self-serve-wash.spec.js", "tests/e2e/self-serve-studio-flow.spec.js"],
projects: chromiumProjects,
},
{
name: "i18n",
patterns: [/^src\/i18n\//u, /i18n/iu, /locales/iu],
specs: ["tests/e2e/i18n.smoke.spec.ts"],
projects: chromiumProjects,
},
];
@@ -1,22 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const helperScriptPath = path.resolve(process.cwd(), "..", "scripts", "sync-playwright-root-links.mjs");
if (!fs.existsSync(helperScriptPath)) {
console.log(
`Skipping root Playwright link sync: helper script not found at ${helperScriptPath}.`
);
process.exit(0);
}
const result = spawnSync(process.execPath, [helperScriptPath], {
stdio: "inherit",
});
if (typeof result.status === "number") {
process.exit(result.status);
}
process.exit(1);
+50
View File
@@ -0,0 +1,50 @@
import { execSync } from "node:child_process";
function gitCommit() {
try {
return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
} catch {
return "";
}
}
async function main() {
const token = process.env.SERVER_UPDATE_TOKEN;
const required = process.env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
if (!token) {
if (required) {
throw new Error("SERVER_UPDATE_TOKEN is required after deploy verification.");
}
console.log("SERVER_UPDATE_TOKEN is not set. Skipping server version update.");
return;
}
const version = process.env.RELEASE_VERSION || process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || gitCommit();
if (!version) {
throw new Error("Could not determine release version for server update.");
}
const baseUrl = process.env.SERVER_UPDATE_URL || "https://api-v2.truckwash.io/master/api/worker/update-version";
const url = new URL(baseUrl);
url.searchParams.set("version", version);
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Cache-Control": "no-cache",
},
});
const body = await response.text();
if (!response.ok) {
throw new Error(`Server version update failed with HTTP ${response.status}: ${body}`);
}
console.log(`Server version updated to ${version}.`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
set -euo pipefail
DIST_DIR="${DIST_DIR:-dist}"
REMOTE_ROOT="${RELEASE_DEPLOY_REMOTE_ROOT:-/}"
HOST="${RELEASE_DEPLOY_HOST:-}"
USER_NAME="${RELEASE_DEPLOY_USER:-}"
PASSWORD="${RELEASE_DEPLOY_PASSWORD:-}"
DEPLOY_URL="${RELEASE_DEPLOY_URL:-}"
if [[ ! -d "$DIST_DIR" ]]; then
echo "Missing dist directory: $DIST_DIR" >&2
exit 1
fi
if ! command -v lftp >/dev/null 2>&1; then
echo "lftp is required for FTP/SFTP uploads." >&2
exit 1
fi
if [[ -z "$DEPLOY_URL" ]]; then
if [[ -z "$HOST" || -z "$USER_NAME" || -z "$PASSWORD" ]]; then
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
exit 1
fi
DEPLOY_URL="ftp://${HOST}"
elif [[ -n "$USER_NAME" || -n "$PASSWORD" ]]; then
if [[ -z "$USER_NAME" || -z "$PASSWORD" ]]; then
echo "Set both RELEASE_DEPLOY_USER and RELEASE_DEPLOY_PASSWORD when providing deploy credentials separately." >&2
exit 1
fi
fi
lftp_quote() {
local value="${1//\'/\'\\\'\'}"
printf "'%s'" "$value"
}
run_lftp() {
local transfer_command="$1"
{
printf 'set ftp:ssl-allow true\n'
printf 'set ftp:ssl-force true\n'
printf 'set ftp:ssl-protect-data true\n'
printf 'set net:max-retries 3\n'
printf 'set net:timeout 20\n'
if [[ -n "$USER_NAME" && -n "$PASSWORD" ]]; then
printf 'open -u %s,%s %s\n' "$(lftp_quote "$USER_NAME")" "$(lftp_quote "$PASSWORD")" "$(lftp_quote "$DEPLOY_URL")"
else
printf 'open %s\n' "$(lftp_quote "$DEPLOY_URL")"
fi
printf 'cd %s\n' "$(lftp_quote "$REMOTE_ROOT")"
printf '%s\n' "$transfer_command"
printf 'bye\n'
} | lftp -f /dev/stdin
}
upload_file() {
local source_file="$1"
local remote_file="$2"
if [[ -f "$source_file" ]]; then
run_lftp "put -O $(lftp_quote "$(dirname "$remote_file")") $(lftp_quote "$source_file") -o $(lftp_quote "$(basename "$remote_file")")"
fi
}
for directory in assets resources favicons icons img sounds .well-known; do
if [[ -d "$DIST_DIR/$directory" ]]; then
run_lftp "mirror -R --only-newer --parallel=4 $(lftp_quote "$DIST_DIR/$directory") $(lftp_quote "$directory")"
fi
done
find "$DIST_DIR" -maxdepth 1 -type f \
! -name 'index.html' \
! -name 'release-entry.json' \
! -name 'release-manifest.json' \
-print0 | while IFS= read -r -d '' file; do
upload_file "$file" "$(basename "$file")"
done
upload_file "$DIST_DIR/release-manifest.json" "release-manifest.json"
upload_file "$DIST_DIR/release-entry.json" "release-entry.json"
upload_file "$DIST_DIR/index.html" "index.html"
+239
View File
@@ -0,0 +1,239 @@
import crypto from "node:crypto";
import fs from "node:fs";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function numberEnv(name, fallback) {
const value = Number.parseInt(process.env[name] || "", 10);
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function booleanEnv(name) {
return /^(1|true|yes)$/i.test(process.env[name] || "");
}
function appendGithubEnv(values) {
const envFile = process.env.GITHUB_ENV;
if (!envFile) {
return;
}
const lines = Object.entries(values)
.filter(([, value]) => value)
.map(([key, value]) => `${key}=${String(value).replace(/\r?\n/g, "")}`);
if (lines.length > 0) {
fs.appendFileSync(envFile, `${lines.join("\n")}\n`);
}
}
function requiredUrl() {
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
if (!value) {
throw new Error("RELEASE_BASE_URL or PLAYWRIGHT_BASE_URL is required.");
}
return value.endsWith("/") ? value : `${value}/`;
}
function pathUrl(baseUrl, assetPath) {
const path = String(assetPath || "").replace(/^\/+/, "");
return new URL(path, baseUrl).href;
}
async function fetchBytes(url) {
const response = await fetch(url, {
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
const bytes = Buffer.from(await response.arrayBuffer());
return {
response,
bytes,
text: () => bytes.toString("utf8"),
};
}
async function fetchJson(baseUrl, assetPath) {
const url = pathUrl(baseUrl, assetPath);
const result = await fetchBytes(url);
const contentType = result.response.headers.get("content-type") || "";
if (!result.response.ok) {
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
}
if (contentType.includes("text/html")) {
throw new Error(`${assetPath} was served as HTML`);
}
if (result.bytes.length === 0) {
throw new Error(`${assetPath} was empty`);
}
try {
return JSON.parse(result.text());
} catch (error) {
throw new Error(`${assetPath} did not contain valid JSON: ${error instanceof Error ? error.message : error}`);
}
}
function compareCommit(actual, expected) {
if (!expected) {
return true;
}
return actual === expected || actual.startsWith(expected);
}
function unique(values) {
return Array.from(new Set(values.filter(Boolean)));
}
function sha256(bytes) {
return crypto.createHash("sha256").update(bytes).digest("hex");
}
function shouldRejectHtml(assetPath) {
return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath);
}
async function verifyAsset(baseUrl, assetPath, expectedHash) {
const url = pathUrl(baseUrl, assetPath);
const result = await fetchBytes(url);
const contentType = result.response.headers.get("content-type") || "";
if (!result.response.ok) {
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
}
if (result.bytes.length === 0) {
throw new Error(`${assetPath} was empty`);
}
if (shouldRejectHtml(assetPath) && contentType.includes("text/html")) {
throw new Error(`${assetPath} was served as HTML (${contentType})`);
}
if (expectedHash?.sha256) {
const actualHash = sha256(result.bytes);
if (actualHash !== expectedHash.sha256) {
throw new Error(`${assetPath} sha256 mismatch: expected ${expectedHash.sha256}, got ${actualHash}`);
}
}
}
async function verifyShell(baseUrl, shellPath) {
const result = await fetchBytes(pathUrl(baseUrl, shellPath));
const contentType = result.response.headers.get("content-type") || "";
const body = result.text();
if (!result.response.ok) {
throw new Error(`${shellPath} returned HTTP ${result.response.status}`);
}
if (!contentType.includes("text/html")) {
throw new Error(`${shellPath} did not return HTML (${contentType})`);
}
if (body.replace(/\s+/g, "").length < 40) {
throw new Error(`${shellPath} returned an empty app shell`);
}
if (!body.includes('<div id="app"></div>')) {
throw new Error(`${shellPath} did not include the Vue app root`);
}
}
async function verifyRelease(baseUrl) {
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
const strictBuildId = booleanEnv("RELEASE_STRICT_BUILD_ID");
const manifest = await fetchJson(baseUrl, "release-manifest.json");
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
if (!manifest.build_id) {
throw new Error("release-manifest.json is missing build_id");
}
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
}
if (strictBuildId && expectedBuildId && manifest.build_id !== expectedBuildId) {
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
}
if (releaseEntry.entry !== manifest.entry) {
throw new Error("release-entry.json entry does not match release-manifest.json");
}
const releaseCss = JSON.stringify(releaseEntry.css || []);
const manifestCss = JSON.stringify(manifest.css || []);
if (releaseCss !== manifestCss) {
throw new Error("release-entry.json css does not match release-manifest.json");
}
const shellPaths = unique((process.env.RELEASE_SHELL_PATHS || "/,/guest/book/wash").split(",").map((value) => value.trim()));
for (const shellPath of shellPaths) {
await verifyShell(baseUrl, shellPath);
}
const assetUrls = unique([
"release-manifest.json",
"release-entry.json",
manifest.entry,
...(manifest.css || []),
...(manifest.index_asset_urls || []),
...(manifest.pwa_asset_urls || []),
...(manifest.asset_urls || []),
]);
for (const assetUrl of assetUrls) {
if (assetUrl === "/index.html") {
continue;
}
await verifyAsset(baseUrl, assetUrl, manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`]);
}
return {
build_id: manifest.build_id,
commit_sha: manifest.commit_sha,
assets: assetUrls.length,
};
}
async function main() {
const baseUrl = requiredUrl();
const initialWaitSeconds = numberEnv("RELEASE_WAIT_INITIAL_SECONDS", 30);
const timeoutSeconds = numberEnv("RELEASE_WAIT_TIMEOUT_SECONDS", 300);
const pollIntervalSeconds = numberEnv("RELEASE_POLL_INTERVAL_SECONDS", 10);
if (initialWaitSeconds > 0) {
console.log(`Waiting ${initialWaitSeconds}s before polling ${baseUrl}`);
await sleep(initialWaitSeconds * 1000);
}
const deadline = Date.now() + timeoutSeconds * 1000;
let attempt = 0;
let lastError = null;
while (Date.now() <= deadline) {
attempt += 1;
try {
const result = await verifyRelease(baseUrl);
appendGithubEnv({
RELEASE_VERIFIED_BUILD_ID: result.build_id,
RELEASE_VERIFIED_COMMIT: result.commit_sha,
});
console.log(
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
);
return;
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
console.log(`Release upload not ready on attempt ${attempt}: ${message}`);
if (Date.now() > deadline) {
break;
}
await sleep(pollIntervalSeconds * 1000);
}
}
throw lastError || new Error("Release upload verification timed out.");
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
+56 -8
View File
@@ -1,30 +1,41 @@
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
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"];
export const roles = ["customer", "subuser", "admin", "superuser"];
const listEntryPattern = /^\s+\[[^\]]+\]\s+\s+(.+?):(\d+):(\d+)\s+\s+(.+)\s*$/u;
const ownedFilesByRole = {
export const ownedFilesByRole = {
customer: [
"auth.smoke.spec.js",
"booking-selfserve.smoke.spec.js",
"connectivityIssue.spec.ts",
"example.spec.ts",
"guest-book-wash-mobile.spec.ts",
"i18n-catalog-switch.spec.ts",
"i18n-v2-integrity.spec.ts",
"i18n.smoke.spec.ts",
"i18n.views.spec.ts",
"navigation.smoke.spec.js",
"qr-new-customer-layout.spec.ts",
"release-bootstrap.spec.js",
"release-channel-switched.spec.js",
"release-channel-unavailable.spec.js",
"release-update-widget.spec.js",
"self-serve-wash.spec.js",
"session-release-runtime.spec.ts",
"user-orders.spec.ts",
"userBookings.spec.ts",
"userBookWash.spec.ts",
"userHome.spec.ts",
"userInvoices.spec.ts",
"userMyWashStart.spec.ts",
"userMyWashStartFlow.spec.ts",
"userProfileInvoicing.spec.ts",
"userProfileNotifications.spec.ts",
"userProfileSecurity.spec.ts",
@@ -52,6 +63,7 @@ const ownedFilesByRole = {
"assign-draft-order-modal-layout.spec.ts",
"change-invoice-collection.spec.ts",
"change-customer.spec.ts",
"default-mobile-redirect.spec.ts",
"economic-queue-workflow.spec.js",
"pos-customer-rules.spec.js",
"pos-desktop-card-payments.spec.js",
@@ -61,23 +73,41 @@ const ownedFilesByRole = {
"pos.visual.spec.js",
],
superuser: [
"coolify-infrastructure.spec.js",
"edge-gateways.fleet-outline.spec.js",
"edge-gateways.routes.spec.js",
"edge-gateways.smoke.spec.js",
"edge-gateways.visual.spec.js",
"errorReports.spec.ts",
"failover-config.source.spec.ts",
"collected-invoice-move-customer.spec.ts",
"invoice-distribution.smoke.spec.js",
"invoice-transfer-monitor.spec.ts",
"invoice-transfer-queue-history.spec.js",
"invoicing-period.smoke.spec.js",
"issue-repro-duplicates-date.spec.js",
"release-manager.spec.js",
"self-serve-sessions.spec.js",
"self-serve-studio-audit-navigation.spec.js",
"self-serve-studio-flow.spec.js",
"session-bootstrap.spec.ts",
"superuser-bookings.spec.ts",
"superuser-customer-complaints.spec.ts",
"superuser-customers-mass-import.spec.ts",
"superuser-department-branding.spec.js",
"superuser-department-gates.spec.ts",
"superuser-department-lanes.spec.ts",
"superuser-departments-archive.spec.ts",
"superuser-drafts.spec.ts",
"superuser-products-layout.spec.ts",
"superuser-system-status.smoke.spec.js",
"superuser-users.spec.ts",
"superuser-vehicles.smoke.spec.js",
"workfeed-config.smoke.spec.js",
],
};
const titleRules = [
export 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] },
@@ -104,6 +134,11 @@ const titleRules = [
file: "subuser-management.spec.ts",
patterns: [/^authorized subuser managers/i, /^subuser self-service/i, /^subusers without /i],
},
{
role: "superuser",
file: "subuser-management.spec.ts",
patterns: [/^superusers can list and invite chauffeurs/i],
},
{ role: "subuser", file: "userProfileVisibility.spec.ts", patterns: [/\[PROFILE\]\[Subuser\]\[Visibility\]/u] },
];
@@ -188,7 +223,7 @@ function toBaseName(filePath) {
return filePath.split(/[\\/]/u).pop() || filePath;
}
function parseListedTests(listOutput) {
export function parseListedTests(listOutput) {
return listOutput
.split(/\r?\n/u)
.map((line) => {
@@ -210,7 +245,7 @@ function parseListedTests(listOutput) {
.filter(Boolean);
}
function classifyTest(testEntry) {
export function classifyTest(testEntry) {
const matches = new Set();
const directOwner = ownedFileToRole.get(testEntry.fileName);
@@ -297,8 +332,8 @@ async function runPlaywright(project, testListPath, forwardedArgs) {
});
}
async function main() {
const { options, forwardedArgs } = parseCliArgs(process.argv.slice(2));
export async function main(argv = process.argv.slice(2)) {
const { options, forwardedArgs } = parseCliArgs(argv);
validateOptions(options, forwardedArgs);
const listOutput = await listProjectTests(options.project, forwardedArgs);
@@ -330,4 +365,17 @@ async function main() {
await runPlaywright(options.project, testListPath, forwardedArgs);
}
await main();
async function isDirectRun() {
if (!process.argv[1]) {
return false;
}
const currentPath = await fs.realpath(fileURLToPath(import.meta.url));
const invokedPath = await fs.realpath(process.argv[1]).catch(() => path.resolve(process.argv[1]));
return currentPath === invokedPath;
}
if (await isDirectRun()) {
await main();
}
+464
View File
@@ -0,0 +1,464 @@
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { chromiumProjects, fallbackChangePatterns, prGrep, smokeGrep, sourceMappings } from "./playwright-pr-mapping.mjs";
const execFileAsync = promisify(execFile);
const workingDirectory = process.cwd();
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
const args = parseArgs(process.argv.slice(2));
const activeChildren = new Set();
let isShuttingDown = false;
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
void shutdown(signal).finally(() => {
process.exit(130);
});
});
}
function parseArgs(rawArgs) {
const parsed = {
help: false,
coreOnly: false,
changedOnly: false,
listOnly: false,
base: "",
head: "",
projects: [],
forwardedArgs: [],
};
for (let index = 0; index < rawArgs.length; index += 1) {
const value = rawArgs[index];
if (value === "--") {
parsed.forwardedArgs.push(...rawArgs.slice(index + 1));
break;
}
if (value === "--help" || value === "-h") {
parsed.help = true;
continue;
}
if (value === "--core-only") {
parsed.coreOnly = true;
continue;
}
if (value === "--changed-only") {
parsed.changedOnly = true;
continue;
}
if (value === "--list-only") {
parsed.listOnly = true;
continue;
}
if (value === "--base" && rawArgs[index + 1] !== undefined) {
parsed.base = rawArgs[index + 1];
index += 1;
continue;
}
if (value.startsWith("--base=")) {
parsed.base = value.slice("--base=".length);
continue;
}
if (value === "--head" && rawArgs[index + 1] !== undefined) {
parsed.head = rawArgs[index + 1];
index += 1;
continue;
}
if (value.startsWith("--head=")) {
parsed.head = value.slice("--head=".length);
continue;
}
if (value === "--project" && rawArgs[index + 1] !== undefined) {
parsed.projects.push(rawArgs[index + 1]);
index += 1;
continue;
}
if (value.startsWith("--project=")) {
parsed.projects.push(value.slice("--project=".length));
continue;
}
parsed.forwardedArgs.push(value);
}
return parsed;
}
function printUsage() {
console.log(`
Playwright PR runner
Usage:
node scripts/run-playwright-pr.mjs [options] [-- <playwright args>]
Options:
--base <ref> Base ref for changed-area detection
--head <ref> Head ref for changed-area detection. Default: HEAD
--project <name> Restrict to one Chromium Playwright project. Can be repeated.
--core-only Run only the core @pr gate
--changed-only Run changed-area selection without the core @pr gate
--list-only List selected tests instead of running them
-h, --help Show help
Examples:
npm run test:e2e:pr
npm run test:e2e:pr -- --core-only --project=chromium-desktop
npm run test:e2e:changed -- --base=HEAD~1 --head=HEAD
`);
}
function normalizePath(value) {
return value.replace(/\\/g, "/").replace(/^\.\//u, "");
}
function isZeroSha(value) {
return /^0{40}$/u.test(value || "");
}
function getSelectedProjects(projects = args.projects) {
return projects.length > 0 ? projects : chromiumProjects;
}
function unique(values) {
return [...new Set(values)];
}
function sanitizeNamespace(value) {
return value.replace(/[^a-zA-Z0-9._-]+/g, "-");
}
function getArtifactNamespace(suffix) {
const baseNamespace = process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "pr";
return sanitizeNamespace(`${baseNamespace}-${suffix}`);
}
function buildProjectArgs(projects) {
return projects.flatMap((project) => ["--project", project]);
}
async function runPlaywright({ label, commandArgs, artifactSuffix }) {
const finalArgs = ["test", ...commandArgs, ...args.forwardedArgs];
if (args.listOnly && !finalArgs.includes("--list")) {
finalArgs.push("--list");
}
console.log(`[playwright-pr] ${args.listOnly ? "Listing" : "Running"} ${label}: playwright ${finalArgs.join(" ")}`);
return new Promise((resolve) => {
const child = spawn(process.execPath, [playwrightCliPath, ...finalArgs], {
cwd: workingDirectory,
env: {
...process.env,
PLAYWRIGHT: "1",
PLAYWRIGHT_ARTIFACT_NAMESPACE: getArtifactNamespace(artifactSuffix),
PLAYWRIGHT_REPORTER_MODE: "line-html",
PLAYWRIGHT_WORKERS: process.env.PLAYWRIGHT_WORKERS || "1",
},
stdio: "inherit",
windowsHide: true,
});
activeChildren.add(child);
child.on("close", (code) => {
activeChildren.delete(child);
resolve(code ?? 1);
});
});
}
async function tryGitDiff(rangeArgs) {
const { stdout } = await execFileAsync("git", ["diff", "--name-only", "--diff-filter=ACMR", ...rangeArgs], {
cwd: workingDirectory,
maxBuffer: 16 * 1024 * 1024,
});
return stdout
.split(/\r?\n/u)
.map((line) => normalizePath(line.trim()))
.filter(Boolean);
}
async function getChangedFiles() {
const base = args.base.trim();
const head = args.head.trim() || "HEAD";
if (base && !isZeroSha(base)) {
try {
return {
files: await tryGitDiff([`${base}...${head}`]),
source: `${base}...${head}`,
};
} catch {
try {
return {
files: await tryGitDiff([`${base}..${head}`]),
source: `${base}..${head}`,
};
} catch {
return {
files: [],
source: `${base}...${head}`,
unavailable: true,
};
}
}
}
try {
return {
files: await tryGitDiff(["HEAD~1...HEAD"]),
source: "HEAD~1...HEAD",
};
} catch {
return {
files: [],
source: "HEAD~1...HEAD",
unavailable: true,
};
}
}
function addSpec(selection, spec, projects) {
const normalizedSpec = normalizePath(spec);
const existingProjects = selection.specProjects.get(normalizedSpec) || new Set();
for (const project of projects) {
existingProjects.add(project);
}
selection.specProjects.set(normalizedSpec, existingProjects);
}
function isE2eSpec(file) {
return /^tests\/e2e\/.+\.spec\.(?:js|ts)$/u.test(file);
}
function shouldFallback(file) {
return fallbackChangePatterns.some((pattern) => pattern.test(file));
}
function isFrontendSource(file) {
return /^src\//u.test(file) || /^public\//u.test(file) || /^index\.html$/u.test(file);
}
function selectChangedTests(changedFiles) {
const selection = {
specProjects: new Map(),
mappedFiles: [],
unmappedFiles: [],
fallback: false,
};
const selectedProjects = getSelectedProjects();
for (const rawFile of changedFiles) {
const file = normalizePath(rawFile);
if (isE2eSpec(file)) {
addSpec(selection, file, selectedProjects);
selection.mappedFiles.push(file);
continue;
}
if (shouldFallback(file)) {
selection.fallback = true;
selection.unmappedFiles.push(file);
continue;
}
if (!isFrontendSource(file)) {
continue;
}
const matches = sourceMappings.filter((mapping) => mapping.patterns.some((pattern) => pattern.test(file)));
if (matches.length === 0) {
selection.fallback = true;
selection.unmappedFiles.push(file);
continue;
}
selection.mappedFiles.push(file);
for (const mapping of matches) {
const projects = mapping.projects.filter((project) => selectedProjects.includes(project));
for (const spec of mapping.specs) {
addSpec(selection, spec, projects.length > 0 ? projects : selectedProjects);
}
}
}
return selection;
}
function groupSpecsByProjects(specProjects) {
const groups = new Map();
for (const [spec, projects] of specProjects.entries()) {
const projectList = unique([...projects]).sort();
const key = projectList.join(",");
const existing = groups.get(key) || {
projects: projectList,
specs: [],
};
existing.specs.push(spec);
groups.set(key, existing);
}
return [...groups.values()].map((group) => ({
...group,
specs: group.specs.sort(),
}));
}
async function runCorePrGate() {
const projects = getSelectedProjects();
for (const project of projects) {
const code = await runPlaywright({
label: `core ${prGrep} gate (${project})`,
artifactSuffix: `core-${project}`,
commandArgs: ["--grep", prGrep, "--project", project],
});
if (code !== 0) {
return code;
}
}
return 0;
}
async function runChangedSelection(selection) {
const projects = getSelectedProjects();
if (selection.fallback) {
console.log(
`[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(", ")}`
);
for (const project of projects) {
const code = await runPlaywright({
label: `fallback ${smokeGrep} gate (${project})`,
artifactSuffix: `smoke-fallback-${project}`,
commandArgs: ["--grep", smokeGrep, "--grep-invert", prGrep, "--project", project],
});
if (code !== 0) {
return code;
}
}
return 0;
}
const groups = groupSpecsByProjects(selection.specProjects);
if (groups.length === 0) {
console.log("[playwright-pr] No changed-area E2E specs selected.");
return 0;
}
for (const [index, group] of groups.entries()) {
for (const project of group.projects) {
const code = await runPlaywright({
label: `changed-area specs ${index + 1}/${groups.length} (${project})`,
artifactSuffix: `changed-${index + 1}-${project}`,
commandArgs: [...group.specs, "--project", project],
});
if (code !== 0) {
return code;
}
}
}
return 0;
}
async function shutdown(signal) {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
console.error(`Received ${signal}. Stopping active Playwright PR children...`);
await Promise.all(
[...activeChildren].map(
(child) =>
new Promise((resolve) => {
if (!child.pid) {
resolve();
return;
}
if (process.platform === "win32") {
const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
killer.on("close", resolve);
return;
}
child.kill("SIGTERM");
resolve();
})
)
);
}
async function main() {
if (args.help) {
printUsage();
return;
}
if (args.coreOnly && args.changedOnly) {
throw new Error("--core-only and --changed-only cannot be used together.");
}
await fs.access(playwrightCliPath);
if (!args.changedOnly) {
const coreCode = await runCorePrGate();
if (coreCode !== 0) {
process.exitCode = coreCode;
return;
}
}
if (args.coreOnly) {
return;
}
const changed = await getChangedFiles();
if (changed.unavailable) {
console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`);
return;
}
console.log(`[playwright-pr] Changed-area diff ${changed.source}: ${changed.files.length} file(s).`);
const selection = selectChangedTests(changed.files);
const changedCode = await runChangedSelection(selection);
if (changedCode !== 0) {
process.exitCode = changedCode;
}
}
await main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
+31 -2
View File
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
const workingDirectory = process.cwd();
const workingDirectory = await fs.realpath(process.cwd());
const vitestCliPath = path.join(workingDirectory, "node_modules", "vitest", "vitest.mjs");
const unitTestsRoot = path.join(workingDirectory, "tests", "unit");
const args = parseArgs(process.argv.slice(2));
@@ -23,6 +23,7 @@ function parseArgs(rawArgs) {
const parsed = {
help: false,
batchSize: null,
fromList: null,
specFilters: [],
forwardedArgs: [],
};
@@ -51,6 +52,17 @@ function parseArgs(rawArgs) {
continue;
}
if (value === "--from-list" && rawArgs[index + 1]) {
parsed.fromList = rawArgs[index + 1];
index += 1;
continue;
}
if (value.startsWith("--from-list=")) {
parsed.fromList = value.slice("--from-list=".length);
continue;
}
if (value.startsWith("-")) {
parsed.forwardedArgs.push(...rawArgs.slice(index));
break;
@@ -80,15 +92,31 @@ Usage:
Options:
--batch-size <n> Spec files per batch. Default: 5
--from-list <file> Read additional spec filters from a newline-delimited file
-h, --help Show help
Examples:
npm run test:unit
npm run test:unit:serial
npm run test:unit -- tests/unit/select-vehicle-form-pos.spec.js
npm run test:unit -- tests/unit/select-vehicle-form-pos.spec.js -- --reporter=verbose
`);
}
async function readSpecFilterList(listFile) {
if (!listFile) {
return [];
}
const listPath = path.isAbsolute(listFile) ? listFile : path.join(workingDirectory, listFile);
const contents = await fs.readFile(listPath, "utf8");
return contents
.split(/\r?\n/u)
.map((line) => line.replace(/#.*/u, "").trim())
.filter(Boolean);
}
async function collectSpecFiles(targetDirectory) {
const directoryEntries = await fs.readdir(targetDirectory, { withFileTypes: true });
const files = [];
@@ -234,7 +262,8 @@ async function main() {
await fs.access(vitestCliPath);
const discoveredFiles = await collectSpecFiles(unitTestsRoot);
const selectedFiles = applyFilters(discoveredFiles, args.specFilters);
const listFilters = await readSpecFilterList(args.fromList);
const selectedFiles = applyFilters(discoveredFiles, [...listFilters, ...args.specFilters]);
if (selectedFiles.length === 0) {
throw new Error("No unit spec files matched the provided filters.");
+219
View File
@@ -0,0 +1,219 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
const workingDirectory = await fs.realpath(process.cwd());
const vitestCliPath = path.join(workingDirectory, "node_modules", "vitest", "vitest.mjs");
const unitTestsRoot = path.join(workingDirectory, "tests", "unit");
const serialListPath = path.join(unitTestsRoot, "serial-tests.txt");
const args = parseArgs(process.argv.slice(2));
let activeChild = null;
let isShuttingDown = false;
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
void shutdown(signal).finally(() => {
process.exit(130);
});
});
}
function parseArgs(rawArgs) {
const parsed = {
help: false,
specFilters: [],
forwardedArgs: [],
};
for (let index = 0; index < rawArgs.length; index += 1) {
const value = rawArgs[index];
if (value === "--") {
parsed.forwardedArgs.push(...rawArgs.slice(index + 1));
break;
}
if (value === "--help" || value === "-h") {
parsed.help = true;
continue;
}
if (value.startsWith("-")) {
parsed.forwardedArgs.push(...rawArgs.slice(index));
break;
}
parsed.specFilters.push(value);
}
return parsed;
}
function normalizePathSegments(value) {
return value.replace(/\\/g, "/");
}
function printUsage() {
console.log(`
Vitest fast unit runner
Usage:
node scripts/run-vitest-unit-fast.mjs [spec-filter ...] [-- <vitest args>]
Runs all unit specs except entries in tests/unit/serial-tests.txt.
Environment:
VITEST_FAST_MAX_WORKERS Optional max workers for the fast pass
Examples:
npm run test:unit:fast
npm run test:unit:fast -- tests/unit/workfeed
npm run test:unit:fast -- --reporter=verbose
`);
}
async function collectSpecFiles(targetDirectory) {
const directoryEntries = await fs.readdir(targetDirectory, { withFileTypes: true });
const files = [];
for (const entry of directoryEntries) {
const absolutePath = path.join(targetDirectory, entry.name);
if (entry.isDirectory()) {
files.push(...(await collectSpecFiles(absolutePath)));
continue;
}
if (entry.isFile() && entry.name.endsWith(".spec.js")) {
files.push(absolutePath);
}
}
return files.sort((left, right) => left.localeCompare(right));
}
async function readSerialAllowlist() {
const contents = await fs.readFile(serialListPath, "utf8");
return new Set(
contents
.split(/\r?\n/u)
.map((line) => line.replace(/#.*/u, "").trim())
.filter(Boolean)
.map(normalizePathSegments)
);
}
function matchesFilter(relativePath, rawFilter) {
const normalizedRelativePath = normalizePathSegments(relativePath);
const normalizedFilter = normalizePathSegments(rawFilter.trim());
if (!normalizedFilter) {
return false;
}
return (
normalizedRelativePath === normalizedFilter ||
normalizedRelativePath.endsWith(normalizedFilter) ||
normalizedRelativePath.includes(normalizedFilter)
);
}
function applyFilters(files, specFilters) {
if (specFilters.length === 0) {
return files;
}
return files.filter((absolutePath) => {
const relativePath = path.relative(workingDirectory, absolutePath);
return specFilters.some((filter) => matchesFilter(relativePath, filter));
});
}
function hasForwardedArg(name) {
return args.forwardedArgs.some((value) => value === name || value.startsWith(`${name}=`));
}
function resolveMaxWorkers() {
return (process.env.VITEST_FAST_MAX_WORKERS || "").trim();
}
async function runVitest(files) {
const enforcedArgs = [];
const configuredMaxWorkers = resolveMaxWorkers();
if (configuredMaxWorkers && !hasForwardedArg("--maxWorkers")) {
enforcedArgs.push(`--maxWorkers=${configuredMaxWorkers}`);
}
return new Promise((resolve) => {
activeChild = spawn(process.execPath, [vitestCliPath, "run", ...files, ...enforcedArgs, ...args.forwardedArgs], {
cwd: workingDirectory,
env: process.env,
stdio: "inherit",
windowsHide: true,
});
activeChild.on("close", (code) => {
activeChild = null;
resolve(code ?? 1);
});
});
}
async function shutdown(signal) {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
console.error(`Received ${signal}. Stopping active Vitest fast run...`);
if (activeChild?.pid) {
if (process.platform === "win32") {
spawn("taskkill", ["/PID", String(activeChild.pid), "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
} else {
activeChild.kill("SIGTERM");
}
}
}
async function main() {
if (args.help) {
printUsage();
return;
}
await fs.access(vitestCliPath);
const serialAllowlist = await readSerialAllowlist();
const discoveredFiles = await collectSpecFiles(unitTestsRoot);
const fastFiles = discoveredFiles.filter((absolutePath) => {
const relativePath = normalizePathSegments(path.relative(workingDirectory, absolutePath));
return !serialAllowlist.has(relativePath);
});
const selectedFiles = applyFilters(fastFiles, args.specFilters);
if (selectedFiles.length === 0) {
console.log("No parallel-safe unit spec files matched the provided filters.");
return;
}
console.log(
`Running ${selectedFiles.length} parallel-safe unit spec file(s); ${serialAllowlist.size} spec file(s) remain in the serial allowlist.`
);
const code = await runVitest(selectedFiles);
if (code !== 0) {
process.exitCode = code;
}
}
await main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
+48 -25
View File
@@ -1,41 +1,52 @@
<script setup>
import { computed, defineAsyncComponent, onMounted, watch } from "vue";
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, watch } from "vue";
// Set the favicon to the /favicon.ico file
import { useHead } from "@vueuse/head";
import favicon from '@/assets/favicon.ico';
import {useRoute} from "vue-router";
import LayoutV2 from "@/components/page/wrappers/LayoutV2.vue";
import favicon from "@/assets/favicon.ico";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { hasStoredSessionToken } from "@/services/sessionStorage.js";
import {
releaseChannelSwitchedStatus,
releaseChannelUnavailableStatus,
startReleaseChannelAvailabilityClock,
stopReleaseChannelAvailabilityClock,
} from "@/services/releaseChannelAvailability.js";
const route = useRoute();
const { t, te, locale } = useI18n({ useScope: "global" });
const APP_TITLE = "Truck Wash";
const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/LayoutV2.vue"));
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
const VersionCheck = defineAsyncComponent(() => import("@/components/global/VersionCheck.vue"));
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
const ErrorReportLauncher = defineAsyncComponent(() => import("@/components/global/ErrorReportLauncher.vue"));
const FrontendMaintenanceMenu = defineAsyncComponent(() => import("@/components/global/FrontendMaintenanceMenu.vue"));
const ReleaseChannelUnavailable = defineAsyncComponent(() =>
import("@/components/release/ReleaseChannelUnavailable.vue")
);
const ReleaseChannelSwitched = defineAsyncComponent(() => import("@/components/release/ReleaseChannelSwitched.vue"));
const routeTitleI18nKeys = {
"about-us": ["header.about_us"],
"book-a-demo": ["about_us.solutions.operator.book_demo"],
"customer-creation": ["header.customer_creation"],
"login": ["header.customer_login"],
"subuserlogin": ["header.customer_login"],
"adminlogin": ["header.operator_login"],
"guestHome": ["guest.home.title"],
"PwaDownload": ["guest.pwa.title"],
login: ["header.customer_login"],
subuserlogin: ["header.customer_login"],
adminlogin: ["header.operator_login"],
guestHome: ["guest.home.title"],
PwaDownload: ["guest.pwa.title"],
"connectivity-issue": ["connectivity.title"],
"selfservestudio": ["admin.self_serve.title"]
selfservestudio: ["admin.self_serve.title"],
};
const toReadableTitle = (value) => {
if (!value) return "";
return value
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (char) => char.toUpperCase());
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (char) => char.toUpperCase());
};
const resolveRouteTitle = () => {
@@ -49,7 +60,7 @@ const resolveRouteTitle = () => {
}
const routeName = typeof route.name === "string" ? route.name : "";
const knownKeys = routeName ? (routeTitleI18nKeys[routeName] || []) : [];
const knownKeys = routeName ? routeTitleI18nKeys[routeName] || [] : [];
for (const key of knownKeys) {
if (te(key)) return t(key);
}
@@ -63,6 +74,13 @@ const updateDocumentTitle = () => {
};
const shouldRenderRequestQueueProgress = computed(() => hasStoredSessionToken());
const shouldRenderReleaseChannelUnavailable = computed(() => releaseChannelUnavailableStatus.value.shouldBlock);
const shouldRenderReleaseChannelSwitched = computed(
() => !shouldRenderReleaseChannelUnavailable.value && releaseChannelSwitchedStatus.value.shouldShow
);
const shouldHideGlobalOverlays = computed(
() => shouldRenderReleaseChannelUnavailable.value || shouldRenderReleaseChannelSwitched.value
);
const initiateStoredSession = async () => {
if (!hasStoredSessionToken()) {
@@ -78,19 +96,24 @@ const initiateStoredSession = async () => {
};
useHead({
link: [
{ rel: 'icon', type: 'image/x-icon', href: favicon }
]
link: [{ rel: "icon", type: "image/x-icon", href: favicon }],
});
onMounted(() => {
startReleaseChannelAvailabilityClock();
void initiateStoredSession();
});
onBeforeUnmount(() => {
stopReleaseChannelAvailabilityClock();
});
watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
</script>
<template>
<LayoutV2 v-if="route.meta?.template === 'with-header'">
<ReleaseChannelUnavailable v-if="shouldRenderReleaseChannelUnavailable" />
<ReleaseChannelSwitched v-else-if="shouldRenderReleaseChannelSwitched" />
<LayoutV2 v-else-if="route.meta?.template === 'with-header'">
<router-view />
</LayoutV2>
<main v-else-if="route.meta?.template === 'clear-main'">
@@ -100,13 +123,13 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
<header></header>
<main>
<DefaultPageWrapper>
<VersionCheck/>
<router-view />
</DefaultPageWrapper>
</main>
</template>
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress" />
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<FrontendMaintenanceMenu />
</template>
<style scoped>
</style>
<style scoped></style>
+3
View File
@@ -760,6 +760,7 @@
.pos-step-one-insights {
display: grid;
align-items: stretch;
gap: 0.9rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
}
@@ -903,6 +904,7 @@
.pos-step-one-insight-card__section {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0.55rem;
min-width: 0;
@@ -970,6 +972,7 @@
.pos-step-one-empty-state,
.pos-step-one-loading-state {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
min-height: 4rem;
-3
View File
@@ -74,10 +74,7 @@ watch(locale, (newLocale) => {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background-color: transparent;
border: 1.5px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
color: #fff;
cursor: pointer;
font-size: 14px;
@@ -1,18 +1,10 @@
<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" />
<CustomerComplaintsPagination :auto-load="true" />
</div>
</template>
+2 -13
View File
@@ -1,26 +1,15 @@
<script setup>
import { ref } from 'vue'
import { getDepartmentListData } from "@/components/session/Session.vue";
import { showCreateDepartmentForm } from "@/components/forms/superUser/createDepartmentForm.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import DepartmentsPagination from "@/components/displays/pagination/models/SuperUserDashboard/DepartmentsPagination.vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const tableData = ref([]);
getDepartmentListData().then((response) => {
tableData.value = response.data.data;
});
const redirect = (path) => {
window.location = path;
}
</script>
<template>
<div>
<PageTitle :title="t('superuser.pages.departments.title')" :subtitle="t('superuser.pages.departments.subtitle')">
<PageTitle :title="t('common.departments')" :subtitle="t('superuser.pages.departments.subtitle')">
<template #buttons>
<button class="button is-dark" @click="showCreateDepartmentForm">
<span class="icon">
@@ -36,4 +25,4 @@ const redirect = (path) => {
<style scoped>
</style>
</style>
+1 -1
View File
@@ -11,7 +11,7 @@ const { t } = useI18n();
<template>
<div>
<PageTitle :title="t('superuser.pages.products.title')" :subtitle="t('superuser.pages.products.subtitle')">
<PageTitle :title="t('common.products')" :subtitle="t('superuser.pages.products.subtitle')">
<template #buttons>
<button class="button is-dark" @click="SessionUser.objects.products.showCreateObjectForm(loadList)">
<span class="icon">
+33 -7
View File
@@ -6,15 +6,35 @@ import SubusersPagination from "@/components/displays/pagination/models/SuperUse
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue";
const props = defineProps({
endpoint: {
type: String,
default: "/subusers",
},
showCustomer: {
type: Boolean,
default: false,
},
superuserPage: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const canInvite = computed(() => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_ADD"));
const canInvite = computed(() =>
props.superuserPage
? SessionUser.canAccessSuperUser()
: SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_ADD")
);
const requiresGrantSelection = computed(
() => SessionUser.isSubuser.value && !SessionUser.subuser.selectedGrantCustomerNumber.value
() => !props.superuserPage && SessionUser.isSubuser.value && !SessionUser.subuser.selectedGrantCustomerNumber.value
);
const paginationVersion = ref(0);
const paginationKey = computed(() =>
SessionUser.isSubuser.value
props.superuserPage
? `subusers-superuser-${paginationVersion.value}`
: SessionUser.isSubuser.value
? `subusers-${SessionUser.subuser.selectedGrantCustomerNumber.value || "none"}-${paginationVersion.value}`
: `subusers-user-${paginationVersion.value}`
);
@@ -22,13 +42,13 @@ const paginationKey = computed(() =>
const onInviteClick = async () => {
await SessionUser.objects.subusers.functions.showInviteForm(() => {
paginationVersion.value += 1;
});
}, { superuser: props.superuserPage });
};
</script>
<template>
<div>
<PageTitle :title="t('superuser.pages.subusers.title')" :subtitle="t('superuser.pages.subusers.subtitle')">
<PageTitle :title="SessionUser.objects.subusers.meta.title" :subtitle="t('superuser.pages.subusers.subtitle')">
<template #buttons>
<button v-if="canInvite" class="button is-dark" type="button" @click="onInviteClick">
<span class="icon">
@@ -39,7 +59,7 @@ const onInviteClick = async () => {
</template>
</PageTitle>
<div v-if="SessionUser.isSubuser.value" class="mb-5">
<div v-if="SessionUser.isSubuser.value && !superuserPage" 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.
@@ -50,7 +70,13 @@ const onInviteClick = async () => {
Vælg først en kunde for at se og administrere chauffører.
</div>
<SubusersPagination v-else :key="paginationKey" auto-load="true" />
<SubusersPagination
v-else
:key="paginationKey"
:endpoint="endpoint"
:show-customer="showCustomer"
auto-load="true"
/>
</div>
</template>
@@ -51,6 +51,10 @@ const props = defineProps({
hasBorder: {
type: Boolean,
default: false
},
headerTestId: {
type: String,
default: ""
}
});
/**
@@ -95,7 +99,7 @@ watch(() => props.forceState, (newVal) => {
</div>
</div>
<!-- Default slot for header -->
<div class="card-header is-radiusless" @click="toggle" :class="{ 'is-clickable': props.toggleable }" v-if="$slots.header && !props.hideHeader">
<div class="card-header is-radiusless" @click="toggle" :class="{ 'is-clickable': props.toggleable }" v-if="$slots.header && !props.hideHeader" :data-testid="props.headerTestId || undefined">
<slot name="header"></slot>
</div>
<!-- Default slot for any content -->
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,10 @@ const props = defineProps({
icon: String,
label: String,
disabled: Boolean,
testId: {
type: String,
default: "",
},
template: String // The style of the button (default, danger, success, warning, info, light)
});
const emit = defineEmits(['selected']);
@@ -131,6 +135,7 @@ const getLabelColor = () => {
@click.stop.prevent="click"
:class="{'is-disabled': isDisabled()}"
:disabled="isDisabled()"
:data-testid="props.testId || undefined"
>
<span class="icon">
<i :class="getIcon() + ' ' + getIconColor()"></i>
@@ -119,9 +119,25 @@ const formatDateInputValue = (date: Date) => {
return "";
}
return date.toISOString().split("T")[0];
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const parseDateInputValue = (value: string) => {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return new Date(value);
}
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
};
const isSameDateInputValue = (left: Date, right: Date) => (
formatDateInputValue(left) === formatDateInputValue(right)
);
const updateSelection = () => {
emits("update:selection", { startDate: startDate.value, endDate: endDate.value });
props.onSelectionChange(startDate.value, endDate.value);
@@ -177,25 +193,38 @@ const isSelectionValid = computed(() => (
));
const handleStartDateChange = (event) => {
const newDate = new Date(event.target.value);
const newDate = parseDateInputValue(event.target.value);
if (Number.isNaN(newDate.getTime())) {
console.warn("Invalid start date:", event.target.value);
return;
}
if (isSameDateInputValue(newDate, startDate.value)) {
return;
}
applyDateRange(newDate, endDate.value);
};
const handleEndDateChange = (event) => {
const newDate = new Date(event.target.value);
const newDate = parseDateInputValue(event.target.value);
if (Number.isNaN(newDate.getTime())) {
console.warn("Invalid end date:", event.target.value);
return;
}
if (isSameDateInputValue(newDate, endDate.value)) {
return;
}
applyDateRange(startDate.value, newDate);
};
const getFullMonthRange = (year: number, month: number): DateRange => ({
startDate: new Date(year, month - 1, 1, 0, 0, 0, 0),
endDate: new Date(year, month, 0, 23, 59, 59, 999),
});
const setMonth = (month: number) => {
if (month < 1 || month > 12) {
console.warn("Invalid month selection:", month);
@@ -203,9 +232,8 @@ const setMonth = (month: number) => {
}
const year = startDate.value.getFullYear();
const startOfMonth = new Date(year, month - 1, 1);
const endOfMonth = new Date(year, month, 0);
applyDateRange(startOfMonth, endOfMonth);
const range = getFullMonthRange(year, month);
applyDateRange(range.startDate, range.endDate);
};
const handleMonthChange = (event) => {
@@ -217,6 +245,10 @@ const handleMonthChange = (event) => {
setMonth(month);
};
const setCurrentSelectionEntireMonth = () => {
setMonth(currentMonthSelection.value);
};
const handleYearChange = (event) => {
const year = parseInt(event.target.value, 10);
if (Number.isNaN(year)) {
@@ -307,24 +339,14 @@ const shortcuts = computed<Shortcut[]>(() => {
label: SessionUser.objects.global.language.text.this_month,
getRange: () => {
const today = new Date();
const firstDayOfThisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const lastDayOfThisMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return {
startDate: new Date(firstDayOfThisMonth.setHours(23, 59, 59, 999)),
endDate: new Date(lastDayOfThisMonth.setHours(23, 59, 59, 999)),
};
return getFullMonthRange(today.getFullYear(), today.getMonth() + 1);
},
},
{
label: SessionUser.objects.global.language.text.last_month,
getRange: () => {
const today = new Date();
const firstDayOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const lastDayOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
return {
startDate: new Date(firstDayOfLastMonth.setHours(23, 59, 59, 999)),
endDate: new Date(lastDayOfLastMonth.setHours(23, 59, 59, 999)),
};
return getFullMonthRange(today.getFullYear(), today.getMonth());
},
},
{
@@ -346,16 +368,7 @@ const shortcuts = computed<Shortcut[]>(() => {
label: SessionUser.objects.global.language.text.same_month_last_year,
getRange: () => {
const today = new Date();
const firstDayOfThisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const lastDayOfThisMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
const firstDayOfLastYearSameMonth = new Date(firstDayOfThisMonth);
firstDayOfLastYearSameMonth.setFullYear(firstDayOfThisMonth.getFullYear() - 1);
const lastDayOfLastYearSameMonth = new Date(lastDayOfThisMonth);
lastDayOfLastYearSameMonth.setFullYear(lastDayOfThisMonth.getFullYear() - 1);
return {
startDate: new Date(firstDayOfLastYearSameMonth.setHours(23, 59, 59, 999)),
endDate: new Date(lastDayOfLastYearSameMonth.setHours(23, 59, 59, 999)),
};
return getFullMonthRange(today.getFullYear() - 1, today.getMonth() + 1);
},
},
];
@@ -402,7 +415,7 @@ const handleShortcutSelection = (event) => {
:disabled="props.isDisabled || props.isReadonly"
@change="handleShortcutSelection"
>
<option value="" disabled>Vaelg periode</option>
<option value="" disabled>Vælg periode</option>
<option v-for="shortcut in shortcuts" :key="shortcut.label" :value="shortcut.label">
{{ shortcut.label }}
</option>
@@ -419,6 +432,7 @@ const handleShortcutSelection = (event) => {
class="input"
data-testid="date-period-start"
:value="formatDateInputValue(startDate)"
@input="handleStartDateChange"
@change="handleStartDateChange"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
@@ -434,6 +448,7 @@ const handleShortcutSelection = (event) => {
class="input"
data-testid="date-period-end"
:value="formatDateInputValue(endDate)"
@input="handleEndDateChange"
@change="handleEndDateChange"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
@@ -494,9 +509,10 @@ const handleShortcutSelection = (event) => {
</div>
</div>
<div class="date-period-selector__mobile-actions" v-if="visibility.showUpdateButton">
<div class="date-period-selector__mobile-actions">
<slot name="right"></slot>
<button
v-if="visibility.showUpdateButton"
class="button is-primary is-fullwidth"
type="button"
@click="updateSelection"
@@ -516,6 +532,7 @@ const handleShortcutSelection = (event) => {
class="input"
data-testid="date-period-start"
:value="formatDateInputValue(startDate)"
@input="handleStartDateChange"
@change="handleStartDateChange"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
@@ -530,6 +547,7 @@ const handleShortcutSelection = (event) => {
class="input"
data-testid="date-period-end"
:value="formatDateInputValue(endDate)"
@input="handleEndDateChange"
@change="handleEndDateChange"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
@@ -648,7 +666,7 @@ const handleShortcutSelection = (event) => {
iconPack="fas"
has-icon
>
Selection is not an entire month <span class="has-text-weight-bold is-clickable" @click="setMonth(currentMonthSelection)">Click to set entire month</span>.
Selection is not an entire month <span class="has-text-weight-bold is-clickable" data-testid="date-period-set-entire-month" @click="setCurrentSelectionEntireMonth">Click to set entire month</span>.
When a partial month selection is made, it may not accurately represent the intended time period. Consider selecting an entire month for more precise results.
</b-message>
</div>
@@ -28,10 +28,10 @@ if (departments.value.length === 0) {
<th>{{ $t('objects.columns.department') }}</th>
<th>{{ $t('tables.bookings.reg_tractor') }}</th>
<th>{{ $t('tables.bookings.reg_trailer') }}</th>
<th>{{ $t('objects.columns.reference') }}</th>
<th>{{ $t('common.reference') }}</th>
<th>{{ $t('objects.columns.notes') }}</th>
<th>{{ $t('objects.columns.date') }}</th>
<th>{{ $t('objects.columns.status') }}</th>
<th>{{ $t('common.date') }}</th>
<th>{{ $t('common.status') }}</th>
<th>{{ $t('objects.bookings.columns.contact_email') }}</th>
<th>{{ $t('objects.bookings.columns.wash_certificate_email') }}</th>
<th>{{ $t('tables.actions') }}</th>
@@ -53,7 +53,7 @@ if (departments.value.length === 0) {
<td>{{ booking.washCertificateEmail }}</td>
<td>
<div class="buttons">
<button class="button is-small is-dark" @click="redirectDepartmentOrderPage(booking.id, booking.department_id)">{{ $t('global.show') }}</button>
<button class="button is-small is-dark" @click="redirectDepartmentOrderPage(booking.id, booking.department_id)">{{ $t('common.show') }}</button>
</div>
</td>
</tr>
@@ -1,5 +1,5 @@
<script setup>
import {computed, onMounted, ref, watch} from 'vue';
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
import { useRouter } from 'vue-router';
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {BSwitch} from "buefy";
@@ -18,6 +18,39 @@ const selfServeEnabled = ref(false);
const isLoadingSelfServeEnabled = ref(false);
const isSavingSelfServeEnabled = ref(false);
const bookingsCount = ref(0);
const selfServeLoadingMinimumMs = import.meta.env.MODE === "test" ? 0 : import.meta.env.VITE_IS_PLAYWRIGHT ? 5000 : 250;
const selfServeLoadingStartedAt = ref(0);
let selfServeLoadingTimer = null;
const clearSelfServeLoadingTimer = () => {
if (selfServeLoadingTimer !== null) {
clearTimeout(selfServeLoadingTimer);
selfServeLoadingTimer = null;
}
};
const setSelfServeLoading = (isLoading) => {
if (isLoading) {
clearSelfServeLoadingTimer();
selfServeLoadingStartedAt.value = Date.now();
isLoadingSelfServeEnabled.value = true;
return;
}
const elapsed = Date.now() - selfServeLoadingStartedAt.value;
const remaining = Math.max(0, selfServeLoadingMinimumMs - elapsed);
clearSelfServeLoadingTimer();
if (remaining === 0) {
isLoadingSelfServeEnabled.value = false;
return;
}
selfServeLoadingTimer = setTimeout(() => {
isLoadingSelfServeEnabled.value = false;
selfServeLoadingTimer = null;
}, remaining);
};
const getDepartmentId = () => {
return parseInt(router.currentRoute.value.params.departmentId);
@@ -94,6 +127,7 @@ const modules = computed(() => [
},
count: 0,
hasSwitch: true,
switchLoading: isLoadingSelfServeEnabled.value || isSavingSelfServeEnabled.value,
switchValue: selfServeEnabled,
switchDisabled: isLoadingSelfServeEnabled.value || isSavingSelfServeEnabled.value,
onToggle: async (newValue) => {
@@ -118,8 +152,8 @@ const modules = computed(() => [
},
{
id: 6,
name: 'Self-Serve Studio',
description: 'Configure questions, conditions, rules and tasks',
name: 'Døgnvask konfiguration',
description: 'Gå til konfiguration af Døgnvask',
icon: "fas fa-project-diagram",
onClick: () => {
router.push(buildDepartmentModulePath('/modules/self-serve/studio'));
@@ -189,13 +223,13 @@ const getSelfServeStatus = async () => {
const departmentId = getDepartmentId();
if (!departmentId) return;
isLoadingSelfServeEnabled.value = true;
setSelfServeLoading(true);
try {
selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId);
} catch (error) {
console.error("Failed to fetch self-serve status", error);
} finally {
isLoadingSelfServeEnabled.value = false;
setSelfServeLoading(false);
}
};
@@ -203,6 +237,10 @@ onMounted(() => {
getSelfServeStatus();
});
onUnmounted(() => {
clearSelfServeLoadingTimer();
});
// Watch the departmentId
watch(() => router.currentRoute.value.params.departmentId, () => {
getTodaysBookings();
@@ -225,9 +263,17 @@ setInterval(() => {
<i :class="module.icon"></i>
</span>
</div>
<p class="card-header-title">{{ module.name }}</p>
<div class="is-pulled-right card-header-title pr-0">
<p class="card-header-title department-module-card__header-title">{{ module.name }}</p>
<div class="department-module-card__actions" data-testid="department-module-card-actions">
<span class="button is-small is-dark department-module-card__count" @click.stop="module.onClickCount" v-if="module.count > 0">{{ module.count }}</span>
<span
v-else-if="module?.switchLoading"
class="tag is-rounded is-small department-module-card__state-loading"
data-testid="department-module-self-serve-state-loading"
role="status"
:aria-label="t('global.loading')"
@click.stop
></span>
<span class="button is-small is-outlined department-module-card__switch" v-else-if="module?.hasSwitch" @click.stop>
<b-switch
size="is-small"
@@ -253,19 +299,72 @@ setInterval(() => {
height: 100%;
}
.department-module-card .card-header-title {
.department-module-card__header-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.department-module-card__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
margin-left: auto;
padding-right: 0.75rem;
}
.department-module-card__count,
.department-module-card__switch {
.department-module-card__switch,
.department-module-card__state-loading {
position: relative;
z-index: 1;
}
.department-module-card__state-loading {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
min-height: 1.25rem;
padding: 0 0.5rem !important;
background-color: #e5e7eb !important;
border: 1px solid #d1d5db !important;
color: transparent !important;
background-image:
radial-gradient(circle, rgba(156, 163, 175, 0.95) 0.16rem, transparent 0.18rem),
radial-gradient(circle, rgba(156, 163, 175, 0.95) 0.16rem, transparent 0.18rem),
radial-gradient(circle, rgba(156, 163, 175, 0.95) 0.16rem, transparent 0.18rem);
background-repeat: no-repeat;
background-size: 0.4rem 0.4rem;
background-position:
calc(50% - 0.5rem) 50%,
50% 50%,
calc(50% + 0.5rem) 50%;
animation: department-module-state-loading-pulse 1.1s ease-in-out infinite;
}
@keyframes department-module-state-loading-pulse {
0%,
100% {
opacity: 0.68;
transform: scale(0.98);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.department-module-card__state-loading {
animation: none;
}
}
@media (max-width: 768px) {
.department-module-card__title {
display: none;
@@ -70,7 +70,7 @@ const hasUpdatePermission = () => {
:editFunction="su_object.showEditObjectFieldForm"
column="enabled"
:parse-function="(value) => {
return value ? t('global.yes') : t('global.no');
return value ? t('common.yes') : t('common.no');
}"
:permission-check-function="hasUpdatePermission"
/>
@@ -1,7 +1,16 @@
<script setup>
import { watch } from 'vue';
import { useRouter } from "vue-router";
import { getCurrentStep, setDepartment, getOrderId, setStep, setOrderId, searchAndSelectCustomer, loadOrderItems } from "@/components/shop/POSDepartmentProcess.vue";
import {
clearActivePosOrderContext,
getCurrentStep,
setDepartment,
getOrderId,
setStep,
setOrderId,
searchAndSelectCustomer,
loadOrderItems,
} from "@/components/shop/POSDepartmentProcess.vue";
import PosDepartmentStep1 from "@/components/displays/department/pos/steps/PosDepartmentStep1.vue";
import PosDepartmentStep2 from "@/components/displays/department/pos/steps/PosDepartmentStep2.vue";
import PosDepartmentStep3 from "@/components/displays/department/pos/steps/PosDepartmentStep3.vue";
@@ -41,20 +50,33 @@ const debugVehicles = () => {
}
);
};
setDepartment();
watch(() => router.currentRoute.value.params.departmentId, (nextDepartmentId) => {
if (nextDepartmentId) {
setDepartment(nextDepartmentId);
}
});
applyPosRouteSearch(window.location.search, {
const routeStateHandlers = {
setOrderId,
loadOrderItems,
setStep,
searchAndSelectCustomer,
});
clearActivePosOrderContext,
resetMobilePos: () => pos.reset.pos(),
};
const getRouteSearch = (route) => {
const fullPath = route?.fullPath || "";
const queryIndex = fullPath.indexOf("?");
return queryIndex >= 0 ? fullPath.slice(queryIndex) : "";
};
const applyCurrentPosRoute = () => {
const currentRoute = router.currentRoute.value;
if (currentRoute.params.departmentId) {
setDepartment(currentRoute.params.departmentId);
} else {
setDepartment();
}
applyPosRouteSearch(getRouteSearch(currentRoute), routeStateHandlers);
};
watch(() => router.currentRoute.value.fullPath, applyCurrentPosRoute, { immediate: true });
/** Define the createOrder function */
</script>
@@ -246,7 +246,7 @@ const getScanBadges = (scan) => {
if (!scan.seen_before) {
badges.push({
key: 'unknown',
text: t('admin.pos.status_unknown'),
text: t('common.new'),
className: 'pos-scan-badge--unknown',
});
}
@@ -560,7 +560,7 @@ setTimeout(() => {
<span class="icon">
<i class="fas fa-redo"></i>
</span>
<span>{{ $t('admin.pos.try_again') }}</span>
<span>{{ $t('common.retry') }}</span>
</button>
</div>
</article>
@@ -1,5 +1,6 @@
<script setup>
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import { showEditOrderItemForm } from "@/components/forms/superUser/editOrderItemForm.vue";
import { removeOrderItem } from "@/components/shop/OrdersItems.vue";
import { computed, ref, watch, useSlots } from "vue";
@@ -26,6 +27,11 @@ const props = defineProps({
},
});
const emits = defineEmits(["update:orderItems", "onDestroyItem"]);
const route = useRoute();
const highlightOrderItemId = computed(() => {
const parsed = Number.parseInt(String(route.query.highlightOrderItem || ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
});
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
@@ -69,6 +75,10 @@ const handleCustomerWishesFieldSaved = async (fieldKey) => {
await loadOrder();
};
const handleOrderNoteSaved = (savedValue) => {
order_notes.value = savedValue;
};
// Check if the size is large
const isLarge = props.isLarge !== undefined ? props.isLarge : false;
const isOrderDetailVariant = computed(() => props.variant === "order-detail");
@@ -502,6 +512,7 @@ const deleteOrderItem = async (orderItemId) => {
:total-price="totalPrice"
:show-actions="Boolean(editPossible)"
:can-edit-order-items="canEditOrderItems"
:highlight-order-item-id="highlightOrderItemId"
@edit="openOrderItemEditor"
@delete="deleteOrderItem"
/>
@@ -518,6 +529,7 @@ const deleteOrderItem = async (orderItemId) => {
:total-price="totalPrice"
:show-actions="Boolean(editPossible)"
:can-edit-order-items="canEditOrderItems"
:highlight-order-item-id="highlightOrderItemId"
@edit="openOrderItemEditor"
@delete="deleteOrderItem"
/>
@@ -528,14 +540,14 @@ const deleteOrderItem = async (orderItemId) => {
<table v-if="false" class="table is-fullwidth pos-order-items__table">
<thead>
<tr>
<th>{{ $t("admin.pos.product") }}</th>
<th>{{ $t("common.product") }}</th>
<th style="width: 20%; text-align: right">{{ $t("admin.pos.price_dkk") }}</th>
<th
v-if="editPossible"
class="pos-order-items__actions-header"
:style="{ width: isOrderDetailVariant ? '18%' : '15%' }"
>
{{ isOrderDetailVariant ? $t("global.actions") : "" }}
{{ isOrderDetailVariant ? $t("common.actions") : "" }}
</th>
</tr>
</thead>
@@ -920,7 +932,12 @@ const deleteOrderItem = async (orderItemId) => {
data-testid="pos-order-metadata-note"
>
<p class="pos-order-metadata-label">{{ $t("admin.pos.order_note") }}</p>
<POSOrderNote v-bind:order_id="activeOrderId" v-bind:loadOrder="loadOrder" v-bind:note="order_notes" />
<POSOrderNote
v-bind:order_id="activeOrderId"
v-bind:loadOrder="loadOrder"
v-bind:note="order_notes"
v-bind:onSaved="handleOrderNoteSaved"
/>
</section>
</div>
</div>
@@ -33,6 +33,7 @@ import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentB
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
const { t } = useI18n();
const emit = defineEmits(["update:activeTab"]);
const panel_tabs = ref([
{
@@ -151,6 +152,7 @@ const rootClasses = computed(() => ({
}));
const isSidebarVariant = computed(() => props.variant === "sidebar");
const isRulesTabActive = computed(() => panel_tabs.value[1]?.active === true);
const activeTabKey = computed(() => panel_tabs.value.find((tab) => tab.active)?.key ?? "details");
const setActiveTab = (selectedIndex) => {
panel_tabs.value.forEach((tab, index) => {
@@ -171,6 +173,14 @@ const hasAttribute = (prop) => {
return customer_attributes.value.some((attribute) => attribute.attribute === prop);
};
watch(
activeTabKey,
(tabKey) => {
emit("update:activeTab", tabKey);
},
{ immediate: true }
);
watch(
isRulesTabActive,
(isActive) => {
@@ -255,52 +265,62 @@ const customer_data_has_empty_details = () => {
/>
<!-- Attributes -->
<RequiresPermission permission="list_customer_attributes">
<div
class="panel-block pos-selected-customer__row"
v-if="panel_tabs[1].active"
v-for="attribute in attributes"
:key="attribute.attribute"
>
<span class="panel-icon pos-selected-customer__icon">
<i :class="attribute.icon" aria-hidden="true"></i>
</span>
<span
class="pos-selected-customer__label"
@mouseover="showPopper(popperBox(attribute.name, attribute.description), $event.target)"
@mouseleave="removePopperIfOpen()"
>{{ attribute.name }}</span
<template v-if="panel_tabs[1].active">
<div
v-for="attribute in attributes"
:key="attribute.attribute"
class="panel-block pos-selected-customer__row"
data-testid="pos-customer-rule-row"
:data-rule-attribute="attribute.attribute"
>
<span class="pos-selected-customer__value">
<span class="panel-icon pos-selected-customer__icon">
<i :class="attribute.icon" aria-hidden="true"></i>
</span>
<span
v-if="hasAttribute(attribute.prop)"
@click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())"
class="pos-selected-customer__label"
@mouseover="showPopper(popperBox(attribute.name, attribute.description), $event.target)"
@mouseleave="removePopperIfOpen()"
>{{ attribute.name }}</span
>
<span class="field">
<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 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')"
/>
<label :for="attribute.prop"></label>
</span>
</span>
<span v-else @click="createAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
<span class="field">
<input
:id="attribute.prop"
type="checkbox"
:name="attribute.prop"
class="switch"
:disabled="!SessionUser.hasPermission('add_customer_attribute')"
/>
<label :for="attribute.prop"></label>
</span>
</span>
</span>
<span v-else @click="createAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
<span class="field">
<input
:id="attribute.prop"
type="checkbox"
:name="attribute.prop"
class="switch"
:disabled="!SessionUser.hasPermission('add_customer_attribute')"
/>
<label :for="attribute.prop"></label>
</span>
</span>
</span>
</div>
</div>
<div
v-if="$slots['rules-footer']"
class="pos-selected-customer__rules-footer"
data-testid="pos-customer-rules-footer"
>
<slot name="rules-footer"></slot>
</div>
</template>
</RequiresPermission>
<!-- Shortcuts -->
<template v-if="panel_tabs[2].active" v-for="shortcut in shortcuts" :key="shortcut.name">
@@ -425,6 +445,14 @@ const customer_data_has_empty_details = () => {
padding: 1rem;
}
.pos-selected-customer__rules-footer {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border-top: 1px solid #eef3f8;
}
.pos-selected-customer--sidebar .pos-selected-customer__title,
.pos-selected-customer--order-detail .pos-selected-customer__title {
font-size: 1.05rem;
@@ -15,6 +15,7 @@ import {
selectPreferredStripeTerminalReaderId,
STRIPE_TERMINAL_STATUS,
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
import { normalizeStripeInvoice } from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
const POLLING_INTERVAL_MS = 5000;
const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required';
@@ -87,29 +88,6 @@ const selectedTaxRate = ref(1);
const paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent.value));
const normalizeStripeInvoice = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
return null;
}
const invoiceId = value.invoice_id || value.id || null;
if (!invoiceId) {
return null;
}
return {
id: value.id ?? props.order_id,
invoice_id: invoiceId,
customer_id: value.customer_id ?? null,
url: value.url || value.hosted_invoice_url || null,
created_at: value.created_at || null,
paid: Boolean(value.paid),
status: value.status || 'unknown',
amount_due: Number(value.amount_due ?? 0),
amount_paid: Number(value.amount_paid ?? 0),
};
};
const hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null);
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
const isStripeEmailInvoiceTerminalState = computed(() => {
@@ -202,7 +180,7 @@ const loadStripeInvoiceState = async () => {
try {
const response = await getOrder(props.order_id, true);
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders);
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders, props.order_id);
stripeInvoice.value = nextInvoice;
if (nextInvoice) {
emailPanelState.value = 'tracking';
@@ -0,0 +1,60 @@
const STRIPE_INVOICE_PAID_STATUS = 'paid';
export const parseStripeInvoicePaidFlag = (value) => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
if (typeof value === 'string') {
const normalizedValue = value.trim().toLowerCase();
if (['true', '1'].includes(normalizedValue)) {
return true;
}
if (['false', '0', ''].includes(normalizedValue)) {
return false;
}
}
return false;
};
const toFiniteNumber = (value, fallback = 0) => {
const parsedValue = Number(value ?? fallback);
return Number.isFinite(parsedValue) ? parsedValue : fallback;
};
export const normalizeStripeInvoice = (value, fallbackOrderId = null) => {
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
return null;
}
const invoiceId = value.invoice_id || value.id || null;
if (!invoiceId) {
return null;
}
const status = String(value.status || 'unknown').toLowerCase();
const amountDue = toFiniteNumber(value.amount_due);
const amountPaid = toFiniteNumber(value.amount_paid);
const hasCoveredAmountDue = amountDue <= 0 || amountPaid >= amountDue;
const isPaid = status === STRIPE_INVOICE_PAID_STATUS
&& parseStripeInvoicePaidFlag(value.paid)
&& hasCoveredAmountDue;
return {
id: value.id ?? fallbackOrderId,
invoice_id: invoiceId,
customer_id: value.customer_id ?? null,
url: value.url || value.hosted_invoice_url || null,
created_at: value.created_at || null,
paid: isPaid,
status,
amount_due: amountDue,
amount_paid: amountPaid,
};
};
@@ -121,7 +121,7 @@ const createCustomerWishField = ({
const referenceField = createCustomerWishField({
key: "reference",
label: computed(() => t("pos.order.reference")),
label: computed(() => t("common.reference")),
source: () => props.reference,
saveValue: (value) => SessionUser.objects.orders.set.reference(props.order_id, value),
testIdBase: "pos-order-customer-wishes-reference",
@@ -16,6 +16,10 @@ const props = defineProps({
loadOrder: {
type: Function,
required: true
},
onSaved: {
type: Function,
default: null
}
});
@@ -29,6 +33,11 @@ const autosave = useOrderMetadataAutosave({
await SessionUser.objects.orders.set.notes(props.order_id, value);
return value;
},
onSaved: async (savedValue, requestedValue) => {
if (typeof props.onSaved === "function") {
await props.onSaved(savedValue, requestedValue);
}
},
});
const previewValue = computed(() => autosave.draft.value);
@@ -186,7 +186,7 @@ watch(normalizedOrderId, async (nextOrderId, previousOrderId) => {
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__label">{{ $t('common.created') }}:</span>
<span class="pos-inline-tag__value">{{ resolvedCreatedAt }}</span>
</span>
</div>
@@ -19,6 +19,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
highlightOrderItemId: {
type: Number,
default: 0,
},
});
const emits = defineEmits(['edit', 'delete']);
@@ -42,6 +46,7 @@ const getRowClass = (row) => {
'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,
'pos-order-items__row--highlight': Number(row?.orderItem?.id) === Number(props.highlightOrderItemId),
};
};
@@ -102,7 +107,7 @@ const getActionsCellAttrs = () => ({
>
<b-table-column
field="product"
:label="$t('admin.pos.product')"
:label="$t('common.product')"
v-slot="tableProps"
>
<div class="pos-order-items__product-cell" :data-testid="`pos-order-item-name-${tableProps.row.orderItem.id}`">
@@ -187,7 +192,7 @@ const getActionsCellAttrs = () => ({
<b-table-column
v-if="showActions"
field="actions"
:label="$t('global.actions')"
:label="$t('common.actions')"
custom-key="actions"
header-class="pos-order-items__actions-header"
:th-attrs="getActionsHeaderAttrs"
@@ -293,6 +298,11 @@ const getActionsCellAttrs = () => ({
border-bottom-color: transparent;
}
.pos-order-items__detail-table :deep(.table tbody tr.pos-order-items__row--highlight td) {
background: #fff3cd !important;
box-shadow: inset 3px 0 0 #ffdd57;
}
.pos-order-items__detail-table :deep(.table tfoot td) {
font-weight: 600;
border-top: 1px solid #dfe7f1;
@@ -116,12 +116,12 @@ const saveChanges = async () => {
>
<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>
<p class="is-size-7 has-text-grey mb-0">{{ t('common.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>
<label class="label" for="pos-order-item-edit-name">{{ t('common.product') }}</label>
<input
id="pos-order-item-edit-name"
class="input"
@@ -133,7 +133,7 @@ const saveChanges = async () => {
</div>
<div class="column is-12-mobile is-6-tablet">
<label class="label" for="pos-order-item-edit-price">{{ t('global.price') }}</label>
<label class="label" for="pos-order-item-edit-price">{{ t('common.price') }}</label>
<input
id="pos-order-item-edit-price"
v-model="form.price"
@@ -147,7 +147,7 @@ const saveChanges = async () => {
</div>
<div class="column is-12-mobile is-6-tablet">
<label class="label" for="pos-order-item-edit-quantity">{{ t('global.quantity') }}</label>
<label class="label" for="pos-order-item-edit-quantity">{{ t('common.quantity') }}</label>
<input
id="pos-order-item-edit-quantity"
v-model="form.quantity"
@@ -173,7 +173,7 @@ const saveChanges = async () => {
</div>
<div class="column is-12">
<label class="label" for="pos-order-item-edit-reference">{{ t('objects.columns.reference') }}</label>
<label class="label" for="pos-order-item-edit-reference">{{ t('common.reference') }}</label>
<input
id="pos-order-item-edit-reference"
v-model="form.reference"
@@ -21,6 +21,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
highlightOrderItemId: {
type: Number,
default: 0,
},
});
const emits = defineEmits(['edit', 'delete']);
@@ -44,6 +48,7 @@ const getRowClass = (row) => {
'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,
'pos-order-items__row--highlight': Number(row?.orderItem?.id) === Number(props.highlightOrderItemId),
};
};
</script>
@@ -59,7 +64,7 @@ const getRowClass = (row) => {
>
<b-table-column
field="product"
:label="$t('admin.pos.product')"
:label="$t('common.product')"
v-slot="tableProps"
>
<div class="pos-order-items__product-cell" :data-testid="`pos-order-item-name-${tableProps.row.orderItem.id}`">
@@ -156,7 +161,9 @@ const getRowClass = (row) => {
>
<div class="pos-order-items__action-cell">
<div class="buttons is-narrow">
<ActionSettingsWheelButton>
<ActionSettingsWheelButton
:flag-target="{ target_type: 'order_item', target_id: tableProps.row.orderItem.id }"
>
<template #actions>
<ActionSettingsWheelItem
:label="$t('admin.pos.delete')"
@@ -232,6 +239,11 @@ const getRowClass = (row) => {
border-bottom-color: transparent;
}
.pos-order-items__default-table :deep(.table tbody tr.pos-order-items__row--highlight td) {
background: #fff3cd !important;
box-shadow: inset 3px 0 0 #ffdd57;
}
.pos-order-items__default-table :deep(.table tfoot td) {
font-weight: 600;
}
@@ -44,6 +44,10 @@ const props = defineProps({
console.warn('setItemValue function not provided', orderItem, column, value);
},
},
highlightOrderItemId: {
type: Number,
default: 0,
},
});
const calculateTax = (price) => {
@@ -76,9 +80,9 @@ const getOrderItemBasePrice = (orderItem) => {
<tr>
<th>{{ $t('tables.products.name') }}</th>
<th>{{ $t('global.note') }}</th>
<th>{{ $t('objects.columns.reference') }}</th>
<th>{{ $t('global.quantity') }}</th>
<th style="text-align: right;">{{ $t('global.price') }}</th>
<th>{{ $t('common.reference') }}</th>
<th>{{ $t('common.quantity') }}</th>
<th style="text-align: right;">{{ $t('common.price') }}</th>
</tr>
</thead>
<tbody>
@@ -88,7 +92,13 @@ const getOrderItemBasePrice = (orderItem) => {
<div class="skeleton-lines"><div style="width: 100%;"></div></div>
</td>
</tr>
<tr v-for="orderItem in props.orderItems" :key="orderItem.id" v-else>
<tr
v-for="orderItem in props.orderItems"
:key="orderItem.id"
v-else
:id="`order-item-${orderItem.id}`"
:class="{ 'order-item-highlight': Number(orderItem.id) === Number(props.highlightOrderItemId) }"
>
<td>{{ getOrderItemProductName(orderItem) }}</td>
<td>
<input type="text" v-if="props.allowEdit" v-model="orderItem.notes" @input="props.setItemValue(orderItem, 'notes', orderItem.notes)" class="input is-small" />
@@ -111,7 +121,7 @@ const getOrderItemBasePrice = (orderItem) => {
<tfoot>
<!-- Tax -->
<tr v-if="props.taxPercentage > 0">
<td colspan="4">{{ $t('global.tax') }} ({{ props.taxPercentage }}%)</td>
<td colspan="4">{{ $t('common.tax') }} ({{ props.taxPercentage }}%)</td>
<td style="text-align: right;" v-if="props.isLoading">
<!-- Skeleton-lines 1 row -->
<div class="skeleton-lines"><div style="width: 100%;"></div></div>
@@ -147,5 +157,8 @@ const getOrderItemBasePrice = (orderItem) => {
</template>
<style scoped>
.order-item-highlight > td {
background: #fff3cd !important;
box-shadow: inset 3px 0 0 #ffdd57;
}
</style>
@@ -10,6 +10,16 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
const acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
const safePreviewBlobTypesByKind = {
image: {
fallback: "image/png",
allowed: new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/svg+xml"]),
},
document: {
fallback: "application/pdf",
allowed: new Set(["application/pdf"]),
},
};
const props = defineProps({
order: {
@@ -294,7 +304,7 @@ const uploadOrderAttachmentFile = async (file) => {
} catch (error) {
console.error("Error uploading order attachment:", error);
Swal.fire({
title: t("admin.pos.settings_wheel.error"),
title: t("common.error"),
text: t("admin.pos.attachments_upload_error"),
icon: "error",
});
@@ -324,7 +334,21 @@ const hasCachedPreviewSource = (attachmentId) => {
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
};
const createEmbeddablePreviewUrl = async (downloadLink) => {
const createSafePreviewBlob = (fileBlob, previewKind) => {
const previewBlobTypes = safePreviewBlobTypesByKind[previewKind];
if (!previewBlobTypes) {
return null;
}
const normalizedBlobType = String(fileBlob.type || "").toLowerCase();
const safeBlobType = previewBlobTypes.allowed.has(normalizedBlobType)
? normalizedBlobType
: previewBlobTypes.fallback;
return new Blob([fileBlob], { type: safeBlobType });
};
const createEmbeddablePreviewUrl = async (downloadLink, previewKind) => {
if (!downloadLink) {
return null;
}
@@ -340,7 +364,12 @@ const createEmbeddablePreviewUrl = async (downloadLink) => {
return null;
}
const objectUrl = URL.createObjectURL(fileBlob);
const safePreviewBlob = createSafePreviewBlob(fileBlob, previewKind);
if (!safePreviewBlob) {
return null;
}
const objectUrl = URL.createObjectURL(safePreviewBlob);
generatedObjectUrls.add(objectUrl);
return objectUrl;
} catch (error) {
@@ -372,7 +401,7 @@ const ensurePreviewSource = async (attachment) => {
attachment.id,
false
);
const previewSource = await createEmbeddablePreviewUrl(downloadLink);
const previewSource = await createEmbeddablePreviewUrl(downloadLink, previewKind);
previewSourcesById.value = {
...previewSourcesById.value,
[attachment.id]: previewSource,
@@ -517,6 +546,7 @@ const toggleDropdown = async () => {
:src="activePreviewSource"
class="order-attachments-preview-panel__document"
title="Attachment preview"
sandbox
></iframe>
<a
v-else-if="activePreviewKind === 'link'"
@@ -6,6 +6,7 @@ const { t } = useI18n();
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import OrderAttachmentsActionButton from "@/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue";
import AssignDraftOrderCustomerModal from "@/components/displays/modals/AssignDraftOrderCustomerModal.vue";
import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue";
const props = defineProps({
orders: {
@@ -44,13 +45,19 @@ const props = defineProps({
type: Boolean,
default: false,
},
invoicePeriodFlags: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["flagStatusChanged", "flagCreated"]);
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { departments, getDepartments, isLoading, getDepartmentName } from "@/components/pagination/departmentTabs.vue";
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { orderBy, orderDirection, setOrder, loadList } from "@/components/pagination/paginatedList.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
const { orderBy, orderDirection, setOrder, loadList } = usePaginatedListInstance();
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
import InvoiceMultipleCollectionsModal from "@/components/displays/modals/InvoiceMultipleCollectionsModal.vue";
@@ -268,6 +275,10 @@ const getInvoiceCollectionResponseOrders = (response) => {
return response.data.orders;
}
if (Array.isArray(response?.data?.data?.orders)) {
return response.data.data.orders;
}
if (Array.isArray(response?.includes?.orders)) {
return response.includes.orders;
}
@@ -397,6 +408,20 @@ const tableHeaders = ref([
},
]);
const isTableHeaderVisible = (header) => {
return header.visible === undefined || header.visible(null) === true;
};
const invoiceCollectionSummaryColumnSpan = computed(() => {
const invoiceControlColumns =
(props.invoiceView && props.allowSelectMultiple ? 1 : 0) + (props.invoiceView ? 1 : 0);
const leadingOrderColumns = tableHeaders.value.filter(
(header) => isTableHeaderVisible(header) && !["total_net_amount", "actions"].includes(header.name)
).length;
return invoiceControlColumns + leadingOrderColumns;
});
const active_dropdown_object_ids = ref([]);
const toggleDropdown = (object) => {
// Check if the object is already in the active dropdown list
@@ -471,6 +496,18 @@ const invoiceCollectionQueueSuccess = invoiceQueue.invoiceCollectionQueueSuccess
const invoiceCollectionQueue = invoiceQueue.invoiceCollectionQueue;
const collectionQueueLog = invoiceQueue.collectionQueueLog;
const isInvoiceMultipleCollectionsModalOpen = ref(false);
const isSuperuserInvoicesRoute = computed(() => (
route.path === "/superuser/invoices" || route.path.startsWith("/superuser/invoices/")
));
const legacyInvoiceProgressCount = computed(() => (
invoiceCollectionQueueSuccess.value.length
+ invoiceCollectionQueueFailed.value.length
+ invoiceCollectionQueueInProgress.value.length
+ invoiceCollectionQueue.value.length
));
const shouldShowLegacyInvoiceProgressBar = computed(() => {
return !isSuperuserInvoicesRoute.value && legacyInvoiceProgressCount.value > 0;
});
const isInvoiceQueueBusy = computed(() => {
return invoiceCollectionQueue.value.length > 0 || invoiceCollectionQueueInProgress.value.length > 0;
});
@@ -616,6 +653,41 @@ const getInvoiceCollectionDetails = (invoiceCollectionId) => {
}
return null;
};
const getInvoiceCollectionDetailOrders = (invoiceCollectionId) => {
const details = getInvoiceCollectionDetails(invoiceCollectionId);
return details ? getInvoiceCollectionResponseOrders(details) : [];
};
const getHiddenOrdersByInvoiceCollection = (invoiceCollectionId) => {
const visibleOrderIds = new Set(getOrdersByInvoiceCollection(invoiceCollectionId).map((order) => Number(order?.id)));
return getInvoiceCollectionDetailOrders(invoiceCollectionId).filter((order) => !visibleOrderIds.has(Number(order?.id)));
};
const getHiddenOrderIdsByInvoiceCollection = (invoiceCollectionId) => (
getHiddenOrdersByInvoiceCollection(invoiceCollectionId).map((order) => Number(order?.id))
);
const getHiddenInvoiceCollectionFlags = (invoiceCollectionId) => {
const hiddenOrderIds = new Set(getHiddenOrderIdsByInvoiceCollection(invoiceCollectionId));
if (hiddenOrderIds.size === 0) {
return [];
}
return sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
if (!isActiveInvoicePeriodFlag(flag)) {
return false;
}
const targetType = String(flag?.target_type || "");
if (!["order", "order_field", "order_item", "order_item_field"].includes(targetType)) {
return false;
}
const flagOrderId = Number(
flag?.order_id
|| flag?.context?.order_id
|| (["order", "order_field"].includes(targetType) ? flag?.target_id : 0)
);
return flagOrderId > 0 && hiddenOrderIds.has(flagOrderId);
}));
};
watch(
[() => props.orders, () => props.excludedOrderIds, () => props.groupInvoiceCollection],
() => {
@@ -666,6 +738,66 @@ const canSeePoField = () => {
//return (SessionUser.hasAttribute('usePONumbers') || SessionUser.hasPermission('department'))
};
const sortInvoicePeriodFlags = (flags = []) => [...flags].sort((left, right) => {
const sourceOrder = { manual: 0, automatic: 1 };
const leftSource = sourceOrder[left?.source] ?? 99;
const rightSource = sourceOrder[right?.source] ?? 99;
if (leftSource !== rightSource) {
return leftSource - rightSource;
}
return String(left?.created_at || left?.fingerprint || "").localeCompare(
String(right?.created_at || right?.fingerprint || "")
);
});
const isActiveInvoicePeriodFlag = (flag) => String(flag?.status || "active") === "active";
const getOrderLineFlags = (order) => sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
if (!isActiveInvoicePeriodFlag(flag)) {
return false;
}
const targetType = String(flag?.target_type || "");
if (!["order", "order_field"].includes(targetType)) {
return false;
}
const flagOrderId = Number(flag?.order_id || flag?.target_id || 0);
return flagOrderId > 0 && flagOrderId === Number(order?.id || 0);
}));
const getOrderItemLineFlags = (order) => sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
if (!isActiveInvoicePeriodFlag(flag)) {
return false;
}
const targetType = String(flag?.target_type || "");
if (!["order_item", "order_item_field"].includes(targetType)) {
return false;
}
return Number(flag?.order_id || flag?.context?.order_id || 0) === Number(order?.id || 0);
}));
const getOrderContainedFlags = (order) => sortInvoicePeriodFlags([
...getOrderLineFlags(order),
...getOrderItemLineFlags(order),
]);
const getOrderIndicatorIconClass = (order) => {
if (getOrderContainedFlags(order).length > 0) {
return "fas fa-flag";
}
return isPendingHandheld(order) ? "fas fa-mobile-alt" : "fas fa-circle";
};
const getOrderIndicatorColorClass = (order) => {
const flags = getOrderContainedFlags(order);
if (flags.length === 0) {
return getOrderInvoiceStatusBarColor(order);
}
return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning";
};
const emitFlagStatusChanged = (flag) => emit("flagStatusChanged", flag);
const emitFlagCreated = (flag) => emit("flagCreated", flag);
/**
* Order actions modal
*/
@@ -764,7 +896,7 @@ const formatCashierName = (order) => {
@click="invoiceSelectedCollections()"
:disabled="selectedInvoiceCollections.length === 0 || isInvoiceQueueBusy"
>
{{ $t("global.invoice") }} {{ $t("global.selected_multiple") }} ({{
{{ $t("common.invoice") }} {{ $t("global.selected_multiple") }} ({{
selectedInvoiceCollections.length
}})
</button>
@@ -777,8 +909,8 @@ const formatCashierName = (order) => {
'is-dark': isInvoiceCollectionSelectedAll(),
}"
>
{{ isInvoiceCollectionSelectedAll() ? $t("global.unselect") : $t("global.select") }}
{{ $t("global.all").toLowerCase() }}
{{ isInvoiceCollectionSelectedAll() ? $t("global.unselect") : $t("common.select") }}
{{ $t("common.all").toLowerCase() }}
</button>
<!-- Expand / Collapse all invoice collections -->
<button
@@ -790,7 +922,7 @@ const formatCashierName = (order) => {
}"
>
{{ isAutoExpandAll() ? $t("global.collapse") : $t("global.expand") }}
{{ $t("global.all").toLowerCase() }}
{{ $t("common.all").toLowerCase() }}
</button>
</div>
</td>
@@ -847,8 +979,11 @@ const formatCashierName = (order) => {
isFirstOrderInCollection(order.invoice_collection_id, order)
"
>
<tr>
<td colspan="13">
<tr :data-testid="`pos-order-invoice-collection-row-${order.invoice_collection_id}`">
<td
:colspan="invoiceCollectionSummaryColumnSpan"
:data-testid="`pos-order-invoice-collection-summary-${order.invoice_collection_id}`"
>
<div class="has-text-weight-bold">
Faktura samling ID: {{ order.invoice_collection_id }}
<span class="has-text-grey"
@@ -860,8 +995,10 @@ const formatCashierName = (order) => {
>
</div>
</td>
<!-- Spacer -->
<td colspan="2">
<td
class="has-text-right"
:data-testid="`pos-order-invoice-collection-total-${order.invoice_collection_id}`"
>
<!-- Total net amount for the invoice collection -->
<span class="has-text-weight-bold">
{{
@@ -901,6 +1038,13 @@ const formatCashierName = (order) => {
}}
er ikke vist, men vil muligvis blive faktureret alligevel.</span
>
<InvoicingPeriodFlagList
v-if="getHiddenInvoiceCollectionFlags(order.invoice_collection_id).length > 0"
class="mt-3"
compact
:flags="getHiddenInvoiceCollectionFlags(order.invoice_collection_id)"
@statusChanged="emitFlagStatusChanged"
/>
</div>
</div>
</td>
@@ -927,7 +1071,11 @@ const formatCashierName = (order) => {
</template>
<template v-if="props.invoiceView">
<td class="is-narrow">
<button class="button is-small" @click="toggleDropdown(order)">
<button
class="button is-small"
:data-testid="`pos-order-expand-${order.id}`"
@click="toggleDropdown(order)"
>
<span
class="icon"
:class="isDropdownActive(order) ? 'has-text-link' : ''"
@@ -941,8 +1089,8 @@ const formatCashierName = (order) => {
</template>
<td>
<ColorIndicator
v-bind:color_class="getOrderInvoiceStatusBarColor(order)"
v-bind:icon_class="isPendingHandheld(order) ? 'fas fa-mobile-alt' : 'fas fa-circle'"
v-bind:color_class="getOrderIndicatorColorClass(order)"
v-bind:icon_class="getOrderIndicatorIconClass(order)"
v-bind:is_narrow="true"
v-bind:label="{
text: order.id,
@@ -1158,9 +1306,15 @@ const formatCashierName = (order) => {
v-bind:user_id="order.user_id"
v-bind:order_id="order.id"
v-bind:invoice_collection_id="order.invoice_collection_id"
v-bind:customer_number="order.customer_id"
v-bind:department_id="order.department_id"
v-bind:reg_1="order.reg_1"
v-bind:reg_2="order.reg_2"
v-bind:reg_3="order.reg_3"
v-bind:order_booking_id="order.booking_id"
:refreshFunction="loadList"
@deleted="loadList()"
@flag-created="emitFlagCreated"
:data-testid="`pos-order-list-settings-${order.id}`"
>
<template v-slot:actions>
@@ -1185,13 +1339,29 @@ const formatCashierName = (order) => {
</div>
</td>
</tr>
<tr
v-if="getOrderLineFlags(order).length > 0"
:data-testid="`pos-order-flags-row-${order.id}`"
>
<td colspan="100%">
<InvoicingPeriodFlagList
compact
:flags="getOrderLineFlags(order)"
@statusChanged="emitFlagStatusChanged"
/>
</td>
</tr>
<!-- Dropdown content (if the invoice view is enabled,and the dropdown is active) -->
<tr v-if="props.invoiceView && isDropdownActive(order)">
<!-- Order line items -->
<td colspan="100%">
<!-- Content -->
<div class="content">
<OrderContentTable :order-id="order.id" />
<OrderContentTable
:order-id="order.id"
:invoice-period-flags="getOrderItemLineFlags(order)"
@flagStatusChanged="emitFlagStatusChanged"
/>
</div>
</td>
</tr>
@@ -1347,6 +1517,11 @@ const formatCashierName = (order) => {
</div>
</div>
</div>
<InvoicingPeriodFlagList
compact
:flags="getOrderLineFlags(order)"
@statusChanged="emitFlagStatusChanged"
/>
</template>
<template #content>
<div>
@@ -1377,9 +1552,15 @@ const formatCashierName = (order) => {
v-bind:user_id="order.user_id"
v-bind:order_id="order.id"
v-bind:invoice_collection_id="order.invoice_collection_id"
v-bind:customer_number="order.customer_id"
v-bind:department_id="order.department_id"
v-bind:reg_1="order.reg_1"
v-bind:reg_2="order.reg_2"
v-bind:reg_3="order.reg_3"
v-bind:order_booking_id="order.booking_id"
:refreshFunction="loadList"
@deleted="loadList()"
@flag-created="emitFlagCreated"
:displayActionsDirectly="true"
>
<template #actions></template>
@@ -1392,7 +1573,11 @@ const formatCashierName = (order) => {
</div>
<!-- Order line items -->
<div class="content" v-if="props.invoiceView && isAutoExpandAll()">
<OrderContentTable :order-id="order.id" />
<OrderContentTable
:order-id="order.id"
:invoice-period-flags="getOrderItemLineFlags(order)"
@flagStatusChanged="emitFlagStatusChanged"
/>
</div>
</div>
</template>
@@ -1457,7 +1642,47 @@ const formatCashierName = (order) => {
</div>
<!-- Orders as cards -->
<template v-for="order in getVisibleOrders" :key="order.id">
<template
v-for="order in props.groupInvoiceCollection ? getVisibleOrdersSortedByInvoiceCollection : getVisibleOrders"
:key="order.id"
>
<!-- If grouping by invoice collection is enabled, show the invoice collection id -->
<template
v-if="
props.groupInvoiceCollection &&
doesOrderHaveInvoiceCollection(order) &&
isFirstOrderInCollection(order.invoice_collection_id, order)
"
>
<div
class="column is-full"
:data-testid="`pos-order-invoice-collection-row-${order.invoice_collection_id}`"
>
<div class="message is-light mb-0">
<div
class="message-body is-flex is-justify-content-space-between is-align-items-center p-2"
:data-testid="`pos-order-invoice-collection-summary-${order.invoice_collection_id}`"
>
<div class="has-text-weight-bold">
Faktura samling ID: {{ order.invoice_collection_id }}
</div>
<div class="has-text-weight-bold">
<span :data-testid="`pos-order-invoice-collection-total-${order.invoice_collection_id}`"
>{{
SessionUser.functions.currency.toLocal(
getOrdersByInvoiceCollection(order.invoice_collection_id)
.reduce((sum, order) => sum + order.total_net_amount, 0)
.toFixed(2)
)
}}
kr.</span
>
</div>
</div>
</div>
</div>
</template>
<div class="column is-full">
<WhiteBoxCard
:forceStateFooter="true"
@@ -1468,6 +1693,7 @@ const formatCashierName = (order) => {
:has-hover-effect="true"
:hasSelectionStyle="false"
:data-testid="`pos-order-list-card-${order.id}`"
:headerTestId="`pos-order-expand-${order.id}`"
>
<template #header>
<div
@@ -1478,9 +1704,17 @@ const formatCashierName = (order) => {
<div>
<small>
<!-- ID and status icon -->
<span class="icon mr-1" :class="getOrderInvoiceStatusBarColor(order)">
<i :class="isPendingHandheld(order) ? 'fas fa-mobile-alt' : 'fas fa-circle'"></i>
</span>
<ColorIndicator
class="mr-1"
style="display: inline-flex"
v-bind:color_class="getOrderIndicatorColorClass(order)"
v-bind:icon_class="getOrderIndicatorIconClass(order)"
v-bind:is_narrow="true"
v-bind:visibility="{
icon: true,
dropdown: false,
}"
/>
{{ order.reg_1 }}<span v-if="order.reg_2">, {{ order.reg_2 }}</span
><span v-if="order.reg_3">, {{ order.reg_3 }}</span>
<span class="has-text-grey-light">#{{ order.id }}</span></small
@@ -1596,6 +1830,8 @@ const formatCashierName = (order) => {
:order-id="order.id"
:summaryMode="true"
:display-price="SessionUser.canAccessAdmin()"
:invoice-period-flags="getOrderItemLineFlags(order)"
@flagStatusChanged="emitFlagStatusChanged"
/>
</div>
<div class="column is-12 has-text-centered">
@@ -1605,7 +1841,7 @@ const formatCashierName = (order) => {
<br />
<!-- Cashier • Time ago -->
<small class="has-text-grey"
>{{ order.cashier_name }} • {{ SessionUser.functions.date.timeAgo(order.created_at) }}</small
>{{ order.cashier_name }} • {{ SessionUser.functions.date.toLocalTimeHours(order.created_at) }}</small
>
<!-- Reg 1 • Reg 2 • Reg 3 -->
<br />
@@ -1703,6 +1939,11 @@ const formatCashierName = (order) => {
</div>
</div>
</div>
<InvoicingPeriodFlagList
compact
:flags="getOrderLineFlags(order)"
@statusChanged="emitFlagStatusChanged"
/>
</template>
<template #content> </template>
<template #footer>
@@ -1765,6 +2006,7 @@ const formatCashierName = (order) => {
/>
<!-- bottom progress bar -->
<InvoicingBillingPeriodInvoiceProgressBar
v-if="shouldShowLegacyInvoiceProgressBar"
v-bind:max-progress="
invoiceCollectionQueueSuccess.length +
invoiceCollectionQueueFailed.length +
@@ -1806,6 +2048,7 @@ const formatCashierName = (order) => {
v-bind:user_id="selectedOrderForActionsMenu.user_id"
v-bind:order_id="selectedOrderForActionsMenu.id"
v-bind:invoice_collection_id="selectedOrderForActionsMenu.invoice_collection_id"
v-bind:customer_number="selectedOrderForActionsMenu.customer_id"
v-bind:reg_1="selectedOrderForActionsMenu.reg_1"
v-bind:reg_2="selectedOrderForActionsMenu.reg_2"
v-bind:reg_3="selectedOrderForActionsMenu.reg_3"
@@ -1813,6 +2056,7 @@ const formatCashierName = (order) => {
v-bind:department_id="selectedOrderForActionsMenu.department_id"
:refreshFunction="loadList"
@deleted="loadList()"
@flag-created="emitFlagCreated"
:displayActionsDirectly="true"
>
<template #actions></template>
@@ -15,8 +15,13 @@ import {
getCurrentStep,
clearCache,
nextStep,
copyLastWashItemsToCurrentOrder,
order_id,
customer_id,
step,
setDesktopStep1PreflightHandler,
clearDesktopStep1PreflightHandler,
pushPosRouteState,
} from "@/components/shop/POSDepartmentProcess.vue";
import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue";
import { POS_STEP_1_VERSION } from "@/config.js";
@@ -24,6 +29,7 @@ import SelectVehicleFormPOS from "@/components/forms/department/pos/SelectVehicl
import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/PosLastScannedLicensePlatesV2.vue";
import PosDesktopOrderBookingSelectorModal from "@/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue";
import PosDesktopCustomerConflictModal from "@/components/displays/department/pos/steps/elements/PosDesktopCustomerConflictModal.vue";
import PosDesktopDuplicateWarning from "@/components/displays/department/pos/steps/elements/PosDesktopDuplicateWarning.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
import {
@@ -58,6 +64,8 @@ const right_tabs = ref([
const getPreferredRailTab = () => (customer_name.value ? "customer" : "license_plates");
const forceActiveTab = ref(getPreferredRailTab());
const activeRailTab = ref(getPreferredRailTab());
const customerPanelActiveTab = ref("details");
const selectVehicleFormRef = ref(null);
const createEmptyDesktopStep1Context = () => ({
@@ -83,23 +91,44 @@ const duplicateOrders = ref([]);
const duplicateWarningKey = ref("");
const duplicateDetailsExpanded = ref(false);
const pendingNextResolution = ref(false);
const isDesktopLastWashCopying = ref(false);
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
let focusOnReg1TimeoutId = null;
const isDesktopStep1Active = computed(() => getCurrentStep() === 1);
const setTab = (tab) => {
forceActiveTab.value = tab;
activeRailTab.value = tab;
};
const setCustomerPanelTab = (tab) => {
customerPanelActiveTab.value = tab || "details";
};
watch(
customer_name,
(newValue) => {
forceActiveTab.value = newValue ? "customer" : "license_plates";
const nextRailTab = newValue ? "customer" : "license_plates";
forceActiveTab.value = nextRailTab;
activeRailTab.value = nextRailTab;
if (!newValue) {
customerPanelActiveTab.value = "details";
}
},
{ immediate: true }
);
const focusOnReg1 = () => {
setTimeout(() => {
if (focusOnReg1TimeoutId !== null) {
clearTimeout(focusOnReg1TimeoutId);
}
focusOnReg1TimeoutId = setTimeout(() => {
focusOnReg1TimeoutId = null;
if (typeof document === "undefined") {
return;
}
const reg1Input = document.getElementById("reg_1");
if (reg1Input) {
reg1Input.focus();
@@ -249,12 +278,8 @@ const bookingSelectionObjects = computed(() => {
const contentSegments = [
`${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`,
`${t("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`,
`${t("admin.pos.order_booking_selector.reference_label")}: ${
getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")
}`,
`${t("admin.pos.order_booking_selector.services_label")}: ${
getOrderBookingServiceText(booking) || t("admin.pos.not_found")
}`,
`${t("common.reference")}: ${getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")}`,
`${t("common.services")}: ${getOrderBookingServiceText(booking) || t("admin.pos.not_found")}`,
];
return {
@@ -266,7 +291,7 @@ const bookingSelectionObjects = computed(() => {
content: contentSegments.join(" • "),
buttons: [
{
label: t("admin.pos.order_booking_selector.use_booking"),
label: t("common.select"),
action: () => handleBookingSelection(booking),
color: "primary",
testId: `pos-desktop-order-booking-use-${booking.id}`,
@@ -307,6 +332,18 @@ const isInlineDuplicateWarningVisible = computed(() => {
return duplicateWarningKey.value === getDuplicateStateKey(normalizedContext);
});
const isCustomerRulesPanelActive = computed(
() => activeRailTab.value === "customer" && customerPanelActiveTab.value === "rules"
);
const shouldShowDuplicateWarningInRulesFooter = computed(
() => isInlineDuplicateWarningVisible.value && isCustomerRulesPanelActive.value
);
const shouldShowDuplicateWarningInActionRail = computed(
() => isInlineDuplicateWarningVisible.value && !isCustomerRulesPanelActive.value
);
const shouldShowRulesFooterActions = computed(() => isCustomerRulesPanelActive.value);
const shouldShowActionRailControls = computed(() => !isCustomerRulesPanelActive.value);
const duplicateDetailsToggleLabel = computed(() =>
duplicateDetailsExpanded.value ? t("admin.pos.hide_order_details") : t("admin.pos.show_order_details")
);
@@ -604,11 +641,57 @@ const handleCustomerConflictCancel = async () => {
}
};
const pushDesktopStepTwoRoute = () => {
if (!order_id.value) {
return;
}
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
};
const handleDesktopLastWashCopy = async (payload = {}) => {
if (isDesktopLastWashCopying.value) {
return;
}
isDesktopLastWashCopying.value = true;
try {
await desktopStep1CoordinationPromise;
desktopStep1CoordinationPromise = coordinateDesktopStep1({
reason: "copy_last_wash",
finalizeReg1Input: true,
});
const result = await desktopStep1CoordinationPromise;
if (!result.canProceed) {
return;
}
const didCopy = await copyLastWashItemsToCurrentOrder(payload?.items || [], {
sourceReference: payload?.order?.reference,
});
if (!didCopy) {
return;
}
step.value = 2;
pushDesktopStepTwoRoute();
} catch (error) {
console.error("Unable to copy last wash to current order:", error);
} finally {
isDesktopLastWashCopying.value = false;
}
};
onMounted(() => {
setDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
});
onBeforeUnmount(() => {
if (focusOnReg1TimeoutId !== null) {
clearTimeout(focusOnReg1TimeoutId);
focusOnReg1TimeoutId = null;
}
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
});
@@ -631,8 +714,10 @@ watch(
<template #default>
<SelectVehicleFormPOS
ref="selectVehicleFormRef"
:is-desktop-last-wash-copying="isDesktopLastWashCopying"
@update:desktopStep1Context="setDesktopStep1Context"
@commit:desktopStep1="handleDesktopStep1Commit"
@copy-last-wash="handleDesktopLastWashCopy"
/>
</template>
</WhiteBox>
@@ -649,7 +734,23 @@ watch(
@update:activeTab="setTab"
>
<template #customer>
<PosSelectedCustomer variant="sidebar" />
<PosSelectedCustomer variant="sidebar" @update:activeTab="setCustomerPanelTab">
<template v-if="shouldShowRulesFooterActions" #rules-footer>
<PosDesktopDuplicateWarning
v-if="shouldShowDuplicateWarningInRulesFooter"
:title="t('admin.pos.warning')"
:message="t('admin.pos.duplicate_order_warning')"
:toggle-label="duplicateDetailsToggleLabel"
:details-expanded="duplicateDetailsExpanded"
:duplicate-orders="duplicateDetailsObjects"
@toggle-details="toggleDuplicateDetails"
/>
<NextStepError class="is-fullwidth" :clear-automatically="true" :clear-delay="8000" />
<ButtonsBox class="pos-actions pos-actions--stacked">
<NextStep class="is-fullwidth" tabindex="6" />
</ButtonsBox>
</template>
</PosSelectedCustomer>
</template>
<template #license_plates>
<PosLastScannedLicensePlatesV2 />
@@ -660,64 +761,18 @@ watch(
</div>
<div class="pos-shell-actions">
<ButtonsBox class="pos-actions pos-actions--stacked">
<Cancel tabindex="2" class="is-fullwidth" />
<Cancel tabindex="5" class="is-fullwidth" />
</ButtonsBox>
<div class="pos-shell-actions__rail">
<div
v-if="isInlineDuplicateWarningVisible"
class="notification is-warning is-light pos-duplicate-warning"
data-testid="pos-desktop-duplicate-warning-inline"
>
<div class="pos-duplicate-warning__header">
<div class="pos-duplicate-warning__header-copy">
<span class="icon pos-duplicate-warning__icon" aria-hidden="true">
<i class="fas fa-triangle-exclamation"></i>
</span>
<p class="pos-duplicate-warning__title">{{ t("admin.pos.warning") }}</p>
</div>
<button
type="button"
class="button is-small is-warning is-light"
data-testid="pos-desktop-duplicate-warning-toggle-details"
@click="toggleDuplicateDetails"
>
{{ duplicateDetailsToggleLabel }}
</button>
</div>
<p class="pos-duplicate-warning__message">
{{ t("admin.pos.duplicate_order_warning") }}
</p>
<div
v-if="duplicateDetailsExpanded"
class="pos-duplicate-warning__details"
data-testid="pos-desktop-duplicate-warning-details-inline"
>
<div
v-for="duplicateOrder in duplicateDetailsObjects"
:key="duplicateOrder.id"
class="pos-duplicate-warning__detail-card"
:data-testid="`pos-desktop-duplicate-warning-detail-${duplicateOrder.id}`"
>
<div class="pos-duplicate-warning__detail-copy">
<p class="pos-duplicate-warning__detail-title">{{ duplicateOrder.label }}</p>
<p class="pos-duplicate-warning__detail-content">{{ duplicateOrder.content }}</p>
</div>
<div class="buttons pos-duplicate-warning__detail-actions">
<button
v-for="button in duplicateOrder.buttons"
:key="button.label"
type="button"
class="button is-small"
:class="`is-${button.color || 'dark'}`"
:data-testid="button.testId"
@click="button.action()"
>
{{ button.label }}
</button>
</div>
</div>
</div>
</div>
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail">
<PosDesktopDuplicateWarning
v-if="shouldShowDuplicateWarningInActionRail"
:title="t('admin.pos.warning')"
:message="t('admin.pos.duplicate_order_warning')"
:toggle-label="duplicateDetailsToggleLabel"
:details-expanded="duplicateDetailsExpanded"
:duplicate-orders="duplicateDetailsObjects"
@toggle-details="toggleDuplicateDetails"
/>
<NextStepError class="is-fullwidth" :clear-automatically="true" :clear-delay="8000" />
<ButtonsBox class="pos-actions pos-actions--stacked">
<NextStep class="is-fullwidth" tabindex="6" />
@@ -126,7 +126,7 @@ const closeModal = () => {
data-testid="pos-desktop-customer-conflict-cancel"
@click="emit('cancel')"
>
{{ t("admin.pos.customer_conflict.cancel") }}
{{ t("common.cancel") }}
</button>
<button
class="button is-primary is-light"
@@ -0,0 +1,83 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
message: {
type: String,
required: true,
},
toggleLabel: {
type: String,
required: true,
},
detailsExpanded: {
type: Boolean,
default: false,
},
duplicateOrders: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["toggle-details"]);
</script>
<template>
<div
class="notification is-warning is-light pos-duplicate-warning"
data-testid="pos-desktop-duplicate-warning-inline"
>
<div class="pos-duplicate-warning__header">
<div class="pos-duplicate-warning__header-copy">
<span class="icon pos-duplicate-warning__icon" aria-hidden="true">
<i class="fas fa-triangle-exclamation"></i>
</span>
<p class="pos-duplicate-warning__title">{{ title }}</p>
</div>
<button
type="button"
class="button is-small is-warning is-light"
data-testid="pos-desktop-duplicate-warning-toggle-details"
@click="emit('toggle-details')"
>
{{ toggleLabel }}
</button>
</div>
<p class="pos-duplicate-warning__message">
{{ message }}
</p>
<div
v-if="detailsExpanded"
class="pos-duplicate-warning__details"
data-testid="pos-desktop-duplicate-warning-details-inline"
>
<div
v-for="duplicateOrder in duplicateOrders"
:key="duplicateOrder.id"
class="pos-duplicate-warning__detail-card"
:data-testid="`pos-desktop-duplicate-warning-detail-${duplicateOrder.id}`"
>
<div class="pos-duplicate-warning__detail-copy">
<p class="pos-duplicate-warning__detail-title">{{ duplicateOrder.label }}</p>
<p class="pos-duplicate-warning__detail-content">{{ duplicateOrder.content }}</p>
</div>
<div class="buttons pos-duplicate-warning__detail-actions">
<button
v-for="button in duplicateOrder.buttons"
:key="button.label"
type="button"
class="button is-small"
:class="`is-${button.color || 'dark'}`"
:data-testid="button.testId"
@click="button.action()"
>
{{ button.label }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -8,8 +8,13 @@ const props = defineProps({
type: [Number, String],
default: null,
},
copying: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["copy"]);
const { locale, t } = useI18n();
const order = ref(null);
@@ -78,12 +83,52 @@ const groupedOrderItems = computed(() => {
}));
});
const getOrderItemProductId = (item) => parsePositiveInteger(item?.product?.id ?? item?.product_id);
const getOrderItemQuantity = (item) => {
const parsedQuantity = Number.parseInt(String(item?.quantity ?? 1), 10);
return Number.isInteger(parsedQuantity) && parsedQuantity > 0 ? parsedQuantity : null;
};
const hasUsableOrderItems = computed(() => {
return groupedOrderItems.value.some((group) => getOrderItemProductId(group.item) && getOrderItemQuantity(group.item));
});
const isCopyDisabled = computed(() => loadState.value !== "loaded" || props.copying || !hasUsableOrderItems.value);
const copyButtonLabel = computed(() => {
const lastWashLabel = SessionUser.objects.global.language.last_wash || t("common.last_wash");
return `${SessionUser.objects.global.language.copy || t("common.copy")} ${lastWashLabel.toLowerCase()}`;
});
const copyButtonText = computed(() => (props.copying ? t("pos.copying_last_wash") : copyButtonLabel.value));
const copyButtonState = computed(() => {
if (props.copying) {
return "copying";
}
if (isCopyDisabled.value) {
return "disabled";
}
return "ready";
});
const onCopy = () => {
if (isCopyDisabled.value) {
return;
}
emit("copy", {
order: order.value,
orderId: normalizedOrderId.value,
items: orderItems.value,
});
};
const lastWashMetadata = computed(() => {
const currentOrder = order.value || {};
return [
{
key: "created",
label: t("pos.order.created"),
label: t("common.created"),
value: formatDateTime(currentOrder.created_at),
},
{
@@ -145,7 +190,7 @@ watch(normalizedOrderId, (nextOrderId, previousOrderId) => {
</div>
</dl>
<section class="pos-step-one-insight-card__section">
<section class="pos-step-one-insight-card__section" data-testid="pos-desktop-last-wash-section">
<div class="pos-step-one-insight-card__section-header">
<h4 class="pos-step-one-insight-card__section-title">Indhold</h4>
</div>
@@ -181,12 +226,15 @@ watch(normalizedOrderId, (nextOrderId, previousOrderId) => {
type="button"
class="button is-small is-light pos-step-one-copy-action"
data-testid="pos-desktop-last-wash-copy"
disabled
:data-state="copyButtonState"
:disabled="isCopyDisabled"
:aria-busy="props.copying ? 'true' : 'false'"
@click="onCopy"
>
<span class="icon is-small">
<i class="fas fa-copy" aria-hidden="true"></i>
<i :class="props.copying ? 'fas fa-spinner fa-spin' : 'fas fa-copy'" aria-hidden="true"></i>
</span>
<span>{{ `${SessionUser.objects.global.language.copy} ${SessionUser.objects.global.language.last_wash.toLowerCase()}` }}</span>
<span>{{ copyButtonText }}</span>
</button>
</template>
</section>
@@ -394,7 +394,7 @@ const closeModal = () => {
class="tag pos-desktop-order-booking-card__today-tag"
:data-testid="`pos-desktop-order-booking-today-${booking.id}`"
>
{{ t("global.time.today") }}
{{ t("common.today") }}
</span>
<span v-if="didBookingDetailFail(booking)" class="tag is-light pos-desktop-order-booking-card__tag">
{{ t("admin.pos.not_found") }}
@@ -413,6 +413,7 @@ const closeModal = () => {
:refreshFunction="emitRefreshBookings"
:icon="'fas fa-ellipsis-v'"
trigger-button-variant="text"
allow-booking-completion
>
<template #actions />
</ActionSettingsWheelButton>
@@ -430,7 +431,7 @@ const closeModal = () => {
<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>
<dt>{{ t("common.reference") }}</dt>
<dd>{{ getBookingReference(booking) || "—" }}</dd>
</div>
<div v-if="getBookingPo(booking)" class="pos-desktop-order-booking-card__meta-item">
@@ -454,7 +455,7 @@ const closeModal = () => {
>
<div class="pos-desktop-order-booking-card__section-header">
<p class="pos-desktop-order-booking-card__section-title">
{{ t("admin.pos.order_booking_selector.services_label") }}
{{ t("common.services") }}
</p>
<div
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
@@ -475,8 +476,8 @@ const closeModal = () => {
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("common.quantity") }}</span>
<span>{{ t("common.services") }}</span>
<span>{{ t("admin.pos.price_dkk") }}</span>
</div>
<div
@@ -528,7 +529,7 @@ const closeModal = () => {
:data-testid="`pos-desktop-order-booking-use-${booking.id}`"
@click="onSelect(booking)"
>
{{ t("admin.pos.order_booking_selector.use_booking") }}
{{ t("common.select") }}
</button>
</div>
</article>
@@ -2,12 +2,21 @@
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
getOrderBookingItemPreviewRows,
getOrderBookingReferenceValue,
getOrderBookingServiceLabels,
} from "@/components/displays/department/pos/utils/orderBookingDisplay.js";
const props = defineProps({
vehicle: {
type: Object,
default: null,
},
booking: {
type: Object,
default: null,
},
});
const { t } = useI18n();
@@ -19,6 +28,8 @@ const parsePositiveInteger = (value) => {
const normalizeStatus = (value) => String(value ?? "").trim().toLowerCase();
const selectedBooking = computed(() => (props.booking?.id ? props.booking : null));
const getVehicleStatusLabel = (vehicle) => {
if (!vehicle) {
return SessionUser.objects.global.language.no_data;
@@ -78,6 +89,11 @@ const getVehicleStatusTone = (vehicle) => {
};
const vehicleTypeName = computed(() => {
const bookingPrimaryService = bookingSummaryRows.value.find((row) => !row.isAddon)?.label || bookingSummaryRows.value[0]?.label;
if (bookingPrimaryService) {
return bookingPrimaryService;
}
const vehicleTypeId = parsePositiveInteger(props.vehicle?.type);
if (!vehicleTypeId) {
return SessionUser.objects.global.language.no_data;
@@ -105,17 +121,24 @@ const vehicleMetadata = computed(() => [
{
key: "reg",
label: SessionUser.objects.orders.columns.reg_1.label,
value: String(props.vehicle?.reg ?? "").trim() || SessionUser.objects.global.language.no_data,
value:
String(selectedBooking.value?.reg_1 ?? props.vehicle?.reg ?? "").trim() ||
SessionUser.objects.global.language.no_data,
},
{
key: "customer",
label: t("admin.pos.customer"),
value: String(props.vehicle?.customer_name ?? "").trim() || SessionUser.objects.global.language.no_data,
value:
String(selectedBooking.value?.customer_name ?? props.vehicle?.customer_name ?? "").trim() ||
SessionUser.objects.global.language.no_data,
},
{
key: "reference",
label: SessionUser.objects.orders.columns.reference.label,
value: String(props.vehicle?.reference ?? "").trim() || SessionUser.objects.global.language.no_data,
value:
getOrderBookingReferenceValue(selectedBooking.value) ||
String(props.vehicle?.reference ?? "").trim() ||
SessionUser.objects.global.language.no_data,
},
{
key: "status",
@@ -124,7 +147,36 @@ const vehicleMetadata = computed(() => [
},
]);
const bookingSummaryRows = computed(() => {
if (!selectedBooking.value) {
return [];
}
const previewRows = getOrderBookingItemPreviewRows(selectedBooking.value);
if (previewRows.length > 0) {
return previewRows.map((row) => ({
key: row.key,
label: row.label,
quantity: row.quantity,
isAddon: row.depth > 0,
includeInInvoice: true,
}));
}
return getOrderBookingServiceLabels(selectedBooking.value).map((label, index) => ({
key: `booking-service-${index}`,
label,
quantity: 1,
isAddon: index > 0,
includeInInvoice: true,
}));
});
const vehicleSummaryRows = computed(() => {
if (bookingSummaryRows.value.length > 0) {
return bookingSummaryRows.value;
}
if (!props.vehicle) {
return [];
}
@@ -166,7 +218,7 @@ const hasVehicleSummaryRows = computed(() => vehicleSummaryRows.value.length > 0
<p class="pos-step-one-insight-card__eyebrow">{{ SessionUser.objects.vehicles.meta.labels.single }}</p>
<h3 class="pos-step-one-insight-card__title">{{ vehicleTypeName }}</h3>
<p class="pos-step-one-insight-card__subtitle">
{{ String(vehicle?.reg ?? "").trim() || SessionUser.objects.global.language.no_data }}
{{ String(selectedBooking?.reg_1 ?? vehicle?.reg ?? "").trim() || SessionUser.objects.global.language.no_data }}
</p>
</div>
<div class="pos-step-one-insight-card__pills">
@@ -196,7 +248,7 @@ const hasVehicleSummaryRows = computed(() => vehicleSummaryRows.value.length > 0
</div>
</dl>
<section class="pos-step-one-insight-card__section">
<section class="pos-step-one-insight-card__section" data-testid="pos-desktop-vehicle-summary-section">
<div class="pos-step-one-insight-card__section-header">
<h4 class="pos-step-one-insight-card__section-title">Ydelse og tilvalg</h4>
</div>
@@ -24,70 +24,79 @@ import {
} 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";
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
import BookedCustomer from "@/components/viewport/elements/icons/BookedCustomer.vue";
import KnownCustomer from "@/components/viewport/elements/icons/KnownCustomer.vue";
import CardPaymentCustomer from "@/components/viewport/elements/icons/CardPaymentCustomer.vue";
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import 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 {
LPR_FRAME_CLIENT_BYTES_FIELD,
LPR_FRAME_CLIENT_CAPTURE_MS_FIELD,
LPR_FRAME_CLIENT_DRAW_MS_FIELD,
LPR_FRAME_CLIENT_ENCODE_MS_FIELD,
LPR_FRAME_CLIENT_HEIGHT_FIELD,
LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD,
LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD,
LPR_FRAME_CLIENT_WIDTH_FIELD,
getVisualFingerprintDistance,
type LPRFrameEncodeCandidate,
type LPRFramePayload,
type LPRFrameViewportRect,
} from "@/components/viewport/page/templates/scanner/lprFrameCapture";
// Debug mode flag
const debug_mode = ref(false);
// Debug array to store request results
const debug_request_results = ref([]);
const debug_request_results = ref<unknown[]>([]);
const lastParsedImage = ref(null);
const setLastCapturedImage = (image: string) => {
camera.latestImage.value = image;
type ParsedFrameFingerprint = {
content: string | null;
contentFingerprintPromise: Promise<string> | null;
getContentFingerprint: (() => Promise<string>) | null;
getVisualFingerprint: (() => string | null) | null;
outcome: "pending" | "miss" | "success";
quick: string;
visual: string | null;
};
const lastParsedImage = ref<ParsedFrameFingerprint | null>(null);
const scannerFocusRef = ref<HTMLElement | null>(null);
type LPRResponse = {
success: boolean;
license_plate_number: string;
};
const latestLPRResponse = ref<LPRResponse | null>(null);
const LPR_IMAGE_MAX_WIDTH = 1280;
const LPR_IMAGE_MAX_HEIGHT = 720;
const LPR_IMAGE_JPEG_QUALITY = 0.72;
const compressImageForLPR = (image: string): Promise<string> => {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const sourceWidth = img.naturalWidth || img.width;
const sourceHeight = img.naturalHeight || img.height;
if (!sourceWidth || !sourceHeight) {
resolve(image);
return;
}
const scale = Math.min(1, LPR_IMAGE_MAX_WIDTH / sourceWidth, LPR_IMAGE_MAX_HEIGHT / sourceHeight);
const targetWidth = Math.max(1, Math.round(sourceWidth * scale));
const targetHeight = Math.max(1, Math.round(sourceHeight * scale));
const canvas = document.createElement("canvas");
canvas.width = targetWidth;
canvas.height = targetHeight;
const context = canvas.getContext("2d");
if (!context) {
resolve(image);
return;
}
context.drawImage(img, 0, 0, targetWidth, targetHeight);
resolve(canvas.toDataURL("image/jpeg", LPR_IMAGE_JPEG_QUALITY));
};
img.onerror = () => resolve(image);
img.src = image;
});
type LPRScanContext = {
activeVehicleIndex: number;
attachmentView: boolean;
manualInput: boolean;
registrationNumbers: string[];
transactionHistoryView: boolean;
};
const latestLPRResponse = ref<LPRResponse | null>(null);
const isLPRFrameProcessing = ref(false);
const isLPRRequestInFlight = ref(false);
const isNoPlateBackoffActive = ref(false);
const isDuplicateFrameBackoffActive = ref(false);
const isSuccessCooldownActive = ref(false);
let lprRequestAbortController: AbortController | null = null;
let noPlateBackoffTimerId: ReturnType<typeof window.setTimeout> | null = null;
let duplicateFrameBackoffTimerId: ReturnType<typeof window.setTimeout> | null = null;
let successCooldownTimerId: ReturnType<typeof window.setTimeout> | null = null;
const NO_PLATE_BACKOFF_DELAYS_MS = [1500, 2500, 4000];
const LPR_VISUAL_DUPLICATE_DISTANCE_THRESHOLD = 4;
const LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS = 700;
const LPR_ENDPOINT = "/modules/scanner/lpr";
let consecutiveNoPlateResponses = 0;
const nowMs = (): number =>
typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
const activeVehicleIndexNext = () => {
// Increment the active vehicle index, wrapping around if necessary
if (vehicles.activeVehicleIndex.value < 3) {
@@ -115,55 +124,518 @@ const handleLPRResult = () => {
}
};
const parseImage = async (image: string) => {
type LPRFrameInput = string | LPRFramePayload;
const isLPRFramePayload = (image: LPRFrameInput): image is LPRFramePayload =>
typeof image === "object" && image !== null && image.blob instanceof Blob;
const getLPRFrameFingerprint = (image: LPRFrameInput): string =>
isLPRFramePayload(image) ? image.fingerprint : image;
const getLPRFrameContentFingerprint = (image: LPRFrameInput): (() => Promise<string>) | null =>
isLPRFramePayload(image) ? image.getContentFingerprint ?? null : null;
const getLPRFrameVisualFingerprint = (image: LPRFrameInput): string | null =>
isLPRFramePayload(image) ? image.visualFingerprint ?? null : null;
const getLPRFrameVisualFingerprintGetter = (image: LPRFrameInput): (() => string | null) | null =>
isLPRFramePayload(image) ? image.getVisualFingerprint ?? null : null;
const rememberParsedImageFingerprint = (image: LPRFrameInput) => {
const quick = getLPRFrameFingerprint(image);
const getContentFingerprint = getLPRFrameContentFingerprint(image);
const entry: ParsedFrameFingerprint = {
content: getContentFingerprint === null ? quick : null,
contentFingerprintPromise: null,
getContentFingerprint,
getVisualFingerprint: getLPRFrameVisualFingerprintGetter(image),
outcome: "pending",
quick,
visual: getLPRFrameVisualFingerprint(image),
};
lastParsedImage.value = entry;
};
const markLastParsedImageOutcome = (outcome: ParsedFrameFingerprint["outcome"]) => {
if (lastParsedImage.value !== null) {
if (outcome === "miss" && lastParsedImage.value.visual === null) {
lastParsedImage.value.visual = lastParsedImage.value.getVisualFingerprint?.() ?? null;
}
lastParsedImage.value.outcome = outcome;
}
};
const resetParsedImageFingerprint = () => {
lastParsedImage.value = null;
};
const resolveParsedFrameContentFingerprint = (entry: ParsedFrameFingerprint): Promise<string> | null => {
if (entry.content !== null) {
return Promise.resolve(entry.content);
}
if (entry.getContentFingerprint === null) {
return null;
}
entry.contentFingerprintPromise ??= entry.getContentFingerprint().then((content) => {
if (lastParsedImage.value === entry) {
entry.content = content;
}
return content;
}).catch((error) => {
if (lastParsedImage.value === entry) {
entry.contentFingerprintPromise = null;
}
throw error;
});
return entry.contentFingerprintPromise;
};
const isVisuallySimilarToLastMiss = (visualFingerprint: string | null | undefined): boolean => {
const lastParsed = lastParsedImage.value;
return lastParsed !== null
&& lastParsed.outcome === "miss"
&& getVisualFingerprintDistance(lastParsed.visual, visualFingerprint) <= LPR_VISUAL_DUPLICATE_DISTANCE_THRESHOLD;
};
const hasLastMissVisualFingerprint = (): boolean =>
lastParsedImage.value !== null
&& lastParsedImage.value.outcome === "miss"
&& lastParsedImage.value.visual !== null;
const shouldBuildLPRVisualFingerprint = (): boolean =>
hasLastMissVisualFingerprint();
const shouldSkipDuplicateFrame = async (image: LPRFrameInput): Promise<boolean> => {
const quick = getLPRFrameFingerprint(image);
const lastParsed = lastParsedImage.value;
if (lastParsed === null) {
return false;
}
if (isVisuallySimilarToLastMiss(getLPRFrameVisualFingerprint(image))) {
return true;
}
if (lastParsed.quick !== quick) {
return false;
}
const currentContentFingerprint = getLPRFrameContentFingerprint(image);
const lastContentFingerprint = resolveParsedFrameContentFingerprint(lastParsed);
if (lastContentFingerprint === null || currentContentFingerprint === null) {
return true;
}
try {
const [lastContent, currentContent] = await Promise.all([
lastContentFingerprint,
currentContentFingerprint(),
]);
return lastContent === currentContent;
} catch {
return false;
}
};
const appendFiniteTimingParam = (
queryParts: string[],
field: string,
value: number | null | undefined
) => {
if (value === null || value === undefined) {
return;
}
const numericValue = Number(value);
if (!Number.isFinite(numericValue) || numericValue < 0) {
return;
}
const roundedValue = numericValue.toFixed(3);
if (Number(roundedValue) > 0) {
queryParts.push(`${field}=${roundedValue}`);
}
};
const appendPositiveIntegerParam = (
queryParts: string[],
field: string,
value: number | null | undefined
) => {
const numericValue = Number(value);
if (!Number.isFinite(numericValue) || numericValue <= 0) {
return;
}
queryParts.push(`${field}=${Math.round(numericValue)}`);
};
type LPRRequestPayload = Blob | { base64_image: string };
type LPRRequestBuildResult = {
headers?: Record<string, string>;
payload: LPRRequestPayload;
url: string;
};
const buildLPRRequestPayload = (
image: LPRFrameInput,
clientPreflightDurationMs: number | null = null
): LPRRequestBuildResult => {
if (!isLPRFramePayload(image)) {
return {
payload: { base64_image: image },
url: LPR_ENDPOINT,
};
}
const headers: Record<string, string> = {
"Content-Type": image.mimeType || image.blob.type || "image/jpeg",
};
const queryParts: string[] = [];
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_CAPTURE_MS_FIELD, image.captureDurationMs);
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD, clientPreflightDurationMs);
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_DRAW_MS_FIELD, image.captureTimings?.drawMs);
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_ENCODE_MS_FIELD, image.captureTimings?.encodeMs);
appendFiniteTimingParam(
queryParts,
LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD,
image.captureTimings?.visualFingerprintMs
);
appendPositiveIntegerParam(queryParts, LPR_FRAME_CLIENT_WIDTH_FIELD, image.width);
appendPositiveIntegerParam(queryParts, LPR_FRAME_CLIENT_HEIGHT_FIELD, image.height);
appendPositiveIntegerParam(queryParts, LPR_FRAME_CLIENT_BYTES_FIELD, image.blob.size);
const queryString = queryParts.join("&");
return {
headers,
payload: image.blob,
url: queryString ? `${LPR_ENDPOINT}?${queryString}` : LPR_ENDPOINT,
};
};
type LPRCurrentStateSkipOptions = {
ignoreNoPlateBackoff?: boolean;
};
const shouldEncodeLPRFrame = (candidate: LPRFrameEncodeCandidate): boolean => {
if (views.attachmentView.value) {
return true;
}
if (
shouldSkipLPRForCurrentState({ ignoreNoPlateBackoff: true })
|| isLPRFrameProcessing.value
|| isLPRRequestInFlight.value
) {
return false;
}
if (isNoPlateBackoffActive.value) {
if (!hasLastMissVisualFingerprint() || isVisuallySimilarToLastMiss(candidate.visualFingerprint)) {
return false;
}
clearNoPlateBackoff();
return true;
}
if (!isVisuallySimilarToLastMiss(candidate.visualFingerprint)) {
return true;
}
scheduleDuplicateFrameBackoff();
return false;
};
const rememberLatestCameraImage = (image: LPRFrameInput) => {
if (isLPRFramePayload(image)) {
camera.setLatestImageBlob(image.blob);
return;
}
camera.setLatestImage(image);
};
const hasRegistrationNumber = (registrationNumber: string | null | undefined): boolean =>
String(registrationNumber ?? "").trim().length > 0;
const isActiveRegistrationSlotFilled = (): boolean =>
hasRegistrationNumber(vehicles.getActiveVehicle()?.reg);
const areAllRegistrationSlotsFilled = (): boolean =>
[1, 2, 3].every((vehicleIndex) => hasRegistrationNumber(vehicles.get(vehicleIndex)?.reg));
const shouldSkipLPRForCurrentState = (options: LPRCurrentStateSkipOptions = {}): boolean =>
views.attachmentView.value
|| isActiveRegistrationSlotFilled()
|| areAllRegistrationSlotsFilled()
|| (!options.ignoreNoPlateBackoff && isNoPlateBackoffActive.value)
|| isDuplicateFrameBackoffActive.value
|| isSuccessCooldownActive.value;
const getScannerFocusViewportRect = (): LPRFrameViewportRect | null => {
if (views.attachmentView.value || scannerFocusRef.value === null) {
return null;
}
const rect = scannerFocusRef.value.getBoundingClientRect();
if (
!Number.isFinite(rect.width) ||
!Number.isFinite(rect.height) ||
rect.width <= 0 ||
rect.height <= 0
) {
return null;
}
return {
height: rect.height,
width: rect.width,
x: rect.left,
y: rect.top,
};
};
const isCameraFrameCaptureEnabled = computed(() => {
if (views.attachmentView.value) {
return true;
}
return !isLPRFrameProcessing.value
&& !isLPRRequestInFlight.value
&& (!isNoPlateBackoffActive.value || hasLastMissVisualFingerprint())
&& !isDuplicateFrameBackoffActive.value
&& !isSuccessCooldownActive.value
&& !isActiveRegistrationSlotFilled()
&& !areAllRegistrationSlotsFilled();
});
const shouldPauseScannerPreview = computed(() =>
!views.attachmentView.value
&& (
isLPRFrameProcessing.value
|| isLPRRequestInFlight.value
|| isDuplicateFrameBackoffActive.value
|| isSuccessCooldownActive.value
|| isActiveRegistrationSlotFilled()
|| areAllRegistrationSlotsFilled()
|| (isNoPlateBackoffActive.value && !hasLastMissVisualFingerprint())
)
);
const lprCameraCaptureIntervalMs = computed(() =>
!views.attachmentView.value && isNoPlateBackoffActive.value && hasLastMissVisualFingerprint()
? LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS
: null
);
const abortLPRRequest = () => {
lprRequestAbortController?.abort();
lprRequestAbortController = null;
};
const isDocumentHidden = (): boolean =>
typeof document !== "undefined" && document.visibilityState === "hidden";
const handleDocumentVisibilityChange = () => {
if (!isDocumentHidden()) {
return;
}
abortLPRRequest();
resetParsedImageFingerprint();
resetNoPlateBackoff();
};
const clearNoPlateBackoff = () => {
if (noPlateBackoffTimerId !== null) {
window.clearTimeout(noPlateBackoffTimerId);
noPlateBackoffTimerId = null;
}
isNoPlateBackoffActive.value = false;
};
const clearDuplicateFrameBackoff = () => {
if (duplicateFrameBackoffTimerId !== null) {
window.clearTimeout(duplicateFrameBackoffTimerId);
duplicateFrameBackoffTimerId = null;
}
isDuplicateFrameBackoffActive.value = false;
};
const clearSuccessCooldown = () => {
if (successCooldownTimerId !== null) {
window.clearTimeout(successCooldownTimerId);
successCooldownTimerId = null;
}
isSuccessCooldownActive.value = false;
};
const resetNoPlateBackoff = () => {
consecutiveNoPlateResponses = 0;
clearNoPlateBackoff();
clearDuplicateFrameBackoff();
clearSuccessCooldown();
};
const scheduleNoPlateBackoff = () => {
consecutiveNoPlateResponses += 1;
const delay = NO_PLATE_BACKOFF_DELAYS_MS[
Math.min(consecutiveNoPlateResponses - 1, NO_PLATE_BACKOFF_DELAYS_MS.length - 1)
];
clearNoPlateBackoff();
isNoPlateBackoffActive.value = true;
noPlateBackoffTimerId = window.setTimeout(() => {
noPlateBackoffTimerId = null;
isNoPlateBackoffActive.value = false;
}, delay);
};
const scheduleDuplicateFrameBackoff = () => {
clearDuplicateFrameBackoff();
isDuplicateFrameBackoffActive.value = true;
duplicateFrameBackoffTimerId = window.setTimeout(() => {
duplicateFrameBackoffTimerId = null;
isDuplicateFrameBackoffActive.value = false;
}, Math.min(camera.getImageCaptureDelay(false), LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS));
};
const scheduleSuccessCooldown = () => {
clearSuccessCooldown();
isSuccessCooldownActive.value = true;
successCooldownTimerId = window.setTimeout(() => {
successCooldownTimerId = null;
isSuccessCooldownActive.value = false;
}, camera.getImageCaptureDelayAfterSuccess());
};
const isAbortError = (error: unknown): boolean => {
if (error instanceof DOMException && error.name === "AbortError") {
return true;
}
return typeof error === "object" && error !== null && (error as { name?: string; code?: string }).code === "ERR_CANCELED";
};
const isSameLPRScanContext = (first: LPRScanContext, second: LPRScanContext): boolean =>
first.activeVehicleIndex === second.activeVehicleIndex
&& first.attachmentView === second.attachmentView
&& first.manualInput === second.manualInput
&& first.transactionHistoryView === second.transactionHistoryView
&& first.registrationNumbers.length === second.registrationNumbers.length
&& first.registrationNumbers.every((registrationNumber, index) => registrationNumber === second.registrationNumbers[index]);
const parseImage = async (image: LPRFrameInput) => {
if (views.attachmentView.value) {
rememberLatestCameraImage(image);
return;
}
if (shouldSkipLPRForCurrentState() || isLPRFrameProcessing.value || isLPRRequestInFlight.value) {
return;
}
// Check if the time since the last successful parse is enough
if (!camera.hasDelayAfterSuccessPassed()) {
return;
}
if (lastParsedImage.value === image) {
// If the image is the same as the last parsed one, skip parsing
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) => {
isLPRFrameProcessing.value = true;
try {
const clientPreflightStartedAt = nowMs();
const isDuplicateFrame = await shouldSkipDuplicateFrame(image);
const clientPreflightDurationMs = Math.max(0, nowMs() - clientPreflightStartedAt);
if (shouldSkipLPRForCurrentState()) {
return;
}
if (isDuplicateFrame) {
// If the image is the same as the last parsed one, skip parsing
scheduleDuplicateFrameBackoff();
return;
}
rememberParsedImageFingerprint(image); // Update the last parsed image
isLPRRequestInFlight.value = true;
const abortController = new AbortController();
lprRequestAbortController = abortController;
const requestScanContext = getLPRScanContext();
const lprRequest = buildLPRRequestPayload(image, clientPreflightDurationMs);
try {
const response = await SessionUser.request(
lprRequest.url,
"POST",
lprRequest.payload,
null,
null,
{
...(lprRequest.headers ? { headers: lprRequest.headers } : {}),
signal: abortController.signal,
transport: "fetch",
}
);
if (debug_mode.value) {
debug_request_results.value.push(response);
}
// If the response is not successful, stop here.
if (!response.data.success) {
if (!isSameLPRScanContext(requestScanContext, getLPRScanContext())) {
return;
}
// If the response is not successful, stop here.
if (!response.data.success) {
markLastParsedImageOutcome("miss");
scheduleNoPlateBackoff();
return;
}
resetNoPlateBackoff();
markLastParsedImageOutcome("success");
rememberLatestCameraImage(image);
latestLPRResponse.value = response.data.data as LPRResponse;
// Set the last successful capture time
camera.setLastSuccess();
scheduleSuccessCooldown();
// Handle parsed result.
handleLPRResult();
})
.catch((error) => {
} catch (error) {
if (isAbortError(error)) {
return;
}
if (debug_mode.value) {
debug_request_results.value.push(error);
}
markLastParsedImageOutcome("miss");
scheduleNoPlateBackoff();
//console.error("Error parsing image:", error);
});
} finally {
if (lprRequestAbortController === abortController) {
lprRequestAbortController = null;
}
isLPRRequestInFlight.value = false;
}
} finally {
isLPRFrameProcessing.value = false;
}
};
watch(manualInput, (newValue) => {
// Update the header transparency when manualInput changes
setTransparency(!newValue);
});
type statusIcon =
| typeof VerifiedCustomer
| typeof KnownCustomer
| typeof UnknownCustomer
| typeof CardPaymentCustomer
| typeof BookedCustomer;
const registrationNumbers = computed(() => {
// Return the registration numbers of all vehicles
return [
@@ -173,6 +645,14 @@ const registrationNumbers = computed(() => {
];
});
const getLPRScanContext = (): LPRScanContext => ({
activeVehicleIndex: vehicles.activeVehicleIndex.value,
attachmentView: views.attachmentView.value,
manualInput: manualInput.value,
registrationNumbers: [...registrationNumbers.value],
transactionHistoryView: transactionHistoryView.value,
});
function getSearchResultIndex(object: PosSearchResult) {
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)
@@ -228,27 +708,49 @@ const getQuery = computed(() => {
return activeVehicle ? activeVehicle.reg : "";
});
const activeSelectionHasRegistrationNumber = computed(() => {
return vehicles.getActiveVehicle()?.reg && vehicles.getActiveVehicle()?.reg.length > 0;
});
const shouldCustomerBeModified = computed(() => {
// Check if the customer should be modified based on the active vehicle index
return vehicles.activeVehicleIndex.value === 1;
});
onMounted(() => {
document.addEventListener("visibilitychange", handleDocumentVisibilityChange);
// Set the header to be transparent
setTransparency(!views.isAnyActive.value);
setBackgroundColor(backgroundColors.default); // Set the default background color
setOverflow(false); // Prevent scrolling
});
onUnmounted(() => {
document.removeEventListener("visibilitychange", handleDocumentVisibilityChange);
abortLPRRequest();
clearNoPlateBackoff();
clearDuplicateFrameBackoff();
clearSuccessCooldown();
// Reset the header settings when the component is unmounted
setTransparency(false); // Reset transparency
setBackgroundColor(backgroundColors.default); // Reset to default background color
setOverflow(true); // Allow scrolling again
});
watch(
() => [manualInput.value, transactionHistoryView.value, views.attachmentView.value],
([isManualInputActive, isTransactionHistoryActive, isAttachmentViewActive]) => {
if (isManualInputActive || isTransactionHistoryActive || isAttachmentViewActive) {
abortLPRRequest();
resetParsedImageFingerprint();
resetNoPlateBackoff();
}
}
);
watch(
() => [vehicles.activeVehicleIndex.value, ...registrationNumbers.value],
() => {
abortLPRRequest();
resetParsedImageFingerprint();
resetNoPlateBackoff();
}
);
</script>
<template>
@@ -263,7 +765,16 @@ onUnmounted(() => {
<!-- Default: Scanner view -->
<template v-else>
<div class="background-fixed">
<ScannerCamera @update:frame="parseImage" />
<ScannerCamera
:capture-enabled="isCameraFrameCaptureEnabled"
:capture-interval-ms="lprCameraCaptureIntervalMs"
:capture-mode="views.attachmentView.value ? 'preview' : 'lpr'"
:get-focus-viewport-rect="getScannerFocusViewportRect"
:pause-preview="shouldPauseScannerPreview"
:should-build-visual-fingerprint="shouldBuildLPRVisualFingerprint"
:should-encode-frame="shouldEncodeLPRFrame"
@update:frame="parseImage"
/>
</div>
<div class="custom-content" data-testid="pos-mobile-step-1">
<!-- Meta objects, registration number auto-lookup -->
@@ -289,7 +800,13 @@ onUnmounted(() => {
/>
<!-- Scanner outline object -->
<div class="is-align-content-center is-flex is-justify-content-center">
<ScannerOutline :loading="false" v-if="!views.attachmentView.value" />
<div
v-if="!views.attachmentView.value"
ref="scannerFocusRef"
class="scanner-focus-target"
>
<ScannerOutline :loading="false" />
</div>
</div>
<!-- Reg. 1, Reg. 2, Reg. 3 -->
<div
@@ -303,7 +820,10 @@ onUnmounted(() => {
<!-- Location -->
<PosDepartmentStepMobile1Location />
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
<PosDepartmentStepMobileFixedBottomControl
variant="pos-step"
:use-backdrop-blur="views.attachmentView.value"
>
<!-- Attachments -->
<div class="is-flex is-justify-content-center">
<PosDepartmentStepMobileAttachments :showDefaultControls="false" v-show="views.attachmentView.value" />
@@ -429,4 +949,10 @@ onUnmounted(() => {
.custom-content > * {
width: min(100%, 48rem);
}
.scanner-focus-target {
display: inline-flex;
max-width: 100%;
width: fit-content;
}
</style>
@@ -53,6 +53,9 @@ import PosDepartmentStepMobile2AdditionalItems from "@/components/displays/depar
import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
onMounted(() => {
// Set the header to be transparent
@@ -477,6 +480,8 @@ const updateLastOrderItemPrices = (items: PosOrderItem[]) => {
const layout = {
classes: <string[]>[],
};
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
const onCopyLastOrder = (vehicleIndex: number) => {
lastOrders.select(vehicleIndex);
@@ -777,15 +782,18 @@ const buildDesiredOrderItemShapes = () => {
const addonShapes = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) => ({
kind: "addon",
relatedKey: "primary",
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
quantity: Number(addon?.quantity ?? 0),
related_item_id: "__PRIMARY__",
price: Number(addon?.product?.price ?? addon?.price ?? 0),
notes: String(addon?.product?.notes ?? ""),
}));
.map((addon: any) => {
const addonProduct = addon?.product ?? addon;
return {
kind: "addon",
relatedKey: "primary",
product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
quantity: Number(addon?.quantity ?? 0),
related_item_id: "__PRIMARY__",
price: Number(addonProduct?.price ?? addon?.price ?? 0),
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
};
});
const additionalShapes = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
@@ -952,16 +960,17 @@ const syncCurrentTransactionToOrder = async () => {
const addonPromises = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) =>
createOrderItem(
.map((addon: any) => {
const addonProduct = addon?.product ?? addon;
return createOrderItem(
normalizedOrderId,
addon.product.id,
addonProduct.id,
Number(addon.quantity),
createdPrimaryItemId,
addon.product?.notes || "",
addon.product.price
)
);
addonProduct?.notes || addon?.notes || "",
addonProduct.price ?? addon.price
);
});
const additionalPromises = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
@@ -973,11 +982,146 @@ const syncCurrentTransactionToOrder = async () => {
return true;
};
const normalizeText = (value: unknown) => String(value ?? "").trim();
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
const getProductId = (product: any) =>
Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
const productRequiresOrderItemNote = (product: any) => {
if (!product) {
return false;
}
return (
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
);
};
const productHasOrderItemNote = (product: any) =>
normalizeText(product?.notes ?? product?.product?.notes).length > 0;
const getSelectedProductsMissingRequiredNotes = () => {
const missingProducts: any[] = [];
const primaryProduct = transactionItems.primaryItem.value;
if (productRequiresOrderItemNote(primaryProduct) && !productHasOrderItemNote(primaryProduct)) {
missingProducts.push(primaryProduct);
}
(primaryProduct?.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.forEach((addon: any) => {
const addonProduct = addon?.product ?? addon;
if (
(productRequiresOrderItemNote(addonProduct) || productRequiresOrderItemNote(addon)) &&
!productHasOrderItemNote(addonProduct) &&
!productHasOrderItemNote(addon)
) {
missingProducts.push(addonProduct);
}
});
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
.forEach((item: any) => {
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
missingProducts.push(item);
}
});
return missingProducts;
};
const promptForRequiredProductNote = (product: any) =>
new Promise<boolean>((resolve) => {
const hadOriginalNote = Object.prototype.hasOwnProperty.call(product, "notes");
const originalNote = product?.notes;
const resolveAndClose = (didConfirm: boolean) => {
if (!didConfirm) {
if (hadOriginalNote) {
product.notes = originalNote;
} else {
delete product.notes;
}
}
if (popups.get()?.id === "add_product_note") {
popups.clear();
}
resolve(didConfirm);
};
popups.select("add_product_note", {
title: `${t("common.note")}: ${getProductName(product) || `#${getProductId(product)}`}`,
message: t("objects.products.columns.requires_note"),
component: "add_product_note",
hideHeader: true,
style: { maxHeight: "40vh" },
props: {
product,
validationMessage: "",
},
actionButtons: [
{
label: t("common.confirm"),
description: t("common.confirm"),
color: "primary",
testId: "pos-mobile-product-note-confirm",
onClick: () => {
const activePopup = popups.get();
const normalizedNote = normalizeText(activePopup?.props?.product?.notes);
if (!normalizedNote) {
if (activePopup?.props) {
activePopup.props.validationMessage = t("objects.products.columns.requires_note");
}
return;
}
product.notes = normalizedNote;
resolveAndClose(true);
},
},
{
label: t("common.cancel"),
description: t("common.cancel"),
color: "light",
testId: "pos-mobile-product-note-cancel",
onClick: () => resolveAndClose(false),
},
],
});
});
const ensureRequiredOrderItemNotes = async () => {
const missingProducts = getSelectedProductsMissingRequiredNotes();
for (const product of missingProducts) {
if (productHasOrderItemNote(product)) {
continue;
}
const didConfirm = await promptForRequiredProductNote(product);
if (!didConfirm) {
return false;
}
}
return true;
};
const onBeforeComplete = async () => {
if (!transactionItems.primaryItem.value) {
throw new Error("No primary item selected");
}
if (!(await ensureRequiredOrderItemNotes())) {
return false;
}
await syncCurrentTransactionToOrder();
return true;
};
@@ -10,6 +10,7 @@ import {
reg_1,
reset_all_values,
setStep,
pushPosRouteState,
} from "@/components/shop/POSDepartmentProcess.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { StripeModule } from "@/components/stripe/StripeModule.vue";
@@ -70,10 +71,10 @@ const formatCurrency = (amount) => {
const navigateToItems = () => {
setStep(2);
if (normalizedOrderId.value) {
window.history.pushState({}, '', `?id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
pushPosRouteState(`id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
return;
}
window.history.pushState({}, '', `?step=2`);
pushPosRouteState("step=2");
};
const clearSuccessfulMobileCardFlow = () => {
@@ -2,25 +2,43 @@
import { useGeolocation } from "@vueuse/core";
import { watch } from "vue";
import { locations } from "../objects/PosDepartmentStepMobileFlow.vue";
const emits = defineEmits<{
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
}>();
const { coords, locatedAt, error, resume, pause } = useGeolocation({
const props = withDefaults(defineProps<{
enableHighAccuracy?: boolean;
maximumAge?: number;
timeout?: number;
}>(), {
enableHighAccuracy: true,
maximumAge: 30000,
timeout: 27000,
});
const emits = defineEmits<{
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
}>();
const { coords, locatedAt, error, resume, pause } = useGeolocation({
enableHighAccuracy: props.enableHighAccuracy,
maximumAge: props.maximumAge,
timeout: props.timeout,
});
const getLocationTimestamp = () => {
const timestamp = Number(locatedAt.value);
return Number.isFinite(timestamp) ? timestamp : Date.now();
};
const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => {
if (newCoords.latitude && newCoords.longitude) {
const normalizedCoords = locations.normalizeCoordinatePair(newCoords);
if (normalizedCoords) {
const timestamp = getLocationTimestamp();
locations.set({
coords: {
latitude: newCoords.latitude,
longitude: newCoords.longitude,
},
timestamp: new Date(),
coords: normalizedCoords,
timestamp: new Date(timestamp),
locatedAt: timestamp,
errorMessage: error.value ? error.value.message : null,
})
emits('location-updated', newCoords);
emits('location-updated', normalizedCoords);
}
};
watch(coords, (newCoords) => {
@@ -320,16 +320,23 @@ const noop = () => {};
.vehicle-card__media {
align-items: center;
background: rgba(255, 255, 255, 0.66);
border: 1px solid rgba(31, 91, 183, 0.12);
border-radius: 1rem;
display: flex;
flex: 0 0 5.25rem;
height: 5.25rem;
justify-content: center;
padding: 0.75rem;
width: 5.25rem;
}
.vehicle-card__image {
height: 100%;
height: auto;
max-height: 100%;
max-width: 100%;
object-fit: contain;
width: 100%;
width: auto;
}
.vehicle-card__details {
@@ -14,7 +14,7 @@ type attachment = {
image: string | null;
document: string | null;
relation: string | null;
other: string | null;
other: unknown;
src: string | null; // For document preview (e.g., PDF URL) // THIS IS NEVER STORED, JUST FOR PREVIEW PURPOSES
};
created_at: string;
@@ -22,6 +22,8 @@ type attachment = {
deleted_at: string | null;
}
const SELF_SERVE_WASH_ATTACHMENT_TYPE = 'SELF_SERVE_WASH';
const getAttachmentContent = (attachmentEntry: attachment) => {
if (!attachmentEntry.content) {
return {
@@ -43,7 +45,19 @@ const getAttachmentContent = (attachmentEntry: attachment) => {
};
const getAttachmentOtherText = (attachmentEntry: attachment) => {
return getAttachmentContent(attachmentEntry).other || '';
const other = getAttachmentContent(attachmentEntry).other;
if (typeof other === 'string') {
return other;
}
if (isSelfServeWashAttachment(attachmentEntry)) {
const customerNumber = getSelfServeWashPayload(attachmentEntry)?.customer_number;
return customerNumber
? `${t('admin.pos.settings_wheel.self_serve_wash_attachment')} #${customerNumber}`
: t('admin.pos.settings_wheel.self_serve_wash_attachment');
}
return other && typeof other === 'object' ? JSON.stringify(other) : '';
};
const props = defineProps({
attachments: {
@@ -79,6 +93,26 @@ const determineAttachmentType = (attachment: attachment): 'image' | 'document' |
return 'unknown';
};
const getSelfServeWashPayload = (attachmentEntry: attachment): Record<string, any> | null => {
const other = getAttachmentContent(attachmentEntry).other;
return other && typeof other === 'object' && (other as Record<string, any>).type === SELF_SERVE_WASH_ATTACHMENT_TYPE
? other as Record<string, any>
: null;
};
const isSelfServeWashAttachment = (attachmentEntry: attachment): boolean => {
return getSelfServeWashPayload(attachmentEntry) !== null;
};
const formatSelfServeDriver = (payload: Record<string, any>): string => {
return payload.subuser?.name || payload.subuser?.username || (payload.subuser_id ? `#${payload.subuser_id}` : '-');
};
const formatElapsedMinutes = (seconds: unknown): string => {
const parsed = Number(seconds);
return Number.isFinite(parsed) && parsed > 0 ? `${Math.ceil(parsed / 60)} min` : '-';
};
const getAttachmentTypeIcon = (attachment: attachment): string => {
const type = determineAttachmentType(attachment);
switch (type) {
@@ -309,14 +343,35 @@ const onClickAttachWashCertificate = () => {
</template>
<!-- OTHER PREVIEW -->
<template v-else-if="determineAttachmentType(attachment) === 'other' && getAttachmentContent(attachment).other">
<template v-if="isSelfServeWashAttachment(attachment)">
<div class="content is-size-7">
<p class="has-text-weight-semibold">{{ t('admin.pos.settings_wheel.self_serve_wash_attachment') }}</p>
<p>
<strong>{{ t('admin.pos.settings_wheel.self_serve_customer') }}:</strong>
#{{ getSelfServeWashPayload(attachment)?.customer_number || '-' }}
</p>
<p>
<strong>{{ t('admin.pos.settings_wheel.self_serve_driver') }}:</strong>
{{ formatSelfServeDriver(getSelfServeWashPayload(attachment) || {}) }}
</p>
<p>
<strong>{{ t('pos.license_plate') }}:</strong>
{{ getSelfServeWashPayload(attachment)?.license_plate || '-' }}
</p>
<p>
<strong>{{ t('admin.pos.settings_wheel.self_serve_elapsed') }}:</strong>
{{ formatElapsedMinutes(getSelfServeWashPayload(attachment)?.elapsed_wash_time_seconds) }}
</p>
</div>
</template>
<!-- If the other type is a URL, you can create a link -->
<template v-if="getAttachmentContent(attachment).other.startsWith('http')">
<a :href="getAttachmentContent(attachment).other" target="_blank" rel="noopener noreferrer">
<template v-else-if="typeof getAttachmentContent(attachment).other === 'string' && getAttachmentContent(attachment).other.startsWith('http')">
<a :href="String(getAttachmentContent(attachment).other)" target="_blank" rel="noopener noreferrer">
{{ getAttachmentContent(attachment).other }}
</a>
</template>
<template v-else>
<span>{{ getAttachmentContent(attachment).other }}</span>
<span>{{ getAttachmentOtherText(attachment) }}</span>
</template>
</template>
<!-- NO PREVIEW -->
@@ -326,7 +381,7 @@ const onClickAttachWashCertificate = () => {
</template>
<template #footer>
<div class="card-footer-item">
{{ t('admin.pos.created') }}: {{ new Date(attachment.created_at).toLocaleString() }}
{{ t('common.created') }}: {{ new Date(attachment.created_at).toLocaleString() }}
</div>
<a class="card-footer-item" v-if="!attachment.deleted_at" @click="onClickDelete(attachment.id)">
<!-- Delete button or action can be placed here -->
@@ -179,24 +179,23 @@ const resetAll = () => {
reset_all_values();
};
const onClickClearAllButton = () => {
const onClickClearAllButton = async () => {
// 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");
}
// 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();
});
try {
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(order_id.value);
if (!deleted) {
return;
}
console.warn("Order deleted successfully");
} catch (error) {
console.error("Error deleting order:", error);
}
resetAll();
} else {
resetAll();
}
@@ -294,7 +293,7 @@ const isAttachmentsVisible = () => {
</div>
<div class="column is-narrow pl-0">
<p class="is-size-7 has-text-weight-semibold">
{{ t("admin.pos.reference") }}
{{ t("common.reference") }}
</p>
</div>
</div>
@@ -1,46 +1,38 @@
<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,
getStoredPosOrderId,
} 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";
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 (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();
});
const toPositiveInteger = (value: unknown) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const onClickClearAll = async () => {
const storedOrderId = getStoredPosOrderId();
const currentOrderId = toPositiveInteger(order_id.value);
const activeDraftOrderId = storedOrderId && currentOrderId === storedOrderId ? storedOrderId : null;
// 1. Delete only the active mobile draft order, never a historical route-loaded order.
if (activeDraftOrderId) {
try {
order_id.value = activeDraftOrderId;
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(activeDraftOrderId);
if (!deleted) {
return;
}
} catch (error) {
console.error("Error deleting order:", error);
}
}
// 2. Reset the POS
resetPos();
// 3. Reset all values
reset_all_values();
};
</script>
@@ -54,7 +46,7 @@ const 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="column is-12 has-text-centered">
<div class="columns is-mobile is-vcentered is-centered">
<div class="column is-narrow pr-1">
<span class="icon is-small">
@@ -132,7 +132,7 @@ const promptForReference = async (initialValue = "") => {
title: t("admin.pos.reference_required_title"),
text: t("admin.pos.reference_required_text"),
input: "text",
inputLabel: t("admin.pos.reference"),
inputLabel: t("common.reference"),
inputValue: initialValue,
showCancelButton: true,
confirmButtonText: t("admin.pos.confirm"),
@@ -177,7 +177,6 @@ const validateReferenceRequirements = async () => {
reference.value = enteredRef;
metadata.setReference(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.");
@@ -188,7 +187,7 @@ const validateReferenceRequirements = async () => {
}
};
const step1 = () => {
const step1 = async () => {
/** This function can be used to perform any specific actions for step 1 */
// Set registration numbers
reg_1.value = vehicles.vehicle_1.value?.reg || "";
@@ -202,18 +201,14 @@ const step1 = () => {
mergeSafetySeal();
// We only need to check if the customer requires reference, if the field is empty.
if (!isNonEmptyString(reference.value)) {
validateReferenceRequirements().then((isValid) => {
if (!isValid) {
// The requirements were not met, do not proceed.
return;
}
// Proceed with setting reference and notes
finalizeStep1();
});
} else {
// Reference is already filled, just finalize step 1
finalizeStep1();
const isValid = await validateReferenceRequirements();
if (!isValid) {
return false;
}
}
// Reference is resolved, persist metadata and pending attachments.
return finalizeStep1();
};
const mergeReferences = () => {
if (metadata.getReference() && metadata.getReference().length >= 1) {
@@ -246,25 +241,34 @@ const mergeSafetySeal = () => {
syncMobileSafetySealState(resolvedSafetySeal);
updateSafetySeal();
};
const finalizeStep1 = () => {
const finalizeStep1 = async () => {
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) => {
const uploadResults = await Promise.all(
attachments.getBase64().map(async (attachment: any) => {
try {
const didUpload = await uploadAttachment(attachment);
if (didUpload) {
console.warn("Attachment uploaded successfully");
attachments.removeBase64(attachment);
}
return didUpload;
} catch (error: any) {
errors.value.push(error);
console.warn("An error occurred while uploading the attachment:", error);
});
return false;
}
})
);
if (uploadResults.some((didUpload) => didUpload === false)) {
console.warn("One or more attachments could not be uploaded before completion.");
}
}
return true;
};
const addTransactionToHistory = async (orderId: number) => {
@@ -295,14 +299,12 @@ const getSelectedBookingId = () => {
const completeStep2Order = async ({
bookingSafetySeal = null,
markOrderCompleted = false,
}: {
bookingSafetySeal?: string | null;
markOrderCompleted?: boolean;
} = {}) => {
await finalizeCurrentMobileOrder({
bookingSafetySeal,
markOrderCompleted,
markOrderCompleted: true,
});
popups.select("completed_transaction", {
message: `Order #${order_id.value} successfully created.`,
@@ -320,16 +322,24 @@ const step2 = async () => {
const selectedBookingId = getSelectedBookingId();
const hasWashCertificateInBasket = Boolean(transactionItems.containsWashCertificate?.());
const resolvedSafetySeal = getResolvedMobileSafetySeal();
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
try {
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
syncMobileSafetySealState(resolvedSafetySeal);
await completeStep2Order({
bookingSafetySeal: resolvedSafetySeal,
});
return;
}
if (requiresBookingCompletionPopup) {
const onCompleteBookingWithSafetySeal = async ({ safetySeal = "" } = {}) => {
syncMobileSafetySealState(safetySeal);
await completeStep2Order({
bookingSafetySeal: String(safetySeal ?? ""),
markOrderCompleted: false,
});
};
@@ -343,9 +353,7 @@ const step2 = async () => {
return;
}
await completeStep2Order({
markOrderCompleted: shouldMarkOrderAsCompleted,
});
await completeStep2Order();
} catch (error: any) {
errors.value.push(error);
console.warn("An error occurred while completing the mobile order:", error);
@@ -397,16 +405,22 @@ const onClick = async () => {
isProcessingClick.value = true;
try {
await props.onBeforeStep();
const beforeStepResult = await props.onBeforeStep();
if (beforeStepResult === false) {
return;
}
// Proceed to the next step
switch (step.value) {
case 1:
step1();
if (!(await step1())) {
return;
}
const restoredOrderId = await restoreStoredPosOrderId({
validateOrder: true,
customerId: getCustomerId(),
departmentId: POSDepartmentProcess.getDepartment(),
allowCompleted: false,
syncDepartment: true,
});
if (restoredOrderId) {
console.warn("Order ID retrieved from local storage:", restoredOrderId);
@@ -424,7 +438,9 @@ const onClick = async () => {
});
break;
case 2:
step1();
if (!(await step1())) {
return;
}
await step2();
break;
case 3:
@@ -11,6 +11,10 @@ const props = defineProps({
type: Boolean,
default: true,
},
useBackdropBlur: {
type: Boolean,
default: true,
},
variant: {
type: String,
default: 'default',
@@ -25,6 +29,13 @@ const props = defineProps({
const FIXED_BOTTOM_HEIGHT_CSS_VAR = '--pos-mobile-fixed-bottom-height';
const style = computed(() => {
const background = `linear-gradient(to top, rgba(255, 255, 255, ${props.blurAmount * 0.8}) 0%, rgba(255, 255, 255, ${props.blurAmount * 0.4}) 100%)`;
if (!props.useBackdropBlur) {
return {
background,
};
}
// Smooth blur transition
if (props.smoothBlur) {
return {
@@ -32,12 +43,13 @@ const style = computed(() => {
WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`,
transition: 'backdrop-filter 0.3s ease, -webkit-backdrop-filter 0.3s ease',
// Add gradient from bottom
background: `linear-gradient(to top, rgba(255, 255, 255, ${props.blurAmount * 0.8}) 0%, rgba(255, 255, 255, ${props.blurAmount * 0.4}) 100%)`,
background,
// Add gradient from top
}
}
return {
background,
backdropFilter: `blur(${props.blurAmount * 10}px)`,
WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`
}
@@ -9,6 +9,9 @@ const product = props.product;
const note = ref(product.notes || "");
watch(note, (newNote) => {
product.notes = newNote;
if (props) {
props.validationMessage = "";
}
// If the note is empty, remove it from the product
if (newNote === "") {
delete product.notes;
@@ -25,9 +28,13 @@ watch(note, (newNote) => {
class="input is-searched"
v-model="note"
type="text"
data-testid="pos-mobile-product-note-input"
placeholder="Indtast note"
/>
</div>
<p v-if="props?.validationMessage" class="help is-danger mt-2">
{{ props.validationMessage }}
</p>
</div>
</div>
</template>
@@ -125,4 +132,4 @@ input.is-searched {
flex-grow: 0;
}
</style>
</style>
@@ -4,11 +4,15 @@ 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;
(e: "close"): void;
}>();
const popupProps = computed(() => popups.get()?.props || {});
const isSubmitting = ref(false);
type SubmitAction = "without_certificate" | "with_certificate";
const submittingAction = ref<SubmitAction | null>(null);
const isSubmitting = computed(() => submittingAction.value !== null);
const isSubmittingAction = (action: SubmitAction) => submittingAction.value === action;
const resolveExistingSafetySeal = () => {
const popupSafetySeal = popupProps.value?.safetySeal;
if (popupSafetySeal !== null && popupSafetySeal !== undefined) {
@@ -20,11 +24,17 @@ const resolveExistingSafetySeal = () => {
const safetySealNumber = ref(resolveExistingSafetySeal());
const closePopup = () => {
emit('close');
emit("close");
};
const runPopupCompletionCallback = async (callback: any, safetySeal: string) => {
isSubmitting.value = true;
const closePopupIfStillActive = () => {
if (popups.get()?.id === "complete_booking") {
closePopup();
}
};
const runPopupCompletionCallback = async (callback: any, safetySeal: string, action: SubmitAction) => {
submittingAction.value = action;
try {
await Promise.resolve(
callback({
@@ -32,26 +42,26 @@ const runPopupCompletionCallback = async (callback: any, safetySeal: string) =>
safetySeal,
})
);
closePopup();
closePopupIfStillActive();
} catch (error) {
console.warn("Failed to complete booking from popup callback:", error);
} finally {
isSubmitting.value = false;
submittingAction.value = null;
}
};
const submitBookingCompletionFallback = async (safetySeal: string) => {
isSubmitting.value = true;
const submitBookingCompletionFallback = async (safetySeal: string, action: SubmitAction) => {
submittingAction.value = action;
try {
await SessionUser.objects.order_bookings.functions.complete(
metadata.getBookingId(),
safetySeal === "" ? null : safetySeal
);
closePopup();
closePopupIfStillActive();
} catch (error) {
console.warn("Failed to complete booking from popup fallback action:", error);
} finally {
isSubmitting.value = false;
submittingAction.value = null;
}
};
@@ -62,10 +72,10 @@ const completeWithoutCertificate = async () => {
const callback = popupProps.value?.onCompleteWithoutCertificate || popupProps.value?.onComplete;
if (typeof callback === "function") {
await runPopupCompletionCallback(callback, "");
await runPopupCompletionCallback(callback, "", "without_certificate");
return;
}
await submitBookingCompletionFallback("");
await submitBookingCompletionFallback("", "without_certificate");
};
const completeWithCertificate = async () => {
@@ -76,10 +86,10 @@ const completeWithCertificate = async () => {
const normalizedSafetySeal = String(safetySealNumber.value ?? "").trim() || resolveExistingSafetySeal();
const callback = popupProps.value?.onCompleteWithCertificate || popupProps.value?.onComplete;
if (typeof callback === "function") {
await runPopupCompletionCallback(callback, normalizedSafetySeal);
await runPopupCompletionCallback(callback, normalizedSafetySeal, "with_certificate");
return;
}
await submitBookingCompletionFallback(normalizedSafetySeal);
await submitBookingCompletionFallback(normalizedSafetySeal, "with_certificate");
};
</script>
@@ -104,29 +114,22 @@ const completeWithCertificate = async () => {
<button
class="button is-light"
data-testid="pos-mobile-booking-complete-without-certificate"
:class="{ 'is-loading': isSubmitting }"
:class="{ 'is-loading': isSubmittingAction('without_certificate') }"
:disabled="isSubmitting"
@click="completeWithoutCertificate"
>
Intet vaskecertifikat
</button>
<button
class="button is-primary"
data-testid="pos-mobile-booking-complete-with-certificate"
:class="{ 'is-loading': isSubmitting }"
:disabled="isSubmitting"
@click="completeWithCertificate"
class="button is-primary"
data-testid="pos-mobile-booking-complete-with-certificate"
:class="{ 'is-loading': isSubmittingAction('with_certificate') }"
:disabled="isSubmitting"
@click="completeWithCertificate"
>
Generer vaskecertifikat
</button>
</div>
<!--<FormDisplay
:form_identifier="'GENERATE_BOOKING_WASH_CERTIFICATE'"
v-bind:validator_options="{validateBookingId: {default: metadata.getBookingId(), locked: true, visible: false}}"
:onSuccessfulSubmission="onSuccessfulCertificateSubmission"
:showSuccessSubmitAlert="false"
:showResetButton="false"
/>-->
</div>
</template>
@@ -152,7 +155,7 @@ input {
width: 100%;
height: 42px;
background: #FFFFFF;
background: #ffffff;
border-color: transparent;
border-radius: 4px;
@@ -185,8 +188,8 @@ input:focus-visible {
gap: 10px;
width: 100%;
height: 42px;
background: #FFFFFF;
border: 1px solid #A6A5A5;
background: #ffffff;
border: 1px solid #a6a5a5;
border-radius: 4px;
/* Inside auto layout */
@@ -211,7 +214,7 @@ input.is-searched {
width: 100%;
height: 42px;
background: #FFFFFF;
background: #ffffff;
border: 1px solid #000000;
border-radius: 4px;
border-bottom: none;
@@ -221,6 +224,5 @@ input.is-searched {
order: 1;
align-self: stretch;
flex-grow: 0;
}
</style>
@@ -339,7 +339,7 @@ const isBookingScheduledForToday = (booking: any) => {
class="tag booking-option__today-tag"
:data-testid="`pos-mobile-order-booking-today-${booking.id}`"
>
{{ t("global.time.today") }}
{{ t("common.today") }}
</span>
<span v-if="didBookingDetailFail(booking)" class="tag is-light booking-option__tag">
{{ t("admin.pos.not_found") }}
@@ -355,6 +355,7 @@ const isBookingScheduledForToday = (booking: any) => {
:refreshFunction="emitRefreshBookings"
:icon="'fas fa-ellipsis-v'"
trigger-button-variant="text"
allow-booking-completion
>
<template #actions />
</ActionSettingsWheelButton>
@@ -372,7 +373,7 @@ const isBookingScheduledForToday = (booking: any) => {
<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>
<dt>{{ t("common.reference") }}</dt>
<dd>{{ getBookingReference(booking) || "-" }}</dd>
</div>
<div v-if="getBookingPo(booking)" class="booking-option__meta-item">
@@ -391,7 +392,7 @@ const isBookingScheduledForToday = (booking: any) => {
:data-testid="`pos-mobile-order-booking-services-${booking.id}`"
>
<div class="booking-option__section-header">
<p class="booking-option__section-title">{{ t("admin.pos.order_booking_selector.services_label") }}</p>
<p class="booking-option__section-title">{{ t("common.services") }}</p>
<div
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
class="booking-option__section-total"
@@ -408,8 +409,8 @@ const isBookingScheduledForToday = (booking: any) => {
</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("common.quantity") }}</span>
<span>{{ t("common.services") }}</span>
<span>{{ t("admin.pos.price_dkk") }}</span>
</div>
<div
@@ -455,7 +456,7 @@ const isBookingScheduledForToday = (booking: any) => {
:data-testid="`pos-mobile-order-booking-use-${booking.id}`"
@click="onSelect(booking)"
>
{{ t("admin.pos.order_booking_selector.use_booking") }}
{{ t("common.select") }}
</button>
</div>
</article>
@@ -4,8 +4,10 @@ export type PosDepartment = {
id: number;
name: string;
description?: string;
order_priority?: number | null;
priority_order?: number | null;
branding?: number;
latitude?: number;
longitude?: number;
}
</script>
};
</script>
@@ -100,18 +100,57 @@ const getLocation = () => {
const clearLocation = () => {
location.value = null;
};
type CoordinatePair = {
latitude?: number | string | null;
longitude?: number | string | null;
};
export const normalizeCoordinatePair = (
coordinates: CoordinatePair | null | undefined,
{ allowZeroPair = true }: { allowZeroPair?: boolean } = {}
): { latitude: number; longitude: number } | null => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return null;
}
if (!allowZeroPair && latitude === 0 && longitude === 0) {
return null;
}
return { latitude, longitude };
};
export const hasValidCoordinatePair = (
coordinates: CoordinatePair | null | undefined,
options: { allowZeroPair?: boolean } = {}
): boolean => normalizeCoordinatePair(coordinates, options) !== null;
// Get distance in kilometers between two locations
const getDistance = (
from: { latitude: number; longitude: number },
to: { latitude: number; longitude: number }
from: CoordinatePair,
to: CoordinatePair
): number => {
const normalizedFrom = normalizeCoordinatePair(from);
const normalizedTo = normalizeCoordinatePair(to);
if (!normalizedFrom || !normalizedTo) {
return Number.POSITIVE_INFINITY;
}
const toRad = (value: number) => (value * Math.PI) / 180;
const R = 6371; // Radius of the Earth in kilometers
const dLat = toRad(to.latitude - from.latitude);
const dLon = toRad(to.longitude - from.longitude);
const lat1 = toRad(from.latitude);
const lat2 = toRad(to.latitude);
const dLat = toRad(normalizedTo.latitude - normalizedFrom.latitude);
const dLon = toRad(normalizedTo.longitude - normalizedFrom.longitude);
const lat1 = toRad(normalizedFrom.latitude);
const lat2 = toRad(normalizedTo.latitude);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
@@ -124,6 +163,8 @@ const locations = {
set: setLocation,
get: getLocation,
clear: clearLocation,
normalizeCoordinatePair,
hasValidCoordinatePair,
getDistance,
defaultTimeout: locationTimeout,
};
@@ -133,7 +174,7 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
return {
/** General purpose action buttons (used by PosAddons)*/
confirm: {
label: t("admin.pos.action_buttons.confirm"),
label: t("common.confirm"),
description: t("admin.pos.action_buttons.confirm_desc"),
onClick: () => {
clearPopup(); // Close the popup
@@ -141,7 +182,7 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
color: "primary",
},
cancel: {
label: t("admin.pos.action_buttons.cancel"),
label: t("common.cancel"),
description: t("admin.pos.action_buttons.cancel_desc"),
onClick: () => {
clearPopup(); // Close the popup
@@ -149,7 +190,7 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
color: "light",
},
close: {
label: t("admin.pos.action_buttons.close"),
label: t("common.close"),
description: t("admin.pos.action_buttons.close_desc"),
onClick: () => {
clearPopup(); // Close the popup
@@ -788,7 +829,15 @@ const promptForNotesIfRequired = (product: PosProduct, callback: (notes: string)
label: "Bekræft",
description: "Bekræft noten og fortsæt",
onClick: () => {
callback(popups.get()?.props?.product?.notes || "");
const activePopup = popups.get();
const note = String(activePopup?.props?.product?.notes || "").trim();
if (!note) {
if (activePopup?.props) {
activePopup.props.validationMessage = "Note er påkrævet for dette produkt";
}
return;
}
callback(note);
clearPopup();
},
color: "primary",
@@ -1016,9 +1065,9 @@ const getTotalAttachmentsCount = () => {
);
};
// Function to take a picture as a base64 attachment
const takePicture = () => {
const takePicture = async () => {
// Save the last picture to the base64 attachments
const lastPicture = latestImage.value;
const lastPicture = await getLatestImage();
if (lastPicture) {
addAttachmentBase64({
filename: "last_picture.jpg",
@@ -1069,6 +1118,7 @@ watch(
/** Camera */
// Define the reactive properties
const latestImage = ref<string | null>(null);
const latestImageBlob = ref<Blob | null>(null);
const isCameraMounted = ref<boolean>(false);
const cameraImageCaptureDelayInitial = ref<number>(500); // Initial delay for camera image capture in milliseconds (First capture)
const cameraImageCaptureDelaySubsequent = ref<number>(1005); // Further captures delay in milliseconds (Every capture after the first one)
@@ -1110,13 +1160,34 @@ const hasCameraImageCaptureDelayAfterSuccessPassed = (): boolean => {
const currentTime = Date.now();
return currentTime - cameraImageCaptureLastSuccess.value > cameraImageCaptureDelayAfterSuccess.value;
};
const blobToDataUrl = (blob: Blob): Promise<string | null> => new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
resolve(typeof reader.result === "string" ? reader.result : null);
};
reader.onerror = () => {
resolve(null);
};
reader.readAsDataURL(blob);
});
// Function to retrieve the latest image frame.
const getLatestImage = async () => {
if (!latestImage.value && latestImageBlob.value) {
latestImage.value = await blobToDataUrl(latestImageBlob.value);
}
return latestImage.value;
};
// Function to set the latest image frame.
const setLatestImage = (image: string | null) => {
latestImage.value = image;
latestImageBlob.value = null;
};
// Function to set the latest image frame as a Blob.
const setLatestImageBlob = (image: Blob | null) => {
latestImage.value = null;
latestImageBlob.value = image;
};
// Function to set the camera mounted state.
const setCameraMounted = (mounted: boolean) => {
@@ -1125,6 +1196,7 @@ const setCameraMounted = (mounted: boolean) => {
// Function to clear the latest image.
const clearLatestImage = () => {
latestImage.value = null;
latestImageBlob.value = null;
};
// Function to clear the camera mounted state.
const clearCameraMounted = () => {
@@ -1145,10 +1217,12 @@ const setCameraImageCaptureDelay = (isFirstCapture: boolean, delay: number) => {
const camera = {
latestImage,
latestImageBlob,
get: getLatestImage,
mounted: isCameraMounted,
setMounted: setCameraMounted,
setLatestImage,
setLatestImageBlob,
clearLatestImage,
clearMounted: clearCameraMounted,
getImageCaptureDelay: getCameraImageCaptureDelay,
@@ -18,7 +18,9 @@ export type PosLocation = {
coords?: PosLocationCoords | null;
/** Timestamp */
timestamp?: Date | null;
/** Browser geolocation timestamp in milliseconds */
locatedAt?: number | null;
/** Error message */
errorMessage?: string | null;
};
</script>
</script>
@@ -4,6 +4,7 @@ import {
order_notes,
order_safety_seal,
reference,
clearStoredPosOrderId,
} from "@/components/shop/POSDepartmentProcess.vue";
import { metadata } from "./PosDepartmentStepMobileFlow.vue";
@@ -115,5 +116,6 @@ export const finalizeCurrentMobileOrder = async (options = {}) => {
if (normalizedOptions.markOrderCompleted) {
await SessionUser.objects.orders.functions.mark_as_completed(normalizedOrderId);
}
clearStoredPosOrderId();
return normalizedOrderId;
};
@@ -183,7 +183,7 @@ const handleMouseWheel = (e: WheelEvent) => {
<span class="icon is-small">
<i class="fas fa-times"></i>
</span>
<small>{{ t("global.close") }}</small>
<small>{{ t("common.close") }}</small>
</button>
</div>
</div>
@@ -286,7 +286,7 @@ const tableHeaders = ref([
},
{
name: 'reference',
title: t('objects.columns.reference'),
title: t('common.reference'),
sortable: true,
},
{
@@ -296,7 +296,7 @@ const tableHeaders = ref([
},
{
name: 'created_at',
title: t('objects.columns.date'),
title: t('common.date'),
sortable: true,
},
{

Some files were not shown because too many files have changed in this diff Show More