Compare commits

..
Author SHA1 Message Date
Jeppe Bundgaard 2f671024ab Add mocks for department daily report components and update Vitest configuration for SCSS 2026-06-02 17:41:59 +02:00
Jeppe Bundgaard 605a7ca433 Refactor department weather component to support multi-day hourly mode and update related tests 2026-06-02 17:26:17 +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
315 changed files with 25935 additions and 7845 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ on:
jobs:
upload-qodana-config:
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64, default]
steps:
- name: Checkout repository
+1 -1
View File
@@ -9,7 +9,7 @@ on:
jobs:
upload-qodana-config:
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64, default]
steps:
- name: Checkout repository
+9 -25
View File
@@ -18,15 +18,15 @@ jobs:
build-upload-and-verify:
runs-on: [self-hosted, Linux, X64, default]
env:
RELEASE_BASE_URL: https://dev.truckwash.io
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_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
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_WAIT_INITIAL_SECONDS: 45
RELEASE_WAIT_TIMEOUT_SECONDS: 600
RELEASE_POLL_INTERVAL_SECONDS: 10
steps:
- name: Checkout repository
@@ -72,26 +72,7 @@ jobs:
path: dist
retention-days: 14
- name: Install lftp
run: |
if ! command -v lftp >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y lftp
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 }}
- name: Wait for exact uploaded build
- name: Wait for Coolify release artifact
run: npm run release:verify-upload
- name: Public live Playwright gate
@@ -118,10 +99,13 @@ jobs:
-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_EXPECTED_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.ref_name }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Update server version after verification
run: npm run release:update-server-version
+25 -5
View File
@@ -16,7 +16,7 @@ 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]
steps:
- name: Checkout repository
@@ -65,15 +65,35 @@ jobs:
if: github.event_name != 'schedule'
needs: build-and-unit
runs-on: [self-hosted, Linux, X64, default]
env:
PLAYWRIGHT_PR_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
PLAYWRIGHT_PR_HEAD: ${{ github.sha }}
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:
@@ -87,7 +107,7 @@ jobs:
run: npx playwright install --with-deps chromium
- name: Run Playwright PR tests
run: npm run test:e2e:pr -- --base="$PLAYWRIGHT_PR_BASE" --head="$PLAYWRIGHT_PR_HEAD"
run: npm run test:e2e:pr -- --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
- name: Upload Playwright report
if: failure()
+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
+13
View File
@@ -5,18 +5,27 @@ 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 ~ ^/(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;
}
@@ -30,11 +39,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:
+44 -45
View File
@@ -7,7 +7,6 @@
"": {
"name": "truckwashdashboardsfrontend",
"version": "0.0.0",
"hasInstallScript": true,
"dependencies": {
"@azure/msal-browser": "^4.12.0",
"@bubblewrap/cli": "^1.23.0",
@@ -42,6 +41,7 @@
"dayspan-vuetify": "^0.4.0",
"flow": "^0.2.3",
"html2canvas": "^1.4.1",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"sass": "^1.81.1",
"sweetalert2": "11.22.4",
@@ -233,7 +233,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1931,6 +1930,16 @@
}
}
},
"node_modules/@bubblewrap/core/node_modules/inquirer/node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@bubblewrap/core/node_modules/mute-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz",
@@ -1954,7 +1963,8 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/@bubblewrap/validator": {
"version": "1.22.0",
@@ -2077,7 +2087,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -2126,7 +2135,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -2564,7 +2572,6 @@
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"preact": "~10.12.1"
}
@@ -2808,7 +2815,6 @@
"resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz",
"integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/core": "^0.22.12"
}
@@ -2845,7 +2851,6 @@
"resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz",
"integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -2858,7 +2863,6 @@
"resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.22.12.tgz",
"integrity": "sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -2883,7 +2887,6 @@
"resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.22.12.tgz",
"integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12",
"tinycolor2": "^1.6.0"
@@ -2927,7 +2930,6 @@
"resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz",
"integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -3051,7 +3053,6 @@
"resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz",
"integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -3064,7 +3065,6 @@
"resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz",
"integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -3080,7 +3080,6 @@
"resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz",
"integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -5071,7 +5070,6 @@
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
@@ -5087,7 +5085,6 @@
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
@@ -5343,7 +5340,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5378,7 +5374,6 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -5472,8 +5467,7 @@
"version": "5.10.4",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.4.tgz",
"integrity": "sha512-gt0VUqZ2+mr25ScbUcKZgJr96jKYm4vjOcxEWCEh/E5F4dWqhyo3dBhPRvNNnkKiWxkMd2cBwj3ZYH3rK39fkA==",
"license": "SEE LICENSE IN LICENSE",
"peer": true
"license": "SEE LICENSE IN LICENSE"
},
"node_modules/aria-query": {
"version": "5.3.1",
@@ -5597,7 +5591,6 @@
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
@@ -5743,7 +5736,6 @@
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
@@ -5821,7 +5813,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5906,8 +5897,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/bulma/-/bulma-1.0.4.tgz",
"integrity": "sha512-Ffb6YGXDiZYX3cqvSbHWqQ8+LkX6tVoTcZuVB3lm93sbAVXlO0D6QlOTMnV6g18gILpAXqkG2z9hf9z4hCjz2g==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/bulma-block-list": {
"version": "1.1.0",
@@ -6165,7 +6155,6 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -6434,7 +6423,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-what": "^5.2.0"
@@ -6588,7 +6576,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -6777,7 +6764,6 @@
"integrity": "sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==",
"deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-sfc": "2.7.16",
"csstype": "^3.1.0"
@@ -8335,8 +8321,7 @@
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/ieee754": {
"version": "1.2.1",
@@ -9000,7 +8985,6 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -9583,7 +9567,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"dev": true,
"license": "MIT"
},
"node_modules/mkdirp": {
@@ -10121,7 +10104,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"dev": true,
"license": "MIT"
},
"node_modules/performance-now": {
@@ -10183,6 +10165,34 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/pixelmatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz",
@@ -10403,7 +10413,6 @@
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"peer": true,
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
@@ -10741,7 +10750,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"dev": true,
"license": "MIT"
},
"node_modules/rgbcolor": {
@@ -10758,7 +10766,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -10942,7 +10949,6 @@
"resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz",
"integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==",
"license": "MIT",
"peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.1.5",
@@ -10964,7 +10970,6 @@
"integrity": "sha512-Do7u6iRb6K+lrllcTkB1BXcHwOxcKe3rEfOF/GcCLE2w3WpddakRAosJOHFUR37DpsvimQXEt5abs3NzUjEIqg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@bufbuild/protobuf": "^2.5.0",
"colorjs.io": "^0.5.0",
@@ -11662,7 +11667,6 @@
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -11934,7 +11938,6 @@
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"copy-anything": "^4"
@@ -12730,7 +12733,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -14123,7 +14125,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -14183,7 +14184,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz",
"integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.31",
"@vue/compiler-sfc": "3.5.31",
@@ -14871,7 +14871,6 @@
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
+1 -1
View File
@@ -11,7 +11,6 @@
"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",
@@ -87,6 +86,7 @@
"dayspan-vuetify": "^0.4.0",
"flow": "^0.2.3",
"html2canvas": "^1.4.1",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"sass": "^1.81.1",
"sweetalert2": "11.22.4",
+33 -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,16 @@ 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 = [];
// 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 +178,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 +374,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 +446,25 @@ 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: {
...process.env,
PLAYWRIGHT: "1",
},
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();
+1 -1
View File
@@ -12,7 +12,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: {
+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
+42 -8
View File
@@ -1,25 +1,34 @@
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",
@@ -53,6 +62,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 +72,23 @@ 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",
"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 +99,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 +132,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 +221,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 +243,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 +330,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 +363,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();
}
+36 -17
View File
@@ -159,6 +159,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 +319,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 +341,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 +363,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;
}
}
}
-2
View File
@@ -18,7 +18,6 @@ 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 ReleaseChannelUnavailable = defineAsyncComponent(() =>
@@ -123,7 +122,6 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
<header></header>
<main>
<DefaultPageWrapper>
<VersionCheck />
<router-view />
</DefaultPageWrapper>
</main>
@@ -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>
+1 -12
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>
@@ -36,4 +25,4 @@ const redirect = (path) => {
<style scoped>
</style>
</style>
+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,
]
);
@@ -2477,7 +2760,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 +3150,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'"
@@ -1260,7 +1260,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 +1506,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 +2002,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"
@@ -92,6 +92,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 +118,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 +277,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 +303,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: [
{
@@ -681,6 +687,10 @@ onMounted(() => {
});
onBeforeUnmount(() => {
if (focusOnReg1TimeoutId !== null) {
clearTimeout(focusOnReg1TimeoutId);
focusOnReg1TimeoutId = null;
}
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
});
@@ -752,11 +762,7 @@ watch(
<ButtonsBox class="pos-actions pos-actions--stacked">
<Cancel tabindex="2" 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')"
@@ -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>
@@ -125,4 +131,4 @@ input.is-searched {
flex-grow: 0;
}
</style>
</style>
@@ -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%;
}
</style>
.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,100 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { BSkeleton } from "buefy";
const props = defineProps<{
src: string | null;
alt?: string;
}>();
const emit = defineEmits<{
(e: "error"): void;
(e: "load"): void;
}>();
const isLoading = ref(false);
watch(
() => props.src,
(src) => {
isLoading.value = !!src;
},
{ immediate: true }
);
const onLoad = () => {
isLoading.value = false;
emit("load");
};
const onError = () => {
isLoading.value = false;
emit("error");
};
</script>
<template>
<div
v-if="src"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isLoading }"
data-testid="self-serve-dynamic-image-frame"
>
<b-skeleton
v-if="isLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
<img
:key="src"
:src="src"
:alt="alt || 'Machine status'"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
:class="{ 'is-loading': isLoading }"
@load="onLoad"
@error="onError"
/>
</div>
</template>
<style scoped>
.self-serve-dynamic-image-frame {
position: relative;
width: 100%;
max-width: 100%;
aspect-ratio: 16 / 9;
margin-left: auto;
margin-right: auto;
overflow: hidden;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
}
.self-serve-dynamic-image-skeleton {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 4px;
}
.self-serve-dynamic-image {
width: 100%;
height: 100%;
border-radius: 4px;
display: block;
object-fit: contain;
transition: opacity 120ms ease;
}
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</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,7 +1,8 @@
<script setup lang="ts">
import { BField, BIcon, BMessage, BRadioButton } from "buefy";
import { computed } from "vue";
import { BIcon, BMessage, BRadioButton } from "buefy";
defineProps<{
const props = defineProps<{
lanes: Array<any>;
selectedLaneId: number | string | null;
washType: string;
@@ -14,19 +15,30 @@ const emit = defineEmits<{
(e: "update:selectedLaneId", value: number): void;
(e: "update:washType", value: string): void;
}>();
const isSelectedMachineAvailable = computed(() => props.isMachineAvailable(props.selectedLaneId));
</script>
<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 +48,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 +61,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,24 +84,27 @@ 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"
:disabled="!isMachineAvailable(selectedLaneId)"
:disabled="!isSelectedMachineAvailable"
:title="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
:aria-label="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:washType', 'Machine')"
@input="emit('update:washType', 'Machine')"
>
<span>
<span>{{ $t("self_wash.machine") }}<br /></span>
<span v-if="!isMachineAvailable(selectedLaneId)">
<span v-if="!isSelectedMachineAvailable">
<small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.unavailable") }}
@@ -105,6 +120,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>;
@@ -74,20 +75,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) {
return title;
}
const programTitle = title.match(/^(.*?\bprogram)(\b.*)$/i);
if (programTitle) {
return `${programTitle[1]} #${programWheelSelection}${programTitle[2]}`;
}
return `${title} #${programWheelSelection}`;
};
</script>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { BIcon, BSkeleton } from "buefy";
import { BIcon } from "buefy";
import SelfServeDynamicImageFrame from "@/components/displays/selfServe/SelfServeDynamicImageFrame.vue";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
const props = defineProps<{
@@ -18,12 +18,6 @@ const emit = defineEmits<{
(e: "clear-dynamic-image"): void;
}>();
const isDynamicImageLoading = ref(false);
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
}, { immediate: true });
const emitToggleTask = (taskId: number, value: boolean) => {
emit("toggle-task", taskId, value);
};
@@ -32,12 +26,7 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
emit("download-attachment", taskId, attachmentId, attachment);
};
const onDynamicImageLoad = () => {
isDynamicImageLoading.value = false;
};
const onDynamicImageError = () => {
isDynamicImageLoading.value = false;
emit("clear-dynamic-image");
};
</script>
@@ -58,29 +47,11 @@ const onDynamicImageError = () => {
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<div
v-if="dynamicImageUrl"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isDynamicImageLoading }"
>
<b-skeleton
v-if="isDynamicImageLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
<img
: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"
/>
</div>
<SelfServeDynamicImageFrame
:src="dynamicImageUrl"
alt="Machine status"
@error="onDynamicImageError"
/>
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
<h1 class="title has-text-centered mb-2" v-if="activeTasks.length > 0">{{ $t("self_wash.start_machine") }}</h1>
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
@@ -94,40 +65,3 @@ const onDynamicImageError = () => {
</div>
</div>
</template>
<style scoped>
.self-serve-dynamic-image-frame {
position: relative;
max-width: 100%;
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;
}
.self-serve-dynamic-image-skeleton {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 4px;
}
.self-serve-dynamic-image {
max-width: 100%;
height: auto;
border-radius: 4px;
display: block;
margin-left: auto;
margin-right: auto;
transition: opacity 120ms ease;
}
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</style>
@@ -16,6 +16,7 @@ const props = defineProps<{
availableProductIds: number[];
vehicleTypes: Array<any>;
vehicleStepError?: string | null;
vehicleStepGuidance?: string | null;
}>();
const emit = defineEmits<{
@@ -109,6 +110,15 @@ 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
@@ -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>
@@ -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,14 +245,6 @@ 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>
@@ -234,36 +252,42 @@ const onResendInvite = async (subuser) => {
<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>
<ActionSettingsWheelButton v-if="hasRowActions(subuser)" display-actions-directly>
<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>
@@ -143,4 +133,4 @@ const parseBalance = (user) => {
width: 1%;
white-space: nowrap;
}
</style>
</style>
@@ -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,44 @@ 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 setVehicleObject = (emittedVehicleObject, options = {}) => {
const normalizedOptions = {
preserveManualReference: true,
@@ -224,17 +262,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 +321,10 @@ const setBookingObject = (emittedBookingObject, options = {}) => {
...options,
};
if (getBookingStateKey(bookingObject.value) === getBookingStateKey(emittedBookingObject)) {
return;
}
bookingObject.value = emittedBookingObject;
if (emittedBookingObject && emittedBookingObject.reference_number) {
@@ -284,7 +335,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) => {
@@ -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
@@ -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">
+17 -6
View File
@@ -118,6 +118,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 +285,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") {
@@ -646,9 +652,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 +778,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>
@@ -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,
@@ -332,6 +332,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" },
@@ -3,38 +3,80 @@
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 } 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(() => {
const clearSessionTimeout = () => {
if (sessionTimeout) {
clearTimeout(sessionTimeout);
sessionTimeout = null;
}
};
const redirectToConnectivityIssue = () => {
if (typeof window === "undefined" || window.location.pathname === "/connectivity-issue") {
return;
}
window.location.href = "/connectivity-issue";
};
const handleSlowSessionBootstrap = async () => {
if (!SessionUser.isInitiated()) {
Swal.fire({
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',
}).then(() => {
SessionUser.auth.forceClearSession();
window.location.reload();
});
if (result.isConfirmed) {
SessionUser.auth.forceClearSession();
SessionUser.functions.redirectTo.pages.login();
return;
}
await SessionUser.initiateOnAppStart({ force: true });
}
}, 5000);
};
onMounted(() => {
sessionTimeout = setTimeout(() => {
void handleSlowSessionBootstrap();
}, 5000);
});
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>
@@ -76,4 +118,4 @@ setTimeout(() => {
<style scoped>
</style>
</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>
+61 -9
View File
@@ -1,11 +1,12 @@
<script>
import { ref, inject, provide } from 'vue';
import { getCurrentScope, inject, onScopeDispose, provide, ref } from 'vue';
import axios from "axios";
import {API_URL} from "@/config.js";
import { parseError, clearErrors } from "@/components/request/HandleGlobalError.vue";
import { exportRowsToExcel } from "@/services/TableExcelExportService.js";
export const PaginatedListKey = Symbol('PaginatedList');
const SEARCH_DEBOUNCE_MS = 250;
/**
* usePaginatedList composable
@@ -33,6 +34,9 @@ export function usePaginatedList() {
/** The latest search */
const latestSearch = ref(null);
const latestRequestId = ref(0);
let activeRequestController = null;
let searchDebounceTimeout = null;
/** Additional query parameters */
const additionalQueryParameters = ref({});
@@ -124,24 +128,58 @@ export function usePaginatedList() {
return search === latestSearch.value;
};
const clearPendingSearch = () => {
if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout);
searchDebounceTimeout = null;
}
};
const abortActiveRequest = () => {
activeRequestController?.abort();
activeRequestController = null;
};
if (getCurrentScope()) {
onScopeDispose(() => {
clearPendingSearch();
abortActiveRequest();
});
}
const isCanceledRequest = (error) => {
return error?.code === "ERR_CANCELED"
|| error?.name === "CanceledError"
|| error?.name === "AbortError"
|| (typeof axios.isCancel === "function" && axios.isCancel(error));
};
/** The paginated get request */
const paginatedGetRequest = async () => {
// Set the latest search
latestSearch.value = metaSearch.value;
const tmp_search = metaSearch.value;
const requestId = latestRequestId.value + 1;
latestRequestId.value = requestId;
const token = localStorage.getItem('token');
if (!token) {
loadSwitch(false);
return null;
}
abortActiveRequest();
const requestController = typeof AbortController !== "undefined" ? new AbortController() : null;
activeRequestController = requestController;
try {
const response = await axios.get(API_URL + endpoint.value, {
params: buildRequestParams(),
headers: buildRequestHeaders(token)
headers: buildRequestHeaders(token),
...(requestController ? { signal: requestController.signal } : {}),
});
// Check if the search is the latest search
if (!isLatestSearch(tmp_search)) {
if (!isLatestSearch(tmp_search) || requestId !== latestRequestId.value) {
return null;
}
@@ -152,22 +190,30 @@ export function usePaginatedList() {
response?.data?.meta?.pagination?.total || 0
);
meta.value = response?.data?.meta || null;
loadSwitch(false);
setLastUpdated();
return response;
} catch (error) {
parseError(error, 'paginatedGetRequest');
console.log(error);
loadSwitch(false);
if (!isCanceledRequest(error)) {
parseError(error, 'paginatedGetRequest');
console.log(error);
}
return null;
} finally {
if (activeRequestController === requestController) {
activeRequestController = null;
}
if (requestId === latestRequestId.value) {
loadSwitch(false);
}
}
};
/** The load list function */
const loadList = () => {
clearPendingSearch();
loadSwitch(true);
// clear the list
paginatedGetRequest();
return paginatedGetRequest();
};
/** Set the page */
@@ -254,11 +300,17 @@ export function usePaginatedList() {
const search = (searchValue, autoLoad = true) => {
// If the search is *, remove the search
metaSearch.value = searchValue === '*' ? null : searchValue;
latestSearch.value = metaSearch.value;
// Reset the page to 1
setPage(1);
// Load the list
if (autoLoad) {
loadList();
clearPendingSearch();
loadSwitch(true);
searchDebounceTimeout = setTimeout(() => {
searchDebounceTimeout = null;
paginatedGetRequest();
}, SEARCH_DEBOUNCE_MS);
}
};
@@ -5,6 +5,7 @@ import {
releaseChannelKey,
releaseChannelOptions,
} from "@/services/releaseChannelAvailability.js";
import ReleaseFrontendVersionBadge from "@/components/release/ReleaseFrontendVersionBadge.vue";
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
const props = defineProps({
@@ -157,7 +158,10 @@ const selectOption = (option) => {
data-testid="release-channel-selector"
>
<div class="release-channel-selector__header">
<p>{{ selectorTitle }}</p>
<div class="release-channel-selector__header-main">
<p>{{ selectorTitle }}</p>
<ReleaseFrontendVersionBadge v-if="isSidebar" />
</div>
<span v-if="selectorSubtitle">{{ selectorSubtitle }}</span>
</div>
@@ -239,6 +243,14 @@ const selectOption = (option) => {
margin-bottom: 10px;
}
.release-channel-selector__header-main {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.release-channel-selector__header p {
margin: 0;
color: #10243f;
@@ -1,21 +1,42 @@
<script setup>
import { ref } from "vue";
import { computed, inject, ref } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue";
import ReleaseUpdateWidget from "@/components/release/ReleaseUpdateWidget.vue";
import {
clearSelectedReleaseChannel,
releaseChannelSelectorVisible,
switchSelectedReleaseChannel,
} from "@/services/releaseChannelAvailability.js";
import {
isReleaseSourceOverrideAvailable,
RELEASE_SOURCE_MODES,
setReleaseSourceOverride,
} from "@/services/releaseBootstrap.js";
import { inspectReleaseRuntimeForUpdate } from "@/services/releaseUpdate.js";
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
const switchingSlug = ref("");
const switchError = ref("");
const { t, te } = useI18n({ useScope: "global" });
const reloadWindow = inject("releaseSourceReload", () => {
if (typeof window !== "undefined") {
window.location.reload();
}
});
const tr = (key, fallback) => {
const path = `configuration.release_manager.channel_selector.${key}`;
return te(path) ? t(path) : fallback;
};
const showUseLocalFrontend = computed(
() =>
releaseChannelSelectorVisible.value &&
isReleaseSourceOverrideAvailable() &&
String(releaseRuntimeState.source || "").toLowerCase() !== RELEASE_SOURCE_MODES.LOCAL
);
const switchChannel = async (option) => {
if (switchingSlug.value) {
return;
@@ -25,29 +46,84 @@ const switchChannel = async (option) => {
switchError.value = "";
try {
await switchSelectedReleaseChannel(option.channel, SessionUser.refreshReleaseRuntime);
void inspectReleaseRuntimeForUpdate(releaseRuntimeState, { autoDownload: true });
} catch (error) {
switchError.value = tr("switch_error", "Release channel could not be switched. The previous channel is still active.");
} finally {
switchingSlug.value = "";
}
};
const useLocalFrontend = () => {
if (switchingSlug.value) {
return;
}
setReleaseSourceOverride(RELEASE_SOURCE_MODES.LOCAL);
clearSelectedReleaseChannel();
reloadWindow();
};
</script>
<template>
<div v-if="releaseChannelSelectorVisible" class="release-channel-sidebar-selector">
<div class="release-channel-sidebar-selector">
<ReleaseChannelSelector
v-if="releaseChannelSelectorVisible"
variant="sidebar"
:switching-slug="switchingSlug"
:disabled="Boolean(switchingSlug)"
@select="switchChannel"
/>
<button
v-if="showUseLocalFrontend"
type="button"
class="release-channel-sidebar-selector__local-button"
data-testid="release-channel-use-local-frontend"
:disabled="Boolean(switchingSlug)"
@click="useLocalFrontend"
>
<i class="fas fa-code" aria-hidden="true"></i>
<span>{{ tr("use_local_frontend", "Use local frontend") }}</span>
</button>
<p v-if="switchError" class="release-channel-sidebar-selector__error" role="alert">
{{ switchError }}
</p>
<ReleaseUpdateWidget />
</div>
</template>
<style scoped>
.release-channel-sidebar-selector__local-button {
width: calc(100% - 32px);
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: -2px 16px 14px;
padding: 9px 10px;
border: 1px solid #b8c7d9;
border-radius: 5px;
background: #f8fbff;
color: #153554;
cursor: pointer;
font-size: 0.82rem;
font-weight: 800;
line-height: 1.2;
text-align: center;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.release-channel-sidebar-selector__local-button:hover:not(:disabled) {
border-color: #6f8fb4;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.release-channel-sidebar-selector__local-button:disabled {
cursor: default;
opacity: 0.7;
}
.release-channel-sidebar-selector__error {
margin: 0 16px 14px;
color: #b42318;
@@ -0,0 +1,115 @@
<script setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import {
isSameReleaseCommit,
releaseUpdateState,
shortReleaseCommit,
} from "@/services/releaseUpdate.js";
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
const { locale } = useI18n({ useScope: "global" });
const textValue = (value) => String(value ?? "").trim();
const githubAccess = (version) =>
version?.metadata?.github_access && typeof version.metadata.github_access === "object"
? version.metadata.github_access
: {};
const githubCommit = (version) => {
const access = githubAccess(version);
return version?.commit && typeof version.commit === "object"
? version.commit
: access.commit || access.latest_commit || null;
};
const versionTimestamp = (version) => {
const commit = githubCommit(version);
return (
textValue(version?.deployed_at) ||
textValue(version?.released_at) ||
textValue(version?.promoted_at) ||
textValue(version?.created_at) ||
textValue(version?.commit_authored_at) ||
textValue(commit?.authored_at) ||
textValue(githubAccess(version)?.commit_authored_at)
);
};
const formatReleaseTime = (value) => {
const timestamp = textValue(value);
if (!timestamp) {
return "";
}
const parsed = new Date(timestamp);
if (Number.isNaN(parsed.getTime())) {
return timestamp;
}
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(parsed);
};
const currentText = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
const latestText = computed(() => shortReleaseCommit(releaseUpdateState.latestCommit));
const currentReleaseTime = computed(() =>
formatReleaseTime(versionTimestamp(releaseRuntimeState.versions?.frontend || null))
);
const hasLatestUpdate = computed(
() =>
Boolean(releaseUpdateState.latestCommit) &&
!isSameReleaseCommit(releaseUpdateState.currentCommit, releaseUpdateState.latestCommit)
);
const title = computed(() => {
const releaseTime = currentReleaseTime.value;
if (!hasLatestUpdate.value) {
return releaseTime ? `Frontend ${currentText.value} released ${releaseTime}` : `Frontend ${currentText.value}`;
}
return releaseTime
? `Frontend ${currentText.value} released ${releaseTime} to ${latestText.value}`
: `Frontend ${currentText.value} to ${latestText.value}`;
});
</script>
<template>
<span
class="release-frontend-version-badge"
:class="{ 'release-frontend-version-badge--update': hasLatestUpdate }"
:title="title"
data-testid="release-frontend-version-badge"
>
<span class="release-frontend-version-badge__commit">{{ currentText }}</span>
<template v-if="hasLatestUpdate">
<span class="release-frontend-version-badge__arrow">-&gt;</span>
<span class="release-frontend-version-badge__commit">{{ latestText }}</span>
</template>
</span>
</template>
<style scoped>
.release-frontend-version-badge {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0;
border: 0;
background: transparent;
color: #64748b;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.68rem;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.release-frontend-version-badge__commit {
padding: 0;
color: #64748b;
}
.release-frontend-version-badge--update {
color: #64748b;
}
.release-frontend-version-badge__arrow {
color: #64748b;
}
</style>
@@ -1,7 +1,9 @@
<script setup>
import { computed } from "vue";
const emit = defineEmits(["retry"]);
defineProps({
const props = defineProps({
operation: {
type: Object,
default: null,
@@ -16,6 +18,32 @@ const statusClass = (status) => {
if (["running", "queued", "pending"].includes(normalized)) return "is-info";
return "is-light";
};
const shortCommit = (value) => {
const text = String(value || "").trim();
return text.length > 12 ? text.slice(0, 12) : text;
};
const operationContext = computed(() => props.operation?.context || {});
const releaseGateContext = computed(() => operationContext.value.release_gate || {});
const autoSyncEvent = computed(
() => operationContext.value.auto_sync_event || props.operation?.steps?.find((step) => step.context?.auto_sync_event)?.context?.auto_sync_event || null
);
const operationMeta = computed(() => {
const context = operationContext.value;
const gate = releaseGateContext.value;
const event = autoSyncEvent.value || {};
const rows = [
["Source", context.source || event.source || (gate.auto_sync ? "release_gate" : "")],
["App", props.operation?.app || context.app || gate.app || event.app],
["Commit", shortCommit(context.commit_sha || gate.expected_commit || event.commit_sha)],
["Auto-sync", event.status || (gate.auto_sync ? "requested" : "")],
];
return rows
.filter(([, value]) => String(value || "").trim() !== "")
.map(([label, value]) => ({ label, value }));
});
</script>
<template>
@@ -27,6 +55,13 @@ const statusClass = (status) => {
<span>{{ operation?.warning_step_count || 0 }} warnings</span>
</div>
<div v-if="operationMeta.length" class="release-operation-timeline__meta" data-testid="release-operation-meta">
<span v-for="item in operationMeta" :key="item.label">
<strong>{{ item.label }}</strong>
<span>{{ item.value }}</span>
</span>
</div>
<ol class="release-operation-timeline__steps">
<li
v-for="step in operation?.steps || []"
@@ -72,6 +107,7 @@ const statusClass = (status) => {
}
.release-operation-timeline__status,
.release-operation-timeline__meta,
.release-operation-timeline__step-head,
.release-operation-timeline__diagnostic,
.release-operation-timeline__solution,
@@ -86,6 +122,21 @@ const statusClass = (status) => {
justify-content: flex-start;
}
.release-operation-timeline__meta {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
flex-wrap: wrap;
justify-content: flex-start;
padding: 0.55rem 0.65rem;
}
.release-operation-timeline__meta span {
align-items: center;
display: inline-flex;
gap: 0.35rem;
}
.release-operation-timeline__steps {
display: grid;
gap: 0.75rem;
@@ -0,0 +1,232 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, watch } from "vue";
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
import {
checkReleaseUpdateNow,
downloadReleaseCandidate,
inspectReleaseRuntimeForUpdate,
installReadyReleaseUpdate,
isSameReleaseCommit,
releaseUpdateState,
shortReleaseCommit,
startReleaseUpdateAutoCheck,
} from "@/services/releaseUpdate.js";
let stopAutoCheck = null;
const updateAvailable = computed(
() =>
Boolean(releaseUpdateState.latestCommit) &&
!isSameReleaseCommit(releaseUpdateState.currentCommit, releaseUpdateState.latestCommit)
);
const isVisible = computed(
() => updateAvailable.value && ["downloading", "ready", "failed"].includes(releaseUpdateState.status)
);
const progress = computed(() => Math.max(0, Math.min(100, Number(releaseUpdateState.progress || 0))));
const progressStyle = computed(() => ({ width: `${progress.value}%` }));
const latestCommitText = computed(() => shortReleaseCommit(releaseUpdateState.latestCommit));
const currentCommitText = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
const statusLabel = computed(() => {
if (releaseUpdateState.status === "ready") {
return "Ready";
}
if (releaseUpdateState.status === "failed") {
return "Failed";
}
return "Downloading";
});
const runtimeSignature = computed(() =>
JSON.stringify({
channel: releaseRuntimeState.channel?.slug || "",
commit: releaseRuntimeState.versions?.frontend?.commit_sha || releaseRuntimeState.versions?.frontend?.commit || "",
frontendBaseUrl: releaseRuntimeState.frontendBaseUrl || "",
configured: releaseRuntimeState.availability?.configured !== false,
})
);
const hasRuntimeCandidate = () =>
Boolean(releaseRuntimeState.channel || releaseRuntimeState.frontendBaseUrl || releaseRuntimeState.versions?.frontend);
const inspectCurrentRuntime = () => {
if (!hasRuntimeCandidate()) {
return;
}
void inspectReleaseRuntimeForUpdate(releaseRuntimeState, { autoDownload: true });
};
const retryDownload = () => {
if (releaseUpdateState.candidate) {
void downloadReleaseCandidate(releaseUpdateState.candidate);
return;
}
void checkReleaseUpdateNow();
};
const installUpdate = () => {
void installReadyReleaseUpdate();
};
onMounted(() => {
stopAutoCheck = startReleaseUpdateAutoCheck();
inspectCurrentRuntime();
});
onBeforeUnmount(() => {
stopAutoCheck?.();
stopAutoCheck = null;
});
watch(runtimeSignature, inspectCurrentRuntime, { immediate: true });
</script>
<template>
<section v-if="isVisible" class="release-update-widget" data-testid="release-update-widget">
<div class="release-update-widget__topline">
<span class="release-update-widget__label">{{ statusLabel }}</span>
<span class="release-update-widget__commits">{{ currentCommitText }} -&gt; {{ latestCommitText }}</span>
</div>
<template v-if="releaseUpdateState.status === 'ready'">
<button
type="button"
class="release-update-widget__install"
data-testid="release-update-install"
@click="installUpdate"
>
Install
</button>
</template>
<template v-else-if="releaseUpdateState.status === 'failed'">
<div class="release-update-widget__failed">
<span>{{ releaseUpdateState.error || "Download failed." }}</span>
<button type="button" data-testid="release-update-retry" @click="retryDownload">Retry</button>
</div>
</template>
<template v-else>
<div
class="release-update-widget__progress"
role="progressbar"
:aria-valuenow="progress"
aria-valuemin="0"
aria-valuemax="100"
data-testid="release-update-progress"
>
<span :style="progressStyle"></span>
</div>
<div class="release-update-widget__progress-meta">
<span>{{ progress }}%</span>
<span v-if="releaseUpdateState.totalAssets">
{{ releaseUpdateState.downloadedAssets }}/{{ releaseUpdateState.totalAssets }}
</span>
</div>
</template>
</section>
</template>
<style scoped>
.release-update-widget {
margin: -2px 16px 14px;
padding: 9px 10px;
border: 1px solid #d7e2ef;
border-radius: 5px;
background: #f8fbff;
color: #1f2937;
}
.release-update-widget__topline,
.release-update-widget__progress-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.release-update-widget__label {
color: #10243f;
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
}
.release-update-widget__commits {
min-width: 0;
overflow: hidden;
color: #475569;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.68rem;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.release-update-widget__progress {
height: 7px;
margin-top: 8px;
border-radius: 999px;
background: #dbe5f0;
overflow: hidden;
}
.release-update-widget__progress span {
display: block;
height: 100%;
border-radius: inherit;
background: #153554;
transition: width 0.18s ease;
}
.release-update-widget__progress-meta {
margin-top: 5px;
color: #64748b;
font-size: 0.68rem;
font-weight: 700;
}
.release-update-widget__install {
width: 100%;
min-height: 30px;
margin-top: 8px;
border: 0;
border-radius: 4px;
background: #153554;
color: #ffffff;
cursor: pointer;
font-size: 0.78rem;
font-weight: 800;
}
.release-update-widget__install:hover {
background: #10243f;
}
.release-update-widget__failed {
display: grid;
gap: 7px;
margin-top: 7px;
}
.release-update-widget__failed span {
color: #b42318;
font-size: 0.7rem;
font-weight: 700;
line-height: 1.25;
}
.release-update-widget__failed button {
min-height: 28px;
border: 1px solid #f4a3a3;
border-radius: 4px;
background: #fff5f5;
color: #9f1c1c;
cursor: pointer;
font-size: 0.74rem;
font-weight: 800;
}
@media (prefers-reduced-motion: reduce) {
.release-update-widget__progress span {
transition: none;
}
}
</style>
+2
View File
@@ -2,6 +2,7 @@
import { ref } from 'vue'
import axios from 'axios'
import {API_URL} from "@/config.js";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
export const currentDepartment = ref(null);
export const selectDepartment = (department) => {
@@ -31,6 +32,7 @@ export const exitSession = () => {
}
});
clearEdgeGatewayWorkspaceCache();
localStorage.removeItem('token');
return result;
};
@@ -2,6 +2,7 @@
import axios from 'axios'
import { enqueueRequest } from "@/services/requestQueue.js";
import { buildCurrentReleaseHeaders, resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import { isTrustedReleaseUrl } from "@/services/releaseTrust.js";
/**
* Get the selected customer number for X-Customer-Number header (used by subusers)
@@ -18,22 +19,24 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
//throw new Error('No token was found, unable to make authenticated request');
}
const requestUrl = resolveReleaseApiUrl(url);
const canSendCredentials = isTrustedReleaseUrl(requestUrl);
// Build headers
const headers = {
...buildCurrentReleaseHeaders(),
};
if (token && token.length > 0) {
if (canSendCredentials && token && token.length > 0) {
headers.Authorization = `Bearer ${token}`;
}
// Add X-Customer-Number header if subuser has selected a grant
const isSubuser = localStorage.getItem('is_subuser') === 'true';
const selectedCustomerNumber = getSelectedCustomerNumber();
if (isSubuser && selectedCustomerNumber) {
if (canSendCredentials && isSubuser && selectedCustomerNumber) {
headers['X-Customer-Number'] = selectedCustomerNumber;
}
const requestUrl = resolveReleaseApiUrl(url);
return enqueueRequest(
() => axios({
method,
+136 -25
View File
@@ -49,6 +49,7 @@ import { SubuserGrants } from "@/components/session/token/SessionUser/Objects/Su
import { Subusers } from "@/components/session/token/SessionUser/Objects/Subusers.vue";
import { configureReleaseRuntime } from "@/services/releaseTimeline.js";
import { fetchReleaseRuntime } from "@/services/releaseBootstrap.js";
import { pingApiServer } from "@/services/apiHealth.js";
import { normalizeSessionPayload } from "@/services/sessionPayload.js";
import {
isReleaseChannelApiAvailabilityError,
@@ -57,6 +58,12 @@ import {
releaseChannelRuntimeRequestParams,
setReleaseChannelSwitchNoticePrincipal,
} from "@/services/releaseChannelAvailability.js";
import { clearCachedXlvaskUsageAmount } from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
import {
clearPeriodCache,
clearSelfWashCountsCache,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js";
const normalizePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -97,7 +104,16 @@ const hydrateSessionFromStorage = () => {
return true;
};
const clearPrivilegedSessionCaches = () => {
clearPeriodCache();
clearSelfWashCountsCache();
clearCachedXlvaskUsageAmount();
clearEdgeGatewayWorkspaceCache();
};
const clearStoredSession = () => {
clearPrivilegedSessionCaches();
if (typeof window === "undefined") {
return;
}
@@ -114,7 +130,8 @@ const applyReleaseRuntimeConfig = (runtime) => {
const normalizedRuntime = runtime || {};
configureReleaseRuntime(normalizedRuntime);
reconcileSelectedReleaseChannel(normalizedRuntime);
const runtimeUrls = normalizedRuntime.urls && typeof normalizedRuntime.urls === "object" ? normalizedRuntime.urls : {};
const runtimeUrls =
normalizedRuntime.urls && typeof normalizedRuntime.urls === "object" ? normalizedRuntime.urls : {};
SessionUser.runtimeConfig.release.traceId.value = normalizedRuntime.trace_id || null;
SessionUser.runtimeConfig.release.channel.value = normalizedRuntime.channel || null;
SessionUser.runtimeConfig.release.availableChannels.value =
@@ -233,6 +250,92 @@ const resetSessionState = () => {
configureReleaseRuntime({});
};
let sessionBootstrapPromise = null;
const extractErrorMessage = (error) => {
if (typeof error === "string") {
return error;
}
if (error?.response?.data?.data?.message) {
return error.response.data.data.message;
}
if (error?.response?.data?.message) {
return error.response.data.message;
}
if (typeof error?.response?.data === "string") {
return error.response.data;
}
if (error?.message) {
return error.message;
}
return "";
};
export const isInvalidSessionError = (error) => {
const status = Number.parseInt(String(error?.response?.status ?? ""), 10);
const message = extractErrorMessage(error).toLowerCase();
if (status === 401 || status === 419) {
return true;
}
if (![400, 403].includes(status)) {
return false;
}
return (
message.includes("invalid session") ||
message.includes("invalid token") ||
message.includes("unauthenticated") ||
message.includes("unauthorized")
);
};
const redirectToConnectivityIssue = () => {
if (typeof window === "undefined" || window.location.pathname === "/connectivity-issue") {
return false;
}
window.location.href = "/connectivity-issue";
return true;
};
export const handleSessionBootstrapFailure = async (error, { title, text } = {}) => {
if (isInvalidSessionError(error)) {
const pingResult = await pingApiServer();
if (!pingResult.ok) {
console.warn("Preserving stored session because API ping failed during invalid-session handling.", pingResult);
const redirected = redirectToConnectivityIssue();
return {
cleared: false,
apiReachable: false,
ping: pingResult,
redirectedTo: redirected ? "/connectivity-issue" : null,
};
}
forceClearSession();
SessionUser.functions.redirectTo.pages.login();
return { cleared: true, apiReachable: true, ping: pingResult, redirectedTo: "/login" };
}
parseError(error, "auth");
console.error(error);
await Swal.fire({
title,
text,
icon: "error",
confirmButtonText: "Ok",
});
return { cleared: false, apiReachable: null, ping: null, redirectedTo: null };
};
export const refreshSessionData = async () => {
if (!hydrateSessionFromStorage()) {
resetSessionState();
@@ -250,12 +353,30 @@ export const refreshSessionData = async () => {
* Initiate the user session on app start
* @returns {Promise<void>}
*/
export const initiateOnAppStart = async () => {
if (hydrateSessionFromStorage()) {
export const initiateOnAppStart = async ({ force = false } = {}) => {
if (sessionBootstrapPromise && !force) {
return await sessionBootstrapPromise;
}
const bootstrapPromise = (async () => {
if (!hydrateSessionFromStorage()) {
return null;
}
if (SessionUser.isSubuser.value) {
await getSubuserSessionData();
} else {
await getSessionData();
return await getSubuserSessionData();
}
return await getSessionData();
})();
sessionBootstrapPromise = bootstrapPromise;
try {
return await bootstrapPromise;
} finally {
if (sessionBootstrapPromise === bootstrapPromise) {
sessionBootstrapPromise = null;
}
}
};
@@ -272,6 +393,7 @@ export const authenticateUser = async (customer_number, password) => {
password,
})
.then((response) => {
clearPrivilegedSessionCaches();
SessionUser.token.value = response.data.token;
SessionUser.authenticated.value = true;
localStorage.setItem("token", response.data.token);
@@ -296,7 +418,7 @@ export const authenticateUser = async (customer_number, password) => {
* @param {number} [credentials.phone] - Phone number (4-15 digits)
* @param {number} [credentials.subuser_id] - Subuser ID
* @param {string} [credentials.username] - Username
* @param {string} credentials.password - Password (8-255 characters)
* @param {string} credentials.password - Password (8-255 characters, uppercase, lowercase, and number)
* @returns {Promise<void>}
*/
export const authenticateSubuser = async (credentials) => {
@@ -318,6 +440,7 @@ export const authenticateSubuser = async (credentials) => {
return await authenticatedRequest("/subusers/auth/password", "POST", requestBody)
.then((response) => {
clearPrivilegedSessionCaches();
SessionUser.token.value = response.data.data.session;
SessionUser.authenticated.value = true;
SessionUser.isSubuser.value = true;
@@ -372,23 +495,16 @@ export const getSubuserSessionData = async () => {
await refreshReleaseRuntime();
SessionUser.initiated.value = true;
})
.catch((error) => {
.catch(async (error) => {
if (isReleaseChannelApiAvailabilityError(error)) {
console.warn("Selected release channel API is unavailable during session bootstrap.", error);
markReleaseChannelApiUnavailable();
return;
}
parseError(error, "auth");
console.error(error);
Swal.fire({
return await handleSessionBootstrapFailure(error, {
title: "Fejl ved hentning af subbrugerdata",
text: "Der opstod en fejl ved hentning af dine brugerdata. Prøv at logge ind igen.",
icon: "error",
confirmButtonText: "Ok",
}).then(() => {
SessionUser.auth.forceClearSession();
SessionUser.functions.redirectTo.pages.login();
});
});
};
@@ -448,23 +564,16 @@ export const getSessionData = async () => {
}
SessionUser.initiated.value = true;
})
.catch((error) => {
.catch(async (error) => {
if (isReleaseChannelApiAvailabilityError(error)) {
console.warn("Selected release channel API is unavailable during session bootstrap.", error);
markReleaseChannelApiUnavailable();
return;
}
parseError(error, "auth");
console.error(error);
Swal.fire({
return await handleSessionBootstrapFailure(error, {
title: "Fejl ved hentning af brugerdata",
text: "Der opstod en fejl ved hentning af dine brugerdata. Prøv at logge ind igen.",
icon: "error",
confirmButtonText: "Ok",
}).then(() => {
SessionUser.auth.forceClearSession();
SessionUser.functions.redirectTo.pages.login();
});
});
};
@@ -578,6 +687,7 @@ export const SessionUser = {
* @param {number|null} customerNumber - The billing_customer_number to select, or null to clear
*/
selectGrant: (customerNumber) => {
clearEdgeGatewayWorkspaceCache();
if (customerNumber === null) {
localStorage.removeItem("selected_customer_number");
SessionUser.subuser.selectedGrantCustomerNumber.value = null;
@@ -795,6 +905,7 @@ export const SessionUser = {
* @param {string} token
*/
setToken: (token) => {
clearPrivilegedSessionCaches();
SessionUser.token.value = token;
localStorage.setItem("token", token);
// Reload the window to make sure the user's session is initiated
@@ -49,7 +49,7 @@ const buildDepartmentOptions = (departments, selectedDepartmentId) => {
const buildComplaintCategoryOptions = (selectedCategory) => {
const normalizedSelectedCategory = String(selectedCategory ?? "").trim();
const options = [
`<option value="">${escapeHtml("Vaelg kategori")}</option>`,
`<option value="">${escapeHtml("Vælg kategori")}</option>`,
...DEPARTMENT_DAILY_REPORT_COMPLAINT_CATEGORY_OPTIONS.map((category) => {
const selected = category.value === normalizedSelectedCategory ? " selected" : "";
return `<option value="${escapeHtml(category.value)}"${selected}>${escapeHtml(category.label)}</option>`;
@@ -236,7 +236,7 @@ const resolveComplaintCustomerNumberFromLookup = (lookupState) => {
return lookupState.selectedCustomer.customer_number;
}
throw new Error("Vaelg en kunde fra listen eller ryd feltet.");
throw new Error("Vælg en kunde fra listen eller ryd feltet.");
};
const setupComplaintCustomerLookupField = (lookupState) => {
@@ -521,28 +521,28 @@ export const DepartmentDailyReportComplaints = {
10
);
if (!Number.isFinite(departmentId) || departmentId <= 0) {
throw new Error("Afdeling er paakraevet.");
throw new Error("Afdeling er påkrævet.");
}
const washDate = (
document.getElementById("superuser-complaint-wash-date")?.value || ""
).trim();
if (washDate === "") {
throw new Error("Dato for vask er paakraevet.");
throw new Error("Dato for vask er påkrævet.");
}
const category = (
document.getElementById("superuser-complaint-category")?.value || ""
).trim();
if (!isDepartmentDailyReportComplaintCategory(category)) {
throw new Error("Kategori er paakraevet.");
throw new Error("Kategori er påkrævet.");
}
const description = (
document.getElementById("superuser-complaint-description")?.value || ""
).trim();
if (description === "") {
throw new Error("Beskrivelse er paakraevet.");
throw new Error("Beskrivelse er påkrævet.");
}
const customerNumber = resolveComplaintCustomerNumberFromLookup(customerLookupState);
@@ -20,6 +20,67 @@
return normalizedRelayId;
};
const OPERATIONAL_STATUSES = ["AVAILABLE", "OCCUPIED", "RESERVED"];
const SELF_SERVE_CONFIGURATION_FIELDS = [
{ field: "relay_in_id", label: "Indgangsrelæ" },
{ field: "relay_out_id", label: "Udgangsrelæ" },
{ field: "relay_machine_id", label: "Maskinrelæ" },
{ field: "relay_machine_program_picker_id", label: "Programvælgerrelæ" },
{ field: "relay_machine_cleaner_id", label: "Vaskerelæ" },
{ field: "dynamic_image_id", label: "Maskinstatusbillede" },
{ field: "machine_type_id", label: "Maskintype" },
];
const hasConfiguredValue = (value) => {
if (value === null || value === undefined) {
return false;
}
if (typeof value === "string") {
const normalizedValue = value.trim().toLowerCase();
return normalizedValue !== "" && normalizedValue !== "0" && normalizedValue !== "null";
}
if (typeof value === "number") {
return value > 0;
}
return Boolean(value);
};
const getSelfServeConfigurationWarnings = (object) => {
if (Array.isArray(object?.dognvask_configuration_warnings)) {
return object.dognvask_configuration_warnings;
}
return SELF_SERVE_CONFIGURATION_FIELDS
.filter(({ field }) => !hasConfiguredValue(object?.[field]))
.map(({ field, label }) => ({
field,
label,
message: `${label} mangler`,
}));
};
const isSelfServeConfigured = (object) => {
if (typeof object?.dognvask_configured === "boolean") {
return object.dognvask_configured;
}
if (typeof object?.selfserve_configured === "boolean") {
return object.selfserve_configured;
}
return getSelfServeConfigurationWarnings(object).length === 0;
};
const isMachineStatusEnabled = (object) => {
if (typeof object?.machine_status_enabled === "boolean") {
return object.machine_status_enabled;
}
return OPERATIONAL_STATUSES.includes(String(object?.status ?? "").toUpperCase());
};
/**
* The Department Lanes object
*/
@@ -276,6 +337,20 @@
},
functions: {
getDepartmentName: getDepartmentName,
getStatusToggles: async (departmentId) => {
return SessionUser.request('/department/lanes/status-toggles', 'GET', {
department_id: parseInt(departmentId)
});
},
setMachineStatusEnabled: async (laneId, enabled) => {
return SessionUser.request('/modules/self-serve/lane/status', 'PUT', {
lane_id: parseInt(laneId),
enabled: Boolean(enabled),
});
},
isMachineStatusEnabled,
isSelfServeConfigured,
getSelfServeConfigurationWarnings,
isOperational: (object) => {
return object.status === "AVAILABLE" || object.status === "OCCUPIED" || object.status === "RESERVED";
},
@@ -7,6 +7,13 @@ import { getSystemUserIds } from "@/components/session/token/SessionUser/Objects
// Helper function to get i18n translation
const t = (key) => i18n.global.t(key);
const escapeHtml = (value = "") => String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
const normalizeDateTimeLocalValue = (value) => {
if (value === null || value === undefined || value === '') {
return '';
@@ -765,9 +772,11 @@ export const ObjectsGlobal = {
console.log(response);
// How long are the list of options
for (let i = 0; i < response.length; i++) {
var tmp = response[i];
const tmp = response[i];
const optionValue = tmp?.id ?? "";
const optionLabel = tmp?.name ? tmp.name : optionValue;
console.log(tmp);
html += `<option value="${tmp.id}" ${(value == tmp.id || (value === null && tmp.id === 0)) ? 'selected' : ''}>${tmp.name ? tmp.name : tmp.id}</option>`;
html += `<option value="${escapeHtml(optionValue)}" ${(value == optionValue || (value === null && optionValue === 0)) ? 'selected' : ''}>${escapeHtml(optionLabel)}</option>`;
}
});
html += `</select>
@@ -356,6 +356,20 @@ export const OrderBookings = {
{ safety_seal: normalizeCompletionSafetySeal(safetySeal) },
onAfterComplete
);
},
resendBookingConfirmation: async (id) => {
return SessionUser.request(
OrderBookings.meta.endpoint + "/booking-confirmation/resend",
"POST",
{ id: parseInt(id, 10) }
).then((response) => response.data.data);
},
resendBookingCompletionConfirmation: async (id) => {
return SessionUser.request(
OrderBookings.meta.endpoint + "/completion-confirmation/resend",
"POST",
{ id: parseInt(id, 10) }
).then((response) => response.data.data);
}
},
/**
@@ -19,6 +19,14 @@ const getProtectedDeletePayload = (error) => {
return payload;
};
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;",
})[character]);
const formatDeleteProtectionReason = (reason) => {
switch (reason) {
case "completed":
@@ -28,22 +36,23 @@ const formatDeleteProtectionReason = (reason) => {
case "attachments":
return "ordren har vedhæftninger";
default:
return String(reason || "").replace(/_/g, " ");
return escapeHtml(String(reason || "").replace(/_/g, " "));
}
};
const showProtectedOrderDeleteConfirmation = async (id, protectionPayload, onAfterSubmit = null) => {
const orderId = String(id);
const escapedOrderId = escapeHtml(orderId);
const reasons = Array.isArray(protectionPayload?.protected_reasons)
? protectionPayload.protected_reasons.map(formatDeleteProtectionReason).filter(Boolean)
: [];
const reasonText = reasons.length > 0 ? reasons.join(", ") : "ordren indeholder gemte data";
const result = await Swal.fire({
title: `Bekræft sletning af ordre #${orderId}`,
titleText: `Bekræft sletning af ordre #${orderId}`,
html: `
<p>Ordren kan stadig slettes, men kræver ekstra bekræftelse fordi ${reasonText}.</p>
<p>Skriv <strong>${orderId}</strong> for at slette ordren.</p>
<p>Skriv <strong>${escapedOrderId}</strong> for at slette ordren.</p>
`,
input: "text",
inputAttributes: {
@@ -924,6 +933,13 @@ const assignDraftOrderCustomer = async ({
console.error(error);
});
},
resendWashCertificate: async (id) => {
return SessionUser.request(
Orders.meta.endpoint + "/wash-certificate/resend",
"POST",
{ id: parseInt(id, 10) }
).then((response) => response.data.data);
},
showAttachWashCertificateForm(order_id, onAfterSubmit = null) {
const normalizedOrderId = Number.parseInt(order_id, 10);
if (!Number.isInteger(normalizedOrderId) || normalizedOrderId <= 0) {
@@ -127,6 +127,13 @@ export const SelfServeVehicleConditions = {
}
},
add: async (department, lane, customer_id, reg, question, value, options = {}) => {
const safeOptions = {};
const normalizedVehicleType = parseInt(options.vehicle_type ?? options.vehicle_type_id);
if (!Number.isNaN(normalizedVehicleType) && normalizedVehicleType > 0) {
safeOptions.vehicle_type = normalizedVehicleType;
}
return ObjectsGlobal.add.object(SelfServeVehicleConditions.meta.endpoint, {
department: parseInt(department),
lane: parseInt(lane),
@@ -134,7 +141,9 @@ export const SelfServeVehicleConditions = {
reg: reg,
question: parseInt(question),
value: value === "true" || value === true,
...options
...safeOptions,
activate_machine: false,
sync_relay_state: false
});
},
set: {
@@ -9,8 +9,16 @@ const escapeHtml = (value) => String(value ?? "")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const buildInviteFormHtml = (defaults = {}) => `
const buildInviteFormHtml = (defaults = {}, options = {}) => `
<div class="subuser-form">
${options.includeCustomerNumber ? `
<div class="field">
<label class="label">Kundenummer</label>
<div class="control">
<input id="subuser-form-customer-number" class="input" type="number" inputmode="numeric" value="${escapeHtml(defaults.customer_number || defaults.customerNumber || "")}" placeholder="Kundens kundenummer" />
</div>
</div>
` : ""}
<div class="field">
<label class="label">Navn</label>
<div class="control">
@@ -41,11 +49,17 @@ const buildInviteFormHtml = (defaults = {}) => `
</div>
`;
const readInviteFormValues = () => {
const readInviteFormValues = (options = {}) => {
const customerNumberRaw = document.getElementById("subuser-form-customer-number")?.value?.trim() || "";
const name = document.getElementById("subuser-form-name")?.value?.trim() || "";
const phoneCountryCodeRaw = document.getElementById("subuser-form-phone-country-code")?.value?.trim() || "";
const phoneRaw = document.getElementById("subuser-form-phone")?.value?.trim() || "";
if (options.includeCustomerNumber && !/^\d+$/.test(customerNumberRaw)) {
Swal.showValidationMessage("Kundenummer skal udfyldes.");
return null;
}
if (name.length < 3) {
Swal.showValidationMessage("Navn skal være mindst 3 tegn.");
return null;
@@ -62,6 +76,7 @@ const readInviteFormValues = () => {
}
return {
...(options.includeCustomerNumber ? { customer_number: Number.parseInt(customerNumberRaw, 10) } : {}),
name,
phone_country_code: Number.parseInt(phoneCountryCodeRaw, 10),
phone: Number.parseInt(phoneRaw, 10),
@@ -91,17 +106,17 @@ const showInviteFeedback = async (response) => {
});
};
const submitInviteForm = async ({ title, confirmButtonText, defaults = {}, endpoint, method, payloadBuilder }) => {
const submitInviteForm = async ({ title, confirmButtonText, defaults = {}, endpoint, method, payloadBuilder, includeCustomerNumber = false }) => {
const result = await Swal.fire({
title,
html: buildInviteFormHtml(defaults),
html: buildInviteFormHtml(defaults, { includeCustomerNumber }),
width: 640,
focusConfirm: false,
showCancelButton: true,
confirmButtonText,
cancelButtonText: "Annuller",
preConfirm: async () => {
const values = readInviteFormValues();
const values = readInviteFormValues({ includeCustomerNumber });
if (!values) {
return false;
}
@@ -142,13 +157,15 @@ export const Subusers = {
return `${normalized.slice(0, 3).join(", ")} +${normalized.length - 3}`;
},
async showInviteForm(refreshCallback = null) {
async showInviteForm(refreshCallback = null, options = {}) {
const superuser = Boolean(options.superuser);
const response = await submitInviteForm({
title: "Invitér chauffør",
confirmButtonText: "Send invitation",
endpoint: "/subusers/invite",
endpoint: superuser ? "/superuser/subusers/invite" : "/subusers/invite",
method: "POST",
payloadBuilder: (values) => values,
includeCustomerNumber: superuser,
});
if (!response) {
@@ -162,9 +179,14 @@ export const Subusers = {
return response;
},
async resendInvite(subuser, refreshCallback = null) {
async resendInvite(subuser, refreshCallback = null, options = {}) {
const superuser = Boolean(options.superuser);
try {
const response = await authenticatedRequest("/subusers/invite/resend", "POST", { id: subuser.id });
const response = await authenticatedRequest(
superuser ? "/superuser/subusers/invite/resend" : "/subusers/invite/resend",
"POST",
{ id: subuser.id, ...(subuser.grant_id ? { grant_id: subuser.grant_id } : {}) }
);
await showInviteFeedback(response);
if (typeof refreshCallback === "function") {
await refreshCallback();
@@ -17,13 +17,15 @@ const normalizeEntries = (entries) => {
return [];
};
const SENSITIVE_WRITE_ONLY_KEYS = new Set(["broker_shared_secret"]);
const toConfigPayload = (entries) => {
const payload = {};
normalizeEntries(entries).forEach((entry) => {
if (!entry || !entry.variable) {
return;
}
payload[entry.variable] = entry.value;
payload[entry.variable] = SENSITIVE_WRITE_ONLY_KEYS.has(entry.variable) ? "" : entry.value;
});
return payload;
};
@@ -42,6 +44,11 @@ export const Config = {
test_broker: async (payload) => testEdgeGatewayBrokerConfig(payload),
set: async (variable, value) => {
const currentConfig = await Config.get_config();
SENSITIVE_WRITE_ONLY_KEYS.forEach((key) => {
if (key !== variable) {
delete currentConfig[key];
}
});
return Config.set_config({
...currentConfig,
[variable]: value,
@@ -73,7 +80,6 @@ export const Config = {
set: async (value) => Config.set("broker_auth_mode", value),
},
broker_shared_secret: {
get: async () => Config.get("broker_shared_secret"),
set: async (value) => Config.set("broker_shared_secret", value),
},
},
@@ -1,6 +1,7 @@
<script>
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { parseError } from "@/components/request/HandleGlobalError.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
import { DatabaseSystemObject } from "@/components/session/token/superUser/systemDatabase.vue";
import { Cron } from "@/components/session/token/superUser/cron.vue";
import { Economic } from "@/components/session/token/superUser/modules/Economic/Economic.vue";
@@ -106,6 +107,7 @@ export const SuperUserObject = {
const currentToken = localStorage.getItem("token");
localStorage.setItem("superuser_token", currentToken);
// Set the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", response.data.data.token);
// Redirect the user to the dashboard
window.location = "/";
@@ -143,6 +145,7 @@ export const SuperUserObject = {
// Get the superuser token
const superuserToken = localStorage.getItem("superuser_token");
// Set the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", superuserToken);
// Clear the superuser token
localStorage.removeItem("superuser_token");
+126 -45
View File
@@ -320,20 +320,26 @@ export const ensureCurrentOrderDepartment = async (targetDepartmentId = null) =>
}
try {
let currentDepartmentId = null;
try {
currentDepartmentId = await getCurrentOrderDepartmentId(normalizedOrderId);
} catch (error) {
currentDepartmentId = null;
}
const currentDepartmentId = await getCurrentOrderDepartmentId(normalizedOrderId);
if (currentDepartmentId === normalizedDepartmentId) {
department_id.value = normalizedDepartmentId;
return true;
}
await SessionUser.objects.orders.set.department_id(normalizedOrderId, normalizedDepartmentId);
department_id.value = normalizedDepartmentId;
return true;
parseError(
{
response: {
data: {
data: {
message: "Ordren tilhører ikke den valgte afdeling",
},
message: "Ordren tilhører ikke den valgte afdeling",
},
},
},
"stepError"
);
return false;
} catch (error) {
parseError(error, "stepError");
return false;
@@ -507,6 +513,63 @@ export const clearStoredPosOrderId = () => {
localStorage.removeItem("pos_order_id");
};
const hasActivePosOrderContext = () => {
return Boolean(
toPositiveInteger(order_id.value) ||
getSelectedCustomerNumber() ||
String(customer_name.value ?? "").trim() ||
(Array.isArray(order_items.value) && order_items.value.length > 0) ||
String(reference.value ?? "").trim() ||
String(order_notes.value ?? "").trim() ||
String(order_po.value ?? "").trim() ||
String(order_safety_seal.value ?? "").trim() ||
String(reg_1.value ?? "").trim() ||
String(reg_2.value ?? "").trim() ||
String(reg_3.value ?? "").trim() ||
invoiceCollectionId.value ||
completed_at.value ||
getStoredPosOrderId()
);
};
export const clearActivePosOrderContext = (options = {}) => {
const normalizedOptions = {
clearStep: false,
clearStoredOrderId: true,
...options,
};
const hadActiveContext = hasActivePosOrderContext();
clearErrors();
if (normalizedOptions.clearStep) {
step.value = 1;
}
order_id.value = null;
clearSelectedCustomerState({ clearOrderNotes: true });
order_items.value = [];
user_discounts.value = [];
has_user_discounts_loaded.value = false;
scans.value = [];
scan_data.value = [];
invoiceCollectionId.value = null;
completed_at.value = null;
reference.value = "";
order_notes.value = "";
order_po.value = "";
order_safety_seal.value = "";
reg_1.value = "";
reg_2.value = "";
reg_3.value = "";
isCreatingOrder.value = false;
createOrderRequest = null;
clearSelectedOrderBookingSelection();
if (normalizedOptions.clearStoredOrderId) {
clearStoredPosOrderId();
}
return hadActiveContext;
};
const SELECTED_ORDER_BOOKING_ID_STORAGE_KEY = "pos_selected_order_booking_id";
const SELECTED_ORDER_BOOKING_PLATE_STORAGE_KEY = "pos_selected_order_booking_plate";
const SELECTED_ORDER_BOOKING_SKIPPED_PLATE_STORAGE_KEY = "pos_selected_order_booking_skipped_plate";
@@ -670,9 +733,9 @@ export const restoreStoredPosOrderId = async (
if (selectedDepartmentId && toPositiveInteger(storedOrder?.department_id) !== selectedDepartmentId) {
if (options.syncDepartment === true) {
order_id.value = storedOrderId;
const didSyncDepartment = await ensureCurrentOrderDepartment(selectedDepartmentId);
if (!didSyncDepartment) {
throw new Error("Stored order department could not be changed to current department");
const isCurrentDepartment = await ensureCurrentOrderDepartment(selectedDepartmentId);
if (!isCurrentDepartment) {
throw new Error("Stored order department does not match current department");
}
} else {
throw new Error("Stored order department does not match current department");
@@ -692,26 +755,7 @@ export const restoreStoredPosOrderId = async (
export const reset_all_values = () => {
// Reset all values to their initial state
clearErrors();
step.value = 1;
order_id.value = null;
clearCustomerSelection();
clearCache();
user_discounts.value = [];
has_user_discounts_loaded.value = false;
scans.value = [];
scan_data.value = [];
invoiceCollectionId.value = null;
completed_at.value = null;
reference.value = "";
order_notes.value = "";
order_po.value = "";
order_safety_seal.value = "";
isCreatingOrder.value = false;
createOrderRequest = null;
clearSelectedOrderBookingSelection();
// Clear the order id from the local storage (if present)
clearStoredPosOrderId();
clearActivePosOrderContext({ clearStep: true });
// Clear the query parameters
window.history.pushState({}, "", `?step=1`);
};
@@ -1150,6 +1194,7 @@ export const selectScan = (scan) => {
};
const cached_customer_names = [];
const activeCustomerSelectionRequests = new Map();
/** Get the customer's name */
export const getCustomerName = async (customerNumber) => {
@@ -1178,23 +1223,62 @@ export const getCustomerName = async (customerNumber) => {
/** Search, then select customer by customer number */
export const searchAndSelectCustomer = async (customerNumber, options = {}) => {
const normalizedOptions = {
forceRefresh: false,
...options,
};
const normalizedCustomerNumber = resolveCustomerNumber(customerNumber);
if (!normalizedCustomerNumber) {
return null;
}
const selectedCustomerNumber = getSelectedCustomerNumber();
const hasSelectedCustomerData =
customer_data.value &&
typeof customer_data.value === "object" &&
!Array.isArray(customer_data.value) &&
Object.keys(customer_data.value).length > 0;
if (
!normalizedOptions.forceRefresh &&
selectedCustomerNumber === normalizedCustomerNumber &&
hasSelectedCustomerData
) {
return customer_data.value;
}
if (!normalizedOptions.forceRefresh && activeCustomerSelectionRequests.has(normalizedCustomerNumber)) {
return await activeCustomerSelectionRequests.get(normalizedCustomerNumber);
}
// Get the customer data
return await authenticatedRequest(`/users/customer?customer_number=${customerNumber}`, "GET")
const request = authenticatedRequest(`/users/customer?customer_number=${normalizedCustomerNumber}`, "GET")
.then((response) => {
console.log(response);
const responseData = response?.data?.data ?? {};
const rawEconomicCustomer = responseData.economic_customer ?? null;
const normalizedCustomer = normalizeCustomerRecord(rawEconomicCustomer, {
...responseData,
customerNumber: resolveCustomerNumber(rawEconomicCustomer) ?? resolveCustomerNumber(customerNumber),
customerNumber: resolveCustomerNumber(rawEconomicCustomer) ?? normalizedCustomerNumber,
});
selectCustomer(normalizedCustomer, options);
selectCustomer(normalizedCustomer, normalizedOptions);
return normalizedCustomer;
})
.catch((error) => {
console.error(error);
return null;
})
.finally(() => {
if (activeCustomerSelectionRequests.get(normalizedCustomerNumber) === request) {
activeCustomerSelectionRequests.delete(normalizedCustomerNumber);
}
});
if (!normalizedOptions.forceRefresh) {
activeCustomerSelectionRequests.set(normalizedCustomerNumber, request);
}
return await request;
};
/** Get order items */
@@ -1934,18 +2018,14 @@ export const getVehiclePlateBookings = (vehiclePlate) => {
return sortPendingOrderBookings(matchingBookings);
};
export const ensureVehiclePlateBookingsLoaded = async (vehiclePlate, options = {}) => {
const normalizedOptions = {
force: false,
...options,
};
export const ensureVehiclePlateBookingsLoaded = async (vehiclePlate, _options = {}) => {
const normalizedVehiclePlate = normalizeVehiclePlateForBookingSelection(vehiclePlate);
const currentDepartmentKey = syncPendingBookingsDepartmentState();
if (!normalizedVehiclePlate || !department_id.value) {
return [];
}
if (!normalizedOptions.force && loadedPendingBookingPlates.has(normalizedVehiclePlate)) {
if (loadedPendingBookingPlates.has(normalizedVehiclePlate)) {
return getVehiclePlateBookings(normalizedVehiclePlate);
}
@@ -2089,7 +2169,7 @@ const getFirstAvailableWashProduct = async () => {
}
};
const hydrateSelectedOrderBookingForDesktop = async () => {
export const hydrateSelectedOrderBookingForDesktop = async () => {
const normalizedOrderId = toPositiveInteger(order_id.value);
const normalizedBookingId = toPositiveInteger(selectedOrderBookingId.value);
@@ -2172,8 +2252,7 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
}
}
const forcedPrimaryPrice =
typeof primaryRawItem?.price === "number" ? Number(primaryRawItem.price) : primaryProduct.price ?? null;
const forcedPrimaryPrice = primaryProduct.price ?? null;
const primaryItemResponse = await createOrderItem(
normalizedOrderId,
primaryProduct.id,
@@ -2191,13 +2270,15 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
continue;
}
const secondaryProduct = await fetchOrderBookingProductWithPricing(secondaryProductId);
await createOrderItem(
normalizedOrderId,
secondaryProductId,
Math.max(1, Number.parseInt(String(bookingItem?.quantity ?? 1), 10) || 1),
relatedPrimaryItemId,
String(bookingItem?.notes ?? "").trim() || null,
typeof bookingItem?.price === "number" ? Number(bookingItem.price) : null
secondaryProduct?.price ?? null
);
}
@@ -26,7 +26,7 @@ const pages = {
to: "/user/wash/start",
label: "Selv vask",
icon: "tint",
disabled: !IS_DEV,
disabled: false,
},
bookings: {
to: "/user/bookings",
@@ -7,7 +7,9 @@ import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelec
import { IS_DEV } from '@/config.js';
import SessionUser from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { computed } from "vue";
const route = useRoute();
const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
</script>
<template>
@@ -62,13 +64,13 @@ const route = useRoute();
<span>{{ $t('header.return_to_account') }}</span>
</router-link>
</template>
<router-link class="button is-white" to="/user/profile" v-if="SessionUser.authenticated.value">
<router-link class="button is-white" to="/user/profile" v-if="isAuthenticatedSessionReady">
<span class="icon">
<i class="fas fa-user-circle" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span :class="{'has-text-black': !IS_DEV, 'has-text-red': IS_DEV}">{{ SessionUser.authenticated.value ? SessionUser.getName() : ''}}</span>
<span :class="{'has-text-black': !IS_DEV, 'has-text-red': IS_DEV}">{{ isAuthenticatedSessionReady ? SessionUser.getName() : ''}}</span>
</router-link>
<template v-else>
<template v-if="!SessionUser.authenticated.value">
<router-link class="button is-white" to="/register">
<span class="icon">
<i class="fas fa-user-plus" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { BIcon, BMenu, BMenuItem, BMenuList, BTooltip } from "buefy";
import type { NavigationItemProps } from "@/components/models/navigation/NavigationItem.vue";
import { useNavigationItems } from "@/components/models/navigation/items/NavigationMenuItems.vue";
@@ -11,6 +12,7 @@ import LanguageSelector from "@/components/i18n/LanguageSelector.vue";
import ReleaseChannelSidebarSelector from "@/components/release/ReleaseChannelSidebarSelector.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import NavigationMenuGlobalSearch from "@/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue";
import { requestOpenErrorReport } from "@/services/errorReportLauncher.js";
type NavigationMenuItem = NavigationItemProps & {
priority_order?: number | null;
@@ -19,9 +21,17 @@ type NavigationMenuItem = NavigationItemProps & {
type NavigationBadge = NonNullable<NavigationMenuItem["badge"]>;
const route = useRoute();
const { t } = useI18n({ useScope: "global" });
const { parsedItems, parsedItemsGlobal } = useNavigationItems();
const ERROR_REPORT_BUTTON_FALLBACK = "Report error";
const isMatchingRoute = (item: NavigationMenuItem) => item.to === route.path;
const isAuthenticated = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
const shouldShowErrorReportAction = computed(() => !isSmall.value && isAuthenticated.value);
const errorReportButtonLabel = computed(() => {
const label = t("error_report.button");
return label === "error_report.button" ? ERROR_REPORT_BUTTON_FALLBACK : label;
});
const navigationItems = computed<NavigationMenuItem[]>(() => {
const items = [...parsedItems(), ...parsedItemsGlobal()] as NavigationMenuItem[];
@@ -454,10 +464,39 @@ const getChildBadgeTestId = (item: NavigationMenuItem) => (isDraftsNavigationChi
</BMenu>
<LanguageSelector />
<ReleaseChannelSidebarSelector />
<div v-if="shouldShowErrorReportAction" class="desktop-buefy-error-report-action">
<button
class="button is-text desktop-buefy-error-report-button"
type="button"
data-testid="error-report-button"
@click="requestOpenErrorReport"
>
<span class="icon is-small"><i class="fas fa-bug" aria-hidden="true"></i></span>
<span>{{ errorReportButtonLabel }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.desktop-buefy-error-report-action {
padding: 0 16px 14px;
}
.desktop-buefy-error-report-button {
width: 100%;
justify-content: flex-start;
color: #172033;
text-decoration: none;
}
.button.desktop-buefy-error-report-button:hover,
.button.desktop-buefy-error-report-button:focus-visible {
background: rgba(180, 35, 24, 0.08);
color: #b42318;
text-decoration: none;
}
.desktop-buefy-item-label,
.desktop-buefy-child-label {
display: flex;
@@ -13,6 +13,7 @@ const showProfile = computed(() => {
const path = route.path;
return path.startsWith("/user");
});
const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
</script>
@@ -23,7 +24,7 @@ const showProfile = computed(() => {
<nav class="navbar is-link is-mobile is-fixed-top mobile-header-section" role="navigation" aria-label="main navigation" :class="{'is-blurred': isTransparent}">
<div class="navbar-item" v-if="showProfile">
<!-- Profile -->
<router-link class="button is-white py-0" to="/user/profile" v-if="SessionUser.authenticated.value">
<router-link class="button is-white py-0" to="/user/profile" v-if="isAuthenticatedSessionReady">
<span class="icon is-large">
<i class="fas fa-user-circle fa-2x"
:class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
@@ -45,7 +46,7 @@ const showProfile = computed(() => {
<NavigationMenuDepartment/>
</div>
<!-- Subuser Grant Selection -->
<div class="navbar-item" v-if="SessionUser.isSubuser.value && showProfile">
<div class="navbar-item" v-if="isAuthenticatedSessionReady && SessionUser.isSubuser.value && showProfile">
<SubuserGrantSelector :showLabel="false" :compact="true" />
</div>
<div class="navbar-end" style="margin-left: auto;" v-show="!SessionUser.canAccessAdmin()">
@@ -1,12 +1,31 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { toggleExpanded, isOpen } from '@/components/viewport/page/headers/ViewportHeaderSettings.vue';
import NavigationMenuItems from "@/components/viewport/page/headers/menu/NavigationMenuItems.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import DesktopNavigationBuefy from "@/components/viewport/page/headers/DesktopNavigationBuefy.vue";
import { requestOpenErrorReport } from "@/services/errorReportLauncher.js";
const { t } = useI18n({ useScope: "global" });
const VITE_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || 'No build date';
const VITE_COMMIT_HASH = import.meta.env.VITE_COMMIT_HASH || 'No commit hash';
const VITE_APP_VERSION = import.meta.env.VITE_APP_VERSION || 'No app version';
const VITE_IS_DEV = import.meta.env.DEV || false;
const ERROR_REPORT_BUTTON_FALLBACK = "Report error";
const isAuthenticated = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
const errorReportButtonLabel = computed(() => {
const label = t("error_report.button");
return label === "error_report.button" ? ERROR_REPORT_BUTTON_FALLBACK : label;
});
const openErrorReport = () => {
if (isOpen.value) {
toggleExpanded();
}
requestOpenErrorReport();
};
/**
* Scrollable menu for mobile viewports.
@@ -65,12 +84,23 @@ const VITE_IS_DEV = import.meta.env.DEV || false;
<NavigationMenuItems />
</div>
<!-- Bottom version info -->
<div class="has-text-centered" style="padding: 16px; font-size: 12px; color: gray;">
<div class="has-text-centered" style="padding: 16px; font-size: 12px; color: gray;" data-testid="mobile-build-info">
<!--<div>Version: {{ VITE_APP_VERSION }}</div>-->
<div>Commit: {{ VITE_COMMIT_HASH }}</div>
<div>Build Date: {{ SessionUser.functions.date.toLocal(VITE_BUILD_DATE) }}</div>
<div v-if="VITE_IS_DEV" style="color: red;">Development Mode</div>
</div>
<div v-if="isAuthenticated" class="mobile-error-report-action">
<button
class="button is-danger mobile-error-report-button"
type="button"
data-testid="error-report-button"
@click="openErrorReport"
>
<span class="icon is-small"><i class="fas fa-bug" aria-hidden="true"></i></span>
<span>{{ errorReportButtonLabel }}</span>
</button>
</div>
</div>
</div>
@@ -111,6 +141,12 @@ const VITE_IS_DEV = import.meta.env.DEV || false;
.mobile-menu-open .mobile-menu-spacer {
flex: 1;
}
.mobile-error-report-action {
padding: 0 16px 16px;
}
.mobile-error-report-button {
width: 100%;
}
.mobile-menu-open .text {
/* Menu */
margin: 0 auto;
@@ -144,4 +180,4 @@ const VITE_IS_DEV = import.meta.env.DEV || false;
/* Prevent click-through */
pointer-events: auto;
}
</style>
</style>
@@ -6,7 +6,9 @@ import CollectedInvoiceQueueMonitor from "@/components/viewport/page/headers/men
import { IS_DEV } from '@/config.js';
import SessionUser from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { computed } from "vue";
const route = useRoute();
const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
</script>
<template>
@@ -62,13 +64,13 @@ const route = useRoute();
<span>{{ $t('header.return_to_account') }}</span>
</router-link>
</template>
<router-link class="button is-white" to="/user/profile" v-if="SessionUser.authenticated.value">
<router-link class="button is-white" to="/user/profile" v-if="isAuthenticatedSessionReady">
<span class="icon">
<i class="fas fa-user-circle" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span :class="{'has-text-black': !IS_DEV, 'has-text-red': IS_DEV}">{{ SessionUser.authenticated.value ? SessionUser.getName() : ''}}</span>
<span :class="{'has-text-black': !IS_DEV, 'has-text-red': IS_DEV}">{{ isAuthenticatedSessionReady ? SessionUser.getName() : ''}}</span>
</router-link>
<template v-else>
<template v-if="!SessionUser.authenticated.value">
<router-link class="button is-white" to="/register">
<span class="icon">
<i class="fas fa-user-plus" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
+7 -5
View File
@@ -64,6 +64,7 @@ export const useOrderMetadataAutosave = ({
const requestedValue = normalizeValue(draft.value);
const sequence = ++nextSequence;
let saveCompleted = false;
isSaving.value = true;
try {
@@ -83,6 +84,8 @@ export const useOrderMetadataAutosave = ({
await onSaved(savedValue, requestedValue);
}
saveCompleted = true;
return true;
} catch (error) {
if (typeof onError === "function") {
@@ -95,12 +98,11 @@ export const useOrderMetadataAutosave = ({
} finally {
isSaving.value = false;
if (saveAgainAfterCurrentRequest || isDirty.value) {
saveAgainAfterCurrentRequest = false;
const shouldSaveAgain = saveAgainAfterCurrentRequest;
saveAgainAfterCurrentRequest = false;
if (isDirty.value) {
void persist();
}
if (saveCompleted && (shouldSaveAgain || isDirty.value) && isDirty.value) {
void persist();
}
}
};

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