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
Jeppe Bundgaard 184ef5670c Enhance session management by normalizing session payload and resetting session state on logout. Update login redirect logic and improve runtime API URL handling for selected release channels. 2026-05-26 17:28:21 +02:00
Jeppe Bundgaard 8ab8915429 Add release management components and update routing logic. Introduce keyboard shortcuts, enhance release data grid, and improve asset handling in Nginx configuration. 2026-05-26 14:14:13 +02:00
342 changed files with 34865 additions and 8638 deletions
+15
View File
@@ -0,0 +1,15 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.pdf binary
*.zip binary
*.webm binary
+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
+123
View File
@@ -0,0 +1,123 @@
name: Frontend Release
on:
push:
branches:
- master
workflow_dispatch:
permissions:
contents: read
actions: read
concurrency:
group: frontend-release-${{ github.ref }}
cancel-in-progress: false
jobs:
build-upload-and-verify:
runs-on: [self-hosted, Linux, X64, default]
env:
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: 45
RELEASE_WAIT_TIMEOUT_SECONDS: 600
RELEASE_POLL_INTERVAL_SECONDS: 10
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Check AI workflow sync
run: node scripts/sync-ai-workflow.mjs --check
- name: Source and i18n checks
run: |
npm run text:check-encoding
npm run i18n:v2:source-check
- name: Unit tests
run: npm run test:unit
env:
VITEST_BATCH_SIZE: 5
- name: Build release artifact
run: npm run build
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
- name: Production Playwright gate
run: npm run test:e2e:prod
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
path: dist
retention-days: 14
- name: Wait for Coolify release artifact
run: npm run release:verify-upload
- name: Public live Playwright gate
run: npm run test:e2e:live:public
env:
NODE_OPTIONS: --use-system-ca
- name: Credentialed live Playwright gate
run: npm run test:e2e:live:roles
env:
NODE_OPTIONS: --use-system-ca
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
PLAYWRIGHT_OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
- name: Record Release Manager gate
run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
curl --fail --show-error --silent \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_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
env:
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
RELEASE_VERSION: ${{ github.sha }}
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
path: output/playwright
if-no-files-found: ignore
retention-days: 14
+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
View File
@@ -8,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
Vendored
+1
View File
@@ -5,6 +5,7 @@ interface ImportMetaEnv {
readonly VITE_APP_VERSION: string,
readonly VITE_COMMIT_HASH: string,
readonly VITE_IS_DEV: string,
readonly VITE_RELEASE_SOURCE: 'local' | 'deployment' | 'auto',
}
interface ImportMeta {
readonly env: ImportMetaEnv
+4 -5
View File
@@ -2,11 +2,10 @@
<html lang="" class="theme-light" data-theme="light">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/png" href="/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicons/favicon.svg" />
<link rel="shortcut icon" href="/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
<link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
+37 -7
View File
@@ -5,29 +5,59 @@ server {
root /usr/share/nginx/html;
index index.html;
location = /release-entry.json {
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 = /internal/frontend/release-entry.json {
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
try_files /release-entry.json =404;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$2.json =404;
}
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$static_asset_path =404;
}
location ~ ^/(?:.+/)?(?<static_file_path>manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
try_files /$static_file_path =404;
}
location ~ \.[^/]+$ {
try_files $uri =404;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files $uri =404;
}
location ^~ /internal/frontend/assets/ {
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
rewrite ^/internal/frontend/(.*)$ /$1 break;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
try_files $uri =404;
}
location ^~ /internal/frontend/ {
rewrite ^/internal/frontend/(.*)$ /$1 break;
location ~ ^/(master|beta|canary|internal)/frontend$ {
try_files /index.html =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/ {
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
try_files $uri $uri/ /index.html;
}
+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"
},
+6 -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",
@@ -42,7 +41,12 @@
"test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile",
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
"test:e2e:live:public": "playwright test --config=playwright.live.config.ts --grep @public-live",
"test:e2e:live:roles": "playwright test --config=playwright.live.config.ts --grep @role-live",
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
"release:verify-upload": "node scripts/release/verify-upload.mjs",
"release:update-server-version": "node scripts/release/update-server-version.mjs",
"release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh",
"test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"",
"test:all": "npm run test:unit && npm run test:e2e:pr",
"twa:build": "bubblewrap build",
@@ -82,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();
+42 -2
View File
@@ -4,10 +4,50 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
RewriteRule ^.+/((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:assets|resources|favicons|icons|img|sounds|\.well-known)/ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule \.[^/]+$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.html [L]
</IfModule>
+2 -2
View File
@@ -4,13 +4,13 @@
"description": "Access your Truck Wash accounts and transactions from anywhere.",
"icons": [
{
"src": "/favicons/web-app-manifest-192x192.png",
"src": "assets/favicons/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/favicons/web-app-manifest-512x512.png",
"src": "assets/favicons/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
+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);
+50
View File
@@ -0,0 +1,50 @@
import { execSync } from "node:child_process";
function gitCommit() {
try {
return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim();
} catch {
return "";
}
}
async function main() {
const token = process.env.SERVER_UPDATE_TOKEN;
const required = process.env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
if (!token) {
if (required) {
throw new Error("SERVER_UPDATE_TOKEN is required after deploy verification.");
}
console.log("SERVER_UPDATE_TOKEN is not set. Skipping server version update.");
return;
}
const version = process.env.RELEASE_VERSION || process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || gitCommit();
if (!version) {
throw new Error("Could not determine release version for server update.");
}
const baseUrl = process.env.SERVER_UPDATE_URL || "https://api-v2.truckwash.io/master/api/worker/update-version";
const url = new URL(baseUrl);
url.searchParams.set("version", version);
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Cache-Control": "no-cache",
},
});
const body = await response.text();
if (!response.ok) {
throw new Error(`Server version update failed with HTTP ${response.status}: ${body}`);
}
console.log(`Server version updated to ${version}.`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
set -euo pipefail
DIST_DIR="${DIST_DIR:-dist}"
REMOTE_ROOT="${RELEASE_DEPLOY_REMOTE_ROOT:-/}"
HOST="${RELEASE_DEPLOY_HOST:-}"
USER_NAME="${RELEASE_DEPLOY_USER:-}"
PASSWORD="${RELEASE_DEPLOY_PASSWORD:-}"
DEPLOY_URL="${RELEASE_DEPLOY_URL:-}"
if [[ ! -d "$DIST_DIR" ]]; then
echo "Missing dist directory: $DIST_DIR" >&2
exit 1
fi
if ! command -v lftp >/dev/null 2>&1; then
echo "lftp is required for FTP/SFTP uploads." >&2
exit 1
fi
if [[ -z "$DEPLOY_URL" ]]; then
if [[ -z "$HOST" || -z "$USER_NAME" || -z "$PASSWORD" ]]; then
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
exit 1
fi
DEPLOY_URL="ftp://${HOST}"
elif [[ -n "$USER_NAME" || -n "$PASSWORD" ]]; then
if [[ -z "$USER_NAME" || -z "$PASSWORD" ]]; then
echo "Set both RELEASE_DEPLOY_USER and RELEASE_DEPLOY_PASSWORD when providing deploy credentials separately." >&2
exit 1
fi
fi
lftp_quote() {
local value="${1//\'/\'\\\'\'}"
printf "'%s'" "$value"
}
run_lftp() {
local transfer_command="$1"
{
printf 'set ftp:ssl-allow true\n'
printf 'set ftp:ssl-force true\n'
printf 'set ftp:ssl-protect-data true\n'
printf 'set net:max-retries 3\n'
printf 'set net:timeout 20\n'
if [[ -n "$USER_NAME" && -n "$PASSWORD" ]]; then
printf 'open -u %s,%s %s\n' "$(lftp_quote "$USER_NAME")" "$(lftp_quote "$PASSWORD")" "$(lftp_quote "$DEPLOY_URL")"
else
printf 'open %s\n' "$(lftp_quote "$DEPLOY_URL")"
fi
printf 'cd %s\n' "$(lftp_quote "$REMOTE_ROOT")"
printf '%s\n' "$transfer_command"
printf 'bye\n'
} | lftp -f /dev/stdin
}
upload_file() {
local source_file="$1"
local remote_file="$2"
if [[ -f "$source_file" ]]; then
run_lftp "put -O $(lftp_quote "$(dirname "$remote_file")") $(lftp_quote "$source_file") -o $(lftp_quote "$(basename "$remote_file")")"
fi
}
for directory in assets resources favicons icons img sounds .well-known; do
if [[ -d "$DIST_DIR/$directory" ]]; then
run_lftp "mirror -R --only-newer --parallel=4 $(lftp_quote "$DIST_DIR/$directory") $(lftp_quote "$directory")"
fi
done
find "$DIST_DIR" -maxdepth 1 -type f \
! -name 'index.html' \
! -name 'release-entry.json' \
! -name 'release-manifest.json' \
-print0 | while IFS= read -r -d '' file; do
upload_file "$file" "$(basename "$file")"
done
upload_file "$DIST_DIR/release-manifest.json" "release-manifest.json"
upload_file "$DIST_DIR/release-entry.json" "release-entry.json"
upload_file "$DIST_DIR/index.html" "index.html"
+215
View File
@@ -0,0 +1,215 @@
import crypto from "node:crypto";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function numberEnv(name, fallback) {
const value = Number.parseInt(process.env[name] || "", 10);
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function requiredUrl() {
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
if (!value) {
throw new Error("RELEASE_BASE_URL or PLAYWRIGHT_BASE_URL is required.");
}
return value.endsWith("/") ? value : `${value}/`;
}
function pathUrl(baseUrl, assetPath) {
const path = String(assetPath || "").replace(/^\/+/, "");
return new URL(path, baseUrl).href;
}
async function fetchBytes(url) {
const response = await fetch(url, {
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
const bytes = Buffer.from(await response.arrayBuffer());
return {
response,
bytes,
text: () => bytes.toString("utf8"),
};
}
async function fetchJson(baseUrl, assetPath) {
const url = pathUrl(baseUrl, assetPath);
const result = await fetchBytes(url);
const contentType = result.response.headers.get("content-type") || "";
if (!result.response.ok) {
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
}
if (contentType.includes("text/html")) {
throw new Error(`${assetPath} was served as HTML`);
}
if (result.bytes.length === 0) {
throw new Error(`${assetPath} was empty`);
}
try {
return JSON.parse(result.text());
} catch (error) {
throw new Error(`${assetPath} did not contain valid JSON: ${error instanceof Error ? error.message : error}`);
}
}
function compareCommit(actual, expected) {
if (!expected) {
return true;
}
return actual === expected || actual.startsWith(expected);
}
function unique(values) {
return Array.from(new Set(values.filter(Boolean)));
}
function sha256(bytes) {
return crypto.createHash("sha256").update(bytes).digest("hex");
}
function shouldRejectHtml(assetPath) {
return /\.(?:js|css|json|webmanifest|svg|png|ico|woff2?|mp3)$/i.test(assetPath);
}
async function verifyAsset(baseUrl, assetPath, expectedHash) {
const url = pathUrl(baseUrl, assetPath);
const result = await fetchBytes(url);
const contentType = result.response.headers.get("content-type") || "";
if (!result.response.ok) {
throw new Error(`${assetPath} returned HTTP ${result.response.status}`);
}
if (result.bytes.length === 0) {
throw new Error(`${assetPath} was empty`);
}
if (shouldRejectHtml(assetPath) && contentType.includes("text/html")) {
throw new Error(`${assetPath} was served as HTML (${contentType})`);
}
if (expectedHash?.sha256) {
const actualHash = sha256(result.bytes);
if (actualHash !== expectedHash.sha256) {
throw new Error(`${assetPath} sha256 mismatch: expected ${expectedHash.sha256}, got ${actualHash}`);
}
}
}
async function verifyShell(baseUrl, shellPath) {
const result = await fetchBytes(pathUrl(baseUrl, shellPath));
const contentType = result.response.headers.get("content-type") || "";
const body = result.text();
if (!result.response.ok) {
throw new Error(`${shellPath} returned HTTP ${result.response.status}`);
}
if (!contentType.includes("text/html")) {
throw new Error(`${shellPath} did not return HTML (${contentType})`);
}
if (body.replace(/\s+/g, "").length < 40) {
throw new Error(`${shellPath} returned an empty app shell`);
}
if (!body.includes('<div id="app"></div>')) {
throw new Error(`${shellPath} did not include the Vue app root`);
}
}
async function verifyRelease(baseUrl) {
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
const manifest = await fetchJson(baseUrl, "release-manifest.json");
const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
if (!manifest.build_id) {
throw new Error("release-manifest.json is missing build_id");
}
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
}
if (expectedBuildId && manifest.build_id !== expectedBuildId) {
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
}
if (releaseEntry.entry !== manifest.entry) {
throw new Error("release-entry.json entry does not match release-manifest.json");
}
const releaseCss = JSON.stringify(releaseEntry.css || []);
const manifestCss = JSON.stringify(manifest.css || []);
if (releaseCss !== manifestCss) {
throw new Error("release-entry.json css does not match release-manifest.json");
}
const shellPaths = unique((process.env.RELEASE_SHELL_PATHS || "/,/guest/book/wash").split(",").map((value) => value.trim()));
for (const shellPath of shellPaths) {
await verifyShell(baseUrl, shellPath);
}
const assetUrls = unique([
"release-manifest.json",
"release-entry.json",
manifest.entry,
...(manifest.css || []),
...(manifest.index_asset_urls || []),
...(manifest.pwa_asset_urls || []),
...(manifest.asset_urls || []),
]);
for (const assetUrl of assetUrls) {
if (assetUrl === "/index.html") {
continue;
}
await verifyAsset(baseUrl, assetUrl, manifest.asset_hashes?.[assetUrl] || manifest.asset_hashes?.[`/${String(assetUrl).replace(/^\/+/, "")}`]);
}
return {
build_id: manifest.build_id,
commit_sha: manifest.commit_sha,
assets: assetUrls.length,
};
}
async function main() {
const baseUrl = requiredUrl();
const initialWaitSeconds = numberEnv("RELEASE_WAIT_INITIAL_SECONDS", 30);
const timeoutSeconds = numberEnv("RELEASE_WAIT_TIMEOUT_SECONDS", 300);
const pollIntervalSeconds = numberEnv("RELEASE_POLL_INTERVAL_SECONDS", 10);
if (initialWaitSeconds > 0) {
console.log(`Waiting ${initialWaitSeconds}s before polling ${baseUrl}`);
await sleep(initialWaitSeconds * 1000);
}
const deadline = Date.now() + timeoutSeconds * 1000;
let attempt = 0;
let lastError = null;
while (Date.now() <= deadline) {
attempt += 1;
try {
const result = await verifyRelease(baseUrl);
console.log(
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
);
return;
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
console.log(`Release upload not ready on attempt ${attempt}: ${message}`);
if (Date.now() > deadline) {
break;
}
await sleep(pollIntervalSeconds * 1000);
}
}
throw lastError || new Error("Release upload verification timed out.");
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
+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.
}
@@ -6,8 +6,11 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useSelfServeLogic } from "@/composables/useSelfServeLogic";
import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import { normalizeSelfServeTaskButtons } from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
import {
buildSelfServeDynamicImageUrl,
getSelfServeDynamicImageButtonsToPress,
getSelfServeDynamicImageThumbPosition,
} from "@/services/selfServeDynamicImage.js";
const { t: $t } = useI18n();
@@ -138,36 +141,9 @@ const canClearAnswers = computed(() => (
));
const hideDynamicImage = ref(false);
const parseNonNegativeInt = (value) => {
const parsed = parseInt(value);
if (Number.isNaN(parsed) || parsed < 0) {
return null;
}
return parsed;
};
const dynamicImageButtons = computed(() => getSelfServeDynamicImageButtonsToPress(activeTasks.value));
const dynamicImageButtons = computed(() => {
const buttonIds = new Map();
activeTasks.value.forEach((task) => {
normalizeSelfServeTaskButtons(task?.buttons).forEach((button) => {
buttonIds.set(`${typeof button}:${button}`, button);
});
});
return [...buttonIds.values()];
});
const dynamicImageThumbPosition = computed(() => {
for (const task of activeTasks.value) {
const parsedPosition = parseNonNegativeInt(task?.dynamic_images_vehicle_type);
if (parsedPosition !== null) {
return parsedPosition;
}
}
return null;
});
const dynamicImageThumbPosition = computed(() => getSelfServeDynamicImageThumbPosition(activeTasks.value));
const hasDynamicImageContext = computed(() => (
dynamicImageButtons.value.length > 0 || dynamicImageThumbPosition.value !== null
@@ -180,23 +156,15 @@ const dynamicImageUrl = computed(() => {
return null;
}
const params = new URLSearchParams({
department: String(departmentId),
lane: String(laneId),
current_step: "0",
buttons: JSON.stringify(dynamicImageButtons.value),
});
const selectedVehicleType = parseInt(selectedVehicleTypeId.value);
if (!Number.isNaN(selectedVehicleType) && selectedVehicleType > 0) {
params.set("vehicle_type", String(selectedVehicleType));
}
if (dynamicImageThumbPosition.value !== null) {
params.set("thumb_position", String(dynamicImageThumbPosition.value));
}
return resolveReleaseApiUrl(`/department/lanes/dynamic-image?${params.toString()}`);
return buildSelfServeDynamicImageUrl({
departmentId,
laneId,
buttons: dynamicImageButtons.value,
currentStep: 0,
vehicleTypeId: !Number.isNaN(selectedVehicleType) && selectedVehicleType > 0 ? selectedVehicleType : null,
thumbPosition: dynamicImageThumbPosition.value,
});
});
const displayedDynamicImageUrl = computed(() => (
@@ -261,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",
@@ -316,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>
@@ -333,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>
@@ -345,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>
@@ -357,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>
@@ -367,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 }})
@@ -379,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>
@@ -396,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
@@ -407,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"
@@ -433,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>
@@ -448,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>;
@@ -48,29 +49,50 @@ const getNonImageAttachments = (task: any) => {
return task.attachments.filter((attachment: any) => !isImageAttachment(attachment));
};
const normalizeTaskService = (service: any) => String(service || "").trim().toUpperCase();
const getTaskServiceTokens = (task: any) => {
if (!Array.isArray(task?.services)) {
return [];
}
return task.services
.map((service: any) => normalizeTaskService(service))
.filter(Boolean);
};
const taskUsesProgramPicker = (task: any) => getTaskServiceTokens(task).includes("PROGRAM_PICKER");
const getVisibleServices = (task: any) => {
if (!Array.isArray(task?.services)) {
return [];
}
return task.services.filter((service: any) => String(service || "").trim().toUpperCase() !== "MACHINE");
return task.services.filter((service: any) => normalizeTaskService(service) !== "MACHINE");
};
const getProgramWheelSelection = (task: any) => {
const rawValue = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
if (rawValue === null || rawValue === undefined || rawValue === "") {
if (!taskUsesProgramPicker(task)) {
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,8 +1,9 @@
<script setup lang="ts">
import { BIcon } from "buefy";
import SelfServeDynamicImageFrame from "@/components/displays/selfServe/SelfServeDynamicImageFrame.vue";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
defineProps<{
const props = defineProps<{
isLoading: boolean;
dynamicImageUrl: string | null;
activeTasks: Array<any>;
@@ -24,6 +25,10 @@ const emitToggleTask = (taskId: number, value: boolean) => {
const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined, attachment?: any) => {
emit("download-attachment", taskId, attachmentId, attachment);
};
const onDynamicImageError = () => {
emit("clear-dynamic-image");
};
</script>
<template>
@@ -42,15 +47,11 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<div v-if="dynamicImageUrl" class="mb-4">
<img
:src="dynamicImageUrl"
alt="Machine status"
data-testid="self-serve-dynamic-image"
style="max-width: 100%; height: auto; border-radius: 4px; display: block; margin-left: auto; margin-right: auto;"
@error="emit('clear-dynamic-image')"
/>
</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>
@@ -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="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>
<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;
+22 -2
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();
@@ -32,6 +33,24 @@ const successMessage = ref('');
const store = useStore();
const router = useRouter();
const resolveLoginRedirectPath = () => {
const redirectQuery = router.currentRoute.value.query.redirect;
const redirectValue = Array.isArray(redirectQuery) ? redirectQuery[0] : redirectQuery;
if (!redirectValue) {
return "/redirect";
}
try {
const redirectUrl = new URL(redirectValue, window.location.origin);
if (redirectUrl.origin !== window.location.origin) {
return "/redirect";
}
return `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`;
} catch {
return "/redirect";
}
};
const login = async () => {
try {
const response = await axios.post(API_URL + '/auth/login', {
@@ -50,13 +69,13 @@ 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!"
// Wait 500ms before reloading the page to show the success message
setTimeout(() => {
// Reload the page to update the UI
router.go(0);
window.location.assign(resolveLoginRedirectPath());
}, 500);
// window.location.reload();
} catch (e) {
@@ -86,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);
}
};
@@ -0,0 +1,18 @@
<script setup>
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
defineProps({
testid: {
type: String,
default: "",
},
});
</script>
<template>
<ActionSettingsWheelButton class="release-action-menu" :data-testid="testid || undefined">
<template #actions>
<slot />
</template>
</ActionSettingsWheelButton>
</template>
@@ -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,322 @@
<script setup>
import { computed } from "vue";
import ReleaseEntityLink from "@/components/release/ReleaseEntityLink.vue";
const props = defineProps({
channels: {
type: Array,
default: () => [],
},
selectedChannelSlug: {
type: String,
default: "",
},
selectedApp: {
type: String,
default: "all",
},
selectedBranch: {
type: String,
default: "master",
},
branchOptions: {
type: Array,
default: () => [],
},
branchStatus: {
type: Object,
default: () => ({}),
},
runtimeSource: {
type: String,
default: "",
},
runtimeApiBaseUrl: {
type: String,
default: "",
},
runtimeFrontendBaseUrl: {
type: String,
default: "",
},
busy: {
type: String,
default: "",
},
loading: {
type: Boolean,
default: false,
},
refreshing: {
type: Boolean,
default: false,
},
loadingText: {
type: String,
default: "Loading release context...",
},
canDeploy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:channel", "update:app", "update:branch", "test", "sync", "refresh", "open-entity"]);
const routeSlugForChannel = (channel) => {
const slug = String(channel?.slug || channel?.channel_slug || "").trim().toLowerCase();
return slug === "stable" ? "master" : slug;
};
const selectedChannel = computed(
() =>
props.channels.find((channel) => String(channel.slug || channel.channel_slug) === props.selectedChannelSlug) ||
props.channels.find((channel) => channel.default_channel) ||
props.channels[0] ||
null
);
const selectedRouteSlug = computed(() => routeSlugForChannel(selectedChannel.value) || props.selectedBranch || "master");
const selectedEndpointApps = computed(() => {
if (props.selectedApp === "frontend") return ["frontend"];
if (props.selectedApp === "api") return ["api"];
return ["frontend", "api"];
});
const isLocalRuntime = computed(() => String(props.runtimeSource || "").toLowerCase() === "local");
const releaseSourceLabel = computed(() => (isLocalRuntime.value ? "Local source" : "Deployment source"));
const runtimeFrontendBaseUrl = computed(() => {
const value = String(props.runtimeFrontendBaseUrl || "").replace(/\/+$/, "");
if (value) return value;
if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
return "/";
});
const runtimeApiBaseUrl = computed(() => String(props.runtimeApiBaseUrl || "/api").replace(/\/+$/, "") || "/api");
const endpointForApp = (app) =>
isLocalRuntime.value
? app === "api"
? runtimeApiBaseUrl.value
: runtimeFrontendBaseUrl.value
: `https://api-v2.truckwash.io/${selectedRouteSlug.value}/${app}`;
const endpointLabelForApp = (app) =>
isLocalRuntime.value
? app === "api"
? runtimeApiBaseUrl.value
: "local frontend"
: `/${selectedRouteSlug.value}/${app}`;
const appBranchStatus = computed(() => {
if (props.selectedApp === "frontend" || props.selectedApp === "api") {
return props.branchStatus?.[props.selectedApp] || {};
}
const values = [props.branchStatus?.frontend, props.branchStatus?.api].filter(Boolean);
const missing = values.find((status) => ["missing", "not_configured", "unknown"].includes(String(status.state || "").toLowerCase()));
return missing || values[0] || {};
});
const branchState = computed(() => String(appBranchStatus.value?.state || "unknown").toLowerCase());
const branchWarning = computed(() =>
["missing", "not_configured", "unknown", "stale", "failed"].includes(branchState.value)
? `${props.selectedBranch || "unknown"} is ${branchState.value || "unknown"} for the selected context.`
: ""
);
</script>
<template>
<section
class="release-context-bar"
data-testid="release-context-bar"
aria-label="Release context"
:aria-busy="loading || refreshing"
>
<div v-if="loading" class="release-context-bar__loading" data-testid="release-context-loading" role="status" aria-live="polite">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span>{{ loadingText }}</span>
</div>
<div class="release-context-bar__controls">
<label>
<span>Channel</span>
<select
class="select is-small"
:value="selectedChannelSlug"
data-testid="release-context-channel"
:disabled="loading"
@change="emit('update:channel', $event.target.value)"
>
<option v-for="channel in channels" :key="channel.id || channel.slug" :value="channel.slug">
{{ channel.name || channel.slug }} / {{ routeSlugForChannel(channel) || "unknown" }}
</option>
</select>
</label>
<label>
<span>App</span>
<select
class="select is-small"
:value="selectedApp"
data-testid="release-context-app"
:disabled="loading"
@change="emit('update:app', $event.target.value)"
>
<option value="all">All</option>
<option value="frontend">Frontend</option>
<option value="api">API</option>
</select>
</label>
<label class="release-context-bar__branch">
<span>Branch</span>
<input
class="input is-small"
list="release-context-branches"
:value="selectedBranch"
data-testid="release-context-branch"
:disabled="loading"
@change="emit('update:branch', $event.target.value)"
@keydown.enter.prevent="emit('update:branch', $event.target.value)"
/>
<datalist id="release-context-branches">
<option v-for="branch in branchOptions" :key="branch" :value="branch" />
</datalist>
</label>
</div>
<div class="release-context-bar__status">
<b-tag
:class="isLocalRuntime ? 'is-info' : 'is-success'"
data-testid="release-context-source"
>
{{ releaseSourceLabel }}
</b-tag>
<b-tag :class="branchState === 'available' || branchState === 'ready' ? 'is-success' : 'is-warning'">
{{ branchState }}
</b-tag>
<span v-if="branchWarning" class="release-context-bar__warning" data-testid="release-context-branch-warning">
{{ branchWarning }}
</span>
</div>
<div class="release-context-bar__endpoints" data-testid="release-context-endpoints">
<ReleaseEntityLink
v-for="app in selectedEndpointApps"
:key="app"
type="endpoint"
:label="endpointLabelForApp(app)"
:entity="{ url: endpointForApp(app), route_prefix: endpointLabelForApp(app), app, channel: selectedChannelSlug }"
@open="(payload) => emit('open-entity', payload)"
/>
</div>
<div class="release-context-bar__actions">
<b-button
size="is-small"
icon-left="vial"
icon-pack="fas"
:disabled="loading || !canDeploy"
:loading="busy === 'release:test'"
data-testid="release-context-test"
@click="emit('test')"
>
Test
</b-button>
<b-button
size="is-small"
icon-left="rotate"
icon-pack="fas"
:disabled="loading || !canDeploy || !selectedChannel"
:loading="busy === `channel:${selectedChannel?.id}:sync`"
data-testid="release-context-sync"
@click="emit('sync', selectedChannel)"
>
Sync
</b-button>
<b-button
size="is-small"
icon-left="refresh"
icon-pack="fas"
data-testid="release-context-refresh"
:disabled="loading || refreshing"
:loading="refreshing"
@click="emit('refresh')"
>
Refresh
</b-button>
</div>
</section>
</template>
<style scoped>
.release-context-bar {
align-items: center;
background: #f8fafc;
border-bottom: 1px solid #d8dee8;
display: grid;
gap: 0.75rem;
grid-template-columns: minmax(0, 1.4fr) minmax(11rem, 0.6fr) minmax(0, 1fr) auto;
min-height: 4rem;
padding: 0.6rem 0.75rem;
}
.release-context-bar__loading {
align-items: center;
color: #52627a;
display: inline-flex;
font-size: 0.82rem;
font-weight: 700;
gap: 0.45rem;
grid-column: 1 / -1;
}
.release-context-bar__controls,
.release-context-bar__status,
.release-context-bar__endpoints,
.release-context-bar__actions {
align-items: center;
display: flex;
gap: 0.5rem;
min-width: 0;
}
.release-context-bar label {
display: grid;
gap: 0.2rem;
min-width: 8rem;
}
.release-context-bar label > span {
color: #667085;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
}
.release-context-bar__branch {
min-width: 12rem;
}
.release-context-bar__warning {
color: #9f1f17;
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.release-context-bar__endpoints {
flex-wrap: wrap;
}
.release-context-bar__actions {
justify-content: flex-end;
}
@media (max-width: 1180px) {
.release-context-bar {
grid-template-columns: 1fr;
}
.release-context-bar__controls,
.release-context-bar__actions {
flex-wrap: wrap;
}
}
</style>
@@ -0,0 +1,74 @@
<script setup>
defineProps({
emptyText: {
type: String,
default: "No records",
},
loading: {
type: Boolean,
default: false,
},
loadingText: {
type: String,
default: "Loading release data...",
},
loadingColspan: {
type: Number,
default: 1,
},
});
</script>
<template>
<div class="release-data-grid" data-testid="release-data-grid" :aria-busy="loading">
<table class="table is-fullwidth is-hoverable">
<slot name="head" />
<tbody v-if="loading" data-testid="release-data-grid-loading">
<tr>
<td :colspan="loadingColspan">
<div class="release-data-grid__loading" role="status" aria-live="polite">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span>{{ loadingText }}</span>
</div>
</td>
</tr>
</tbody>
<slot v-else />
<tbody v-if="!loading && $slots.empty">
<slot name="empty" />
</tbody>
</table>
</div>
</template>
<style scoped>
.release-data-grid {
border: 1px solid #d8dee8;
border-radius: 8px;
max-height: 100%;
min-height: 0;
overflow: auto;
}
.release-data-grid :deep(table) {
margin-bottom: 0;
}
.release-data-grid :deep(thead th) {
background: #f8fafc;
position: sticky;
top: 0;
z-index: 1;
}
.release-data-grid__loading {
align-items: center;
color: #52627a;
display: flex;
font-size: 0.9rem;
font-weight: 700;
gap: 0.5rem;
justify-content: center;
min-height: 5rem;
}
</style>
@@ -0,0 +1,129 @@
<script setup>
import { computed } from "vue";
import {
releaseEntityDisplayLabel,
releaseEntityFacts,
releaseEntityTooltipText,
} from "@/components/release/releaseEntityFacts.js";
const emit = defineEmits(["open"]);
const props = defineProps({
type: {
type: String,
default: "entity",
},
label: {
type: String,
default: "",
},
entity: {
type: Object,
default: () => ({}),
},
detail: {
type: String,
default: "",
},
facts: {
type: Array,
default: null,
},
});
const displayLabel = computed(() => releaseEntityDisplayLabel(props.label, props.entity));
const tooltipFacts = computed(() =>
Array.isArray(props.facts) && props.facts.length > 0
? props.facts
: releaseEntityFacts({ type: props.type, entity: props.entity || {}, detail: props.detail })
);
const tooltip = computed(() => releaseEntityTooltipText(tooltipFacts.value));
const open = () => {
emit("open", {
type: props.type,
label: displayLabel.value,
detail: props.detail,
entity: props.entity || {},
facts: tooltipFacts.value,
});
};
</script>
<template>
<b-tooltip :label="tooltip || displayLabel" multilined type="is-dark">
<template #content>
<div class="release-entity-tooltip" data-testid="release-entity-tooltip">
<strong>{{ displayLabel }}</strong>
<dl>
<template v-for="fact in tooltipFacts" :key="`${fact.label}-${fact.value}`">
<dt>{{ fact.label }}</dt>
<dd>{{ fact.value }}</dd>
</template>
</dl>
</div>
</template>
<button
class="release-entity-link"
type="button"
:title="tooltip || displayLabel"
:aria-label="tooltip || displayLabel"
:data-entity-type="type"
@click="open"
>
<span>{{ displayLabel }}</span>
<i class="fas fa-circle-info" aria-hidden="true"></i>
</button>
</b-tooltip>
</template>
<style scoped>
.release-entity-link {
align-items: center;
background: transparent;
border: 0;
color: #0747a6;
cursor: pointer;
display: inline-flex;
font: inherit;
gap: 0.35rem;
max-width: 100%;
min-width: 0;
padding: 0;
text-align: left;
}
.release-entity-link span {
min-width: 0;
overflow-wrap: anywhere;
}
.release-entity-link i {
color: #52627a;
font-size: 0.78rem;
}
.release-entity-tooltip {
display: grid;
gap: 0.45rem;
max-width: min(28rem, 82vw);
text-align: left;
}
.release-entity-tooltip dl {
display: grid;
gap: 0.25rem 0.75rem;
grid-template-columns: max-content minmax(0, 1fr);
margin: 0;
}
.release-entity-tooltip dt {
color: #cbd5e1;
font-weight: 700;
}
.release-entity-tooltip dd {
margin: 0;
overflow-wrap: anywhere;
}
</style>
@@ -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>
@@ -0,0 +1,188 @@
<script setup>
import ReleaseContextBar from "@/components/release/ReleaseContextBar.vue";
import ReleaseRouteTabs from "@/components/release/ReleaseRouteTabs.vue";
defineProps({
tabs: {
type: Array,
default: () => [],
},
activePanel: {
type: String,
default: "overview",
},
channels: {
type: Array,
default: () => [],
},
selectedChannelSlug: {
type: String,
default: "",
},
selectedApp: {
type: String,
default: "all",
},
selectedBranch: {
type: String,
default: "master",
},
branchOptions: {
type: Array,
default: () => [],
},
branchStatus: {
type: Object,
default: () => ({}),
},
runtimeSource: {
type: String,
default: "",
},
runtimeApiBaseUrl: {
type: String,
default: "",
},
runtimeFrontendBaseUrl: {
type: String,
default: "",
},
busy: {
type: String,
default: "",
},
loading: {
type: Boolean,
default: false,
},
refreshing: {
type: Boolean,
default: false,
},
loadingText: {
type: String,
default: "Loading Release Manager...",
},
refreshingText: {
type: String,
default: "Refreshing release data...",
},
canDeploy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
"navigate",
"update:channel",
"update:app",
"update:branch",
"test",
"sync",
"refresh",
"open-entity",
]);
</script>
<template>
<section class="release-manager-workspace" data-testid="release-manager-workspace" :aria-busy="loading || refreshing">
<ReleaseContextBar
:channels="channels"
:selected-channel-slug="selectedChannelSlug"
:selected-app="selectedApp"
:selected-branch="selectedBranch"
:branch-options="branchOptions"
:branch-status="branchStatus"
:runtime-source="runtimeSource"
:runtime-api-base-url="runtimeApiBaseUrl"
:runtime-frontend-base-url="runtimeFrontendBaseUrl"
:busy="busy"
:loading="loading"
:refreshing="refreshing"
:loading-text="loadingText"
:can-deploy="canDeploy"
@update:channel="(value) => emit('update:channel', value)"
@update:app="(value) => emit('update:app', value)"
@update:branch="(value) => emit('update:branch', value)"
@test="emit('test')"
@sync="(channel) => emit('sync', channel)"
@refresh="emit('refresh')"
@open-entity="(payload) => emit('open-entity', payload)"
/>
<ReleaseRouteTabs :tabs="tabs" :active-key="activePanel" @navigate="(panel) => emit('navigate', panel)" />
<div
class="release-manager-workspace__refreshing"
:class="{ 'release-manager-workspace__refreshing--active': refreshing && !loading }"
data-testid="release-manager-refreshing"
role="status"
aria-live="polite"
:aria-hidden="!(refreshing && !loading)"
>
<template v-if="refreshing && !loading">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span>{{ refreshingText }}</span>
</template>
</div>
<main class="release-manager-workspace__panel" :data-active-panel="activePanel">
<slot />
</main>
<slot name="overlays" />
</section>
</template>
<style scoped>
.release-manager-workspace {
background: #ffffff;
border: 1px solid #d8dee8;
border-radius: 8px;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
height: calc(100vh - 11rem);
min-height: 42rem;
overflow: hidden;
}
.release-manager-workspace__refreshing {
align-items: center;
background: transparent;
border-bottom: 0 solid transparent;
color: #17416f;
display: flex;
font-size: 0.82rem;
font-weight: 700;
gap: 0.45rem;
min-height: 0;
overflow: hidden;
padding: 0 0.75rem;
}
.release-manager-workspace__refreshing--active {
background: #eef6ff;
border-bottom-color: #cfe3ff;
border-bottom-width: 1px;
min-height: 2.35rem;
padding-bottom: 0.45rem;
padding-top: 0.45rem;
}
.release-manager-workspace__panel {
background: #eef3f8;
min-height: 0;
overflow: auto;
padding: 0.75rem;
}
@media (max-width: 768px) {
.release-manager-workspace {
display: block;
height: auto;
min-height: 0;
overflow: visible;
}
.release-manager-workspace__panel {
overflow: visible;
}
}
</style>
@@ -0,0 +1,89 @@
<script setup>
import ReleaseOperationTimeline from "@/components/release/ReleaseOperationTimeline.vue";
const emit = defineEmits(["close", "retry"]);
defineProps({
operation: {
type: Object,
default: null,
},
});
</script>
<template>
<div v-if="operation" class="release-operation-modal" role="dialog" aria-modal="true" data-testid="release-operation-modal">
<div class="release-operation-modal__backdrop" @click="emit('close')"></div>
<section class="release-operation-modal__panel">
<header>
<div>
<span class="release-operation-modal__eyebrow">{{ operation.operation_type || "operation" }}</span>
<h3>{{ operation.title || `Operation #${operation.id}` }}</h3>
<p>{{ operation.summary || "Operation is running." }}</p>
</div>
<button type="button" class="delete" aria-label="Close" @click="emit('close')"></button>
</header>
<ReleaseOperationTimeline :operation="operation" @retry="(step) => emit('retry', step)" />
</section>
</div>
</template>
<style scoped>
.release-operation-modal {
inset: 0;
position: fixed;
z-index: 60;
}
.release-operation-modal__backdrop {
background: rgba(15, 23, 42, 0.42);
inset: 0;
position: absolute;
}
.release-operation-modal__panel {
background: #ffffff;
border-radius: 8px;
box-shadow: 0 22px 70px rgba(15, 23, 42, 0.28);
display: grid;
gap: 1rem;
max-height: min(86vh, 780px);
max-width: min(920px, calc(100vw - 2rem));
overflow: auto;
padding: 1rem;
position: absolute;
right: 1rem;
top: 1rem;
width: 56rem;
}
.release-operation-modal__panel header {
align-items: start;
display: flex;
gap: 0.75rem;
justify-content: space-between;
}
.release-operation-modal__panel h3,
.release-operation-modal__panel p {
margin: 0;
}
.release-operation-modal__eyebrow {
color: #667085;
font-size: 0.76rem;
font-weight: 700;
text-transform: uppercase;
}
@media (max-width: 768px) {
.release-operation-modal__panel {
inset: 0.5rem;
max-height: calc(100vh - 1rem);
max-width: none;
width: auto;
}
}
</style>
@@ -0,0 +1,188 @@
<script setup>
import { computed } from "vue";
const emit = defineEmits(["retry"]);
const props = defineProps({
operation: {
type: Object,
default: null,
},
});
const statusClass = (status) => {
const normalized = String(status || "").toLowerCase();
if (["passed", "deployed", "completed", "success"].includes(normalized)) return "is-success";
if (["failed", "error"].includes(normalized)) return "is-danger";
if (["warning", "skipped"].includes(normalized)) return "is-warning";
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>
<section class="release-operation-timeline" data-testid="release-operation-timeline">
<div class="release-operation-timeline__status">
<b-tag :class="statusClass(operation?.status)">{{ operation?.status || "unknown" }}</b-tag>
<span>{{ operation?.passed_step_count || 0 }} passed</span>
<span>{{ operation?.failed_step_count || 0 }} failed</span>
<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 || []"
:key="step.id || step.step_key"
:class="`is-${step.status || 'unknown'}`"
data-testid="release-operation-step"
>
<div class="release-operation-timeline__step-head">
<strong>{{ step.label || step.step_key }}</strong>
<b-tag :class="statusClass(step.status)" size="is-small">{{ step.status || "unknown" }}</b-tag>
</div>
<p v-if="step.message">{{ step.message }}</p>
<div v-if="step.diagnostic" class="release-operation-timeline__diagnostic">
<strong>Diagnostic</strong>
<span>{{ step.diagnostic }}</span>
</div>
<div v-if="step.solution_hint" class="release-operation-timeline__solution">
<strong>Solution</strong>
<span>{{ step.solution_hint }}</span>
</div>
<button
v-if="step.context?.retry_action"
class="button is-small"
type="button"
@click="emit('retry', step)"
>
Retry
</button>
</li>
</ol>
<footer v-if="operation?.solution_hint">
<strong>Next</strong>
<span>{{ operation.solution_hint }}</span>
</footer>
</section>
</template>
<style scoped>
.release-operation-timeline {
display: grid;
gap: 1rem;
}
.release-operation-timeline__status,
.release-operation-timeline__meta,
.release-operation-timeline__step-head,
.release-operation-timeline__diagnostic,
.release-operation-timeline__solution,
.release-operation-timeline footer {
align-items: start;
display: flex;
gap: 0.75rem;
justify-content: space-between;
}
.release-operation-timeline__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;
list-style: none;
margin: 0;
padding: 0;
}
.release-operation-timeline__steps li {
border: 1px solid #d8dee8;
border-left: 4px solid #94a3b8;
border-radius: 7px;
display: grid;
gap: 0.45rem;
padding: 0.75rem;
}
.release-operation-timeline__steps li.is-passed,
.release-operation-timeline__steps li.is-deployed,
.release-operation-timeline__steps li.is-completed,
.release-operation-timeline__steps li.is-success {
border-left-color: #1f9d5a;
}
.release-operation-timeline__steps li.is-failed,
.release-operation-timeline__steps li.is-error {
border-left-color: #d92d20;
}
.release-operation-timeline__steps li.is-warning,
.release-operation-timeline__steps li.is-skipped {
border-left-color: #d99a20;
}
.release-operation-timeline__steps li.is-running,
.release-operation-timeline__steps li.is-queued,
.release-operation-timeline__steps li.is-pending {
border-left-color: #1f6feb;
}
.release-operation-timeline__diagnostic,
.release-operation-timeline__solution,
.release-operation-timeline footer {
background: #f8fafc;
border-radius: 6px;
justify-content: flex-start;
padding: 0.55rem 0.65rem;
}
</style>
@@ -97,6 +97,10 @@ const detailErrorReports = computed(() =>
Array.isArray(selectedDetail.value?.error_reports) ? selectedDetail.value.error_reports : []
);
const selectedRelease = computed(() => selectedDetail.value?.release || selectedSession.value?.release || {});
const timelineLoading = computed(() => busy.value === "timeline:search");
const timelineRefreshing = computed(() => timelineLoading.value && (sessionRows.value.length > 0 || timelineEvents.value.length > 0));
const sessionRowsLoading = computed(() => timelineLoading.value && sessionRows.value.length === 0);
const timelineEventsLoading = computed(() => timelineLoading.value && timelineEvents.value.length === 0);
const filterPayload = () => {
const payload = {};
@@ -382,6 +386,17 @@ onMounted(loadReplayData);
</div>
</form>
<div
v-if="timelineRefreshing"
class="release-replay-inspector__refreshing"
data-testid="release-timeline-refreshing"
role="status"
aria-live="polite"
>
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span>{{ trFallback("loading.refreshing_timeline", "Refreshing release timeline...") }}</span>
</div>
<div class="table-container mt-3">
<table class="table is-fullwidth is-hoverable release-session-table" data-testid="release-timeline-sessions">
<thead>
@@ -397,37 +412,47 @@ onMounted(loadReplayData);
</tr>
</thead>
<tbody>
<tr v-for="session in sessionRows" :key="session.trace_id">
<td><code>{{ shortValue(session.trace_id) }}</code></td>
<td>{{ userLabel(session) }}</td>
<td>{{ deviceLabel(session) }}</td>
<td>{{ session.channel_slug || "--" }}</td>
<td>{{ releaseLabel(session, "frontend") }} / {{ releaseLabel(session, "api") }}</td>
<td>
{{ session.event_count || 0 }}
<b-tag v-if="session.error_count" type="is-danger" size="is-small">{{ session.error_count }}</b-tag>
<b-tag v-if="session.error_report_count" type="is-warning" size="is-small">
{{ session.error_report_count }}
</b-tag>
</td>
<td>{{ formatDate(session.last_event_at || session.last_seen_at) }}</td>
<td class="release-action-cell">
<ActionSettingsWheelButton
class="release-session-actions"
:data-testid="`release-session-actions-${session.trace_id}`"
>
<template #actions>
<ActionSettingsWheelItem
icon="fas fa-search"
:label="trFallback('actions.inspect', 'Inspect')"
:click-action="() => inspectSession(session)"
:disabled="busy === `session:${session.trace_id}`"
/>
</template>
</ActionSettingsWheelButton>
<tr v-if="sessionRowsLoading" data-testid="release-timeline-sessions-loading">
<td colspan="8">
<div class="release-replay-inspector__loading" role="status" aria-live="polite">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span>{{ trFallback("loading.timeline_sessions", "Loading release timeline sessions...") }}</span>
</div>
</td>
</tr>
<tr v-if="sessionRows.length === 0">
<template v-else>
<tr v-for="session in sessionRows" :key="session.trace_id">
<td><code>{{ shortValue(session.trace_id) }}</code></td>
<td>{{ userLabel(session) }}</td>
<td>{{ deviceLabel(session) }}</td>
<td>{{ session.channel_slug || "--" }}</td>
<td>{{ releaseLabel(session, "frontend") }} / {{ releaseLabel(session, "api") }}</td>
<td>
{{ session.event_count || 0 }}
<b-tag v-if="session.error_count" type="is-danger" size="is-small">{{ session.error_count }}</b-tag>
<b-tag v-if="session.error_report_count" type="is-warning" size="is-small">
{{ session.error_report_count }}
</b-tag>
</td>
<td>{{ formatDate(session.last_event_at || session.last_seen_at) }}</td>
<td class="release-action-cell">
<ActionSettingsWheelButton
class="release-session-actions"
:data-testid="`release-session-actions-${session.trace_id}`"
>
<template #actions>
<ActionSettingsWheelItem
icon="fas fa-search"
:label="trFallback('actions.inspect', 'Inspect')"
:click-action="() => inspectSession(session)"
:disabled="busy === `session:${session.trace_id}`"
/>
</template>
</ActionSettingsWheelButton>
</td>
</tr>
</template>
<tr v-if="!sessionRowsLoading && sessionRows.length === 0">
<td colspan="8">{{ tr("replay.no_timeline_events") }}</td>
</tr>
</tbody>
@@ -559,7 +584,17 @@ onMounted(loadReplayData);
</div>
</Teleport>
<ol v-if="!selectedDetail" class="release-timeline" data-testid="release-timeline-events">
<div
v-if="!selectedDetail && timelineEventsLoading"
class="release-replay-inspector__loading"
data-testid="release-timeline-events-loading"
role="status"
aria-live="polite"
>
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span>{{ trFallback("loading.timeline_events", "Loading release timeline events...") }}</span>
</div>
<ol v-else-if="!selectedDetail" class="release-timeline" data-testid="release-timeline-events">
<li v-for="event in timelineEvents" :key="event.id">
<time>{{ formatDate(event.occurred_at) }}</time>
<strong>{{ event.event_type }}</strong>
@@ -585,6 +620,27 @@ onMounted(loadReplayData);
gap: 1rem;
}
.release-replay-inspector__loading,
.release-replay-inspector__refreshing {
align-items: center;
color: #52627a;
display: flex;
font-weight: 700;
gap: 0.5rem;
justify-content: center;
min-height: 4rem;
}
.release-replay-inspector__refreshing {
background: #eef6ff;
border: 1px solid #cfe3ff;
border-radius: 6px;
color: #17416f;
justify-content: flex-start;
min-height: 0;
padding: 0.55rem 0.75rem;
}
.release-session-table td {
vertical-align: middle;
}
@@ -0,0 +1,71 @@
<script setup>
defineProps({
tabs: {
type: Array,
default: () => [],
},
activeKey: {
type: String,
default: "overview",
},
});
const emit = defineEmits(["navigate"]);
</script>
<template>
<nav class="release-route-tabs" data-testid="release-management-interface" aria-label="Release Manager">
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
class="release-route-tabs__item"
:class="{ 'release-route-tabs__item--active': activeKey === tab.key }"
:data-testid="`release-section-link-${tab.key}`"
@click="emit('navigate', tab.key)"
>
<i :class="`fas fa-${tab.icon}`" aria-hidden="true"></i>
<span>{{ tab.label }}</span>
</button>
</nav>
</template>
<style scoped>
.release-route-tabs {
align-items: center;
border-bottom: 1px solid #d8dee8;
display: flex;
flex-wrap: nowrap;
gap: 0.25rem;
min-height: 2.75rem;
overflow-x: auto;
padding: 0 0.25rem;
}
.release-route-tabs__item {
align-items: center;
background: transparent;
border: 0;
border-bottom: 3px solid transparent;
color: #52627a;
cursor: pointer;
display: inline-flex;
flex: 0 0 auto;
font: inherit;
font-size: 0.9rem;
gap: 0.45rem;
min-height: 2.75rem;
padding: 0 0.75rem;
}
.release-route-tabs__item--active {
border-bottom-color: #1f6feb;
color: #172033;
font-weight: 700;
}
.release-route-tabs__item:focus-visible {
outline: 2px solid #1f6feb;
outline-offset: -2px;
}
</style>
@@ -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>
@@ -0,0 +1,208 @@
<script setup>
import { useAttrs } from "vue";
defineOptions({
inheritAttrs: false,
});
const attrs = useAttrs();
defineProps({
title: {
type: String,
default: "",
},
subtitle: {
type: String,
default: "",
},
icon: {
type: String,
default: "fas fa-table-columns",
},
padded: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
},
loadingText: {
type: String,
default: "Loading release data...",
},
loadingDetail: {
type: String,
default: "",
},
loadingRows: {
type: Number,
default: 4,
},
});
</script>
<template>
<section
v-bind="attrs"
class="release-workspace-panel"
:data-testid="attrs['data-testid'] || 'release-workspace-panel'"
:aria-busy="loading"
>
<header class="release-workspace-panel__header">
<div>
<span class="release-workspace-panel__kicker">
<i :class="icon" aria-hidden="true"></i>
Release Manager
</span>
<h3>{{ title }}</h3>
<p v-if="subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.actions" class="release-workspace-panel__actions">
<slot name="actions" />
</div>
</header>
<div
v-if="loading"
class="release-workspace-panel__body release-workspace-panel__body--padded"
data-testid="release-workspace-panel-loading"
role="status"
aria-live="polite"
>
<div class="release-workspace-panel__loading">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<div>
<strong>{{ loadingText }}</strong>
<span v-if="loadingDetail">{{ loadingDetail }}</span>
</div>
</div>
<div class="release-workspace-panel__skeleton" aria-hidden="true">
<span v-for="index in loadingRows" :key="index"></span>
</div>
</div>
<div v-else class="release-workspace-panel__body" :class="{ 'release-workspace-panel__body--padded': padded }">
<slot />
</div>
</section>
</template>
<style scoped>
.release-workspace-panel {
background: #ffffff;
border: 1px solid #d8dee8;
border-radius: 8px;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
}
.release-workspace-panel__header {
align-items: center;
border-bottom: 1px solid #e1e7ef;
display: flex;
gap: 1rem;
justify-content: space-between;
min-height: 4rem;
padding: 0.75rem 1rem;
}
.release-workspace-panel__header h3,
.release-workspace-panel__header p {
margin: 0;
}
.release-workspace-panel__header h3 {
color: #172033;
font-size: 1rem;
line-height: 1.25;
}
.release-workspace-panel__header p,
.release-workspace-panel__kicker {
color: #667085;
font-size: 0.82rem;
}
.release-workspace-panel__kicker {
align-items: center;
display: inline-flex;
font-weight: 700;
gap: 0.35rem;
text-transform: uppercase;
}
.release-workspace-panel__actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
}
.release-workspace-panel__body {
min-height: 0;
overflow: auto;
}
.release-workspace-panel__body--padded {
padding: 1rem;
}
.release-workspace-panel__loading {
align-items: center;
color: #172033;
display: flex;
gap: 0.75rem;
}
.release-workspace-panel__loading > div {
display: grid;
gap: 0.15rem;
}
.release-workspace-panel__loading span {
color: #667085;
font-size: 0.86rem;
}
.release-workspace-panel__skeleton {
display: grid;
gap: 0.65rem;
margin-top: 1rem;
}
.release-workspace-panel__skeleton span {
animation: release-loading-shimmer 1.2s ease-in-out infinite;
background: linear-gradient(90deg, #edf2f7 0%, #f8fafc 50%, #edf2f7 100%);
background-size: 220% 100%;
border-radius: 6px;
display: block;
height: 2.25rem;
}
@keyframes release-loading-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@media (max-width: 768px) {
.release-workspace-panel {
display: block;
}
.release-workspace-panel__header {
align-items: stretch;
flex-direction: column;
}
.release-workspace-panel__body {
overflow: visible;
}
}
</style>
@@ -0,0 +1,236 @@
const UNKNOWN = "unknown";
const NOT_CONFIGURED = "not configured";
const NOT_CHECKED = "not checked";
const isBlank = (value) => value === null || value === undefined || String(value).trim() === "";
const valueOr = (value, fallback = UNKNOWN) => (isBlank(value) ? fallback : String(value));
const boolValue = (value, trueLabel = "yes", falseLabel = "no", fallback = UNKNOWN) => {
if (value === true || value === 1 || value === "1") return trueLabel;
if (value === false || value === 0 || value === "0") return falseLabel;
return fallback;
};
const shortSha = (value) => {
const normalized = String(value || "").trim();
return normalized.length > 12 ? normalized.slice(0, 12) : normalized || UNKNOWN;
};
const publicRouteSlug = (entity = {}) => {
const slug = String(entity.route_slug || entity.public_route_slug || entity.channel_slug || entity.slug || "").trim();
if (!slug) return UNKNOWN;
return slug.toLowerCase() === "stable" ? "master" : slug;
};
const readableDuration = (start, end) => {
const started = Date.parse(start || "");
const finished = Date.parse(end || "");
if (!Number.isFinite(started) || !Number.isFinite(finished) || finished < started) {
return UNKNOWN;
}
const seconds = Math.round((finished - started) / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m`;
return `${Math.round(minutes / 60)}h`;
};
const uptimeText = (entity = {}) => {
const direct = entity.uptime || entity.uptime_text || entity.running_for || entity.uptime_human;
if (!isBlank(direct)) return String(direct);
const seconds = Number(entity.uptime_seconds || entity.uptime_sec || entity.last_status?.uptime_seconds);
if (!Number.isFinite(seconds) || seconds <= 0) return UNKNOWN;
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
return `${Math.round(seconds / 86400)}d`;
};
const endpointHost = (entity = {}) =>
entity.hostname ||
entity.host ||
entity.ip ||
entity.endpoint?.host ||
entity.replication?.host ||
entity.target?.endpoint?.host ||
entity.target?.replication?.host;
const endpointPort = (entity = {}) =>
entity.port || entity.endpoint?.port || entity.replication?.port || entity.target?.endpoint?.port || entity.target?.replication?.port;
const lastCheck = (entity = {}) =>
entity.last_checked_at ||
entity.checked_at ||
entity.health_checked_at ||
entity.last_health_check_at ||
entity.last_check ||
entity.replication?.last_checked_at ||
entity.target?.last_reconciled_at ||
entity.target?.replication?.last_checked_at ||
NOT_CHECKED;
const addFact = (facts, label, value, fallback = UNKNOWN) => {
facts.push({ label, value: valueOr(value, fallback) });
};
const currentDeploymentLabel = (deployment) => {
if (!deployment) return NOT_CONFIGURED;
return [`#${deployment.id || UNKNOWN}`, deployment.status || UNKNOWN, shortSha(deployment.commit_sha)].join(" / ");
};
const channelFacts = (entity = {}) => {
const facts = [];
const frontend = entity.current_deployments?.frontend;
const api = entity.current_deployments?.api;
const branch = entity.branch || entity.mapped_branch || entity.branch_status?.frontend?.branch || publicRouteSlug(entity);
addFact(facts, "Public route", `/${publicRouteSlug(entity)}/{api|frontend}`);
addFact(facts, "Mapped branch", branch);
addFact(facts, "Frontend deployment", currentDeploymentLabel(frontend));
addFact(facts, "API deployment", currentDeploymentLabel(api));
addFact(facts, "Health", entity.readiness || entity.state || entity.status || entity.availability?.status);
addFact(facts, "Last sync", entity.last_sync_at || entity.last_synced_at || entity.operation?.completed_at, NOT_CHECKED);
addFact(facts, "Active blockers", entity.active_blockers ?? entity.issues?.length ?? entity.blockers?.length ?? 0);
return facts;
};
const branchFacts = (entity = {}) => {
const facts = [];
addFact(facts, "Repository", entity.repository || entity.full_name);
addFact(facts, "Branch", entity.branch || entity.name);
addFact(facts, "Existence", entity.exists === false ? "missing" : entity.state || entity.status || "available");
addFact(facts, "Latest commit", shortSha(entity.latest_commit_sha || entity.commit_sha || entity.commit?.sha));
addFact(facts, "Commit date", entity.commit_date || entity.latest_commit_at || entity.commit?.date, UNKNOWN);
addFact(facts, "Protected", boolValue(entity.protected));
addFact(facts, "Last sync", entity.last_sync_result || entity.last_webhook_result || entity.last_checked_at, NOT_CHECKED);
return facts;
};
const deploymentFacts = (entity = {}) => {
const facts = [];
addFact(facts, "Channel", entity.channel_slug || entity.channel_name);
addFact(facts, "App", entity.app || entity.deployment_kind);
addFact(facts, "Commit", shortSha(entity.commit_sha));
addFact(facts, "Status", entity.status);
addFact(facts, "Target", entity.coolify_service_uuid || entity.target_id || entity.service_set_id, NOT_CONFIGURED);
addFact(facts, "Started", entity.started_at || entity.created_at, UNKNOWN);
addFact(facts, "Finished", entity.completed_at || entity.updated_at, entity.status === "running" ? "running" : UNKNOWN);
addFact(facts, "Duration", entity.duration || readableDuration(entity.started_at || entity.created_at, entity.completed_at || entity.updated_at));
addFact(facts, "Current state", entity.active_current || entity.current ? "current" : entity.status === "superseded" ? "superseded" : "history");
return facts;
};
const endpointFacts = (entity = {}) => {
const facts = [];
const url = entity.url || entity.public_url || entity.endpoint?.url || entity.endpoint?.display || entity.endpoint;
addFact(facts, "Public URL", url);
addFact(facts, "Route prefix", entity.route_prefix || entity.prefix || (url ? `/${String(url).split("/").slice(3, 5).join("/")}` : ""));
addFact(facts, "Target", entity.app || entity.service || entity.target || entity.target_service, UNKNOWN);
addFact(facts, "Last probe", entity.probe_status || entity.health_status || entity.status, NOT_CHECKED);
addFact(facts, "Response time", entity.response_time_ms ? `${entity.response_time_ms}ms` : entity.latency_ms ? `${entity.latency_ms}ms` : "", UNKNOWN);
addFact(facts, "TLS/route", entity.tls_warning || entity.route_warning || entity.warning || "ok");
return facts;
};
const coolifyFacts = (entity = {}) => {
const facts = [];
addFact(facts, "Instance", entity.instance_label || entity.instance || entity.instance_id);
addFact(facts, "Project/App", entity.project_uuid || entity.project_id || entity.app_id || entity.resource_uuid, NOT_CONFIGURED);
addFact(facts, "Environment", entity.environment || entity.environment_name || entity.coolify_environment_name, UNKNOWN);
addFact(facts, "Target host", endpointHost(entity), UNKNOWN);
addFact(facts, "Status", entity.status || entity.deployment_status || entity.availability_state || (entity.integrated ? "integrated" : ""));
addFact(facts, "Last deploy", entity.last_deployed_at || entity.last_reconciled_at || entity.updated_at, NOT_CHECKED);
addFact(facts, "Exposed URL", entity.public_url || entity.coolify_public_url || entity.endpoint?.url || entity.endpoint?.display, NOT_CONFIGURED);
addFact(facts, "Health check", entity.health_status || entity.health_check_result || entity.last_reconcile_status, NOT_CHECKED);
return facts;
};
const hostFacts = (entity = {}) => {
const facts = [];
addFact(facts, "Node", entity.node || entity.node_name || entity.label || entity.host_label);
addFact(facts, "Hostname/IP", endpointHost(entity));
addFact(facts, "Provider/location", entity.provider || entity.location || entity.region, UNKNOWN);
addFact(facts, "SSH", boolValue(entity.ssh_available ?? entity.ssh_enabled ?? entity.ssh));
addFact(facts, "Services", entity.service_count ?? entity.services?.length, UNKNOWN);
addFact(facts, "Last health check", lastCheck(entity), NOT_CHECKED);
return facts;
};
const dataServiceFacts = (entity = {}) => {
const target = entity.target || entity;
const replication = entity.replication || target.replication || {};
const facts = [];
addFact(facts, "Service", entity.kind || target.kind || replication.kind);
addFact(facts, "Mode", entity.mode || target.mode || entity.replication_policy?.mode);
addFact(facts, "Node", entity.node || entity.node_name || target.instance_label || replication.label, UNKNOWN);
addFact(facts, "Online state", entity.online === true ? "online" : entity.online === false ? "offline" : entity.online_state || entity.state || target.availability_state || target.deployment_status || replication.status);
addFact(facts, "Hostname", endpointHost({ ...entity, target, replication }), NOT_CONFIGURED);
addFact(facts, "Port", endpointPort({ ...entity, target, replication }), NOT_CONFIGURED);
addFact(facts, "Uptime", uptimeText({ ...entity, ...target, ...replication }));
addFact(facts, "Version", entity.version || target.version || replication.version, UNKNOWN);
addFact(facts, "Replication role", entity.replication_role || replication.role || entity.role, UNKNOWN);
addFact(facts, "Replication lag", entity.replication_lag || replication.lag || replication.lag_seconds, UNKNOWN);
addFact(facts, "Last check", lastCheck({ ...entity, target, replication }), NOT_CHECKED);
return facts;
};
const failoverFacts = (entity = {}) => {
const facts = [];
addFact(facts, "Primary node", entity.primary_node || entity.primary_host || entity.primary?.host, UNKNOWN);
addFact(facts, "Replica node", entity.replica_node || entity.replica_host || entity.replica?.host, UNKNOWN);
addFact(facts, "Readiness", entity.readiness || entity.status || (entity.integrated ? "ready" : "not configured"));
addFact(facts, "Blockers", entity.blockers?.length ?? entity.issues?.length ?? 0);
addFact(facts, "Last failover", entity.last_failover_at || entity.last_checked_at, NOT_CHECKED);
addFact(facts, "Recovery action", entity.recovery_action || entity.next_action || entity.change_control, UNKNOWN);
return facts;
};
const operationFacts = (entity = {}) => {
const currentStep =
entity.current_step ||
(Array.isArray(entity.steps) ? entity.steps.find((step) => ["running", "failed"].includes(step.status)) : null);
const failedStep =
entity.failed_step ||
(Array.isArray(entity.steps) ? entity.steps.find((step) => String(step.status || "") === "failed") : null);
const facts = [];
addFact(facts, "Status", entity.status);
addFact(facts, "Current step", currentStep?.label || currentStep?.step_key, UNKNOWN);
addFact(facts, "Failed step", failedStep?.label || failedStep?.step_key, entity.status === "failed" ? UNKNOWN : "none");
addFact(facts, "Diagnostic", failedStep?.diagnostic || entity.diagnostic, UNKNOWN);
addFact(facts, "Solution", failedStep?.solution_hint || entity.solution_hint, UNKNOWN);
addFact(facts, "Started", entity.started_at || entity.created_at, UNKNOWN);
addFact(facts, "Duration", entity.duration || readableDuration(entity.started_at || entity.created_at, entity.completed_at || entity.updated_at));
return facts;
};
const genericFacts = (entity = {}, detail = "") => {
const facts = [];
addFact(facts, "Type", entity.type || entity.kind || UNKNOWN);
addFact(facts, "Status", entity.status || entity.state || entity.availability_state, UNKNOWN);
addFact(facts, "Host", endpointHost(entity), UNKNOWN);
addFact(facts, "Port", endpointPort(entity), UNKNOWN);
addFact(facts, "Last check", lastCheck(entity), NOT_CHECKED);
if (!isBlank(detail)) addFact(facts, "Detail", detail);
return facts;
};
export const releaseEntityFacts = ({ type = "entity", entity = {}, detail = "" } = {}) => {
const normalized = String(type || "").toLowerCase();
const enriched = { ...(entity || {}), ...(entity?.entity_facts || {}) };
if (normalized.includes("channel")) return channelFacts(enriched);
if (normalized.includes("branch") || normalized.includes("repo") || normalized.includes("source")) return branchFacts(enriched);
if (normalized.includes("deployment")) return deploymentFacts(enriched);
if (normalized.includes("endpoint")) return endpointFacts(enriched);
if (normalized.includes("coolify")) return coolifyFacts(enriched);
if (normalized.includes("host") || normalized.includes("location")) return hostFacts(enriched);
if (normalized.includes("data") || ["database", "redis", "minio"].some((kind) => normalized.includes(kind))) return dataServiceFacts(enriched);
if (normalized.includes("failover")) return failoverFacts(enriched);
if (normalized.includes("operation") || normalized.includes("test")) return operationFacts(enriched);
return genericFacts(enriched, detail);
};
export const releaseEntityTooltipText = (facts = []) =>
facts.map((fact) => `${fact.label}: ${valueOr(fact.value)}`).join("\n");
export const releaseEntityDisplayLabel = (label, entity = {}) =>
label || entity.label || entity.name || entity.slug || entity.resource_name || entity.resource_uuid || "--";
@@ -0,0 +1,19 @@
import { ref } from "vue";
export function useReleaseEntityDrawer() {
const selectedReleaseEntity = ref(null);
const openReleaseEntity = (payload) => {
selectedReleaseEntity.value = payload;
};
const closeReleaseEntity = () => {
selectedReleaseEntity.value = null;
};
return {
selectedReleaseEntity,
openReleaseEntity,
closeReleaseEntity,
};
}
@@ -0,0 +1,11 @@
import { onBeforeUnmount, onMounted } from "vue";
export function useReleaseKeyboardShortcuts(handler) {
onMounted(() => {
window.addEventListener("keydown", handler);
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", handler);
});
}
@@ -0,0 +1,93 @@
import { computed } from "vue";
export const RELEASE_MANAGER_PANELS = [
"overview",
"channels",
"deployments",
"operations",
"integrations",
"data-services",
"assignments",
"settings",
];
export const RELEASE_MANAGER_PANEL_ALIASES = {
bundles: "deployments",
replay: "operations",
coolify: "integrations",
failover: "data-services",
"data": "data-services",
};
export const routeSlugForReleaseChannel = (channel) => {
const slug = String(channel?.slug || channel?.channel_slug || "").trim().toLowerCase();
return slug === "stable" ? "master" : slug;
};
export const normalizeReleasePanel = (panel) => {
const normalized = String(panel || "overview").trim().toLowerCase();
const mapped = RELEASE_MANAGER_PANEL_ALIASES[normalized] || normalized;
return RELEASE_MANAGER_PANELS.includes(mapped) ? mapped : "overview";
};
export const defaultBranchForReleaseChannel = (channel) =>
routeSlugForReleaseChannel(channel) || String(channel?.branch || "").trim() || "master";
export function useReleaseManagerContext({ route, router, channels, branchSuggestions }) {
const selectedChannel = computed(() => {
const selectedSlug = String(route.query.channel || "").trim().toLowerCase();
return (
channels.value.find((channel) => String(channel.slug || channel.channel_slug).toLowerCase() === selectedSlug) ||
channels.value.find((channel) => channel.default_channel) ||
channels.value[0] ||
null
);
});
const selectedChannelSlug = computed(
() => String(route.query.channel || selectedChannel.value?.slug || selectedChannel.value?.channel_slug || "").trim()
);
const selectedApp = computed(() => {
const app = String(route.query.app || "all").trim().toLowerCase();
return ["all", "frontend", "api"].includes(app) ? app : "all";
});
const selectedBranch = computed(() =>
String(route.query.branch || defaultBranchForReleaseChannel(selectedChannel.value)).trim()
);
const branchOptions = computed(() => {
const values = new Set([
"master",
"beta",
"canary",
"internal",
defaultBranchForReleaseChannel(selectedChannel.value),
...(Array.isArray(branchSuggestions.value) ? branchSuggestions.value : []),
]);
return Array.from(values).filter(Boolean);
});
const updateContext = (patch = {}) => {
const query = {
...route.query,
...patch,
};
for (const key of ["channel", "app", "branch"]) {
if (query[key] === "" || query[key] === null || query[key] === undefined) {
delete query[key];
}
}
router.replace({ path: route.path, query });
};
return {
selectedChannel,
selectedChannelSlug,
selectedApp,
selectedBranch,
branchOptions,
updateContext,
};
}
+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;
};

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