Compare commits

..
Author SHA1 Message Date
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
445 changed files with 36043 additions and 9568 deletions
-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
+84 -31
View File
@@ -1,105 +1,151 @@
name: Frontend Release
on:
push:
workflow_run:
workflows:
- Automated Tests
types:
- completed
branches:
- master
workflow_dispatch:
permissions:
contents: read
actions: read
concurrency:
group: frontend-release-${{ github.ref }}
cancel-in-progress: false
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://dev.truckwash.io
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: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
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.sha }}
RELEASE_WAIT_INITIAL_SECONDS: 30
RELEASE_WAIT_TIMEOUT_SECONDS: 300
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
run: npx playwright install --with-deps 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: 14
retention-days: 3
- name: Install lftp
- name: Request Release Manager auto sync
if: steps.branch-head.outputs.current == 'true'
run: |
if ! command -v lftp >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y lftp
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
- name: Upload hashed assets before release metadata
run: |
test -n "$RELEASE_DEPLOY_HOST" || (echo "RELEASE_DEPLOY_HOST is required" >&2; exit 1)
test -n "$RELEASE_DEPLOY_USER" || (echo "RELEASE_DEPLOY_USER is required" >&2; exit 1)
test -n "$RELEASE_DEPLOY_PASSWORD" || (echo "RELEASE_DEPLOY_PASSWORD is required" >&2; exit 1)
npm run release:upload:lftp
env:
RELEASE_DEPLOY_HOST: ${{ secrets.RELEASE_DEPLOY_HOST }}
RELEASE_DEPLOY_USER: ${{ secrets.RELEASE_DEPLOY_USER }}
RELEASE_DEPLOY_PASSWORD: ${{ secrets.RELEASE_DEPLOY_PASSWORD }}
RELEASE_DEPLOY_REMOTE_ROOT: ${{ secrets.RELEASE_DEPLOY_REMOTE_ROOT }}
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 exact uploaded build
- 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
@@ -112,28 +158,35 @@ jobs:
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\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
--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.sha }}
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
- name: Upload Playwright report
if: failure()
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: 14
retention-days: 3
+84 -18
View File
@@ -16,8 +16,9 @@ concurrency:
jobs:
format-tests:
# Match the labels exposed by the Coolify-managed GitHub runner.
# CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v5
@@ -40,6 +41,7 @@ jobs:
build-and-unit:
needs: format-tests
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v5
@@ -53,6 +55,12 @@ jobs:
- 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
@@ -64,16 +72,47 @@ jobs:
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]
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 4
matrix:
suite: [core, changed]
project: [chromium-desktop, chromium-mobile]
env:
PLAYWRIGHT_PR_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
PLAYWRIGHT_PR_HEAD: ${{ github.sha }}
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
PLAYWRIGHT_REPORTER_MODE: line-html
steps:
- 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:
@@ -84,27 +123,51 @@ jobs:
run: npm ci --legacy-peer-deps
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
run: node scripts/install-playwright-browsers.mjs chromium
- name: Run Playwright PR tests
run: npm run test:e2e:pr -- --base="$PLAYWRIGHT_PR_BASE" --head="$PLAYWRIGHT_PR_HEAD"
- name: Run Playwright smoke tests
if: matrix.suite == 'core'
run: |
ulimit -n 16384 || true
npx playwright test --grep @smoke --project="${{ matrix.project }}"
- name: Run Playwright PR core tests
if: matrix.suite == 'core'
run: |
ulimit -n 16384 || true
npm run test:e2e:pr -- --core-only --project="${{ matrix.project }}"
- name: Run Playwright changed-area tests
if: matrix.suite == 'changed'
run: |
ulimit -n 16384 || true
npm run test:e2e:pr -- --changed-only --project="${{ matrix.project }}" --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
- name: Upload Playwright report
if: failure()
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: playwright-report-pr
path: output/playwright
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }}
path: |
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
if-no-files-found: ignore
retention-days: 7
retention-days: 3
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]
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 4
matrix:
role: [customer, subuser, admin, superuser]
browser: [chromium, firefox, webkit]
@@ -136,13 +199,16 @@ jobs:
run: npm ci --legacy-peer-deps
- name: Install Playwright browsers
run: npx playwright install --with-deps ${{ matrix.browser_install }}
run: node scripts/install-playwright-browsers.mjs ${{ matrix.browser_install }}
- name: Run full Playwright slice
run: npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
run: |
ulimit -n 16384 || true
npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
- name: Upload Playwright report
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 }}
@@ -151,4 +217,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: 3
+4
View File
@@ -0,0 +1,4 @@
{
"version": 1,
"setupCompletedAt": "2026-06-07T11:37:04.444Z"
}
+8 -10
View File
@@ -1,16 +1,6 @@
FROM node:24-alpine AS build
WORKDIR /app
ARG RELEASE_COMMIT_SHA=""
ARG COMMIT_SHA=""
ARG GITHUB_SHA=""
ARG SOURCE_COMMIT=""
ARG VITE_BASE_PATH=""
ENV RELEASE_COMMIT_SHA="${RELEASE_COMMIT_SHA}"
ENV COMMIT_SHA="${COMMIT_SHA}"
ENV GITHUB_SHA="${GITHUB_SHA}"
ENV SOURCE_COMMIT="${SOURCE_COMMIT}"
ENV VITE_BASE_PATH="${VITE_BASE_PATH}"
RUN apk add --no-cache git
@@ -18,6 +8,14 @@ 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
+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. |
+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",
},
},
];
+13 -4
View File
@@ -2,10 +2,19 @@
<html lang="" class="theme-light" data-theme="light">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
<link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
<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">
+22 -1
View File
@@ -5,22 +5,39 @@ server {
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>manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
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;
}
@@ -30,11 +47,15 @@ server {
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;
}
+115 -19
View File
@@ -3056,6 +3056,8 @@ paths:
get:
tags:
- Orders
x-api-coverage:
happy: true
summary: List orders
description: Retrieve a paginated list of orders
operationId: listOrders
@@ -4622,7 +4624,7 @@ paths:
tags:
- Self-Serve
summary: Add vehicle condition
description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles.
description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow.
operationId: addSelfserveVehicleCondition
requestBody:
required: true
@@ -4657,14 +4659,6 @@ paths:
type: integer
nullable: true
description: Alias for vehicle_type.
activate_machine:
type: boolean
default: true
description: Whether the session synchronization may enable the machine relay. User wash-start saves answers with false.
sync_relay_state:
type: boolean
default: true
description: Whether the answer mutation should synchronize live relay state.
responses:
'200':
description: Successfully added vehicle condition
@@ -4681,7 +4675,7 @@ paths:
tags:
- Self-Serve
summary: Update vehicle condition
description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles.
description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow.
operationId: updateSelfserveVehicleCondition
parameters:
- name: id
@@ -4717,14 +4711,6 @@ paths:
type: integer
nullable: true
description: Alias for vehicle_type.
activate_machine:
type: boolean
default: true
description: Whether the session synchronization may enable the machine relay.
sync_relay_state:
type: boolean
default: true
description: Whether the mutation should synchronize live relay state.
responses:
'200':
description: Successfully updated vehicle condition
@@ -5400,7 +5386,7 @@ paths:
application/json:
schema:
type: object
required: [department, gateway_id, action]
required: [department, gateway_id, action, confirm]
properties:
department: { type: integer }
gateway_id: { type: integer }
@@ -8702,6 +8688,116 @@ paths:
schema:
$ref: '#/components/schemas/SelfServeLaneStatus'
/modules/self-serve/lane/wash/my-active-wash:
get:
tags:
- Modules
summary: Get the authenticated customer's active self-serve wash
description: |
Returns the current authenticated customer's open self-serve wash session,
if one exists. Regular customers must only receive their own active wash
details from this endpoint.
operationId: getMyActiveSelfServeWash
responses:
'200':
description: Authenticated customer's active wash details resolved
content:
application/json:
schema:
type: object
properties:
lane_id:
type: integer
nullable: true
in_progress:
type: boolean
session:
type: object
nullable: true
properties:
id:
type: integer
lane_id:
type: integer
nullable: true
department_id:
type: integer
nullable: true
status:
type: string
reg:
type: string
customer_number:
type: integer
nullable: true
vehicle_id:
type: integer
nullable: true
vehicle_type_id:
type: integer
nullable: true
included_minutes:
type: integer
nullable: true
machine_type_id:
type: integer
nullable: true
machine_relay_enabled:
type: boolean
machine_relay_enabled_at:
type: string
nullable: true
machine_start_triggered:
type: boolean
machine_start_triggered_at:
type: string
nullable: true
wash_started_at:
type: string
nullable: true
created_at:
type: string
updated_at:
type: string
nullable: true
customer:
type: object
nullable: true
properties:
id:
type: integer
nullable: true
customer_number:
type: integer
nullable: true
display_name:
type: string
nullable: true
email:
type: string
nullable: true
phone_country_code:
type: integer
nullable: true
phone:
type: string
nullable: true
vehicle:
type: object
nullable: true
properties:
id:
type: integer
customer_id:
type: integer
type:
type: integer
reg:
type: string
reference:
type: string
nullable: true
/modules/self-serve/lane/wash/in-progress:
get:
tags:
+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":"/"}
+1120 -42
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -7,11 +7,13 @@
"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",
@@ -109,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
? {}
+40 -29
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;
}
@@ -368,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();
@@ -436,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();
+52 -21
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/,
@@ -12,7 +62,7 @@ export default defineConfig({
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: isCI ? 2 : 1,
workers: isCI ? 2 : 3,
reporter: [["list"], ["html", { open: "never", outputFolder: "output/playwright/prod/report" }]],
outputDir: "output/playwright/prod/test-results",
use: {
@@ -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,
});
+8 -4
View File
@@ -2,6 +2,10 @@
Options -MultiViews
</IfModule>
<IfModule mod_mime.c>
AddType application/manifest+json .webmanifest
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
@@ -23,17 +27,17 @@
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?: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]
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 ^(?:.*?/)?((?: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]
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 ^(?:.*?/)?((?: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]
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
@@ -41,7 +45,7 @@
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?: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]
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
+1
View File
@@ -2,6 +2,7 @@
"name": "Truck Wash Kundeportal",
"short_name": "Truck Wash",
"description": "Access your Truck Wash accounts and transactions from anywhere.",
"id": "/",
"icons": [
{
"src": "assets/favicons/web-app-manifest-192x192.png",
+91
View File
@@ -0,0 +1,91 @@
import { spawnSync } from "node:child_process";
const browsers = process.argv.slice(2);
const requestedBrowsers = browsers.length > 0 ? browsers : ["chromium"];
const fallbackHostPlatformByUnsupportedPlatform = (platform) => {
const ubuntuMatch = platform.match(/^ubuntu(\d+\.\d+)-(x64|arm64)$/);
if (ubuntuMatch && Number.parseInt(ubuntuMatch[1], 10) >= 26) {
return `ubuntu24.04-${ubuntuMatch[2]}`;
}
return null;
};
const outputText = (result) => `${result.stdout || ""}\n${result.stderr || ""}`;
const unsupportedHostPlatform = (result) => {
const output = outputText(result);
return (
output.match(/Cannot install dependencies for (?<platform>\S+) with Playwright/i)?.groups?.platform ||
output.match(/Playwright does not support \S+ on (?<platform>\S+)/i)?.groups?.platform ||
null
);
};
const runPlaywrightInstall = (args, env = {}) =>
spawnSync("npx", ["playwright", "install", ...args], {
cwd: process.cwd(),
env: {
...process.env,
...env,
},
encoding: "utf8",
});
const writeOutput = (result) => {
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
};
const isUnsupportedWithDepsFailure = (result) => {
const output = outputText(result);
return (
result.status !== 0 &&
/Cannot install dependencies for .* with Playwright/i.test(output) &&
/Playwright does not support .* on /i.test(output)
);
};
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
writeOutput(withDepsResult);
if (withDepsResult.status === 0) {
process.exit(0);
}
if (!isUnsupportedWithDepsFailure(withDepsResult)) {
process.exit(withDepsResult.status ?? 1);
}
const unsupportedPlatform = unsupportedHostPlatform(withDepsResult);
const fallbackHostPlatform = unsupportedPlatform
? fallbackHostPlatformByUnsupportedPlatform(unsupportedPlatform)
: null;
if (!fallbackHostPlatform) {
console.error(
`Playwright dependency install is unsupported for ${unsupportedPlatform || "this platform"}, ` +
"and this script does not have a safe browser archive fallback for it."
);
process.exit(withDepsResult.status ?? 1);
}
console.warn(
[
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
`Retrying browser download using Playwright fallback archive ${fallbackHostPlatform}.`,
"The self-hosted runner image must provide the required browser system libraries.",
].join("\n")
);
const browserOnlyResult = runPlaywrightInstall(requestedBrowsers, {
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE: fallbackHostPlatform,
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: "1",
});
writeOutput(browserOnlyResult);
process.exit(browserOnlyResult.status ?? 1);
+6
View File
@@ -34,6 +34,12 @@ export const sourceMappings = [
],
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],
@@ -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);
+35 -3
View File
@@ -23,20 +23,52 @@ if [[ -z "$DEPLOY_URL" ]]; then
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
exit 1
fi
DEPLOY_URL="ftp://${USER_NAME}:${PASSWORD}@${HOST}"
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
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; put -O \"$(dirname "$remote_file")\" \"$source_file\" -o \"$(basename "$remote_file")\"; bye"
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
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; mirror -R --only-newer --parallel=4 \"$DIST_DIR/$directory\" \"$directory\"; bye"
run_lftp "mirror -R --only-newer --parallel=4 $(lftp_quote "$DIST_DIR/$directory") $(lftp_quote "$directory")"
fi
done
+25 -1
View File
@@ -1,4 +1,5 @@
import crypto from "node:crypto";
import fs from "node:fs";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -7,6 +8,24 @@ function numberEnv(name, fallback) {
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) {
@@ -121,6 +140,7 @@ async function verifyShell(baseUrl, shellPath) {
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");
@@ -130,7 +150,7 @@ async function verifyRelease(baseUrl) {
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
}
if (expectedBuildId && manifest.build_id !== expectedBuildId) {
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) {
@@ -191,6 +211,10 @@ async function main() {
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}`
);
+44 -8
View File
@@ -1,31 +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",
@@ -53,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",
@@ -62,18 +73,24 @@ 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",
@@ -84,12 +101,13 @@ const ownedFilesByRole = {
"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] },
@@ -116,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] },
];
@@ -200,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) => {
@@ -222,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);
@@ -309,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);
@@ -342,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();
}
+52 -17
View File
@@ -22,6 +22,7 @@ for (const signal of ["SIGINT", "SIGTERM"]) {
function parseArgs(rawArgs) {
const parsed = {
help: false,
coreOnly: false,
changedOnly: false,
listOnly: false,
base: "",
@@ -43,6 +44,11 @@ function parseArgs(rawArgs) {
continue;
}
if (value === "--core-only") {
parsed.coreOnly = true;
continue;
}
if (value === "--changed-only") {
parsed.changedOnly = true;
continue;
@@ -103,12 +109,14 @@ 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
`);
}
@@ -159,6 +167,7 @@ async function runPlaywright({ label, commandArgs, artifactSuffix }) {
PLAYWRIGHT: "1",
PLAYWRIGHT_ARTIFACT_NAMESPACE: getArtifactNamespace(artifactSuffix),
PLAYWRIGHT_REPORTER_MODE: "line-html",
PLAYWRIGHT_WORKERS: process.env.PLAYWRIGHT_WORKERS || "1",
},
stdio: "inherit",
windowsHide: true,
@@ -318,11 +327,19 @@ function groupSpecsByProjects(specProjects) {
async function runCorePrGate() {
const projects = getSelectedProjects();
return runPlaywright({
label: `core ${prGrep} gate`,
artifactSuffix: "core",
commandArgs: ["--grep", prGrep, ...buildProjectArgs(projects)],
});
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) {
@@ -332,11 +349,19 @@ async function runChangedSelection(selection) {
console.log(
`[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(", ")}`
);
return runPlaywright({
label: `fallback ${smokeGrep} gate`,
artifactSuffix: "smoke-fallback",
commandArgs: ["--grep", smokeGrep, "--grep-invert", prGrep, ...buildProjectArgs(projects)],
});
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);
@@ -346,14 +371,16 @@ async function runChangedSelection(selection) {
}
for (const [index, group] of groups.entries()) {
const code = await runPlaywright({
label: `changed-area specs ${index + 1}/${groups.length}`,
artifactSuffix: `changed-${index + 1}`,
commandArgs: [...group.specs, ...buildProjectArgs(group.projects)],
});
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;
if (code !== 0) {
return code;
}
}
}
@@ -399,6 +426,10 @@ async function main() {
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) {
@@ -409,6 +440,10 @@ async function main() {
}
}
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.`);
+2 -2
View File
@@ -18,9 +18,9 @@ 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")
);
@@ -123,13 +123,13 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
<header></header>
<main>
<DefaultPageWrapper>
<VersionCheck />
<router-view />
</DefaultPageWrapper>
</main>
</template>
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<FrontendMaintenanceMenu />
</template>
<style scoped></style>
@@ -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>
-11
View File
@@ -1,21 +1,10 @@
<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>
+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>
@@ -63,6 +63,10 @@ const props = defineProps({
type: String,
default: null,
},
reg_3: {
type: String,
default: null,
},
order_booking_id: {
type: Number,
default: null,
@@ -93,6 +97,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
allowBookingDeletion: {
type: Boolean,
default: false,
},
department_lane_id: {
type: Number,
default: null,
@@ -839,6 +847,38 @@ watch(isDropdownOpen, async (isOpen) => {
const attachmentsFromOrder = ref([]);
const attachmentsFromOrderError = ref(null);
const SELF_SERVE_WASH_ATTACHMENT_TYPE = "SELF_SERVE_WASH";
const normalizePositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const getAttachmentOtherPayload = (attachment) => attachment?.content?.other ?? null;
const isSelfServeWashAttachment = (attachment) => {
const other = getAttachmentOtherPayload(attachment);
return Boolean(other && typeof other === "object" && other.type === SELF_SERVE_WASH_ATTACHMENT_TYPE);
};
const getSelfServeWashAttachment = computed(() =>
attachmentsFromOrder.value.find((attachment) => isSelfServeWashAttachment(attachment)) || null
);
const getSelfServeWashPayload = computed(() => getAttachmentOtherPayload(getSelfServeWashAttachment.value));
const getSelfServeWashCustomerNumber = computed(() =>
normalizePositiveInteger(getSelfServeWashPayload.value?.customer_number)
);
const canAcceptSelfServeWashDraft = computed(() =>
Boolean(
props.order_id
&& getSelfServeWashCustomerNumber.value
&& normalizePositiveInteger(props.customer_number) !== getSelfServeWashCustomerNumber.value
&& (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser())
)
);
const isObjectUrl = (value) => typeof value === "string" && value.startsWith("blob:");
@@ -863,16 +903,39 @@ const clearAttachmentPreviewState = () => {
};
const getAttachmentLabel = (attachment) => {
if (isSelfServeWashAttachment(attachment)) {
const customerNumber = normalizePositiveInteger(attachment?.content?.other?.customer_number);
return customerNumber
? t("admin.pos.settings_wheel.self_serve_wash_attachment_for_customer", { customerNumber })
: t("admin.pos.settings_wheel.self_serve_wash_attachment");
}
const other = getAttachmentOtherPayload(attachment);
const otherLabel = typeof other === "string"
? other
: other && typeof other === "object"
? (other.label || other.type || JSON.stringify(other))
: null;
return (
attachment?.content?.document ||
attachment?.content?.image ||
attachment?.content?.other ||
otherLabel ||
`Attachment ${attachment?.id ?? ""}`.trim()
);
};
const isWashCertificateAttachment = (attachment) => {
const marker = String(getAttachmentOtherPayload(attachment) || "").trim().toUpperCase();
if (marker === "WASH_CERTIFICATE") {
return true;
}
return /(?:^|[/\\])wash[_-]?certificate.*\.pdf$/i.test(getAttachmentLabel(attachment));
};
const getAttachmentExtension = (attachment) => {
const match = getAttachmentLabel(attachment)
const match = String(getAttachmentLabel(attachment))
.toLowerCase()
.match(/(\.[a-z0-9]+)$/);
@@ -894,17 +957,51 @@ const getAttachmentPreviewKind = (attachment) => {
return "office";
}
if (String(attachment?.content?.other || "").startsWith("http")) {
const other = getAttachmentOtherPayload(attachment);
if (typeof other === "string" && other.startsWith("http")) {
return "link";
}
if (attachment?.content?.other) {
if (other) {
return "text";
}
return "none";
};
const formatAttachmentText = (attachment) => {
const other = getAttachmentOtherPayload(attachment);
if (isSelfServeWashAttachment(attachment)) {
const parts = [
t("admin.pos.settings_wheel.self_serve_wash_attachment"),
other?.customer_number
? `${t("admin.pos.settings_wheel.self_serve_customer")}: #${other.customer_number}`
: null,
other?.subuser?.name || other?.subuser?.username || other?.subuser_id
? `${t("admin.pos.settings_wheel.self_serve_driver")}: ${other?.subuser?.name || other?.subuser?.username || `#${other.subuser_id}`}`
: null,
other?.license_plate
? `${t("pos.license_plate")}: ${other.license_plate}`
: null,
other?.elapsed_wash_time_seconds
? `${t("admin.pos.settings_wheel.self_serve_elapsed")}: ${Math.ceil(Number(other.elapsed_wash_time_seconds) / 60)} min`
: null,
].filter(Boolean);
return parts.join("\n");
}
if (typeof other === "string") {
return other;
}
if (other && typeof other === "object") {
return JSON.stringify(other, null, 2);
}
return "";
};
const getAttachmentPreviewPlaceholderIcon = (attachment) => {
const previewKind = getAttachmentPreviewKind(attachment);
@@ -965,6 +1062,10 @@ const activeAttachmentPreviewSource = computed(() => {
return previewSourcesById.value[activeAttachment.value.id] ?? null;
});
const hasWashCertificateAttachment = computed(() =>
attachmentsFromOrder.value.some((attachment) => isWashCertificateAttachment(attachment))
);
const hasCachedPreviewSource = (attachmentId) => {
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
};
@@ -1529,6 +1630,137 @@ const showCompleteOrderBookingConfirmation = async () => {
}
};
const showEmailNotificationActionResult = async (requestAction, successKey, errorKey) => {
try {
await requestAction();
await Swal.fire({
title: t(successKey),
icon: "success",
showConfirmButton: false,
timer: 2000,
heightAuto: false,
});
} catch (error) {
console.error(error);
await Swal.fire({
title: t("common.error"),
text: [t(errorKey), SessionUser.functions.parseErrorMessage?.(error)].filter(Boolean).join(": "),
icon: "error",
heightAuto: false,
});
}
};
const resendBookingConfirmation = () =>
showEmailNotificationActionResult(
() => SessionUser.objects.order_bookings.functions.resendBookingConfirmation(props.order_booking_id),
"admin.pos.settings_wheel.resend_booking_confirmation_success",
"admin.pos.settings_wheel.resend_booking_confirmation_error"
);
const resendBookingCompletionConfirmation = () =>
showEmailNotificationActionResult(
() => SessionUser.objects.order_bookings.functions.resendBookingCompletionConfirmation(props.order_booking_id),
"admin.pos.settings_wheel.resend_booking_completion_confirmation_success",
"admin.pos.settings_wheel.resend_booking_completion_confirmation_error"
);
const resendWashCertificate = () =>
showEmailNotificationActionResult(
() => SessionUser.objects.orders.functions.resendWashCertificate(props.order_id),
"admin.pos.settings_wheel.resend_wash_certificate_success",
"admin.pos.settings_wheel.resend_wash_certificate_error"
);
const getAvailableInvoiceCollectionsForCustomer = async (customerNumber) => {
const normalizedCustomerNumber = normalizePositiveInteger(customerNumber);
if (!normalizedCustomerNumber) {
return [];
}
const response = await SessionUser.request("/collected-invoices", "GET", {
page: 1,
limit: 100,
order: "closed_at:asc",
filters: `customer_number:${normalizedCustomerNumber},booked_invoice_id:is_null`,
});
return Array.isArray(response?.data?.data) ? response.data.data : [];
};
const ensureOpenInvoiceCollectionForCustomer = async (customerNumber) => {
const collections = await getAvailableInvoiceCollectionsForCustomer(customerNumber);
const openCollection = collections.find((collection) => collection?.closed_at === null);
const openCollectionId = normalizePositiveInteger(openCollection?.id);
if (openCollectionId) {
return openCollectionId;
}
const response = await SessionUser.objects.collectedOrderInvoices.add(
customerNumber,
t("admin.pos.drafts_assignment.new_collection_name"),
t("admin.pos.drafts_assignment.new_collection_description"),
null
);
return normalizePositiveInteger(response?.data?.data?.id ?? response?.data?.id);
};
const acceptSelfServeWashDraft = async () => {
const customerNumber = getSelfServeWashCustomerNumber.value;
const orderId = normalizePositiveInteger(props.order_id);
if (!customerNumber || !orderId) {
return;
}
const result = await Swal.fire({
title: t("admin.pos.settings_wheel.accept_self_serve_wash"),
text: t("admin.pos.settings_wheel.accept_self_serve_wash_confirm", { customerNumber }),
icon: "question",
showCancelButton: true,
confirmButtonText: t("common.confirm"),
cancelButtonText: t("common.cancel"),
});
if (!result.isConfirmed) {
return;
}
try {
const invoiceCollectionId = await ensureOpenInvoiceCollectionForCustomer(customerNumber);
if (!invoiceCollectionId) {
throw new Error(t("admin.pos.drafts_assignment.invoice_collection_empty"));
}
await SessionUser.objects.orders.functions.assignDraftCustomer({
order_id: orderId,
customer_id: customerNumber,
invoice_collection_id: invoiceCollectionId,
department_id: normalizePositiveInteger(props.department_id),
recalculate_prices: true,
});
await props.refreshFunction();
await Swal.fire({
icon: "success",
title: t("admin.pos.settings_wheel.accept_self_serve_wash_success"),
timer: 1800,
showConfirmButton: false,
});
} catch (error) {
await Swal.fire({
icon: "error",
title: t("admin.pos.drafts_assignment.error"),
text: SessionUser.functions.parseErrorMessage(error) || t("admin.pos.drafts_assignment.error"),
});
}
};
const canDeleteOrderBooking = computed(() =>
!props.order_id &&
(props.allowBookingDeletion || SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser())
);
const flatBuiltInMenuSections = computed(() => {
const sections = [];
@@ -1583,7 +1815,7 @@ const flatBuiltInMenuSections = computed(() => {
),
})
: null,
!props.order_id
canDeleteOrderBooking.value
? buildMenuAction("booking-delete", {
icon: "fas fa-trash-alt",
label: t("admin.pos.settings_wheel.delete_booking"),
@@ -1614,6 +1846,16 @@ const flatBuiltInMenuSections = computed(() => {
? redirectDepartmentOrderPage(props.order_id, true)
: SessionUser.functions.redirectTo.user("/orders/" + props.order_id, true),
}),
SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()
? canAcceptSelfServeWashDraft.value
? buildMenuAction("order-accept-self-serve-wash", {
icon: "fas fa-check-circle",
label: t("admin.pos.settings_wheel.accept_self_serve_wash"),
template: "success",
clickAction: acceptSelfServeWashDraft,
})
: null
: null,
SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()
? buildMenuAction("order-attach-wash-certificate", {
icon: "fas fa-paperclip",
@@ -1661,6 +1903,40 @@ const flatBuiltInMenuSections = computed(() => {
}
}
if (props.order_booking_id || hasWashCertificateAttachment.value) {
const emailNotificationsSection = buildMenuSection(
"email-notifications",
t("admin.pos.settings_wheel.email_notifications_section"),
[
props.order_booking_id
? buildMenuAction("email-notifications-resend-booking-confirmation", {
icon: "fas fa-envelope",
label: t("admin.pos.settings_wheel.resend_booking_confirmation"),
clickAction: resendBookingConfirmation,
})
: null,
props.order_booking_id && hasWashCertificateAttachment.value
? buildMenuAction("email-notifications-resend-booking-completion-confirmation", {
icon: "fas fa-envelope-open-text",
label: t("admin.pos.settings_wheel.resend_booking_completion_confirmation"),
clickAction: resendBookingCompletionConfirmation,
})
: null,
!props.order_booking_id && hasWashCertificateAttachment.value
? buildMenuAction("email-notifications-resend-wash-certificate", {
icon: "fas fa-file-pdf",
label: t("admin.pos.settings_wheel.resend_wash_certificate"),
clickAction: resendWashCertificate,
})
: null,
]
);
if (emailNotificationsSection) {
sections.push(emailNotificationsSection);
}
}
if (props.invoice_collection_id && SessionUser.canAccessSuperUser()) {
const invoiceCollectionLinkSection = buildMenuSection(
"invoice-collection-link",
@@ -1962,10 +2238,10 @@ const flatBuiltInMenuSections = computed(() => {
}
}
if (props.reg_1 || props.reg_2) {
if (props.reg_1 || props.reg_2 || props.reg_3) {
const vehicleSection = buildMenuSection(
"vehicle",
props.reg_1 && props.reg_2
[props.reg_1, props.reg_2, props.reg_3].filter(Boolean).length > 1
? SessionUser.objects.vehicles.meta.labels.multiple
: SessionUser.objects.vehicles.meta.labels.single,
[
@@ -1983,6 +2259,13 @@ const flatBuiltInMenuSections = computed(() => {
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_2, true),
})
: null,
props.reg_3 && SessionUser.canAccessSuperUser()
? buildMenuAction("vehicle-reg-3", {
icon: "fas fa-car",
label: t("admin.pos.settings_wheel.view_vehicle_new_tab", { reg: props.reg_3 }),
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_3, true),
})
: null,
]
);
@@ -2252,11 +2535,13 @@ const syncDesktopFlyoutPosition = () => {
dropdownContentEl.style.top = "";
}
const padding = 12;
const viewportInsets = getViewportInsets();
const viewportTop = viewportInsets.top;
const viewportBottom = viewportHeight - viewportInsets.bottom;
let top = triggerRect.bottom;
if (top + contentHeight > viewportHeight - padding) {
top = Math.max(padding, triggerRect.top - contentHeight);
if (top + contentHeight > viewportBottom) {
top = Math.max(viewportTop, triggerRect.top - contentHeight);
}
const nextFixedStyles = {
@@ -2477,7 +2762,7 @@ const syncDesktopFlyoutPosition = () => {
{{ t("admin.pos.attachments_office_preview_unavailable") }}
</span>
<span v-else-if="activeAttachmentPreviewKind === 'text'" class="action-settings-wheel-attachment-panel__text">
{{ activeAttachment.content?.other }}
{{ formatAttachmentText(activeAttachment) }}
</span>
<span v-else class="action-settings-wheel-attachment-panel__text">
{{ t("admin.pos.attachments_no_preview") }}
@@ -2867,6 +3152,7 @@ const syncDesktopFlyoutPosition = () => {
text-align: center;
color: #4a5568;
overflow-wrap: anywhere;
white-space: pre-line;
}
.action-settings-wheel-attachment-panel__link {
@@ -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>
@@ -415,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>
@@ -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>
@@ -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,
};
};
@@ -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: {
@@ -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'"
@@ -275,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;
}
@@ -649,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],
() => {
@@ -999,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>
@@ -1260,7 +1306,12 @@ 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"
@@ -1501,7 +1552,12 @@ 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"
@@ -1992,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"
@@ -21,6 +21,7 @@ import {
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";
@@ -92,6 +93,7 @@ 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) => {
@@ -117,7 +119,16 @@ watch(
);
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();
@@ -267,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("common.reference")}: ${
getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")
}`,
`${t("common.services")}: ${
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 {
@@ -297,7 +304,7 @@ const bookingSelectionObjects = computed(() => {
const duplicateDetailsObjects = computed(() => {
return duplicateOrders.value.map((order) => ({
id: Number(order.id),
label: `${t("common.order")} #${order.id} - ${formatDuplicateOrderDate(order.created_at)}`,
label: `${t("admin.pos.order")} #${order.id} - ${formatDuplicateOrderDate(order.created_at)}`,
content: getDuplicateOrderContent(order),
buttons: [
{
@@ -639,7 +646,7 @@ const pushDesktopStepTwoRoute = () => {
return;
}
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
};
const handleDesktopLastWashCopy = async (payload = {}) => {
@@ -681,6 +688,10 @@ onMounted(() => {
});
onBeforeUnmount(() => {
if (focusOnReg1TimeoutId !== null) {
clearTimeout(focusOnReg1TimeoutId);
focusOnReg1TimeoutId = null;
}
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
});
@@ -750,13 +761,9 @@ 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
v-if="shouldShowActionRailControls"
class="pos-shell-actions__rail"
data-testid="pos-step-1-action-rail"
>
<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')"
@@ -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 = () => {
@@ -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 -->
@@ -1,34 +1,27 @@
<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 toPositiveInteger = (value: unknown) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const onClickClearAll = async () => {
// 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) {
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 {
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(order_id.value);
order_id.value = activeDraftOrderId;
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(activeDraftOrderId);
if (!deleted) {
return;
}
@@ -299,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.`,
@@ -327,14 +325,12 @@ const step2 = async () => {
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,
markOrderCompleted: false,
});
return;
}
@@ -344,7 +340,6 @@ const step2 = async () => {
syncMobileSafetySealState(safetySeal);
await completeStep2Order({
bookingSafetySeal: String(safetySeal ?? ""),
markOrderCompleted: false,
});
};
@@ -358,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);
@@ -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;
@@ -28,6 +31,9 @@ watch(note, (newNote) => {
placeholder="Indtast note"
/>
</div>
<p v-if="props?.validationMessage" class="help is-danger mt-2">
{{ props.validationMessage }}
</p>
</div>
</div>
</template>
@@ -788,7 +788,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",
@@ -3,6 +3,45 @@ export const XLVASK_USAGE_AMOUNT_CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_PREFIX = "xlvask-usage-amount:";
const memoryCache = new Map();
const getStorageValue = (key) => {
try {
if (typeof window === "undefined" || !window.localStorage) {
return "";
}
return window.localStorage.getItem(key) || "";
} catch {
return "";
}
};
const hashCacheScopePart = (value) => {
let hash = 5381;
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
}
return (hash >>> 0).toString(36);
};
const getAuthenticatedCacheScope = () => {
const token = getStorageValue("token");
if (!token) {
return "";
}
return [
hashCacheScopePart(token),
getStorageValue("is_subuser") === "true" ? "subuser" : "user",
getStorageValue("selected_customer_number"),
]
.map((part) => encodeURIComponent(String(part ?? "")))
.join("|");
};
const getScopedCacheKey = (cacheKey) => {
const scope = getAuthenticatedCacheScope();
return scope ? `${scope}:${cacheKey}` : "";
};
const safeSessionStorage = () => {
try {
if (typeof window === "undefined" || !window.sessionStorage) {
@@ -15,9 +54,7 @@ const safeSessionStorage = () => {
};
const normalizeUsageLogId = (objectOrId) => {
const value = typeof objectOrId === "object"
? objectOrId?.usage_log_id ?? objectOrId?.id
: objectOrId;
const value = typeof objectOrId === "object" ? objectOrId?.usage_log_id ?? objectOrId?.id : objectOrId;
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
};
@@ -34,17 +71,18 @@ export const buildXlvaskUsageAmountCacheKey = (objectOrId) => {
export const getCachedXlvaskUsageAmount = (objectOrId) => {
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
if (!cacheKey) {
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
if (!scopedCacheKey) {
return null;
}
const memoryEntry = memoryCache.get(cacheKey);
const memoryEntry = memoryCache.get(scopedCacheKey);
if (isFreshEntry(memoryEntry)) {
return memoryEntry.data;
}
if (memoryEntry) {
memoryCache.delete(cacheKey);
memoryCache.delete(scopedCacheKey);
}
const storage = safeSessionStorage();
@@ -52,14 +90,14 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
return null;
}
const storageKey = `${CACHE_PREFIX}${cacheKey}`;
const storageKey = `${CACHE_PREFIX}${scopedCacheKey}`;
try {
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
if (!isFreshEntry(parsed)) {
storage.removeItem(storageKey);
return null;
}
memoryCache.set(cacheKey, parsed);
memoryCache.set(scopedCacheKey, parsed);
return parsed.data;
} catch {
storage.removeItem(storageKey);
@@ -69,7 +107,8 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
if (!cacheKey) {
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
if (!scopedCacheKey) {
return;
}
@@ -77,7 +116,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
storedAt: Date.now(),
data,
};
memoryCache.set(cacheKey, entry);
memoryCache.set(scopedCacheKey, entry);
const storage = safeSessionStorage();
if (!storage) {
@@ -85,7 +124,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
}
try {
storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
storage.setItem(`${CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry));
} catch {
// Best-effort cache. Quota errors should not block XL Vask usage rows.
}
@@ -94,11 +133,12 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
export const clearCachedXlvaskUsageAmount = (objectOrId = null) => {
const storage = safeSessionStorage();
const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId);
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
if (cacheKey) {
memoryCache.delete(cacheKey);
if (scopedCacheKey) {
memoryCache.delete(scopedCacheKey);
try {
storage?.removeItem(`${CACHE_PREFIX}${cacheKey}`);
storage?.removeItem(`${CACHE_PREFIX}${scopedCacheKey}`);
} catch {
// Best-effort cache cleanup.
}
@@ -229,7 +229,7 @@ const clearAnswers = async () => {
const confirmation = await Swal.fire({
title: "Ryd besvarelser?",
text: `Registreringsnummer ${normalizedReg.value} pa bane ${selectedLaneId.value} bliver ryddet.`,
text: `Registreringsnummer ${normalizedReg.value} på bane ${selectedLaneId.value} bliver ryddet.`,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Ja, ryd besvarelser",
@@ -284,7 +284,7 @@ watch(dynamicImageUrl, () => {
<div class="modal-background" @click="closeModal"></div>
<div class="modal-card" style="width: 95%; max-width: 1200px;">
<header class="modal-card-head">
<p class="modal-card-title">Self-serve preview</p>
<p class="modal-card-title">Forhåndsvisning af selvvask</p>
<button class="delete" aria-label="close" @click="closeModal"></button>
</header>
@@ -301,7 +301,7 @@ watch(dynamicImageUrl, () => {
<label class="label">Bane</label>
<div class="select is-fullwidth">
<select v-model="selectedLaneId" data-testid="self-serve-try-lane">
<option :value="null">Vaelg bane</option>
<option :value="null">Vælg bane</option>
<option v-for="entry in availableLanes" :key="entry.id" :value="entry.id">
{{ entry.name }} (ID: {{ entry.id }})
</option>
@@ -313,7 +313,7 @@ watch(dynamicImageUrl, () => {
<input class="input" :value="selectedLaneId" type="text" disabled>
</div>
<div class="column is-3">
<label class="label">Koretojstype</label>
<label class="label">Køretøjstype</label>
<div class="select is-fullwidth">
<select v-model="selectedVehicleTypeId" data-testid="self-serve-try-vehicle-type">
<option :value="null">Auto (fra registreringsnummer)</option>
@@ -325,7 +325,7 @@ watch(dynamicImageUrl, () => {
</div>
<div class="column is-3 is-flex is-align-items-flex-end">
<button class="button is-link is-fullwidth" data-testid="self-serve-try-refresh" :class="{ 'is-loading': loading }" @click="refresh">
Hent preview
Hent forhåndsvisning
</button>
</div>
</div>
@@ -335,10 +335,10 @@ watch(dynamicImageUrl, () => {
Tilladt: {{ allowed ? "Ja" : "Nej" }}
</span>
<span class="tag" :class="machineAvailable ? 'is-success' : 'is-light'">
Maskine tilgaengelig: {{ machineAvailable ? "Ja" : "Nej" }}
Maskine tilgængelig: {{ machineAvailable ? "Ja" : "Nej" }}
</span>
<span class="tag" :class="allDisplayQuestionsAnswered ? 'is-success' : 'is-warning'">
Alle synlige sporgsmal besvaret: {{ allDisplayQuestionsAnswered ? "Ja" : "Nej" }}
Alle synlige spørgsmål besvaret: {{ allDisplayQuestionsAnswered ? "Ja" : "Nej" }}
</span>
<span v-if="session" class="tag is-info">
Session: {{ session.status }} (#{{ session.id }})
@@ -347,10 +347,10 @@ watch(dynamicImageUrl, () => {
Maskintype: {{ machineType.name }}
</span>
<span v-if="lane" class="tag is-light">
Lane: {{ lane.name || lane.id }}
Bane: {{ lane.name || lane.id }}
</span>
<span v-if="configVersionId" class="tag is-dark">
Config version: #{{ configVersionId }}
Konfigurationsversion: #{{ configVersionId }}
</span>
</div>
@@ -364,10 +364,10 @@ watch(dynamicImageUrl, () => {
<div class="column is-6">
<div class="box" style="height: 100%">
<h4 class="title is-5">Sporgsmal</h4>
<h4 class="title is-5">Spørgsmål</h4>
<div v-if="displayQuestions.length === 0" class="notification is-success is-light">
<p>Ingen synlige sporgsmal for denne preview.</p>
<p>Ingen synlige spørgsmål for denne forhåndsvisning.</p>
</div>
<SelfServeQuestionCards
@@ -375,18 +375,18 @@ watch(dynamicImageUrl, () => {
:answers="answers"
@answer-question="submitAnswer"
/>
<p v-if="questions.length === 0" class="has-text-centered is-italic">Ingen sporgsmal fundet.</p>
<p v-if="questions.length === 0" class="has-text-centered is-italic">Ingen spørgsmål fundet.</p>
</div>
</div>
<div class="column is-6">
<div class="box" style="height: 100%">
<h4 class="title is-5">Tasks og session</h4>
<h4 class="title is-5">Opgaver og session</h4>
<div v-if="displayedDynamicImageUrl" class="mb-4">
<img
:src="displayedDynamicImageUrl"
alt="Machine status preview"
alt="Forhåndsvisning af maskinstatus"
data-testid="self-serve-try-dynamic-image"
style="max-width: 100%; height: auto; border-radius: 4px; display: block; margin-left: auto; margin-right: auto;"
@error="onDynamicImageError"
@@ -401,11 +401,11 @@ watch(dynamicImageUrl, () => {
@download-attachment="downloadAttachment"
/>
<p v-if="activeTasks.length === 0" class="is-italic">Ingen aktive tasks.</p>
<p v-if="activeTasks.length === 0" class="is-italic">Ingen aktive opgaver.</p>
<hr />
<h5 class="subtitle is-6">Seneste haendelser</h5>
<h5 class="subtitle is-6">Seneste hændelser</h5>
<ul>
<li v-for="event in events" :key="event.id" class="mb-2">
<strong>{{ event.type }}</strong>
@@ -416,16 +416,16 @@ watch(dynamicImageUrl, () => {
<hr />
<h5 class="subtitle is-6">Evaluation trace</h5>
<h5 class="subtitle is-6">Evalueringsspor</h5>
<div v-if="evaluationTrace" class="content is-small">
<pre>{{ JSON.stringify(evaluationTrace, null, 2) }}</pre>
</div>
<p v-else class="is-italic">Ingen trace-data returneret.</p>
<p v-else class="is-italic">Ingen sporingsdata returneret.</p>
<hr />
<div class="is-flex is-align-items-center is-justify-content-space-between mb-2">
<h5 class="subtitle is-6 mb-0">Besvarede sporgsmal</h5>
<h5 class="subtitle is-6 mb-0">Besvarede spørgsmål</h5>
<button
class="button is-small is-light"
data-testid="self-serve-try-clear-answers"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { computed, inject, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import * as paginatedListModule from "@/components/pagination/paginatedList.vue";
const paginatedList = inject(paginatedListModule.PaginatedListKey, paginatedListModule);
const { search, metaSearch, isLoading, loadList } = paginatedList;
@@ -13,10 +13,15 @@ const props = defineProps({
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: "",
},
});
const canExport = computed(() => typeof paginatedList?.exportToExcel === 'function');
const canExport = computed(() => typeof paginatedList?.exportToExcel === "function");
const isExporting = computed(() => Boolean(paginatedList?.isExporting?.value));
const resolvedSearchPlaceholder = computed(() => props.searchPlaceholder || t("global.search_placeholder"));
const isActionDropdownOpen = ref(false);
const actionDropdownRef = ref<HTMLElement | null>(null);
@@ -61,11 +66,11 @@ const handleDocumentClick = (event: MouseEvent) => {
};
onMounted(() => {
document.addEventListener('click', handleDocumentClick);
document.addEventListener("click", handleDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick);
document.removeEventListener("click", handleDocumentClick);
});
</script>
@@ -74,12 +79,12 @@ onBeforeUnmount(() => {
<div class="columns is-vcentered is-multiline pagination-general-search-reload">
<div v-if="!props.hideSearch" class="column pagination-general-search-reload__search-column">
<input
data-testid="pagination-search-input"
class="input"
type="text"
:placeholder="$t('global.search_placeholder')"
v-model="metaSearch"
@input="search($event.target.value)"
data-testid="pagination-search-input"
class="input"
type="text"
:placeholder="resolvedSearchPlaceholder"
v-model="metaSearch"
@input="search($event.target.value)"
/>
</div>
<div class="column is-narrow pagination-general-search-reload__buttons-column" v-if="$slots.buttons">
@@ -134,7 +139,7 @@ onBeforeUnmount(() => {
@click.prevent="handleExportExcel"
>
<i class="fas fa-file-excel" aria-hidden="true"></i>
<span>{{ t('pagination.download_excel') }}</span>
<span>{{ t("pagination.download_excel") }}</span>
</a>
</div>
</div>
@@ -1,20 +1,12 @@
<script setup>
import { ref, inject } from 'vue';
import { ref, inject } from "vue";
import * as paginatedListModule from "@/components/pagination/paginatedList.vue";
const paginatedList = inject(paginatedListModule.PaginatedListKey, paginatedListModule);
const {
isLoading,
loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setMetaItemsPerPage,
setPage,
} = paginatedList;
const { isLoading, loadList, metaCurrentPage, metaItemsPerPage, metaTotalItems, setMetaItemsPerPage, setPage } =
paginatedList;
import PaginationDisplayGeneralSearchReload
from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
import PaginationDisplayGeneralSearchReload from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
@@ -31,6 +23,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: "",
},
hidePagination: {
type: Boolean,
default: false,
@@ -48,6 +44,7 @@ const isSmall = ref(window.innerWidth < 1024);
<PaginationDisplayGeneralSearchReload
v-if="!props.hideSearch || $slots.buttons"
:hide-search="props.hideSearch"
:search-placeholder="props.searchPlaceholder"
>
<template #buttons="{ loadList }" v-if="$slots.buttons">
<slot name="buttons" :loadList="loadList"></slot>
@@ -66,7 +63,7 @@ const isSmall = ref(window.innerWidth < 1024);
<template #paginationColumns>
<slot name="paginationDisplayFiltersElement"></slot>
<slot name="leftPaginationColumns"></slot>
<div class="column is-auto-fill my-3" v-if="!isSmall"/>
<div class="column is-auto-fill my-3" v-if="!isSmall" />
<div class="columns is-multiline">
<div class="column is-12 p-1 m-0"></div>
<slot name="rightPaginationColumns"></slot>
@@ -89,5 +86,4 @@ const isSmall = ref(window.innerWidth < 1024);
</div>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -1,30 +1,30 @@
<script setup>
import { provide, ref } from "vue";
import { useI18n } from "vue-i18n";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
import CustomerComplaintsTable from "@/components/displays/superuser/tables/customerComplaintsTable.vue";
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const props = defineProps(["hideSearch", "autoLoad"]);
const props = defineProps({
hideSearch: {
type: Boolean,
default: false,
},
autoLoad: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
isLoading,
list,
loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setEndpoint,
setMetaItemsPerPage,
setPage,
search,
setFilter,
setOrder,
hideSearchField,
@@ -54,6 +54,11 @@ const onDepartmentFilterChange = (value) => {
setFilter("department_id", parsedDepartmentId, true);
};
const onOrderDirectionChange = (value) => {
setOrder("created_at", value);
loadList();
};
setEndpoint("/departments/daily-reports/complaints", false);
setOrder("created_at", "desc");
@@ -69,22 +74,15 @@ if (props.autoLoad) {
</script>
<template>
<input
v-if="!hideSearchField"
class="input"
data-testid="superuser-complaints-search"
type="text"
:placeholder="t('superuser.pages.complaints.search_placeholder')"
@input="search($event.target.value)"
/>
<PaginationDisplay
:metaItemsPerPage="metaItemsPerPage"
:loadFunction="loadList"
:isLoading="isLoading"
:setMetaItemsPerPage="setMetaItemsPerPage"
<TableLabeledPagination
:label="t('superuser.pages.complaints.title')"
:hide-search="hideSearchField"
>
<template #paginationColumns>
<template #description>
<p class="is-size-6 mb-4">{{ t('superuser.pages.complaints.subtitle') }}</p>
</template>
<template #paginationDisplayFiltersElement>
<div class="column is-narrow my-3">
<label class="label is-small">{{ t('superuser.pages.complaints.department_filter') }}</label>
<div class="control">
@@ -112,7 +110,7 @@ if (props.autoLoad) {
<div class="select">
<select
data-testid="superuser-complaints-order-direction"
@change="setOrder('created_at', $event.target.value); loadList();"
@change="onOrderDirectionChange($event.target.value)"
>
<option value="desc" selected>{{ t('pagination.descending') }}</option>
<option value="asc">{{ t('pagination.ascending') }}</option>
@@ -121,26 +119,11 @@ if (props.autoLoad) {
</div>
</div>
</template>
</PaginationDisplay>
<CustomerComplaintsTable :objects="list" />
<PaginationNavigation
:currentPage="metaCurrentPage"
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
:loadFunction="loadList"
:setPage="setPage"
:isLoading="isLoading"
/>
<LoadButtonWhileAwait
class="is-dark"
:isLoading="isLoading"
:loadFunction="loadList"
icon="fas fa-sync-alt"
>
{{ t('pagination.reload') }}
</LoadButtonWhileAwait>
<template #default>
<CustomerComplaintsTable :objects="list" />
</template>
</TableLabeledPagination>
</template>
<style scoped>
@@ -1,45 +1,27 @@
<script setup>
let props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
import { useRouter } from "vue-router";
import {
usePaginatedList,
PaginatedListKey
} from "@/components/pagination/paginatedList.vue";
import { provide } from "vue";
import CustomersTable from "@/components/displays/superuser/tables/customersTable.vue";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
const props = defineProps({
hideSearch: {
type: Boolean,
default: false,
},
autoLoad: {
type: Boolean,
default: false,
},
});
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
isLoaded,
isLoading,
list,
loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setEndpoint,
setMetaItemsPerPage,
setPage,
metaSearch,
search,
setFilter,
setOrder,
hideSearchField,
setHideSearchField,
} = paginatedList;
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
import CustomersTable from "@/components/displays/superuser/tables/customersTable.vue";
import { useI18n } from 'vue-i18n'
const { list, loadList, setEndpoint, hideSearchField, setHideSearchField } = paginatedList;
const { t } = useI18n()
const router = useRouter();
setEndpoint("/customers", false);
// Hide the search field, if the hideSearch prop is set
if (props.hideSearch) {
setHideSearchField(true);
}
@@ -47,20 +29,12 @@ if (props.hideSearch) {
if (props.autoLoad) {
loadList();
}
</script>
<template>
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_customers')" v-if="!hideSearchField"/>
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
<template #paginationColumns>
</template>
</PaginationDisplay>
<CustomersTable :objects="list" />
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{ t('pagination.reload') }}</LoadButtonWhileAwait>
<TableLabeledPagination :label="$t('customers.title')" :hide-search="hideSearchField">
<CustomersTable :objects="list" />
</TableLabeledPagination>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -3,9 +3,7 @@ import { computed, provide, ref } from "vue";
import { useI18n } from "vue-i18n";
import DepartmentsTable from "@/components/displays/superuser/tables/departmentsTable.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import PaginationDisplayGeneralSearchReload from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
@@ -14,15 +12,9 @@ const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
isLoading,
list,
loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setEndpoint,
setMetaItemsPerPage,
setPage,
setFilter,
setOrder,
hideSearchField,
@@ -54,14 +46,11 @@ if (props.autoLoad) {
</script>
<template>
<PaginationDisplayGeneralSearchReload v-if="!hideSearchField" />
<PaginationDisplay
:metaItemsPerPage="metaItemsPerPage"
:loadFunction="loadList"
:isLoading="isLoading"
:setMetaItemsPerPage="setMetaItemsPerPage"
<TableLabeledPagination
:label="t('common.departments')"
:hide-search="hideSearchField"
>
<template #paginationColumns>
<template #paginationDisplayFiltersElement>
<div class="column is-narrow my-3 department-status-filter">
<label class="label is-small" for="department-archive-filter">{{
t("common.status")
@@ -81,23 +70,8 @@ if (props.autoLoad) {
</div>
</div>
</template>
</PaginationDisplay>
<div v-if="hideSearchField" class="mb-3">
<button class="button is-dark" :class="{ 'is-loading': isLoading }" :disabled="isLoading" @click="loadList">
<span class="icon is-small">
<i class="fas fa-sync-alt" aria-hidden="true"></i>
</span>
<span>{{ t("pagination.reload") }}</span>
</button>
</div>
<DepartmentsTable :objects="sortedList" />
<PaginationNavigation
:currentPage="metaCurrentPage"
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
:loadFunction="loadList"
:setPage="setPage"
:isLoading="isLoading"
/>
<DepartmentsTable :objects="sortedList" />
</TableLabeledPagination>
</template>
<style scoped>
@@ -7,7 +7,24 @@ import PaginationDisplay from "@/components/displays/pagination/PaginationDispla
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
const props = defineProps(["hideSearch", "autoLoad"]);
const props = defineProps({
hideSearch: {
type: Boolean,
default: false,
},
autoLoad: {
type: [Boolean, String],
default: false,
},
endpoint: {
type: String,
default: "/subusers",
},
showCustomer: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const paginatedList = usePaginatedList();
@@ -30,7 +47,7 @@ const {
setAdditionalQueryParameters,
} = paginatedList;
setEndpoint("/subusers", false);
setEndpoint(props.endpoint, false);
setAdditionalQueryParameters({ include_non_enabled: true });
setOrder("created_at", "desc");
@@ -82,7 +99,7 @@ if (props.autoLoad) {
</template>
</PaginationDisplay>
<SubusersTable :objects="list" />
<SubusersTable :objects="list" :show-customer="showCustomer" />
<PaginationNavigation
:currentPage="metaCurrentPage"
@@ -1,45 +1,21 @@
<script setup>
import UsersTable from "@/components/displays/superuser/tables/usersTable.vue";
let props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
import { useRouter } from "vue-router";
import {
usePaginatedList,
PaginatedListKey
} from "@/components/pagination/paginatedList.vue";
import { provide } from "vue";
import { useI18n } from "vue-i18n";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
import UsersTable from "@/components/displays/superuser/tables/usersTable.vue";
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
isLoaded,
isLoading,
list,
loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setEndpoint,
setMetaItemsPerPage,
setPage,
metaSearch,
search,
setFilter,
setOrder,
hideSearchField,
setHideSearchField,
} = paginatedList;
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
const { list, loadList, setEndpoint, setFilter, setOrder, hideSearchField, setHideSearchField } = paginatedList;
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const router = useRouter();
const { t } = useI18n();
setEndpoint("/users", false);
setFilter("customer_number", 0, false);
setOrder("created_at", "desc");
// Hide the search field, if the hideSearch prop is set
if (props.hideSearch) {
@@ -49,32 +25,35 @@ if (props.hideSearch) {
if (props.autoLoad) {
loadList();
}
</script>
<template>
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_user')" v-if="!hideSearchField"/>
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
<template #paginationColumns>
<TableLabeledPagination
:label="t('superuser.pages.employees.title')"
:hide-search="hideSearchField"
:search-placeholder="t('global.search_user')"
>
<template #paginationDisplayFiltersElement>
<!-- Sort by created_at -->
<div class="column is-narrow my-3">
<label class="label is-small">{{ t('pagination.order_direction') }}</label>
<label class="label is-small">{{ t("pagination.order_direction") }}</label>
<div class="control">
<div class="select">
<select @change="setOrder('created_at', $event.target.value); loadList();">
<option value="asc">{{ t('pagination.ascending') }}</option>
<option value="desc" selected>{{ t('pagination.descending') }}</option>
<select
@change="
setOrder('created_at', $event.target.value);
loadList();
"
>
<option value="asc">{{ t("pagination.ascending") }}</option>
<option value="desc" selected>{{ t("pagination.descending") }}</option>
</select>
</div>
</div>
</div>
</template>
</PaginationDisplay>
<UsersTable :objects="list" />
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{ t('pagination.reload') }}</LoadButtonWhileAwait>
<UsersTable :objects="list" />
</TableLabeledPagination>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -1,25 +1,21 @@
<script setup>
import {
list,
loadList,
setEndpoint,
setFilter,
setOrder
} from "@/components/pagination/paginatedList.vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { list, loadList, setEndpoint, setFilter, setOrder } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRouter } from "vue-router";
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displays/tables/OrderBookingsTable.vue";
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
import { Colors } from "@/ThemeConfig.vue";
import { useI18n } from 'vue-i18n'
import { useI18n } from "vue-i18n";
const { t } = useI18n()
const { t } = useI18n();
/**
* Router
*/
const router = useRouter();
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
const isAdminRoute = computed(() => router.currentRoute.value.path.startsWith("/admin"));
/**
* Props
*/
@@ -27,41 +23,40 @@ const props = defineProps({
filters: {
type: Object,
default: () => ({}),
required: false
required: false,
},
})
const orderIdFilter = ref('*');
});
const orderIdFilter = ref("*");
const onOrderIdFilterChange = (event) => {
const val = event.target.value;
orderIdFilter.value = val;
// Apply filter: '*' clears, 'is null' shows without order, 'not null' shows with order
setFilter('order_id', val);
setFilter("order_id", val);
};
// Department filter
const departmentFilter = ref('*');
const departmentFilter = ref("*");
const onDepartmentFilterChange = (event) => {
const val = event.target.value;
departmentFilter.value = val;
setFilter('department', val);
setFilter("department", val);
};
// Only today filter
const onlyTodayFilter = ref('*');
const onlyTodayFilter = ref("*");
const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
const val = event.target.value;
onlyTodayFilter.value = val;
if (val === '*') {
setFilter('datetime', val, false); // Clear filter
setFilter('datetime-date_from', null, false);
setFilter('datetime-date_to', null, false);
if (val === "*") {
setFilter("datetime", val, false); // Clear filter
setFilter("datetime-date_from", null, false);
setFilter("datetime-date_to", null, false);
} else {
const startOfDay = new Date().setHours(0, 0, 0, 0);
const endOfDay = new Date().setHours(23, 59, 59, 999);
//setFilter('datetime', null, false);
setFilter('datetime-date_from', new Date(startOfDay).toISOString(), false);
setFilter('datetime-date_to', new Date(endOfDay).toISOString(), false);
setFilter("datetime-date_from", new Date(startOfDay).toISOString(), false);
setFilter("datetime-date_to", new Date(endOfDay).toISOString(), false);
}
if (autoLoadList) {
loadList();
@@ -69,34 +64,38 @@ const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
};
// Version selector + helpers
const versionSelector = ref('new');
const versionSelector = ref("new");
const resetVersionToNew = () => {
versionSelector.value = 'new';
versionSelector.value = "new";
};
const showLegacyOrderBookingsPortal = () => {
const departmentId = SessionUser.functions.getDepartmentIdFromUrl();
if (!departmentId) {
SessionUser.functions.redirectTo.user('/bookings-legacy', true);
SessionUser.functions.redirectTo.user("/bookings-legacy", true);
return;
}
SessionUser.functions.redirectTo.department(SessionUser.functions.getDepartmentIdFromUrl(), 'modules/bookings-legacy', true);
SessionUser.functions.redirectTo.department(
SessionUser.functions.getDepartmentIdFromUrl(),
"modules/bookings-legacy",
true
);
};
// Filters
onMounted(() => {
setEndpoint(SessionUser.objects.order_bookings.meta.endpoint, false);
setOrder('datetime', 'desc', false);
setOrder("datetime", "desc", false);
// Apply initial filters from props
for (const [key, value] of Object.entries(props.filters)) {
let setFilterKey = true;
// Also set the filter controls if applicable
if (key === 'order_id') {
if (key === "order_id") {
orderIdFilter.value = value;
} else if (key === 'department') {
} else if (key === "department") {
departmentFilter.value = value;
} else if (key === 'only_today' && value === true) {
} else if (key === "only_today" && value === true) {
setFilterKey = false; // Since the only_today filter is handled separately
onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split('T')[0] } }, false);
onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split("T")[0] } }, false);
}
if (setFilterKey) {
setFilter(key, value, false);
@@ -106,108 +105,218 @@ onMounted(() => {
// Load departments for filter options
getDepartments().catch(() => {});
});
</script>
<template>
<div>
<TableLabeledPagination :label="t('pagination.bookings_overview')">
<template #paginationDisplayFiltersElement>
<!-- Status filter -->
<div class="column is-narrow">
<div class="field mb-0">
<label class="label is-small">{{ t('common.status') }}</label>
<div class="control">
<div class="select">
<select :value="orderIdFilter" @change="onOrderIdFilterChange">
<option value="*">{{ t('common.all') }}</option>
<option value="is null">{{ t('pagination.not_completed') }}</option>
<option value="not null">{{ t('pagination.completed') }}</option>
</select>
<div class="order-bookings-pagination">
<TableLabeledPagination :label="t('pagination.bookings_overview')" class="order-bookings-pagination__table">
<template #paginationDisplayFiltersElement>
<!-- Status filter -->
<div class="column is-narrow order-bookings-pagination__filter">
<div class="field mb-0">
<label class="label is-small">{{ t("common.status") }}</label>
<div class="control">
<div class="select">
<select :value="orderIdFilter" @change="onOrderIdFilterChange">
<option value="*">{{ t("common.all") }}</option>
<option value="is null">{{ t("pagination.not_completed") }}</option>
<option value="not null">{{ t("pagination.completed") }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Department filter -->
<div class="column is-narrow">
<div class="field mb-0">
<label class="label is-small">{{ t('pagination.department') }}</label>
<div class="control">
<div class="select">
<select :value="departmentFilter" @change="onDepartmentFilterChange">
<option value="*">{{ t('common.all') }}</option>
<option v-for="department in departments" :key="department.id" :value="department.id">{{ department.name }}</option>
</select>
<!-- Department filter -->
<div class="column is-narrow order-bookings-pagination__filter">
<div class="field mb-0">
<label class="label is-small">{{ t("pagination.department") }}</label>
<div class="control">
<div class="select">
<select :value="departmentFilter" @change="onDepartmentFilterChange">
<option value="*">{{ t("common.all") }}</option>
<option v-for="department in departments" :key="department.id" :value="department.id">
{{ department.name }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Version selector -->
<div class="column is-narrow">
<div class="field mb-0">
<label class="label is-small">{{ t('pagination.version') }}</label>
<div class="control">
<div class="select">
<select @change="showLegacyOrderBookingsPortal(); resetVersionToNew()" v-model="versionSelector">
<option value="new">{{ t('common.new') }}</option>
<option value="legacy">{{ t('pagination.old') }}</option>
</select>
<!-- Version selector -->
<div class="column is-narrow order-bookings-pagination__filter">
<div class="field mb-0">
<label class="label is-small">{{ t("pagination.version") }}</label>
<div class="control">
<div class="select">
<select
@change="
showLegacyOrderBookingsPortal();
resetVersionToNew();
"
v-model="versionSelector"
>
<option value="new">{{ t("common.new") }}</option>
<option value="legacy">{{ t("pagination.old") }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Only today filter -->
<div class="column is-narrow">
<div class="field mb-0">
<label class="label is-small">{{ t('pagination.only_today') }}</label>
<div class="control">
<div class="select">
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
<option value="*">{{ t('common.all') }}</option>
<option :value="new Date().toISOString().split('T')[0]">{{ t('common.yes') }}</option>
</select>
<!-- Only today filter -->
<div class="column is-narrow order-bookings-pagination__filter" v-if="!isUserRoute">
<div class="field mb-0">
<label class="label is-small">{{ t("pagination.only_today") }}</label>
<div class="control">
<div class="select">
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
<option value="*">{{ t("common.all") }}</option>
<option :value="new Date().toISOString().split('T')[0]">{{ t("common.yes") }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>
<template #leftPaginationColumns>
</template>
<template #rightPaginationColumns>
<!-- Toggles and actions depending on route -->
<!-- User: Only today switch + New booking button -->
<div class="column is-narrow" v-if="router.currentRoute.value.path.startsWith('/user')">
<label class="label is-small">{{ t('pagination.show_only_today') }}</label>
<div class="field">
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { onOnlyTodayFilterChange({ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } }) }" :class="{ 'is-link': onlyTodayFilter !== '*' }" :checked="onlyTodayFilter !== '*'" />
<label for="today"></label>
</template>
<template #leftPaginationColumns> </template>
<template #rightPaginationColumns>
<!-- Toggles and actions depending on route -->
<!-- User: Only today switch + New booking button -->
<div class="column is-narrow order-bookings-pagination__actions" v-if="isUserRoute">
<div class="order-bookings-pagination__actions-grid">
<div class="order-bookings-pagination__today-action">
<label class="label is-small">{{ t("pagination.show_only_today") }}</label>
<div class="field mb-0">
<input
id="today"
type="checkbox"
class="switch is-rounded"
@change="
(event) => {
onOnlyTodayFilterChange({
target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' },
});
}
"
:class="{ 'is-link': onlyTodayFilter !== '*' }"
:checked="onlyTodayFilter !== '*'"
/>
<label for="today"></label>
</div>
</div>
<div class="order-bookings-pagination__new-booking-action">
<label class="label is-small order-bookings-pagination__desktop-spacer">&nbsp;</label>
<button
class="button is-link button-same-width"
data-testid="user-bookings-new-booking"
@click="SessionUser.functions.redirectTo.external('/user/bookings/new', true)"
>
{{ t("pagination.new_booking") }}
</button>
</div>
</div>
</div>
</div>
<div class="column is-narrow is-float-right" v-if="router.currentRoute.value.path.startsWith('/user')">
<label class="label is-small">&nbsp;</label>
<button
class="button is-link button-same-width"
@click="SessionUser.functions.redirectTo.external('/user/bookings/new', true)"
>
{{ t('pagination.new_booking') }}
</button>
</div>
<!-- Admin: pending + only today combined switch -->
<div class="column is-narrow" v-if="router.currentRoute.value.path.startsWith('/admin')">
<label class="label is-small">{{ t('pagination.show_only_todays_pending') }}</label>
<div class="field">
<input id="today-pending" type="checkbox" class="switch is-rounded" @change="(event) => { onOnlyTodayFilterChange({ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } }, false); onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } }); }" :class="{ 'is-link': onlyTodayFilter !== '*' && orderIdFilter === 'is null' }" :checked="onlyTodayFilter !== '*' && orderIdFilter === 'is null'" />
<label for="today-pending"></label>
<!-- Admin: pending + only today combined switch -->
<div class="column is-narrow" v-if="isAdminRoute">
<label class="label is-small">{{ t("pagination.show_only_todays_pending") }}</label>
<div class="field">
<input
id="today-pending"
type="checkbox"
class="switch is-rounded"
@change="
(event) => {
onOnlyTodayFilterChange(
{ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } },
false
);
onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } });
}
"
:class="{ 'is-link': onlyTodayFilter !== '*' && orderIdFilter === 'is null' }"
:checked="onlyTodayFilter !== '*' && orderIdFilter === 'is null'"
/>
<label for="today-pending"></label>
</div>
</div>
</div>
</template>
<template #default>
<OrderBookingsTable :objects="list" />
</template>
</TableLabeledPagination>
</template>
<template #default>
<OrderBookingsTable :objects="list" />
</template>
</TableLabeledPagination>
</div>
</template>
<style scoped>
.order-bookings-pagination__filter .select,
.order-bookings-pagination__filter select {
width: 100%;
}
.order-bookings-pagination__actions-grid {
align-items: flex-end;
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.order-bookings-pagination__today-action .field {
min-height: 2.5rem;
}
.order-bookings-pagination__new-booking-action .button {
min-width: 10rem;
}
@media screen and (max-width: 768px) {
.order-bookings-pagination__table :deep([data-testid="table-labeled-pagination-filters"] > .columns) {
align-items: flex-start;
column-gap: 0.75rem;
margin-left: 0;
margin-right: 0;
row-gap: 0.85rem;
}
.order-bookings-pagination__table :deep([data-testid="table-labeled-pagination-filters"] > .columns > .column),
.order-bookings-pagination__filter,
.order-bookings-pagination__actions {
margin: 0;
padding: 0;
}
.order-bookings-pagination__filter {
flex: 1 1 calc(50% - 0.375rem);
max-width: calc(50% - 0.375rem);
}
.order-bookings-pagination__table
:deep([data-testid="table-labeled-pagination-filters"] > .columns > .columns.is-multiline) {
flex: 1 1 100%;
margin: 0;
max-width: 100%;
padding: 0;
width: 100%;
}
.order-bookings-pagination__actions {
flex: 1 1 100%;
max-width: 100%;
width: 100%;
}
.order-bookings-pagination__actions-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
justify-content: stretch;
}
.order-bookings-pagination__new-booking-action .button {
min-width: 0;
width: 100%;
}
.order-bookings-pagination__desktop-spacer {
display: none;
}
}
</style>
@@ -0,0 +1,70 @@
<!--
ErrorBanner.vue reusable self-serve error notification bar.
Props:
message error text to display (blank hidden)
type Bulma tint: is-danger | is-warning (default is-danger)
iconLeft Font Awesome icon name
:loading whether retry button is in loading state
:showRetry whether to show the retry button
Emit:
retry fired when retry button is clicked
-->
<script setup lang="ts">
interface Props {
message: string | null;
type?: "is-danger" | "is-warning";
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
retryTestId?: string | null;
}
withDefaults(
defineProps<{
message: string | null;
type?: "is-danger" | "is-warning";
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
retryTestId?: string | null;
}>(),
{
type: "is-danger",
iconLeft: "sync-alt",
loading: false,
showRetry: true,
retryTestId: null,
}
);
const emit = defineEmits<{
retry: [];
}>();
</script>
<template>
<b-message v-if="message" :type="type" has-icon :closable="false" class="self-serve-error-banner">
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
<span class="mr-3">{{ message }}</span>
<b-button
v-if="showRetry"
size="is-small"
:type="`${type} is-light`"
icon-pack="fas"
:icon-left="iconLeft"
:loading="loading"
:data-testid="retryTestId || undefined"
@click="emit('retry')"
>
{{ $t("common.try_again") }}
</b-button>
</div>
</b-message>
</template>
<style scoped>
.self-serve-error-banner {
margin-bottom: 1rem;
}
</style>
@@ -0,0 +1,59 @@
<!--
LaneSelectionSection.vue bane-valg radio buttons and lane status display.
Props:
lanes array of lane objects from the department
selectedLaneId currently selected lane id
washType "Manual" | "Machine"
departmentName human-readable department name
isLaneAvailableFn function(lane) => boolean
isMachineAvailableFn function(laneId) => boolean
Emit:
update:selectedLaneId new lane id selected
update:washType "Manual" | "Machine"
-->
<script setup lang="ts">
import SelfServeLaneStep from "@/components/displays/selfServe/SelfServeLaneStep.vue";
interface Props {
lanes: any[];
selectedLaneId: number | string | null;
washType: string;
departmentName: string | null;
isLaneAvailableFn: (lane: any) => boolean;
isMachineAvailableFn: (laneId: number | string | null) => boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:selectedLaneId": [id: number];
"update:washType": [type: string];
}>();
function handleLaneUpdate(id: number) {
emit("update:selectedLaneId", id);
}
function handleWashTypeUpdate(type: string) {
emit("update:washType", type);
}
</script>
<template>
<SelfServeLaneStep
:lanes="lanes"
:selected-lane-id="selectedLaneId"
:wash-type="washType"
:department-name="departmentName"
:is-lane-available="isLaneAvailableFn"
:is-machine-available="isMachineAvailableFn"
@update:selected-lane-id="handleLaneUpdate"
@update:wash-type="handleWashTypeUpdate"
/>
</template>
<style scoped>
/* Lane selection styling is inherited from SelfServeLaneStep */
</style>
@@ -2,13 +2,16 @@
import { computed } from "vue";
import { BButton, BField } from "buefy";
const props = withDefaults(defineProps<{
steps: Array<any>;
currentStep: number;
showActions?: boolean;
}>(), {
showActions: true,
});
const props = withDefaults(
defineProps<{
steps: Array<any>;
currentStep: number;
showActions?: boolean;
}>(),
{
showActions: true,
}
);
const emit = defineEmits<{
(e: "update:currentStep", value: number): void;
@@ -45,9 +48,6 @@ const goNext = () => {
<div class="guided-instructions" data-testid="self-serve-guided-instructions">
<div class="guided-instructions__header">
<h3 class="title is-6">{{ $t("self_wash.follow_steps") }}</h3>
<span class="guided-instructions__counter">
{{ normalizedStepIndex + 1 }} / {{ steps.length }}
</span>
</div>
<section
@@ -55,8 +55,18 @@ const goNext = () => {
class="guided-instructions__step"
:data-testid="`self-serve-guided-step-${normalizedStepIndex}`"
>
<b-field :label="`${normalizedStepIndex + 1}. ${activeStep.title}`">
<div class="guided-instructions__content">
<b-field>
<template #label>
<span class="guided-instructions__step-label" data-testid="self-serve-guided-step-label">
<span class="guided-instructions__step-title" data-testid="self-serve-guided-step-title">
{{ normalizedStepIndex + 1 }}. {{ activeStep.title }}
</span>
<span class="guided-instructions__counter" data-testid="self-serve-guided-counter">
{{ normalizedStepIndex + 1 }}/{{ steps.length }}
</span>
</span>
</template>
<div class="guided-instructions__content" data-testid="self-serve-guided-content">
<p v-if="activeStep.content" class="guided-instructions__paragraph">{{ activeStep.content }}</p>
<template v-for="(brush, brushIndex) in activeStep.brushes || []" :key="brushIndex">
<p class="guided-instructions__paragraph">
@@ -105,14 +115,28 @@ const goNext = () => {
.guided-instructions__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.guided-instructions__step-label {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
width: 100%;
}
.guided-instructions__step-title {
min-width: 0;
}
.guided-instructions__counter {
flex: 0 0 auto;
color: #566074;
font-size: 0.9rem;
font-weight: 600;
margin-left: auto;
text-align: right;
white-space: nowrap;
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BField, BIcon, BMessage, BRadioButton } from "buefy";
import { BIcon, BMessage, BRadioButton } from "buefy";
defineProps<{
lanes: Array<any>;
@@ -19,14 +19,23 @@ const emit = defineEmits<{
<template>
<div data-testid="self-serve-lane-step">
<h1 class="title has-text-centered">{{ $t("self_wash.start_wash") }}</h1>
<b-field :label="$t('self_wash.wash_lane')">
<div class="columns is-multiline is-mobile" :class="{ 'is-centered': lanes.length >= 2 }">
<section class="self-serve-choice-group" data-testid="self-serve-lane-choice-group">
<h2 class="self-serve-choice-label">{{ $t("self_wash.wash_lane") }}</h2>
<div
class="self-serve-choice-grid"
:class="{ 'self-serve-choice-grid--centered': lanes.length >= 2 }"
data-testid="self-serve-lane-options"
>
<template v-for="lane in lanes" :key="lane.id">
<div class="column is-half-mobile is-one-third-tablet is-one-quarter-desktop">
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="selectedLaneId"
:native-value="lane.id"
type="is-link"
:disabled="!isLaneAvailable(lane)"
:title="!isLaneAvailable(lane) ? $t('self_wash.lane_unavailable') : null"
:aria-label="!isLaneAvailable(lane) ? $t('self_wash.lane_unavailable') : null"
:data-testid="`self-serve-lane-option-${lane.id}`"
@update:model-value="emit('update:selectedLaneId', lane.id)"
@input="emit('update:selectedLaneId', lane.id)"
@@ -36,7 +45,7 @@ const emit = defineEmits<{
<span v-if="!isLaneAvailable(lane)">
<small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.occupied") }}
{{ $t("self_wash.lane_unavailable") }}
</small>
</span>
<span v-else>
@@ -49,18 +58,18 @@ const emit = defineEmits<{
</b-radio-button>
</div>
</template>
<div v-if="lanes.length === 0" class="column is-12">
<b-message type="is-warning" aria-close-label="Luk besked">
Ingen vaskebaner tilgaengelige for selvvask i <b>{{ departmentName || "denne" }}</b> afdeling.
</b-message>
</div>
</div>
</b-field>
<b-message v-if="lanes.length === 0" type="is-warning" aria-close-label="Luk besked">
Ingen vaskebaner tilgængelige for selvvask i <b>{{ departmentName || "denne" }}</b> afdeling.
</b-message>
</section>
<b-field label="Maskine eller manuel vask">
<div class="columns is-mobile is-centered is-multiline">
<div class="column is-half-mobile is-one-third-tablet is-one-quarter-desktop">
<section class="self-serve-choice-group" data-testid="self-serve-wash-type-group">
<h2 class="self-serve-choice-label">Maskine eller manuel vask</h2>
<div class="self-serve-choice-grid self-serve-choice-grid--centered" data-testid="self-serve-wash-type-options">
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="washType"
native-value="Manual"
type="is-link"
@@ -72,13 +81,14 @@ const emit = defineEmits<{
<span>Manuel<br /></span>
<small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
Tilgaengelig
Tilgængelig
</small>
</span>
</b-radio-button>
</div>
<div class="column is-half-mobile is-one-third-tablet is-one-quarter-desktop">
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="washType"
native-value="Machine"
type="is-link"
@@ -105,6 +115,77 @@ const emit = defineEmits<{
</b-radio-button>
</div>
</div>
</b-field>
</section>
</div>
</template>
<style scoped>
.self-serve-choice-group {
margin-bottom: 1.25rem;
}
.self-serve-choice-label {
color: #303440;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.2;
margin: 0 0 0.75rem;
}
.self-serve-choice-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 0.75rem;
width: 100%;
}
.self-serve-choice-grid--centered {
justify-content: center;
}
.self-serve-choice-grid__item {
min-width: 0;
}
.self-serve-choice-card {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button) {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button),
.self-serve-choice-card span,
.self-serve-choice-card :deep(.button span) {
min-width: 0;
}
.self-serve-choice-card small,
.self-serve-choice-card :deep(.button small) {
display: inline-flex;
align-items: center;
line-height: 1.25;
}
@media screen and (min-width: 769px) {
.self-serve-choice-grid {
grid-template-columns: repeat(auto-fit, minmax(11rem, 15rem));
}
}
</style>
@@ -38,7 +38,7 @@ const emit = defineEmits<{
<div class="card-footer-item">
<button
class="button is-fullwidth"
:class="[answers[question.id] === false ? 'is-danger is-light' : 'is-light']"
:class="[answers[question.id] === false ? 'is-danger' : 'is-light']"
:data-testid="`self-serve-question-${question.id}-no`"
@click="emit('answer-question', question.id, false)"
>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BButton, BIcon, BTooltip } from "buefy";
import { BButton, BIcon } from "buefy";
import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue";
defineProps<{
@@ -7,18 +7,9 @@ defineProps<{
visibleQuestions: Array<any>;
answers: Record<number, boolean | undefined>;
editAnswers: boolean;
showDebug: boolean;
conditions: Array<any>;
rules: Array<any>;
evaluateCondition: (conditionId: number) => boolean;
evaluateRule: (rule: any, visited?: Set<number>) => boolean;
isQuestionVisible: (question: any) => boolean;
getConditionById: (conditionId: number) => any;
getRuleTypeLabel: (type: string) => string;
}>();
const emit = defineEmits<{
(e: "toggle-debug"): void;
(e: "toggle-edit"): void;
(e: "answer-question", questionId: number, value: boolean): void;
}>();
@@ -35,27 +26,8 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
<p>{{ $t("self_wash.loading_data") }}</p>
</div>
<div v-else>
<div
v-if="isLoading"
class="notification is-info is-light py-2 px-3 mb-4"
data-testid="self-serve-questions-inline-loading"
>
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-small" />
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<div class="is-flex is-justify-content-center is-align-items-center mb-4">
<h1 class="title mb-0">{{ $t("self_wash.answer_questions") }}</h1>
<b-button
size="is-small"
icon-left="bug"
type="is-ghost"
class="ml-2"
data-testid="self-serve-toggle-debug"
@click="emit('toggle-debug')"
>
Debug
</b-button>
<b-button
v-if="editAnswers"
size="is-small"
@@ -69,75 +41,16 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
</b-button>
</div>
<div v-if="showDebug" class="box mb-4 has-background-light" data-testid="self-serve-debug-panel">
<h5 class="subtitle is-5">Debug: Betingelser</h5>
<div class="tags">
<b-tooltip
v-for="condition in conditions"
:key="condition.id"
position="is-top"
multilined
type="is-dark"
>
<template #content>
<div class="has-text-left">
<p v-if="condition.description" class="mb-2"><i>{{ condition.description }}</i></p>
<p class="is-size-7 has-text-weight-bold mb-1">Regler:</p>
<div
v-for="rule in rules.filter(entry => parseInt(entry.condition_id as any) === parseInt(condition.id))"
:key="rule.id"
class="is-size-7"
>
<span class="icon is-small">
<i :class="evaluateRule(rule, new Set([parseInt(condition.id)])) ? 'fas fa-check has-text-success' : 'fas fa-times has-text-danger'" />
</span>
<span class="ml-1">[{{ getRuleTypeLabel(rule.type) }}] {{ rule.name }}</span>
</div>
</div>
</template>
<span class="tag" :class="evaluateCondition(condition.id) ? 'is-success' : 'is-light'">
<span class="icon is-small mr-1">
<i :class="evaluateCondition(condition.id) ? 'fas fa-check-circle' : 'fas fa-times-circle'" />
</span>
{{ condition.name }}
</span>
</b-tooltip>
<div class="self-serve-questions-status-slot" aria-live="polite">
<div
class="notification is-info is-light py-2 px-3 mb-0"
:class="{ 'is-invisible': !isLoading }"
data-testid="self-serve-questions-inline-loading"
:aria-hidden="!isLoading"
>
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-small" />
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<hr />
<h5 class="subtitle is-5">Debug: Alle mulige sporgsmal (synlighed)</h5>
<ul>
<li v-for="question in visibleQuestions" :key="question.id" class="is-size-7">
<span class="icon is-small">
<i :class="isQuestionVisible(question) ? 'fas fa-eye has-text-success' : 'fas fa-eye-slash has-text-grey-light'" />
</span>
{{ question.question }}
<b-tooltip v-if="question.condition_id" position="is-top" multilined type="is-dark">
<template #content>
<div v-if="getConditionById(question.condition_id)" class="has-text-left">
<p v-if="getConditionById(question.condition_id).description" class="mb-2">
<i>{{ getConditionById(question.condition_id).description }}</i>
</p>
<p class="is-size-7 has-text-weight-bold mb-1">Regler:</p>
<div
v-for="rule in rules.filter(entry => parseInt(entry.condition_id as any) === parseInt(question.condition_id))"
:key="rule.id"
class="is-size-7"
>
<span class="icon is-small">
<i :class="evaluateRule(rule, new Set([parseInt(question.condition_id)])) ? 'fas fa-check has-text-success' : 'fas fa-times has-text-danger'" />
</span>
<span class="ml-1">[{{ getRuleTypeLabel(rule.type) }}] {{ rule.name }}</span>
</div>
</div>
</template>
<span class="has-text-grey is-clickable">
(Hvis: {{ getConditionById(question.condition_id)?.name || question.condition_id }})
</span>
</b-tooltip>
</li>
</ul>
</div>
<SelfServeQuestionCards
@@ -148,3 +61,20 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
</div>
</div>
</template>
<style scoped>
.self-serve-questions-status-slot {
align-items: center;
display: flex;
justify-content: center;
min-height: 2.75rem;
}
.self-serve-questions-status-slot .notification {
width: 100%;
}
.self-serve-questions-status-slot .notification.is-invisible {
visibility: hidden;
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { BCheckbox, BField, BIcon } from "buefy";
import { getSelfServeTaskDynamicImagePresentation } from "@/services/selfServeDynamicImage.js";
const props = withDefaults(defineProps<{
tasks: Array<any>;
@@ -20,16 +21,6 @@ const isImageAttachment = (attachment: any) => (
&& attachment.content.other.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)
);
const hasTaskDescription = (task: any) => {
const description = task?.description;
if (typeof description !== "string") {
return !!description;
}
const trimmedDescription = description.trim();
return trimmedDescription.length > 0 && trimmedDescription !== "-";
};
const getImageAttachments = (task: any) => {
if (!Array.isArray(task?.attachments)) {
return [];
@@ -74,20 +65,24 @@ const getProgramWheelSelection = (task: any) => {
if (!taskUsesProgramPicker(task)) {
return null;
}
const rawValue = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
if (rawValue === null || rawValue === undefined || rawValue === "") {
return null;
}
const parsed = Number.parseInt(String(rawValue), 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
return getSelfServeTaskDynamicImagePresentation(task).thumbPosition;
};
const formatTaskTitle = (task: any) => {
const title = String(task?.task || "");
const programWheelSelection = getProgramWheelSelection(task);
return programWheelSelection === null ? title : `#${programWheelSelection} ${title}`;
if (programWheelSelection === null || /\bprogram\s*#\d+\b/i.test(title)) {
return title;
}
const programTitle = title.match(/^(.*?\bprogram)(\b.*)$/i);
if (programTitle) {
return `${programTitle[1]} #${programWheelSelection}${programTitle[2]}`;
}
return `${title} #${programWheelSelection}`;
};
</script>
@@ -122,13 +117,11 @@ const formatTaskTitle = (task: any) => {
type="is-success"
:data-testid="`self-serve-task-${task.id}-toggle`"
@update:model-value="emit('toggle-task', task.id, $event)"
@input="emit('toggle-task', task.id, $event)"
/>
</b-field>
</div>
<div class="self-serve-task-image-overlay-text">
<p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p>
<p v-if="hasTaskDescription(task)" class="self-serve-task-description">{{ task.description }}</p>
</div>
</div>
</div>
@@ -151,22 +144,21 @@ const formatTaskTitle = (task: any) => {
</div>
</div>
</template>
<div v-else class="columns is-mobile is-vcentered">
<div v-if="props.showCheckboxes" class="column is-narrow">
<div v-else class="self-serve-task-row">
<div v-if="props.showCheckboxes" class="self-serve-task-checkbox">
<b-field>
<b-checkbox
class="mr-0 pr-0"
size="is-large"
:model-value="completedTasks[task.id] === true"
type="is-success"
:data-testid="`self-serve-task-${task.id}-toggle`"
@update:model-value="emit('toggle-task', task.id, $event)"
@input="emit('toggle-task', task.id, $event)"
/>
</b-field>
</div>
<div class="column">
<div class="self-serve-task-content">
<p><strong>{{ formatTaskTitle(task) }}</strong></p>
<p v-if="hasTaskDescription(task)" class="is-size-7">{{ task.description }}</p>
<div v-if="getVisibleServices(task).length > 0" class="tags mt-2">
<span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span>
</div>
@@ -234,7 +226,7 @@ const formatTaskTitle = (task: any) => {
padding: 0.65rem 0.75rem;
display: flex;
align-items: flex-start;
gap: 0.6rem;
gap: 0.35rem;
pointer-events: auto;
}
@@ -242,12 +234,30 @@ const formatTaskTitle = (task: any) => {
margin-bottom: 0;
}
.self-serve-task-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.self-serve-task-checkbox {
flex: 0 0 auto;
}
.self-serve-task-checkbox :deep(.field) {
margin-bottom: 0;
}
.self-serve-task-content {
flex: 1 1 auto;
min-width: 0;
}
.self-serve-task-content p {
margin-bottom: 0;
}
.self-serve-task-title {
margin: 0;
}
.self-serve-task-description {
margin: 0.25rem 0 0;
font-size: 0.85rem;
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { BIcon, BSkeleton } from "buefy";
import { computed, ref, watch } from "vue";
import { BIcon } from "buefy";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
const props = defineProps<{
@@ -19,10 +19,21 @@ const emit = defineEmits<{
}>();
const isDynamicImageLoading = ref(false);
const failedDynamicImageUrl = ref<string | null>(null);
const hasRenderableDynamicImageUrl = computed(
() => !!props.dynamicImageUrl && failedDynamicImageUrl.value !== props.dynamicImageUrl
);
const showDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value && !isDynamicImageLoading.value);
const shouldRenderDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value);
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
}, { immediate: true });
watch(
() => props.dynamicImageUrl,
(dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
failedDynamicImageUrl.value = null;
},
{ immediate: true }
);
const emitToggleTask = (taskId: number, value: boolean) => {
emit("toggle-task", taskId, value);
@@ -38,6 +49,7 @@ const onDynamicImageLoad = () => {
const onDynamicImageError = () => {
isDynamicImageLoading.value = false;
failedDynamicImageUrl.value = props.dynamicImageUrl;
emit("clear-dynamic-image");
};
</script>
@@ -58,25 +70,37 @@ const onDynamicImageError = () => {
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<img
v-if="dynamicImageUrl && isDynamicImageLoading"
:key="`${dynamicImageUrl}:preload`"
:src="dynamicImageUrl"
alt=""
data-testid="self-serve-dynamic-image-preload"
class="self-serve-dynamic-image-preload"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
<div
v-if="dynamicImageUrl"
v-if="shouldRenderDynamicImageFrame"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isDynamicImageLoading }"
data-testid="self-serve-dynamic-image-frame"
>
<b-skeleton
<div
v-if="isDynamicImageLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
>
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-large" />
<span class="is-sr-only">{{ $t("self_wash.loading_data") }}</span>
</div>
<img
v-if="showDynamicImageFrame"
:key="dynamicImageUrl"
:src="dynamicImageUrl"
alt="Machine status"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
:class="{ 'is-loading': isDynamicImageLoading }"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
@@ -98,36 +122,45 @@ const onDynamicImageError = () => {
<style scoped>
.self-serve-dynamic-image-frame {
position: relative;
width: 100%;
max-width: 100%;
aspect-ratio: 16 / 9;
margin-left: auto;
margin-right: auto;
}
.self-serve-dynamic-image-frame.is-loading {
width: 100%;
max-width: 640px;
min-height: 180px;
aspect-ratio: 16 / 9;
overflow: hidden;
}
.self-serve-dynamic-image-skeleton {
align-items: center;
aspect-ratio: 16 / 9;
background: #edf2f7;
color: #112f5f;
display: flex;
height: 100%;
justify-content: center;
width: 100%;
}
.self-serve-dynamic-image-preload {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 4px;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
}
.self-serve-dynamic-image {
max-width: 100%;
height: auto;
width: 100%;
height: 100%;
border-radius: 4px;
display: block;
margin-left: auto;
margin-right: auto;
object-fit: contain;
transition: opacity 120ms ease;
}
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
@@ -16,12 +16,16 @@ const props = defineProps<{
availableProductIds: number[];
vehicleTypes: Array<any>;
vehicleStepError?: string | null;
vehicleStepGuidance?: string | null;
}>();
const emit = defineEmits<{
(e: "update:customerNumber", value: string): void;
(e: "update:customer-number", value: string): void;
(e: "update:registrationNumber", value: string): void;
(e: "update:registration-number", value: string): void;
(e: "select-vehicle-type", selection: any): void;
(e: "selected", selection: any): void;
}>();
const extractRegistrationValue = (value: unknown): string => {
@@ -48,6 +52,19 @@ const normalizeRegistration = (value: unknown) => extractRegistrationValue(value
const typedRegistration = ref(normalizeRegistration(props.registrationNumber));
const filteredRegistrationOptions = computed(() => {
const query = normalizeRegistration(typedRegistration.value);
const options = props.registrationOptions || [];
if (!query) {
return options;
}
return options.filter((option) => {
const normalizedOption = normalizeRegistration(option);
return normalizedOption !== query && normalizedOption.includes(query);
});
});
watch(() => props.registrationNumber, (newValue) => {
typedRegistration.value = normalizeRegistration(newValue);
});
@@ -56,6 +73,18 @@ const emitRegistration = (value: unknown) => {
const normalized = normalizeRegistration(value);
typedRegistration.value = normalized;
emit("update:registrationNumber", normalized);
emit("update:registration-number", normalized);
};
const emitCustomerNumber = (value: unknown) => {
const normalized = String(value || "");
emit("update:customerNumber", normalized);
emit("update:customer-number", normalized);
};
const emitVehicleTypeSelection = (selection: any) => {
emit("select-vehicle-type", selection);
emit("selected", selection);
};
</script>
@@ -67,38 +96,41 @@ const emitRegistration = (value: unknown) => {
:model-value="customerNumber || ''"
data-testid="self-serve-customer-number"
:placeholder="$t('self_wash.enter_customer_number')"
@input="emit('update:customerNumber', String($event || ''))"
@input="emitCustomerNumber($event)"
@update:modelValue="emitCustomerNumber($event)"
/>
</b-field>
<b-field :label="$t('self_wash.registration_number')">
<b-autocomplete
:model-value="typedRegistration"
data-testid="self-serve-registration"
:data="registrationOptions"
:placeholder="$t('self_wash.enter_registration_number')"
:debounce="300"
:min-length="1"
:filter="(option, query) => option.toLowerCase().includes(query.toLowerCase())"
icon-pack="fas"
icon="car-side"
clearable
:loading="registrationOptions.length === 0 && isCustomerVehiclesLoading"
:selectable-header="true"
@select="emitRegistration($event)"
@typing="emitRegistration($event)"
@update:modelValue="emitRegistration($event)"
@select-header="emitRegistration(typedRegistration)"
>
<template #header>
<b-field :label="$t('self_wash.registration_number')" class="self-serve-registration-field">
<div class="self-serve-registration-control">
<b-autocomplete
:model-value="typedRegistration"
data-testid="self-serve-registration"
:data="filteredRegistrationOptions"
:placeholder="$t('self_wash.enter_registration_number')"
:debounce="300"
:min-length="1"
icon-pack="fas"
icon="car-side"
clearable
:loading="filteredRegistrationOptions.length === 0 && isCustomerVehiclesLoading"
@select="emitRegistration($event)"
@typing="emitRegistration($event)"
@update:modelValue="emitRegistration($event)"
>
</b-autocomplete>
<p
v-if="typedRegistration"
class="help self-serve-registration-guidance"
data-testid="self-serve-registration-guidance"
>
<template v-if="hasMatchingVehicle">
{{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }}
</template>
<template v-else>
{{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }}
</template>
</template>
<template #empty>{{ $t("self_wash.no_vehicles_found") }}</template>
</b-autocomplete>
</p>
</div>
</b-field>
<b-message
v-if="props.vehicleStepError"
@@ -109,12 +141,21 @@ const emitRegistration = (value: unknown) => {
>
{{ props.vehicleStepError }}
</b-message>
<b-message
v-else-if="props.vehicleStepGuidance"
type="is-info"
has-icon
:closable="false"
data-testid="self-serve-vehicle-step-guidance"
>
{{ props.vehicleStepGuidance }}
</b-message>
<b-field :label="$t('self_wash.select_your_vehicle')">
<template v-if="availableProductIds.length > 0">
<SelfServeVehicleTypeSelector
:selectedVehicleTypeId="selectedVehicleTypeId"
:restrictToProductIds="availableProductIds"
@selected="emit('select-vehicle-type', $event)"
@selected="emitVehicleTypeSelection"
/>
</template>
<template v-else-if="vehicleTypes.length === 0">
@@ -130,3 +171,34 @@ const emitRegistration = (value: unknown) => {
</b-field>
</div>
</template>
<style scoped>
.self-serve-registration-control {
display: block;
width: 100%;
min-width: 0;
}
.self-serve-registration-guidance {
display: block;
margin-top: 0.5rem;
line-height: 1.35;
white-space: normal;
}
.self-serve-registration-field :deep(.dropdown),
.self-serve-registration-field :deep(.autocomplete) {
width: 100%;
}
.self-serve-registration-field :deep(.dropdown-menu) {
left: 0;
right: 0;
width: 100%;
max-width: 100%;
}
.self-serve-registration-field :deep(.dropdown-item) {
white-space: normal;
}
</style>
@@ -29,7 +29,7 @@ const getLoadingVehicleTypes = (count: number): VehicleTypeTemplate[] => {
for (let index = 0; index < count; index += 1) {
loadingTypes.push({
id: index,
name: "Indlaeser...",
name: "Indlæser...",
price: 0,
loading: true,
});
@@ -146,7 +146,7 @@ watch(() => props.selectedVehicleTypeId, (newId) => {
</template>
</template>
<template v-else>
Vaelg venligst din koretojstype ved at klikke pa ikonet ovenfor.
Vælg en køretøjstype ved at trykke et af ikonerne ovenfor.
</template>
</p>
</div>
@@ -0,0 +1,81 @@
<!--
VehicleInputSection.vue customer number, license plate, and vehicle type inputs.
Props:
showCustomerNumberInput whether to render the customer number field
customerNumber current customer number string
registrationNumber current license plate string
registrationOptions autocomplete options for existing vehicles
selectedVehicleTypeId currently selected vehicle type id
selectedVehicleName human-readable name of selected vehicle type
availableProductIds allowed product ids for the department
vehicleTypes list of vehicle type options
Emit:
update:customerNumber new customer number string
update:registrationNumber new license plate string
selectVehicleType selected VehicleTypeTemplate object
-->
<script setup lang="ts">
import { computed } from "vue";
import SelfServeVehicleStep from "@/components/displays/selfServe/SelfServeVehicleStep.vue";
interface Props {
showCustomerNumberInput: boolean;
customerNumber: string | null;
registrationNumber: string | null;
registrationOptions: string[];
selectedVehicleTypeId: number | null;
selectedVehicleName: string | null;
availableProductIds: (number | string)[];
vehicleTypes: any[];
}
defineProps<Props>();
const emit = defineEmits<{
"update:customerNumber": [value: string];
"update:registrationNumber": [value: string];
selectVehicleType: [type: { id: number }];
}>();
function handleCustomerNumberUpdate(value: string) {
emit("update:customerNumber", value);
}
function handleRegistrationNumberUpdate(value: string) {
emit("update:registrationNumber", value);
}
function handleSelectVehicleType(selection: any) {
emit("selectVehicleType", selection);
}
</script>
<template>
<SelfServeVehicleStep
:customer-number="customerNumber"
:show-customer-number-input="showCustomerNumberInput"
:registration-number="registrationNumber"
:registration-options="registrationOptions"
:is-customer-vehicles-loading="false"
:has-matching-vehicle="false"
:selected-vehicle-type-id="selectedVehicleTypeId"
:selected-vehicle-name="selectedVehicleName"
:selected-vehicle-description="null"
:available-product-ids="availableProductIds"
:vehicle-types="vehicleTypes"
:vehicle-step-error="null"
:vehicle-step-guidance="null"
@update:customer-number="handleCustomerNumberUpdate"
@update:customerNumber="handleCustomerNumberUpdate"
@update:registration-number="handleRegistrationNumberUpdate"
@update:registrationNumber="handleRegistrationNumberUpdate"
@select-vehicle-type="handleSelectVehicleType"
@selected="handleSelectVehicleType"
/>
</template>
<style scoped>
/* Vehicle input section styling is inherited from SelfServeVehicleStep */
</style>
@@ -0,0 +1,260 @@
<!--
WashProgressCard.vue timer, guided instructions, and bottom actions during active wash.
Renders when a wash session is in progress (WASH_IN_PROGRESS step).
Props:
currentGuidedWashStep index into guidedWashFlowSteps
guidedWashFlowSteps array of instruction step objects
formattedElapsed human-readable elapsed time string
isCompletingWash whether the wash is finishing right now
openingPropertyAccessGate loading state for access gate button
openingPropertyExitGate loading state for exit gate button
Emit:
update:currentGuidedWashStep new step index
goPreviousGuidedWashStep navigate to previous instruction
goNextGuidedWashStep navigate to next instruction
completeWash finish the wash session
openPropertyAccessGate open the property access gate (laneId)
openPropertyExitGate open the property exit gate (laneId)
requestAssistance call for assistance
-->
<script setup lang="ts">
import { computed } from "vue";
import { BButton } from "buefy";
interface Props {
currentGuidedWashStep: number;
guidedWashFlowSteps: any[];
formattedElapsed: string;
isCompletingWash: boolean;
openingPropertyAccessGate: boolean;
openingPropertyExitGate: boolean;
showProgressActions?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showProgressActions: true,
});
const emit = defineEmits<{
"update:currentGuidedWashStep": [step: number];
goPreviousGuidedWashStep: [];
goNextGuidedWashStep: [];
completeWash: [];
openPropertyAccessGate: [];
openPropertyExitGate: [];
requestAssistance: [];
}>();
const isLastGuidedWashStep = computed(
() =>
Array.isArray(props.guidedWashFlowSteps) &&
props.guidedWashFlowSteps.length > 0 &&
props.currentGuidedWashStep >= props.guidedWashFlowSteps.length - 1
);
function handleGoPrevious() {
emit("goPreviousGuidedWashStep");
}
function handleGoNext() {
emit("goNextGuidedWashStep");
}
function handleComplete() {
emit("completeWash");
}
function handleOpenAccessGate() {
emit("openPropertyAccessGate");
}
function handleOpenExitGate() {
emit("openPropertyExitGate");
}
function handleRequestAssistance() {
emit("requestAssistance");
}
</script>
<template>
<div data-testid="self-serve-wash-progress">
<!-- Finishing screen -->
<div
v-if="isCompletingWash && showProgressActions"
class="notification is-info is-light self-serve-finishing-screen"
data-testid="self-serve-finishing-wash"
>
<p class="title is-5 mb-0">{{ $t("self_wash.finishing_wash_exit_opening") }}</p>
</div>
<!-- Guided wash instructions (stepped walkthrough) -->
<template v-if="!isCompletingWash">
<!--
Accept a callback slot for the guided-instructions component so the
parent can plug in SelfServeGuidedInstructions or its own implementation.
-->
<slot
v-if="showProgressActions"
name="guided-instructions"
:current-step="currentGuidedWashStep"
:steps="guidedWashFlowSteps"
:is-last-step="isLastGuidedWashStep"
@update:current-step="emit('update:currentGuidedWashStep', $event)"
>
<!-- Default: render nothing (parent provides via slot) -->
</slot>
<!-- Bottom action bar while wash is in progress -->
<div
v-show="showProgressActions"
class="self-serve-bottom-actions"
data-testid="self-serve-bottom-actions"
>
<!-- Help / assistance -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-session-bottom-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-right="info-circle"
data-testid="self-serve-nav-help"
@click.prevent="handleRequestAssistance"
>
{{ $t("self_wash.assistance") }}
</b-button>
</div>
<!-- Property gate controls -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-property-gate-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="sign-in-alt"
data-testid="self-serve-nav-open-property-access-gate"
:loading="openingPropertyAccessGate"
:disabled="openingPropertyAccessGate"
@click.prevent="handleOpenAccessGate"
>
{{ $t("self_wash.open_property_access_gate") }}
</b-button>
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="sign-out-alt"
data-testid="self-serve-nav-open-property-exit-gate"
:loading="openingPropertyExitGate"
:disabled="openingPropertyExitGate"
@click.prevent="handleOpenExitGate"
>
{{ $t("self_wash.open_property_exit_gate") }}
</b-button>
</div>
<!-- Guided step navigation -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-guided-bottom-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="arrow-left"
data-testid="self-serve-guided-prev"
:disabled="currentGuidedWashStep === 0"
@click.prevent="handleGoPrevious"
>
{{ $t("common.previous") }}
</b-button>
<b-button
v-if="!isLastGuidedWashStep"
class="self-serve-bottom-actions__button"
type="is-link"
icon-pack="fas"
icon-right="arrow-right"
data-testid="self-serve-guided-next"
@click.prevent="handleGoNext"
>
{{ $t("common.next") }}
</b-button>
<b-button
v-else
class="self-serve-bottom-actions__button"
type="is-link"
icon-pack="fas"
icon-right="check"
data-testid="self-serve-nav-complete"
:loading="isCompletingWash"
:disabled="isCompletingWash"
@click.prevent="handleComplete"
>
{{ $t("common.done") }}
</b-button>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.self-serve-finishing-screen {
margin: 0 auto;
max-width: 32rem;
text-align: left;
}
.self-serve-bottom-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
justify-content: center;
margin: 0.75rem auto 0;
max-width: 30rem;
}
.self-serve-bottom-actions__row {
display: flex;
gap: 0.75rem;
justify-content: center;
width: 100%;
}
.self-serve-bottom-actions__button {
flex: 1 1 0;
font-weight: 700;
line-height: 1.15;
max-width: 14rem;
min-height: 2.75rem;
min-width: 0;
white-space: normal;
}
@media screen and (max-width: 768px) {
.self-serve-bottom-actions {
background: #ffffff;
border-top: 1px solid #dfe5f0;
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -0.25rem 0.75rem rgba(17, 47, 95, 0.08);
left: 0;
margin: 0;
max-width: none;
padding: 0.5rem 0.75rem;
position: fixed;
right: 0;
z-index: 41;
}
.self-serve-bottom-actions__button {
font-size: 0.88rem;
max-width: none;
min-height: 2.65rem;
padding-left: 0.55rem;
padding-right: 0.55rem;
}
}
</style>
@@ -0,0 +1,159 @@
<!--
WashTypeSelector.vue manual vs machine wash toggle.
Renders two radio-style cards: "Manual" (always available) and
"Machine" (available when the lane supports it).
Props:
modelValue current wash type ("Manual" | "Machine")
isMachineAvailable whether machine wash is available for this lane
Emit:
update:modelValue new wash type string
-->
<script setup lang="ts">
import { BRadioButton } from "buefy";
interface Props {
modelValue: string;
isMachineAvailable: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:modelValue": [type: string];
}>();
</script>
<template>
<section class="self-serve-choice-group" data-testid="self-serve-wash-type-group">
<h2 class="self-serve-choice-label">{{ $t("self_wash.wash_type") }}</h2>
<div
class="self-serve-choice-grid self-serve-choice-grid--centered"
data-testid="self-serve-wash-type-options"
>
<!-- Manual option (always available) -->
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="modelValue"
native-value="Manual"
type="is-link"
data-testid="self-serve-wash-type-manual"
@update:model-value="emit('update:modelValue', 'Manual')"
@input="emit('update:modelValue', 'Manual')"
>
<span>
<span>{{ $t("self_wash.manual") }}<br /></span>
<small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
{{ $t("self_wash.available") }}
</small>
</span>
</b-radio-button>
</div>
<!-- Machine option (conditioned) -->
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="modelValue"
native-value="Machine"
type="is-link"
:disabled="!isMachineAvailable"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:modelValue', 'Machine')"
@input="emit('update:modelValue', 'Machine')"
>
<span>
<span>{{ $t("self_wash.machine") }}<br /></span>
<span v-if="!isMachineAvailable">
<small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.unavailable") }}
</small>
</span>
<span v-else>
<small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
{{ $t("self_wash.available") }}
</small>
</span>
</span>
</b-radio-button>
</div>
</div>
</section>
</template>
<style scoped>
.self-serve-choice-group {
margin-bottom: 1.25rem;
}
.self-serve-choice-label {
color: #303440;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.2;
margin: 0 0 0.75rem;
}
.self-serve-choice-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 0.75rem;
width: 100%;
}
.self-serve-choice-grid--centered {
justify-content: center;
}
.self-serve-choice-grid__item {
min-width: 0;
}
.self-serve-choice-card {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button) {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button),
.self-serve-choice-card span,
.self-serve-choice-card :deep(.button span) {
min-width: 0;
}
.self-serve-choice-card small,
.self-serve-choice-card :deep(.button small) {
display: inline-flex;
align-items: center;
line-height: 1.25;
}
@media screen and (min-width: 769px) {
.self-serve-choice-grid {
grid-template-columns: repeat(auto-fit, minmax(11rem, 15rem));
}
}
</style>
@@ -129,6 +129,19 @@ const getOrderItemFlags = (orderItem) => sortInvoicePeriodFlags((props.invoicePe
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
}));
const getOrderItemFlagIconClass = (orderItem) => (
getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : ""
);
const getOrderItemFlagColorClass = (orderItem) => {
const flags = getOrderItemFlags(orderItem);
if (flags.length === 0) {
return "";
}
return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning";
};
const displayColumnCount = computed(() => (
2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
));
@@ -162,7 +175,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
<template v-if="!isOrderItemAddon(item)">
<!-- If the item is included in the invoice, show it normally -->
<tr v-if="item.include_in_invoice">
<td>{{ item.product.name }}</td>
<td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
{{ item.product.name }}
</td>
<td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
@@ -170,7 +193,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
</tr>
<!-- If the item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning-light">
<td><span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
<td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
<span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
@@ -193,7 +226,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
<!-- If the add-on item is included in the invoice, show it normally -->
<tr v-if="addon_item.include_in_invoice">
<td>+ {{ addon_item.product.name }}</td>
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }}
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
@@ -201,7 +244,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
</tr>
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning">
<td>+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
@@ -237,6 +290,14 @@ watch(() => props.orderId, (newValue, oldValue) => {
<!-- If the item is a primary item, show it -->
<template v-if="!isOrderItemAddon(item)">
<div>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
<strong>{{ item.product.name }}</strong> x {{ item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span>
</div>
@@ -248,6 +309,14 @@ watch(() => props.orderId, (newValue, oldValue) => {
<!-- Show the add-on items -->
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
<small>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }} x {{ addon_item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
</small>
@@ -265,5 +334,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
</template>
<style scoped>
.invoice-period-item-flag-indicator {
vertical-align: middle;
}
</style>
@@ -2,21 +2,31 @@
import Swal from "sweetalert2";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
const props = defineProps({
objects: {
type: Array,
default: () => [],
},
showCustomer: {
type: Boolean,
default: false,
},
});
const { loadList } = usePaginatedListInstance();
const canEditPermissions = () => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT");
const canDisableAccess = () => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_DELETE");
const canEditPermissions = () =>
props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT");
const canDisableAccess = () =>
props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_DELETE");
const canResendInvite = (subuser) =>
(SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
(props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
const hasRowActions = (subuser) =>
(canEditPermissions() && subuser?.grant_id) || canResendInvite(subuser) || (canDisableAccess() && subuser?.grant_id);
const formatDateTime = (dateString) => {
if (!dateString) {
@@ -50,6 +60,16 @@ const formatEmail = (subuser) => {
return subuser?.setup_required ? "E-mail oplyses ved accept" : "-";
};
const formatCustomer = (subuser) => {
if (!props.showCustomer) {
return "";
}
const number = subuser?.customer_number ?? "-";
const name = subuser?.customer_name || "Ukendt kunde";
return `${number} - ${name}`;
};
const permissionSummary = (subuser) =>
SessionUser.objects.subusers.functions.permissionSummary(subuser?.grant_permissions || []);
@@ -161,7 +181,7 @@ const onToggleEnabled = async (subuser, enabled) => {
};
const onResendInvite = async (subuser) => {
await SessionUser.objects.subusers.functions.resendInvite(subuser, refreshList);
await SessionUser.objects.subusers.functions.resendInvite(subuser, refreshList, { superuser: props.showCustomer });
};
</script>
@@ -171,6 +191,7 @@ const onResendInvite = async (subuser) => {
<thead>
<tr>
<th>ID</th>
<th v-if="showCustomer">Kunde</th>
<th>Chauffør</th>
<th>Kontakt</th>
<th>Adgang</th>
@@ -183,12 +204,17 @@ const onResendInvite = async (subuser) => {
</thead>
<tbody>
<tr v-if="props.objects.length === 0">
<td colspan="9" class="has-text-centered has-text-grey py-6">Ingen chauffører fundet.</td>
<td :colspan="showCustomer ? 10 : 9" class="has-text-centered has-text-grey py-6">Ingen chauffører fundet.</td>
</tr>
<tr v-for="subuser in props.objects" :key="subuser.id">
<tr v-for="subuser in props.objects" :key="`${subuser.id}-${subuser.grant_id || 'none'}`">
<td>{{ subuser.id }}</td>
<td v-if="showCustomer">
<div class="has-text-weight-semibold">{{ formatCustomer(subuser) }}</div>
<div class="is-size-7 has-text-grey">Grant #{{ subuser.grant_id }}</div>
</td>
<td>
<div class="has-text-weight-semibold">{{ subuser.name || "-" }}</div>
<div class="is-size-7 has-text-grey" :data-testid="`subuser-username-${subuser.id}`">
@@ -219,51 +245,49 @@ const onResendInvite = async (subuser) => {
<td>
<div>{{ subuser.grant_note || "-" }}</div>
<button
v-if="canEditPermissions() && subuser.grant_id"
class="button is-text is-small px-0 mt-1"
type="button"
@click="onEditNote(subuser)"
>
Redigér note
</button>
</td>
<td>{{ formatDateTime(subuser.created_at) }}</td>
<td>{{ formatDateTime(subuser.updated_at) }}</td>
<td>
<div class="buttons is-justify-content-flex-end action-buttons">
<button
v-if="canEditPermissions() && subuser.grant_id"
class="button is-small"
type="button"
:data-testid="`subuser-permissions-${subuser.id}`"
@click="onEditPermissions(subuser)"
>
Tilladelser
</button>
<div class="buttons is-justify-content-flex-end action-buttons" :data-testid="`subuser-actions-${subuser.id}`">
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
<template #actions>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
icon="fas fa-pen"
label="Redigér note"
:click-action="() => onEditNote(subuser)"
:test-id="`subuser-note-${subuser.id}`"
/>
<button
v-if="canResendInvite(subuser)"
class="button is-small"
type="button"
:data-testid="`subuser-resend-${subuser.id}`"
@click="onResendInvite(subuser)"
>
Gensend
</button>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
icon="fas fa-user-shield"
label="Tilladelser"
:click-action="() => onEditPermissions(subuser)"
:test-id="`subuser-permissions-${subuser.id}`"
/>
<button
v-if="canDisableAccess() && subuser.grant_id"
class="button is-small"
:class="subuser.grant_enabled ? 'is-danger is-light' : 'is-success is-light'"
type="button"
:data-testid="`subuser-toggle-${subuser.id}`"
@click="onToggleEnabled(subuser, !subuser.grant_enabled)"
>
{{ subuser.grant_enabled ? "Deaktivér" : "Aktivér" }}
</button>
<ActionSettingsWheelItem
v-if="canResendInvite(subuser)"
icon="fas fa-paper-plane"
label="Gensend"
:click-action="() => onResendInvite(subuser)"
:test-id="`subuser-resend-${subuser.id}`"
/>
<ActionSettingsWheelItem
v-if="canDisableAccess() && subuser.grant_id"
:icon="subuser.grant_enabled ? 'fas fa-ban' : 'fas fa-check'"
:label="subuser.grant_enabled ? 'Deaktivér' : 'Aktivér'"
:template="subuser.grant_enabled ? 'danger' : 'success'"
:click-action="() => onToggleEnabled(subuser, !subuser.grant_enabled)"
:test-id="`subuser-toggle-${subuser.id}`"
/>
</template>
</ActionSettingsWheelButton>
</div>
</td>
</tr>
@@ -1,4 +1,6 @@
<script setup>
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { getDepartmentDailyReportComplaintCategoryLabel } from "@/services/departmentDailyReportComplaintCategories.js";
@@ -92,29 +94,24 @@ const formatCategory = (value) => (
<td class="complaint-description">{{ complaint.description }}</td>
<td>{{ formatCreatedBy(complaint) }}</td>
<td class="has-text-right">
<div class="buttons is-right is-justify-content-flex-end">
<button
class="button is-small is-dark"
type="button"
:data-testid="`superuser-complaint-edit-${complaint.id}`"
@click="SessionUser.objects.department_daily_report_complaints.functions.showEditForm(complaint, loadList)"
>
<span class="icon is-small">
<i class="fas fa-pen"></i>
</span>
<span>{{ $t('global.edit') }}</span>
</button>
<button
class="button is-small is-danger"
type="button"
:data-testid="`superuser-complaint-delete-${complaint.id}`"
@click="SessionUser.objects.department_daily_report_complaints.functions.showDeleteConfirmationModal(complaint.id, loadList)"
>
<span class="icon is-small">
<i class="fas fa-trash"></i>
</span>
<span>{{ $t('global.delete') }}</span>
</button>
<div class="buttons is-right is-justify-content-flex-end" :data-testid="`superuser-complaint-actions-${complaint.id}`">
<ActionSettingsWheelButton>
<template #actions>
<ActionSettingsWheelItem
icon="fas fa-pen"
:label="$t('global.edit')"
:test-id="`superuser-complaint-edit-${complaint.id}`"
:click-action="() => SessionUser.objects.department_daily_report_complaints.functions.showEditForm(complaint, loadList)"
/>
<ActionSettingsWheelItem
icon="fas fa-trash"
:label="$t('global.delete')"
template="danger"
:test-id="`superuser-complaint-delete-${complaint.id}`"
:click-action="() => SessionUser.objects.department_daily_report_complaints.functions.showDeleteConfirmationModal(complaint.id, loadList)"
/>
</template>
</ActionSettingsWheelButton>
</div>
</td>
</tr>
@@ -102,26 +102,16 @@ const parseBalance = (user) => {
{{ parseBalance(user) }}</td>
<td>
<div class="buttons is-float-right">
<!-- Disabled customer actions -->
<div class="dropdown is-right is-hoverable">
<div class="dropdown-trigger">
<button class="button is-small is-danger is-inverted" aria-haspopup="true" aria-controls="dropdown-menu" @click="showCustomerBarred">
<span class="icon">
<i class="fas fa-exclamation-triangle"></i>
</span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content">
<a class="dropdown-item has-text-warning" @click="showCustomerBarred">
<span class="icon">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span class="ml-1">Kundekonto er lukket i E-conomic</span>
</a>
</div>
</div>
</div>
<ActionSettingsWheelButton icon="fas fa-exclamation-triangle">
<template #actions>
<ActionSettingsWheelItem
label="Kundekonto er lukket i E-conomic"
icon="fas fa-exclamation-triangle"
template="warning"
:click-action="showCustomerBarred"
/>
</template>
</ActionSettingsWheelButton>
</div>
</td>
</tr>
@@ -1,15 +1,17 @@
<script setup>
const props = defineProps(["objects"]);
import { ref } from "vue";
import { departments, getDepartments, isLoading, getDepartmentName } from "@/components/pagination/departmentTabs.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
const { loadList, loadSwitch, metaCurrentPage, metaItemsPerPage, setList } = usePaginatedListInstance();
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import { useI18n } from "vue-i18n";
// Get the departments (If the departments are not already loaded)
if (departments.value.length === 0) {
getDepartments();
}
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import { showEditDepartmentForm } from "@/components/forms/superUser/editDepartmentForm.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const props = defineProps(["objects"]);
const { t } = useI18n();
const { loadList, loadSwitch, metaCurrentPage, metaItemsPerPage, setList } = usePaginatedListInstance();
const draggingIndex = ref(null);
const dragOverIndex = ref(null);
@@ -68,18 +70,6 @@ const onDrop = async (event, newIndex) => {
}
};
const parseCustomerName = (user) => {
if (user.customer_name) {
return user.customer_name;
} else {
return "-";
}
};
import { showEditDepartmentForm } from "@/components/forms/superUser/editDepartmentForm.vue";
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const redirect = (path) => {
window.location = path;
};
@@ -136,7 +126,7 @@ const toggleArchived = async (department) => {
<th>{{ SessionUser.objects.departments.columns.archived.label }}</th>
<th>{{ SessionUser.objects.departments.columns.latitude.label }}</th>
<th>{{ SessionUser.objects.departments.columns.longitude.label }}</th>
<th>{{ $t("tables.actions") }}</th>
<th class="has-text-right">{{ $t("tables.actions") }}</th>
</tr>
</thead>
<tbody>
@@ -212,34 +202,41 @@ const toggleArchived = async (department) => {
column="longitude"
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
/>
<td>
<div class="buttons">
<button
class="button is-small"
@click="
showEditDepartmentForm(
department.id,
department.name,
department.description,
department.economic_department_id
)
"
>
<span class="icon">
<i class="fas fa-edit"></i>
</span>
</button>
<button class="button is-small" @click="redirect('/admin/' + department.id)">
<!-- External link icon -->
<span class="icon">
<i class="fas fa-external-link-alt"></i>
</span>
</button>
<button class="button is-small is-dark" @click="redirect('/superuser/departments/' + department.id)">
<span class="icon">
<i class="fas fa-cog"></i>
</span>
</button>
<td class="has-text-right">
<div
class="buttons is-right is-justify-content-flex-end"
:data-testid="`superuser-department-actions-${department.id}`"
>
<ActionSettingsWheelButton>
<template #actions>
<ActionSettingsWheelItem
icon="fas fa-edit"
:label="t('global.edit')"
:test-id="`superuser-department-edit-${department.id}`"
:click-action="
() =>
showEditDepartmentForm(
department.id,
department.name,
department.description,
department.economic_department_id
)
"
/>
<ActionSettingsWheelItem
icon="fas fa-external-link-alt"
:label="t('global.open')"
:test-id="`superuser-department-open-${department.id}`"
:click-action="() => redirect('/admin/' + department.id)"
/>
<ActionSettingsWheelItem
icon="fas fa-cog"
:label="t('global.settings')"
:test-id="`superuser-department-settings-${department.id}`"
:click-action="() => redirect('/superuser/departments/' + department.id)"
/>
</template>
</ActionSettingsWheelButton>
</div>
</td>
</tr>
@@ -1,44 +1,32 @@
<script setup>
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
defineProps(['objects']);
import { departments, getDepartments, isLoading, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
import Swal from "sweetalert2";
// Get the departments (If the departments are not already loaded)
if (departments.value.length === 0) {
getDepartments();
}
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
const redirect = (path) => {
window.location = path;
}
defineProps({
objects: {
type: Array,
default: () => [],
},
});
const editUser = (user) => {
showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id);
};
</script>
<template>
<table class="table is-fullwidth">
<thead>
<table class="table is-fullwidth" data-testid="superuser-users-table">
<thead>
<tr>
<th>{{ $t('objects.columns.id') }}</th>
<th>{{ $t('tables.users.name') }}</th>
<th>{{ $t('tables.users.role') }}</th>
<th class="has-text-right">{{ $t('tables.actions') }}</th>
<th>{{ $t("objects.columns.id") }}</th>
<th>{{ $t("tables.users.name") }}</th>
<th>{{ $t("tables.users.role") }}</th>
<th class="has-text-right">{{ $t("tables.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="user in objects" :key="user.id">
</thead>
<tbody>
<tr v-for="user in objects" :key="user.id" :data-testid="`superuser-users-row-${user.id}`">
<td>{{ user.id }}</td>
<td>{{ user.display_name }}</td>
<td>{{ user.group_id }}</td>
@@ -46,25 +34,27 @@ const redirect = (path) => {
<div class="buttons is-float-right">
<!-- Settings wheel -->
<ActionSettingsWheelButton
:customer_number="user.customer_number"
:user_id="user.id"
:customer_number="user.customer_number"
:user_id="user.id"
:data-testid="`superuser-user-actions-${user.id}`"
>
<template #actions>
<ActionSettingsWheelItem
@click="showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id)"
icon="fas fa-user-edit"
:label="$t('global.edit')"
:click-action="() => editUser(user)"
icon="fas fa-user-edit"
:label="$t('global.edit')"
:test-id="`superuser-user-edit-${user.id}`"
/>
</template>
</ActionSettingsWheelButton>
</div>
</td>
</tr>
</tbody>
</table>
<tr v-if="objects.length === 0">
<td colspan="4">{{ $t("global.no_data") }}</td>
</tr>
</tbody>
</table>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -1,10 +1,10 @@
<script setup>
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import { ref } from "vue";
import { useI18n } from 'vue-i18n';
import { useI18n } from "vue-i18n";
const { t } = useI18n();
import { departments, getDepartments} from "@/components/pagination/departmentTabs.vue";
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { loadList } from "@/components/pagination/paginatedList.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
@@ -27,16 +27,15 @@ const props = defineProps({
reloadList: {
type: Function,
required: false,
default: null
default: null,
},
add_other_customer_id: {
type: Number,
required: false,
default: null
}
default: null,
},
});
const reload = () => {
// Load the list of vehicles
if (props.reloadList) {
@@ -59,13 +58,9 @@ if (departments.value.length === 0) {
const onClickListAddons = (vehicleId) => {
// Redirect to the product addons page
console.log("Fetching product addons for vehicle: " + vehicleId);
SessionUser.request(
'/vehicles/addons/available',
'GET',
{
id: vehicleId
},
)
SessionUser.request("/vehicles/addons/available", "GET", {
id: vehicleId,
});
};
const vehicleAddons = ref(null);
@@ -76,13 +71,9 @@ const getVehicleAvailableAddons = (vehicleId, forceReload = false) => {
// Get the vehicle addons from the server
if (vehicleAddons.value === null) {
vehicleAddons.value = [];
SessionUser.request(
'/vehicles/addons/available',
'GET',
{
id: vehicleId
},
).then((response) => {
SessionUser.request("/vehicles/addons/available", "GET", {
id: vehicleId,
}).then((response) => {
console.log("Vehicle addons: ", response.data.data);
vehicleAddons.value = response.data.data;
});
@@ -93,14 +84,10 @@ const getVehicleAvailableAddons = (vehicleId, forceReload = false) => {
const toggleVehicleAddon = (vehicleId, addonId) => {
// Toggle the vehicle addon
SessionUser.request(
'/vehicles/addons/toggle',
'POST',
{
vehicle_id: vehicleId,
addon_id: addonId
},
).then(() => {
SessionUser.request("/vehicles/addons/toggle", "POST", {
vehicle_id: vehicleId,
addon_id: addonId,
}).then(() => {
reload();
});
};
@@ -108,9 +95,9 @@ const toggleVehicleAddon = (vehicleId, addonId) => {
const getVehicleAddonToggleIcon = (vehicleAddon) => {
// Check if the vehicle addon is applied
if (isVehicleAddonApplied(vehicleAddon)) {
return 'fas fa-minus';
return "fas fa-minus";
} else {
return 'fas fa-plus';
return "fas fa-plus";
}
};
@@ -137,140 +124,142 @@ const getProductOptionsLabel = (vehicle) => {
<div class="table-container" data-testid="user-vehicles-table-container">
<table class="table is-fullwidth" data-testid="user-vehicles-table">
<thead>
<tr>
<th v-if="!props.compact">{{ $t('objects.columns.id') }}</th>
<th v-if="!props.compact">{{ $t('objects.columns.customer_id') }}</th>
<th>{{ $t('objects.bookings.columns.reg_1') }}</th>
<th>{{ $t('vehicles.type') }}</th>
<th>{{ $t('objects.vehicles.columns.wash_subscription') }}</th>
<th v-if="!props.compact">{{ SessionUser.objects.product_options.meta.title }}</th>
<th v-if="!props.compact">{{ $t('common.reference') }}</th>
<th v-if="!props.compact"></th>
</tr>
<tr>
<th v-if="!props.compact">{{ $t("objects.columns.id") }}</th>
<th v-if="!props.compact">{{ $t("objects.columns.customer_id") }}</th>
<th>{{ $t("objects.bookings.columns.reg_1") }}</th>
<th>{{ $t("vehicles.type") }}</th>
<th>{{ $t("objects.vehicles.columns.wash_subscription") }}</th>
<th v-if="!props.compact">{{ SessionUser.objects.product_options.meta.title }}</th>
<th v-if="!props.compact">{{ $t("common.reference") }}</th>
<th v-if="!props.compact"></th>
</tr>
</thead>
<tbody>
<tr v-for="object in props.vehicles" :key="object.id">
<!-- ID -->
<EditableTableColumn
v-if="!props.compact"
:object="object"
:loadList="reload"
column="id"
/>
<!-- Customer ID -->
<EditableTableColumn
v-if="!props.compact"
:object="object"
:loadList="reload"
column="customer_id"
/>
<!-- Reg -->
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="reg"
/>
<!-- Type -->
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="type"
:parse-function="(value) => {
return SessionUser.objects.products.functions.getProductName(value, 'Ukendt');
}"
/>
<!-- Wash Subscription -->
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="wash_subscription"
:parse-function="(value) => {
return value ? t('common.yes') : t('common.no');
}"
/>
<!-- Product Options, if the wash subscription is set to true -->
<td v-if="!props.compact">
<template v-if="object.wash_subscription">
<!-- Enabled subscription -->
<ActionSettingsWheelButton
:label="getProductOptionsLabel(object)"
:icon="SessionUser.objects.product_options.meta.icon"
@mouseenter="getVehicleAvailableAddons(object.id, true)"
>
<template #actions>
<!-- List addons -->
<template v-for="vehicleAddon in vehicleAddons" :key="vehicleAddon.id">
<ActionSettingsWheelItem
:label="(isVehicleAddonApplied(vehicleAddon) ? SessionUser.objects.global.language.remove : SessionUser.objects.global.language.add) + ' ' + vehicleAddon.name"
:icon="getVehicleAddonToggleIcon(vehicleAddon)"
:click-action="() => toggleVehicleAddon(object.id, vehicleAddon.id)"
:template="isVehicleAddonApplied(vehicleAddon) ? 'danger' : 'default'"
/>
<tr v-for="object in props.vehicles" :key="object.id">
<!-- ID -->
<EditableTableColumn v-if="!props.compact" :object="object" :loadList="reload" column="id" />
<!-- Customer ID -->
<EditableTableColumn v-if="!props.compact" :object="object" :loadList="reload" column="customer_id" />
<!-- Reg -->
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="reg"
/>
<!-- Type -->
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="type"
:parse-function="
(value) => {
return SessionUser.objects.products.functions.getProductName(value, 'Ukendt');
}
"
/>
<!-- Wash Subscription -->
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="wash_subscription"
:parse-function="
(value) => {
return value ? t('common.yes') : t('common.no');
}
"
/>
<!-- Product Options, if the wash subscription is set to true -->
<td v-if="!props.compact">
<template v-if="object.wash_subscription">
<!-- Enabled subscription -->
<ActionSettingsWheelButton
:label="getProductOptionsLabel(object)"
:icon="SessionUser.objects.product_options.meta.icon"
@mouseenter="getVehicleAvailableAddons(object.id, true)"
>
<template #actions>
<!-- List addons -->
<template v-for="vehicleAddon in vehicleAddons" :key="vehicleAddon.id">
<ActionSettingsWheelItem
:label="
(isVehicleAddonApplied(vehicleAddon)
? SessionUser.objects.global.language.remove
: SessionUser.objects.global.language.add) +
' ' +
vehicleAddon.name
"
:icon="getVehicleAddonToggleIcon(vehicleAddon)"
:click-action="() => toggleVehicleAddon(object.id, vehicleAddon.id)"
:template="isVehicleAddonApplied(vehicleAddon) ? 'danger' : 'default'"
/>
</template>
<!-- If there are no addons, show a message -->
<ActionSettingsWheelItem
v-if="vehicleAddons ? vehicleAddons.length === 0 : true"
:label="$t('global.no_data')"
icon="fas fa-list"
/>
<!-- Addons -->
</template>
</ActionSettingsWheelButton>
</template>
<!-- If there are no addons, show a message -->
<ActionSettingsWheelItem
v-if="vehicleAddons ? vehicleAddons.length === 0 : true"
:label="$t('global.no_data')"
icon="fas fa-list"
/>
<!-- Addons -->
</template>
</ActionSettingsWheelButton>
</template>
<template v-else>
<!-- Disabled subscription -->
{{ $t('global.no_data') }}
</template>
</td>
<!-- Reference to the vehicle -->
<EditableTableColumn
v-if="!props.compact"
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="reference"
/>
<!-- Actions -->
<td>
<!-- Actions -->
<ActionSettingsWheelButton
v-if="!props.compact"
:user_id="object.user_id"
:reg_1="object.reg"
:displayActionsDirectly="true"
>
<template #actions>
<!-- View (Redirect to the vehicle page) -->
<ActionSettingsWheelItem
:label="$t('global.manage') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-eye"
:click-action="() => redirectUserVehiclePage(object.id)"
/>
<!-- Delete -->
<ActionSettingsWheelItem
:label="$t('global.delete') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-trash"
:template="'danger'"
:click-action="() => SessionUser.objects.vehicles.functions.showDeleteObjectForm(object.id, () => reload())"
/>
</template>
</ActionSettingsWheelButton>
</td>
</tr>
<template v-else>
<!-- Disabled subscription -->
{{ $t("global.no_data") }}
</template>
</td>
<!-- Reference to the vehicle -->
<EditableTableColumn
v-if="!props.compact"
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
column="reference"
/>
<!-- Actions -->
<td>
<!-- Actions stay grouped behind the wheel menu. -->
<ActionSettingsWheelButton
v-if="!props.compact"
:user_id="object.user_id"
:reg_1="object.reg"
:displayActionsDirectly="false"
>
<template #actions>
<!-- View (Redirect to the vehicle page) -->
<ActionSettingsWheelItem
:label="$t('global.manage') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-eye"
:click-action="() => redirectUserVehiclePage(object.id)"
/>
<!-- Delete -->
<ActionSettingsWheelItem
:label="$t('global.delete') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-trash"
:template="'danger'"
:click-action="
() => SessionUser.objects.vehicles.functions.showDeleteObjectForm(object.id, () => reload())
"
/>
</template>
</ActionSettingsWheelButton>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="10">{{ $t('tables.showing') }} {{ props.vehicles.length }} {{ $t('objects.vehicles.multiple') }}</td>
</tr>
<tr>
<td colspan="10">
{{ $t("tables.showing") }} {{ props.vehicles.length }} {{ $t("objects.vehicles.multiple") }}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -10,6 +10,7 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const employees = ref([]);
const { t } = useI18n();
@@ -52,6 +53,7 @@ const login = async () => {
}
// Save the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', data.token);
// Redirect to the dashboard
window.location.href = '/admin';
@@ -72,6 +74,7 @@ const loginWithPasskey = async () => {
const result = await authenticateWithPasskey('employee', null, recaptchaToken);
if (result.token) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.token);
window.location.href = '/admin';
return;
+3
View File
@@ -9,6 +9,7 @@ import { parseError, clearErrors, addError, getError } from "@/components/reques
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const { t } = useI18n();
@@ -68,6 +69,7 @@ const login = async () => {
}
// Save the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', data.token);
// Set the success message
successMessage.value = "Du er nu logget ind!"
@@ -103,6 +105,7 @@ const loginWithPasskey = async () => {
const result = await authenticateWithPasskey('user', customerNum, recaptchaToken);
console.log('Passkey authentication result:', result);
if (result.token) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.token);
successMessage.value = "Du er nu logget ind!";
setTimeout(() => {
@@ -7,7 +7,9 @@ import { parseError, getError, addError, clearErrors } from "@/components/reques
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import { getSubuserPasswordPolicyError } from "@/services/subuserPasswordPolicy.js";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const { t } = useI18n();
@@ -39,6 +41,12 @@ const twoFactorToken = ref('');
const login = async () => {
clearErrors();
const passwordPolicyError = getSubuserPasswordPolicyError(password.value);
if (passwordPolicyError) {
addError(passwordPolicyError, 'auth');
return;
}
try {
let requestBody = { password: password.value };
@@ -62,6 +70,7 @@ const login = async () => {
// Save the session token
if (data.session) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', data.session);
localStorage.setItem('is_subuser', 'true');
window.location.reload();
@@ -90,6 +99,7 @@ const loginWithPasskey = async () => {
// Subuser login returns 'session' token, user login returns 'token'
const sessionToken = result.session || result.token;
if (sessionToken) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', sessionToken);
localStorage.setItem('is_subuser', 'true');
// Reload the page to update the UI
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import { verify2FA } from "@/services/TwoFactorAuthService.js";
import { parseError, getError, clearErrors } from "@/components/request/HandleGlobalError.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const { t } = useI18n();
const router = useRouter();
@@ -43,9 +44,11 @@ const verify = async () => {
// Handle the response based on user type
if (props.userType === 'subuser' && result.session) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.session);
localStorage.setItem('is_subuser', 'true');
} else if (result.token) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.token);
localStorage.removeItem('is_subuser');
}
@@ -441,16 +441,23 @@ const addProductWithAddonsToOrder = async (product_id) => {
showCancelButton: true,
confirmButtonText: 'Tilføj',
showLoaderOnConfirm: true,
inputValidator: (note) => {
if (!String(note || '').trim()) {
return 'Note er påkrævet for dette produkt';
}
return null;
},
preConfirm: (note) => {
const normalizedNote = String(note || '').trim();
const confirmedOrderId = getValidOrderId();
if (!confirmedOrderId) {
Swal.showValidationMessage('Order ID is required');
return false;
}
// Show the fake create order item
showFakeCreateOrderItem(product_id, 1, 0, 0, note);
showFakeCreateOrderItem(product_id, 1, 0, 0, normalizedNote);
// Create the order item
return createOrderItem(confirmedOrderId, product_id, 1, 0, note).then(async (result) => {
return createOrderItem(confirmedOrderId, product_id, 1, 0, normalizedNote).then(async (result) => {
let order_item_id = result.data.data.id;
// Add the addons to the order
await addAddonsToOrderMiddleware(product_id, 1, order_item_id, confirmedOrderId).then(() => {
@@ -217,6 +217,54 @@ const getVehicleReferenceValue = () => {
return String(vehicleObject.value?.reference ?? "").trim();
};
const getVehicleStateKey = (vehicle) => {
if (!vehicle) {
return "";
}
return [
vehicle?.id ?? "",
normalizePlateValue(vehicle?.reg),
normalizeCustomerNumber(vehicle?.customer_id) ?? "",
vehicle?.type ?? "",
vehicle?.booking_id ?? "",
String(vehicle?.reference ?? "").trim(),
].join("|");
};
const getBookingStateKey = (booking) => {
if (!booking) {
return "";
}
return [
booking?.id ?? "",
getBookingReg1Value(booking),
getBookingReg2Value(booking),
getBookingCustomerNumber(booking) ?? "",
String(booking?.reference_number ?? booking?.reference ?? "").trim(),
String(booking?.po ?? "").trim(),
].join("|");
};
const getBookingMatchesStateKey = (matches) => {
if (!Array.isArray(matches) || matches.length === 0) {
return "";
}
return matches.map((booking) => getBookingStateKey(booking)).join("||");
};
const hasBookingDetailsChanged = (currentBooking, nextBooking) => {
const currentKeys = Object.keys(currentBooking || {});
const nextKeys = Object.keys(nextBooking || {});
if (currentKeys.length !== nextKeys.length) {
return true;
}
return nextKeys.some((key) => currentBooking?.[key] !== nextBooking?.[key]);
};
const setVehicleObject = (emittedVehicleObject, options = {}) => {
const normalizedOptions = {
preserveManualReference: true,
@@ -224,17 +272,26 @@ const setVehicleObject = (emittedVehicleObject, options = {}) => {
...options,
};
vehicleObject.value = emittedVehicleObject;
const emittedVehiclePlate = normalizePlateValue(vehicleObject.value?.reg || reg_1.value);
const emittedVehiclePlate = normalizePlateValue(emittedVehicleObject?.reg || reg_1.value);
if (
emittedVehiclePlate &&
skippedDesktopBookingVehiclePlate.value === emittedVehiclePlate &&
!isBookingMarkedVehicle(vehicleObject.value)
!isBookingMarkedVehicle(emittedVehicleObject)
) {
skippedDesktopBookingVehiclePlate.value = "";
}
const currentVehicleKey = getVehicleStateKey(vehicleObject.value);
const nextVehicleKey = getVehicleStateKey(emittedVehicleObject);
if (currentVehicleKey === nextVehicleKey) {
if (normalizedOptions.nextSelectionSource) {
setSelectionSource(normalizedOptions.nextSelectionSource);
}
return;
}
vehicleObject.value = emittedVehicleObject;
const matchedVehicleCustomerNumber = normalizeCustomerNumber(vehicleObject.value?.customer_id);
const selectedCustomerNumber = normalizeCustomerNumber(customer_id.value);
@@ -274,6 +331,10 @@ const setBookingObject = (emittedBookingObject, options = {}) => {
...options,
};
if (getBookingStateKey(bookingObject.value) === getBookingStateKey(emittedBookingObject)) {
return;
}
bookingObject.value = emittedBookingObject;
if (emittedBookingObject && emittedBookingObject.reference_number) {
@@ -284,7 +345,12 @@ const setBookingObject = (emittedBookingObject, options = {}) => {
};
const setBookingMatches = (emittedBookingMatches) => {
bookingMatches.value = Array.isArray(emittedBookingMatches) ? emittedBookingMatches : [];
const nextBookingMatches = Array.isArray(emittedBookingMatches) ? emittedBookingMatches : [];
if (getBookingMatchesStateKey(bookingMatches.value) === getBookingMatchesStateKey(nextBookingMatches)) {
return;
}
bookingMatches.value = nextBookingMatches;
};
const mergeBookingMatchDetails = (booking) => {
@@ -294,17 +360,23 @@ const mergeBookingMatchDetails = (booking) => {
}
let didMergeBooking = false;
bookingMatches.value = bookingMatches.value.map((entry) => {
let didChangeBooking = false;
const nextBookingMatches = bookingMatches.value.map((entry) => {
if (normalizeBookingId(entry?.id) !== normalizedBookingId) {
return entry;
}
didMergeBooking = true;
return { ...entry, ...booking };
const mergedBooking = { ...entry, ...booking };
const hasChangedEntry = hasBookingDetailsChanged(entry, mergedBooking);
didChangeBooking = didChangeBooking || hasChangedEntry;
return hasChangedEntry ? mergedBooking : entry;
});
if (!didMergeBooking) {
bookingMatches.value = [...bookingMatches.value, booking];
} else if (didChangeBooking) {
bookingMatches.value = nextBookingMatches;
}
};
@@ -416,6 +416,21 @@ const shouldPreserveCustomerSelection = (plateValue) => {
};
let pendingVehicleCustomerLookup = null;
const lastAutoSyncedVehicleKey = ref("");
const getVehicleAutoSyncKey = (vehicle, plateOverride = null) => {
if (!vehicle) {
return "";
}
return [
resolveBookingPlate(vehicle, plateOverride),
vehicle?.id ?? "",
normalizeCustomerNumber(vehicle?.customer_id) ?? "",
normalizeCustomerNumber(customer_id.value) ?? "",
].join("|");
};
const syncMatchedVehicleSelection = (vehicle, options = {}) => {
const normalizedOptions = {
plateOverride: null,
@@ -590,6 +605,7 @@ watch(reg_1, (newValue) => {
// Define the search ID for this input change
const search_id = register_new_search();
console.log("reg_1 changed:", newValue);
lastAutoSyncedVehicleKey.value = "";
const shouldKeepCustomerSelection = shouldPreserveCustomerSelection(newValue);
selectedDropdownItem.value = -1;
// Check if the new value is empty, if so, clear the vehicles_matching array
@@ -847,11 +863,17 @@ watch(vehicles_matching, (newValue) => {
const currentValue = reg_1.value;
const vehicle = newValue.find((vehicle) => vehicle.reg === currentValue);
if (vehicle) {
syncMatchedVehicleSelection(vehicle);
const nextAutoSyncKey = getVehicleAutoSyncKey(vehicle, currentValue);
if (lastAutoSyncedVehicleKey.value !== nextAutoSyncKey) {
lastAutoSyncedVehicleKey.value = nextAutoSyncKey;
syncMatchedVehicleSelection(vehicle);
}
} else if (getBookingMatchesForSelection(null, currentValue).length > 0) {
lastAutoSyncedVehicleKey.value = "";
clearCustomerConflict();
emitVehicleObject(null, currentValue);
} else {
lastAutoSyncedVehicleKey.value = "";
clearCustomerConflict();
}
// Check if the selectedDropdownItem index is valid
@@ -1015,6 +1037,7 @@ const getCurrentIconColor = () => {
@blur="lostfocus"
@keydown="arrowKeyHandler"
id="reg_1"
tabindex="1"
autocomplete="off"
/>
</template>
@@ -1037,6 +1060,7 @@ const getCurrentIconColor = () => {
@blur="lostfocus"
@keydown="arrowKeyHandler"
id="reg_1"
tabindex="1"
autocomplete="off"
/>
</div>
@@ -394,12 +394,16 @@ onBeforeUnmount(() => {
v-model="typedReference"
:data="groupedSuggestions"
field="reference"
input-id="reference"
data-testid="pos-desktop-step-1-reference-input"
group-field="group"
group-options="items"
:keep-first="true"
open-on-focus
expanded
:loading="isFetching"
:tabindex="props.tabindex"
:aria-invalid="props.requiredWarning ? 'true' : undefined"
custom-class="has-sharp-edges"
icon-pack="fas"
max-height="320"
@@ -52,6 +52,7 @@ const customer_suggestions = ref([
const isSettingCustomer = ref(false);
const isSettingCustomerToInteger = ref(0);
const isSettingCustomerStartTime = ref(null);
let customerSuggestionsRequestId = 0;
const isSettingCustomerTo = (customer_number) => {
return isSettingCustomerToInteger.value === parseInt(customer_number);
@@ -88,10 +89,17 @@ const getCustomerSuggestions = () => {
if (!props.reg_1) {
return;
}
const requestedReg1 = props.reg_1;
const requestId = ++customerSuggestionsRequestId;
SessionUser.request("/department/vehicle/customer-suggestions", "GET", {
reg_1: props.reg_1,
reg_1: requestedReg1,
})
.then((response) => {
if (requestId !== customerSuggestionsRequestId || requestedReg1 !== props.reg_1) {
return;
}
// Assuming the response contains an array of customer suggestions
console.log("Customer suggestions:", response.data.data);
let suggestions = [];
@@ -126,6 +134,7 @@ watch(
if (newValue) {
getCustomerSuggestions();
} else {
customerSuggestionsRequestId += 1;
customer_suggestions.value = [];
}
}
+40 -5
View File
@@ -1,5 +1,6 @@
<script setup>
import { computed, nextTick, reactive, ref } from "vue";
import { computed, nextTick, reactive, ref, watch } from "vue";
import { useMediaQuery } from "@vueuse/core";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
@@ -9,6 +10,7 @@ import {
releaseRuntimeState,
} from "@/services/releaseTimeline.js";
import { submitErrorReport } from "@/services/errorReports.js";
import { errorReportLaunchRequestId } from "@/services/errorReportLauncher.js";
const route = useRoute();
const { t } = useI18n({ useScope: "global" });
@@ -21,6 +23,9 @@ const FALLBACK_LABELS = {
"error_report.before_error": "What were you doing before the error occurred?",
"error_report.expected": "What did you expect would happen?",
"error_report.actual": "What actually happened?",
"error_report.before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
"error_report.expected_placeholder": "Describe the result you expected to see.",
"error_report.actual_placeholder": "Describe what you saw instead, including any error text.",
"error_report.consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"error_report.submit": "Submit report",
"error_report.submitted": "Error report submitted.",
@@ -46,7 +51,12 @@ const form = reactive({
data_collection_accepted: false,
});
const isMobileReportPlacement = useMediaQuery("(max-width: 768px)");
const isDesktopNavigationReportPlacement = useMediaQuery("(min-width: 1024px)");
const isAuthenticated = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
const shouldShowFloatingButton = computed(
() => !isMobileReportPlacement.value && !isDesktopNavigationReportPlacement.value
);
const isValid = computed(() => (
form.before_error.trim().length > 0
&& form.expected.trim().length > 0
@@ -96,6 +106,12 @@ const open = () => {
void loadHtml2Canvas().catch(() => {});
};
watch(errorReportLaunchRequestId, () => {
if (isAuthenticated.value) {
open();
}
});
const close = () => {
if (isSubmitting.value) {
return;
@@ -300,6 +316,7 @@ const submit = async () => {
<template>
<div v-if="isAuthenticated" data-error-report-exclude>
<button
v-if="shouldShowFloatingButton"
class="button is-danger error-report-button"
type="button"
data-testid="error-report-button"
@@ -317,7 +334,7 @@ const submit = async () => {
<h2 class="title is-4">{{ tr("error_report.title") }}</h2>
<p class="subtitle is-6">{{ tr("error_report.subtitle") }}</p>
</div>
<button class="delete" type="button" aria-label="close" :disabled="isSubmitting" @click="close"></button>
<button class="delete" type="button" :aria-label="t('common.close')" :disabled="isSubmitting" @click="close"></button>
</header>
<div v-if="submitted" class="notification is-success is-light" data-testid="error-report-submitted">
@@ -332,17 +349,35 @@ const submit = async () => {
<label class="field">
<span class="label">{{ tr("error_report.before_error") }}</span>
<textarea v-model="form.before_error" class="textarea" maxlength="4000" required></textarea>
<textarea
v-model="form.before_error"
class="textarea"
maxlength="4000"
:placeholder="tr('error_report.before_error_placeholder')"
required
></textarea>
</label>
<label class="field">
<span class="label">{{ tr("error_report.expected") }}</span>
<textarea v-model="form.expected" class="textarea" maxlength="4000" required></textarea>
<textarea
v-model="form.expected"
class="textarea"
maxlength="4000"
:placeholder="tr('error_report.expected_placeholder')"
required
></textarea>
</label>
<label class="field">
<span class="label">{{ tr("error_report.actual") }}</span>
<textarea v-model="form.actual" class="textarea" maxlength="4000" required></textarea>
<textarea
v-model="form.actual"
class="textarea"
maxlength="4000"
:placeholder="tr('error_report.actual_placeholder')"
required
></textarea>
</label>
<label class="checkbox error-report-consent">
@@ -0,0 +1,217 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { releaseUpdateState, shortReleaseCommit } from "@/services/releaseUpdate.js";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
const SHIFT_PRESS_WINDOW_MS = 1200;
const CLOSE_EVENT = "frontend-maintenance-menu:close";
const { t } = useI18n({ useScope: "global" });
const isOpen = ref(false);
const isBusy = ref(false);
const shiftPresses = ref([]);
const currentVersion = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
const latestVersion = computed(() => shortReleaseCommit(releaseUpdateState.latestCommit || releaseUpdateState.currentCommit));
const closeMenu = () => {
if (!isBusy.value) {
isOpen.value = false;
}
};
const handleKeydown = (event) => {
if (event.key !== "Shift" || event.repeat) {
return;
}
const now = Date.now();
shiftPresses.value = [...shiftPresses.value.filter((pressedAt) => now - pressedAt <= SHIFT_PRESS_WINDOW_MS), now];
if (shiftPresses.value.length >= 3) {
shiftPresses.value = [];
isOpen.value = true;
}
};
const forceUpdateAndClearLocal = async () => {
if (isBusy.value || !window.confirm(t("maintenance_menu.confirm_clear_local"))) {
return;
}
isBusy.value = true;
try {
await forceFrontendUpdateAndClearLocal();
} catch (error) {
isBusy.value = false;
console.error("Failed to force frontend update and clear local data:", error);
}
};
onMounted(() => {
window.addEventListener("keydown", handleKeydown);
window.addEventListener(CLOSE_EVENT, closeMenu);
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", handleKeydown);
window.removeEventListener(CLOSE_EVENT, closeMenu);
});
</script>
<template>
<Teleport to="body">
<div v-if="isOpen" class="frontend-maintenance-menu" data-testid="frontend-maintenance-menu">
<button
type="button"
class="frontend-maintenance-menu__backdrop"
:aria-label="t('common.close')"
@click="closeMenu"
></button>
<section class="frontend-maintenance-menu__panel" role="dialog" aria-modal="true" :aria-label="t('maintenance_menu.title')">
<header class="frontend-maintenance-menu__header">
<div>
<p>{{ t("maintenance_menu.title") }}</p>
<span>{{ t("maintenance_menu.version", { current: currentVersion, latest: latestVersion }) }}</span>
</div>
<button type="button" class="frontend-maintenance-menu__close" :aria-label="t('common.close')" @click="closeMenu">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</header>
<button
type="button"
class="frontend-maintenance-menu__danger"
data-testid="frontend-maintenance-force-clear"
:disabled="isBusy"
@click="forceUpdateAndClearLocal"
>
<i class="fas fa-sync-alt" aria-hidden="true"></i>
<span>
<strong>{{ t("maintenance_menu.force_update_clear") }}</strong>
<small>{{ t("maintenance_menu.force_update_clear_hint") }}</small>
</span>
</button>
</section>
</div>
</Teleport>
</template>
<style scoped>
.frontend-maintenance-menu {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: end center;
padding: 16px;
pointer-events: none;
}
.frontend-maintenance-menu__backdrop {
position: fixed;
inset: 0;
border: 0;
background: rgba(12, 22, 34, 0.32);
pointer-events: auto;
}
.frontend-maintenance-menu__panel {
position: relative;
width: min(420px, calc(100vw - 32px));
border: 1px solid #d6dde6;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
color: #111827;
pointer-events: auto;
}
.frontend-maintenance-menu__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 14px 10px;
border-bottom: 1px solid #edf1f5;
}
.frontend-maintenance-menu__header p {
margin: 0;
color: #172033;
font-size: 0.96rem;
font-weight: 800;
}
.frontend-maintenance-menu__header span {
display: block;
margin-top: 3px;
color: #667085;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.72rem;
font-weight: 700;
}
.frontend-maintenance-menu__close {
width: 34px;
height: 34px;
flex: 0 0 auto;
border: 1px solid #d9e0e8;
border-radius: 6px;
background: #ffffff;
color: #334155;
cursor: pointer;
}
.frontend-maintenance-menu__danger {
width: calc(100% - 28px);
min-height: 58px;
display: flex;
align-items: center;
gap: 10px;
margin: 14px;
padding: 10px 12px;
border: 1px solid #f2b8b5;
border-radius: 6px;
background: #fff7f6;
color: #981b1b;
cursor: pointer;
text-align: left;
}
.frontend-maintenance-menu__danger:disabled {
cursor: wait;
opacity: 0.7;
}
.frontend-maintenance-menu__danger i {
width: 20px;
flex: 0 0 auto;
text-align: center;
}
.frontend-maintenance-menu__danger strong,
.frontend-maintenance-menu__danger small {
display: block;
}
.frontend-maintenance-menu__danger strong {
font-size: 0.86rem;
font-weight: 800;
}
.frontend-maintenance-menu__danger small {
margin-top: 2px;
color: #9f1c1c;
font-size: 0.72rem;
font-weight: 650;
line-height: 1.25;
}
@media (min-width: 720px) {
.frontend-maintenance-menu {
place-items: end end;
padding: 22px;
}
}
</style>
+21 -7
View File
@@ -44,6 +44,7 @@ const REQUEST_INSIGHT_DEFINITIONS = Object.freeze([
]);
const SHIFT_MULTI_PRESS_WINDOW_MS = 700;
const SYSTEM_SEARCH_CLOSE_EVENT = "system-search:close";
const FRONTEND_MAINTENANCE_MENU_CLOSE_EVENT = "frontend-maintenance-menu:close";
const isExpanded = ref(REQUEST_QUEUE_CONFIG.inspector.expandedByDefault);
const isShortcutActivated = ref(false);
@@ -118,6 +119,7 @@ const impersonatedUserRoleId = computed(() => {
const canGrantMissingPermissions = computed(() =>
hasSuperuserToken.value && !SessionUser.isSubuser.value && impersonatedUserRoleId.value !== null
);
const canInspectReleaseRuntime = computed(() => hasSuperuserToken.value || SessionUser.canAccessAdmin?.() === true);
const userDetailRows = computed(() => {
if (SessionUser.isSubuser.value) {
@@ -284,7 +286,12 @@ const resolvePingUrl = () => {
};
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
const releaseSessionSummary = computed(() => buildReleaseSessionSummary());
const activeApiUrlLabel = computed(() =>
canInspectReleaseRuntime.value ? activeApiUrl.value : "Restricted to release operators"
);
const releaseSessionSummary = computed(() =>
buildReleaseSessionSummary(undefined, { includeInfrastructureDetails: canInspectReleaseRuntime.value })
);
const measurePingLatency = async () => {
if (typeof fetch !== "function") {
@@ -360,6 +367,9 @@ const handleWindowKeydown = (event) => {
}
if (shiftKeyPressCount.value >= 3) {
event.preventDefault?.();
event.stopImmediatePropagation?.();
window.dispatchEvent(new CustomEvent(FRONTEND_MAINTENANCE_MENU_CLOSE_EVENT));
closeSystemSearchForSuperuser();
isShortcutActivated.value = true;
isExpanded.value = true;
@@ -646,9 +656,10 @@ onBeforeUnmount(() => {
</aside>
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
<div class="request-queue-progress__section-title">Session release</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list">
<template v-if="canInspectReleaseRuntime">
<div class="request-queue-progress__section-title">Session release</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Channel</span>
<span
@@ -771,11 +782,15 @@ onBeforeUnmount(() => {
</li>
</ul>
<div class="request-queue-progress__subsection-title">Runtime details</div>
</div>
</template>
<div class="request-queue-progress__section-title">Runtime details</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">API URL</span>
<span class="request-queue-progress__meta-value" :title="activeApiUrl">{{ activeApiUrl }}</span>
<span class="request-queue-progress__meta-value" :title="activeApiUrlLabel">{{ activeApiUrlLabel }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Current host</span>
@@ -1485,4 +1500,3 @@ onBeforeUnmount(() => {
opacity: 0;
}
</style>
+2 -2
View File
@@ -3,7 +3,7 @@ import MenuDefault from "@/components/menus/MenuDefault.vue";
import { ref, watch, onMounted } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { isAccessibleVisibleDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
import { isAccessibleVisibleNamedDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
// Get the department ID from the URL
const route = useRoute();
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
@@ -26,7 +26,7 @@ const loadDepartments = () => {
value: department.id,
icon: "fas fa-building",
children: [],
hidden: !isAccessibleVisibleDepartment(department, SessionUser.canAccessAssignedDepartment),
hidden: !isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment),
});
});
menu_items.value[1].options = options;
@@ -86,6 +86,13 @@ const menu_items = ref([
children: [],
hidden: false
},
{
label: 'Chauffører',
value: '/subusers',
icon: 'fas fa-id-card',
children: [],
hidden: false
},
{
label: SessionUser.objects.roles.meta.title,
value: SessionUser.objects.roles.meta.endpoint,
@@ -13,7 +13,9 @@ import {
} from "@/composables/useDraftTransactionCustomer.js";
import { fetchDepartmentOrderBookingCounts } from "@/components/models/navigation/items/adminBookingCount.js";
import { fetchDepartmentDraftCount } from "@/components/models/navigation/items/adminDraftCount.js";
import { hasExplicitBookingCountPermission } from "@/components/models/navigation/items/bookingCountGuards.js";
import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js";
import { isAccessibleVisibleNamedDepartment } from "@/services/departmentVisibility.js";
const t = (key: string) => i18n.global.t(key);
@@ -70,6 +72,8 @@ let queuedBookingLoadingIndicator = false;
let queuedDraftLoadingIndicator = false;
const getDepartmentIdNumber = () => Number.parseInt(String(department_id.value), 10);
const canUseAdminNavigationCounts = () => SessionUser.canAccessAdmin();
const canUseAdminBookingNavigationCounts = () =>
canUseAdminNavigationCounts() && hasExplicitBookingCountPermission(SessionUser);
const getDepartmentById = (id: number) => {
return departments_cache.value?.find((department: any) => Number(department?.id) === Number(id)) || null;
};
@@ -148,6 +152,19 @@ const getDepartmentDraftBadge = () => {
};
const fetchDepartmentBookingCount = async ({ showLoadingIndicator = false } = {}) => {
if (!canUseAdminBookingNavigationCounts()) {
department_booking_counts.value = {
past: 0,
current: 0,
future: 0,
};
department_booking_counts_loading.value = false;
bookingCountFetchInFlight = false;
bookingCountRefreshQueued = false;
queuedBookingLoadingIndicator = false;
return;
}
if (!hasValidDepartmentId.value) {
department_booking_counts.value = {
past: 0,
@@ -376,17 +393,26 @@ const items = computed<NavigationItemProps[]>(() => [
hidden: isDepartmentSet.value,
classes: [ADMIN_DEPARTMENT_SELECTION_CLASS],
permissions: ["admin", "list_departments"],
children: SessionUser.functions.getAccessibleDepartments().map((departmentId: any) => {
const department = getDepartmentById(departmentId);
return {
label: getDepartmentName(departmentId),
to: `/admin/${departmentId}`,
type: "department",
permissions: ["admin", "view_department"],
order_priority: department?.order_priority ?? department?.priority_order ?? null,
priority_order: department?.priority_order ?? department?.order_priority ?? null,
};
}),
children: SessionUser.functions
.getAccessibleDepartments()
.map((departmentId: any) => {
const label = getDepartmentName(departmentId);
const department = getDepartmentById(departmentId);
if (!isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment)) {
return null;
}
return {
label,
to: `/admin/${department.id}`,
type: "department",
permissions: ["admin", "view_department"],
order_priority: department?.order_priority ?? department?.priority_order ?? null,
priority_order: department?.priority_order ?? department?.order_priority ?? null,
};
})
.filter(Boolean) as NavigationItemProps[],
},
// Kassesystem
{
@@ -9,6 +9,7 @@ import {
} from "@/composables/useDraftTransactionCustomer.js";
import { fetchSuperUserDraftCount } from "@/components/models/navigation/items/superUserDraftCount.js";
import { fetchSuperUserBookingCounts } from "@/components/models/navigation/items/superUserBookingCount.js";
import { hasExplicitBookingCountPermission } from "@/components/models/navigation/items/bookingCountGuards.js";
import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js";
const firstToUpperCase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
@@ -37,23 +38,29 @@ let queuedSuperUserBookingLoadingIndicator = false;
let queuedSuperUserDraftLoadingIndicator = false;
const canUseSuperUserNavigationCounts = () => SessionUser.canAccessSuperUser();
const canUseSuperUserBookingNavigationCounts = () =>
canUseSuperUserNavigationCounts() && hasExplicitBookingCountPermission(SessionUser);
const resetSuperUserNavigationCounts = () => {
const resetSuperUserBookingCounts = () => {
bookingCountRequestId += 1;
draftCountRequestId += 1;
superuser_booking_counts.value = {
past: 0,
current: 0,
future: 0,
};
superuser_booking_count_loading.value = false;
superUserBookingCountFetchInFlight = false;
superUserBookingCountRefreshQueued = false;
queuedSuperUserBookingLoadingIndicator = false;
};
const resetSuperUserNavigationCounts = () => {
resetSuperUserBookingCounts();
draftCountRequestId += 1;
superuser_draft_count.value = 0;
superuser_draft_count_loading.value = false;
superUserBookingCountFetchInFlight = false;
superUserDraftCountFetchInFlight = false;
superUserBookingCountRefreshQueued = false;
superUserDraftCountRefreshQueued = false;
queuedSuperUserBookingLoadingIndicator = false;
queuedSuperUserDraftLoadingIndicator = false;
};
@@ -125,8 +132,8 @@ const getSuperUserDraftsBadge = () => {
};
const fetchCurrentSuperUserBookingCount = async ({ showLoadingIndicator = false } = {}) => {
if (!canUseSuperUserNavigationCounts()) {
resetSuperUserNavigationCounts();
if (!canUseSuperUserBookingNavigationCounts()) {
resetSuperUserBookingCounts();
return;
}
@@ -332,6 +339,7 @@ const items = computed<NavigationItemProps[]>(() => [
type: "category",
children: [
{ label: t("superuser.nav.employees"), to: "/superuser/users" },
{ label: "Chauffører", to: "/superuser/subusers" },
{ label: t("superuser.nav.customers"), to: "/superuser/customers" },
{ label: t("superuser.nav.complaints"), to: "/superuser/complaints" },
{ label: t("superuser.nav.roles"), to: "/superuser/roles" },
@@ -1,4 +1,8 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {
emptyBookingCounts,
isExpectedBookingCountRequestFailure,
} from "@/components/models/navigation/items/bookingCountGuards.js";
const normalizePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -24,11 +28,7 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
if (!normalizedDepartmentId) {
return {
past: 0,
current: 0,
future: 0,
};
return emptyBookingCounts();
}
try {
@@ -38,12 +38,11 @@ export const fetchDepartmentOrderBookingCounts = async ({ departmentId }) => {
return normalizeBookingCounts(response);
} catch (error) {
console.error("Error fetching department order-booking counts:", error);
return {
past: 0,
current: 0,
future: 0,
};
if (!isExpectedBookingCountRequestFailure(error)) {
console.error("Error fetching department order-booking counts:", error);
}
return emptyBookingCounts();
}
};
@@ -0,0 +1,24 @@
export const BOOKING_COUNT_PERMISSIONS = ["list_bookings", "list_own_bookings"];
export const emptyBookingCounts = () => ({
past: 0,
current: 0,
future: 0,
});
export const hasExplicitBookingCountPermission = (sessionUser) => {
const permissions = sessionUser?.permissions?.value;
if (!Array.isArray(permissions)) {
return false;
}
return BOOKING_COUNT_PERMISSIONS.some((permission) => permissions.includes(permission));
};
export const getRequestErrorStatus = (error) =>
Number.parseInt(String(error?.response?.status ?? error?.status ?? ""), 10);
export const isExpectedBookingCountRequestFailure = (error) => {
return [401, 403, 404].includes(getRequestErrorStatus(error));
};
@@ -1,4 +1,8 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {
emptyBookingCounts,
isExpectedBookingCountRequestFailure,
} from "@/components/models/navigation/items/bookingCountGuards.js";
const toNonNegativeInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -20,12 +24,11 @@ export const fetchSuperUserBookingCounts = async () => {
const response = await authenticatedRequest("/order-bookings/counts", "GET", {});
return normalizeBookingCounts(response);
} catch (error) {
console.error("Error fetching superuser booking counts:", error);
return {
past: 0,
current: 0,
future: 0,
};
if (!isExpectedBookingCountRequestFailure(error)) {
console.error("Error fetching superuser booking counts:", error);
}
return emptyBookingCounts();
}
};
@@ -1,40 +1,87 @@
<script setup>
import { useRouter } from 'vue-router';
import { useRouter } from "vue-router";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2";
import { ref } from 'vue';
defineProps(['hasPermission']);
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { pingApiServer } from "@/services/apiHealth.js";
const props = defineProps({
hasPermission: {
type: Boolean,
default: false,
},
});
const router = useRouter();
const showDebugInfo = ref(false);
const isSessionInitiated = computed(() => SessionUser.isInitiated());
let sessionTimeout = null;
// If the user is not initiated, and it takes more than 5 seconds, show an error message
setTimeout(() => {
if (!SessionUser.isInitiated()) {
Swal.fire({
title: 'Error',
text: 'The user session could not be initiated.',
icon: 'error',
confirmButtonText: 'Clear session, and try again',
showCancelButton: true,
cancelButtonText: 'Retry',
}).then(() => {
SessionUser.auth.forceClearSession();
window.location.reload();
});
const clearSessionTimeout = () => {
if (sessionTimeout) {
clearTimeout(sessionTimeout);
sessionTimeout = null;
}
}, 5000);
};
const redirectToConnectivityIssue = () => {
if (typeof window === "undefined" || window.location.pathname === "/connectivity-issue") {
return;
}
window.location.href = "/connectivity-issue";
};
const handleSlowSessionBootstrap = async () => {
if (!SessionUser.isInitiated()) {
const pingResult = await pingApiServer();
if (!pingResult.ok) {
redirectToConnectivityIssue();
return;
}
const result = await Swal.fire({
title: "Error",
text: "The user session could not be initiated.",
icon: "error",
confirmButtonText: "Clear session, and try again",
showCancelButton: true,
cancelButtonText: "Retry",
});
if (result.isConfirmed) {
SessionUser.auth.forceClearSession();
SessionUser.functions.redirectTo.pages.login();
return;
}
await SessionUser.initiateOnAppStart({ force: true });
}
};
watch(isSessionInitiated, (initiated) => {
if (initiated) {
clearSessionTimeout();
}
});
onMounted(() => {
sessionTimeout = setTimeout(() => {
void handleSlowSessionBootstrap();
}, 15000);
});
onBeforeUnmount(clearSessionTimeout);
</script>
<template>
<div>
<div v-if="!SessionUser.isInitiated()">
<div v-if="!isSessionInitiated">
<div class="pageloader is-active is-dark">
<span class="title">Bekræfter bruger...</span>
</div>
</div>
<slot v-if="hasPermission" />
<slot v-else-if="props.hasPermission" />
<div v-else>
<div class="my-6">
<h2 class="title is-2">403 Forbidden</h2>
@@ -47,9 +94,18 @@ setTimeout(() => {
</div>
</div>
<!-- Simple checks -->
<p v-if="router.currentRoute.value.path === '/user'">You are on the /user page. <br>You do {{ SessionUser.hasPermission('user') ? '' : 'not' }} have permission ( user ) to view this page.</p>
<p v-if="router.currentRoute.value.path === '/admin'">You are on the /admin page. <br>You do {{ SessionUser.hasPermission('admin') ? '' : 'not' }} have permission ( admin ) to view this page.</p>
<p v-if="router.currentRoute.value.path === '/superuser'">You are on the /superuser <br>page. You do {{ SessionUser.hasPermission('superuser') ? '' : 'not' }} have permission ( superuser ) to view this page.</p>
<p v-if="router.currentRoute.value.path === '/user'">
You are on the /user page. <br />You do {{ SessionUser.hasPermission("user") ? "" : "not" }} have permission (
user ) to view this page.
</p>
<p v-if="router.currentRoute.value.path === '/admin'">
You are on the /admin page. <br />You do {{ SessionUser.hasPermission("admin") ? "" : "not" }} have permission (
admin ) to view this page.
</p>
<p v-if="router.currentRoute.value.path === '/superuser'">
You are on the /superuser <br />page. You do {{ SessionUser.hasPermission("superuser") ? "" : "not" }} have
permission ( superuser ) to view this page.
</p>
<div v-if="showDebugInfo">
<div class="message mb-6">
<div class="message-header">
@@ -66,7 +122,9 @@ setTimeout(() => {
<!-- Button to go back to the previous page -->
<button class="button is-dark" @click="router.go(-1)">Go back</button>
<!-- Button to see the debug information -->
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-if="!showDebugInfo">Show debug information</button>
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-if="!showDebugInfo">
Show debug information
</button>
<!-- Button to hide the debug information -->
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-else>Hide debug information</button>
</div>
@@ -74,6 +132,4 @@ setTimeout(() => {
</div>
</template>
<style scoped>
</style>
<style scoped></style>
+8 -5
View File
@@ -48,10 +48,13 @@ export const getDepartmentDescription = (id) => {
export const getDepartmentsGuest = async (queryParams = {}) => {
isLoading.value = true;
const queryString = new URLSearchParams(queryParams).toString();
const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get");
departments.value = sortByDepartmentPriorityOrder(request.data.data || []);
isLoading.value = false;
return departments.value;
try {
const queryString = new URLSearchParams(queryParams).toString();
const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get");
departments.value = sortByDepartmentPriorityOrder(request.data.data || []);
return departments.value;
} finally {
isLoading.value = false;
}
};
</script>

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