Compare commits

...
Author SHA1 Message Date
Jeppe B fa19f5a704 Fix i18n v2 catalog integrity 2026-06-01 19:44:50 +02:00
Jeppe B 10358d4f53 Merge pull request #58 from copenhagentruckwash/fix-self-serve-path-editor-for-all-departments
Fix self-serve path editor vehicle scope
2026-06-01 18:32:11 +02:00
Jeppe B 78f41b8380 Fix self-serve path editor vehicle scope 2026-06-01 18:31:53 +02:00
Jeppe B 90d4bc7c04 Merge pull request #57 from copenhagentruckwash/fix-bugs-in-start-wash-flow
Harden customer wash start flow
2026-06-01 18:01:29 +02:00
Jeppe B b59c17f120 Harden customer wash start flow 2026-06-01 17:54:42 +02:00
Jeppe Bundgaard c279e7fd38 Merge remote-tracking branch 'origin/master' 2026-06-01 17:23:06 +02:00
Jeppe Bundgaard 8a4fcb3e62 Remove unused return messages and cleanup peer dependency flags in package-lock.json 2026-06-01 17:22:33 +02:00
Jeppe B 680ef7088b Merge pull request #56 from copenhagentruckwash/investigate-playwright-exit-code-1-issue
Stabilize POS email notification E2E setup
2026-06-01 17:20:54 +02:00
Jeppe B 83935af973 Stabilize POS email notification E2E setup 2026-06-01 16:45:40 +02:00
Jeppe B 37973f8fcb Add x-api-coverage happy path flag to listOrders operation 2026-06-01 13:31:48 +02:00
Jeppe B acf19c281a Add email notification actions for booking confirmations and wash certificates 2026-06-01 13:24:29 +02:00
Jeppe B 683fce32da Fix POS step 1 customer lookup loop 2026-06-01 12:41:08 +02:00
Jeppe B 4c9ba7c30d Add unit tests for Playwright full-slice ownership 2026-06-01 12:28:42 +02:00
Jeppe B ca1f3ab477 Merge pull request #55 from copenhagentruckwash/codex/frontend-release-gate-coolify-20260530
Fix frontend release gate for Coolify deploys
2026-05-31 10:22:29 +02:00
Jeppe B 2ab09076f5 Stabilize edge terminal smoke navigation 2026-05-30 21:51:07 +02:00
Jeppe B 97e17fadd4 Respect mobile self-serve studio layout in e2e 2026-05-30 20:33:27 +02:00
Jeppe B ef780e5542 Keep completed self-serve wash step visible 2026-05-30 19:44:52 +02:00
Jeppe B 931c31c7be Stabilize self-serve question loading layout 2026-05-30 18:37:37 +02:00
Jeppe B 25f2be875c Wait for self-serve completion command response 2026-05-30 17:45:06 +02:00
Jeppe B ff70326665 Stabilize frontend PR smoke gates 2026-05-30 16:58:04 +02:00
Jeppe B 52ed49211a Fix frontend release gate for Coolify deploys 2026-05-30 15:39:03 +02:00
Jeppe B 0565fc2a7c Merge pull request #54 from copenhagentruckwash/codex/master-tests-pass-frontend-20260528
[codex] Keep user vehicle actions in wheel menu
2026-05-29 16:33:25 +02:00
Jeppe B d30273cda8 Map user vehicle changes to PR Playwright coverage 2026-05-29 14:46:52 +02:00
Jeppe B b893e69b54 Keep user vehicle actions in wheel menu 2026-05-28 23:35:14 +02:00
Jeppe B eabb12ef97 Merge pull request #53 from copenhagentruckwash/fix-issues-and-verify-with-tests
Format DepartmentSelfServeStudio.vue and update vehicle actions test to reflect wheel menu behavior
2026-05-28 19:52:53 +02:00
Jeppe Bundgaard 225741cf7b Refactor unit tests and clean up Vue component formatting
- Update `self-serve-studio-managed-inspector.spec.js` to improve node kind checks.
- Tidy up and standardize `SessionUser.request` calls in `vehiclesTable.vue`.
- Format code consistently in tables, conditions, and task specs for clarity and maintenance.
2026-05-28 19:47:02 +02:00
Jeppe B dad42da6d9 Keep vehicle actions in wheel menu 2026-05-28 19:01:27 +02:00
Jeppe Bundgaard 0fa2284e89 Add connectivity issue handling logic and tests
- Add `ConnectivityIssue.vue` component for displaying server connection issues.
- Implement polling logic with retry intervals to check server health.
- Update i18n with new connectivity issue messages.
- Add unit tests to cover connectivity failure and recovery scenarios.
- Update test suite for breadth of coverage, including router contract and timer cleanup.
2026-05-28 18:11:21 +02:00
Jeppe Bundgaard cc241e5e19 Add subuser support and require notes for specific products 2026-05-28 16:36:42 +02:00
Jeppe Bundgaard c5a536fbea Add subuser support and require notes for specific products 2026-05-28 16:12:41 +02:00
Jeppe Bundgaard 3a7fef11ef Implement subuser password policy validation and update related components 2026-05-27 19:16:26 +02:00
Jeppe Bundgaard 625757fafc Fix Danish translations and enhance mobile department auto-selection logic 2026-05-27 17:34:51 +02:00
Jeppe Bundgaard e1a8af5a72 Extend i18n with new self-serve prompts and lane availability messages, and add E2E test for lane confirmation logic. 2026-05-27 13:25:31 +02:00
Jeppe Bundgaard c437c89cfa Add release management enhancements, runtime-aware frontend updates, and extensive test coverage
- Implement release update detection, asset preloading, and frontend version management.
- Add unit and E2E tests for release update workflows, widget behavior, and failure scenarios.
- Introduce new services for handling release ping, error reporting, and update installation workflows.
- Extend i18n for release-related components and error report localization.
- Add `ReleaseFrontendVersionBadge.vue` and related styles to display frontend update statuses.
2026-05-27 13:23:22 +02:00
Jeppe Bundgaard 184ef5670c Enhance session management by normalizing session payload and resetting session state on logout. Update login redirect logic and improve runtime API URL handling for selected release channels. 2026-05-26 17:28:21 +02:00
Jeppe Bundgaard 8ab8915429 Add release management components and update routing logic. Introduce keyboard shortcuts, enhance release data grid, and improve asset handling in Nginx configuration. 2026-05-26 14:14:13 +02:00
Jeppe B a952dd2c61 Merge pull request #52
Add endpoint mode options and enhance runtime refresh handling
2026-05-21 14:48:44 +02:00
Jeppe Bundgaard ebcd52a019 Add endpoint mode options, release channel rollback handling, and runtime refresh improvements
- Implement `endpoint_mode` (manual/auto) and related configuration in i18n across multiple locales.
- Add new utility methods for channel rollback and runtime confirmation workflows.
- Update unit and E2E tests to cover runtime refresh behavior, channel switching mechanics, and release references.
- Enhance `SessionUser` and related services to improve error handling during release runtime refresh.
- Add data-test attributes for frontend components to support improved test coverage.
2026-05-21 14:47:46 +02:00
Jeppe Bundgaard b2ddf0db5c Add beta frontend debug display in build info 2026-05-21 11:45:20 +02:00
Jeppe Bundgaard eaf4b965d5 Add runtime support for service sets, release management enhancements, and new unit tests
- Extend `releaseTimeline.js` and related modules for runtime-aware service sets and URLs.
- Update the release session builder to include `service_set` handling.
- Introduce release service definitions, status tones, and enhanced runtime display text.
- Add new dataset mode options, preview rows, and warnings for release configurations in `ConfigurationReleaseManager.vue`.
- Implement extensive utility methods for data service handling, target context, and deployment endpoints.
- Add `vite-api-proxy.spec.js` and extend `release-bootstrap.spec.js` to ensure coverage for new functionality.
2026-05-21 11:41:50 +02:00
Jeppe Bundgaard 6ba1c754a1 Serve frontend releases under channel prefixes 2026-05-20 19:27:01 +02:00
Jeppe Bundgaard 468613ab68 Add Coolify frontend release Docker image 2026-05-20 18:53:17 +02:00
Jeppe Bundgaard 261d84bd01 Route selected release runtime through gateway 2026-05-20 18:32:21 +02:00
Jeppe Bundgaard ea8db593c5 Add release headers service, runtime-aware API header handling, and i18n updates for release configuration
- Implement `releaseHeaders.js` to manage release-related HTTP headers.
- Update requests to include runtime-based release headers.
- Extend i18n phrases across multiple locales for release runtime and configuration labels.
- Enhance unit tests for header building and API availability error handling.
2026-05-20 17:52:54 +02:00
Jeppe Bundgaard 130cc2fc10 Add release runtime bootstrap implementation with unit and E2E tests
- Create `releaseBootstrap.js` service to handle runtime configuration and remote frontend loading.
- Integrate runtime-aware API URL resolution across components and services.
- Update i18n phrases to clarify new domains for `ssl_domain_message`.
- Enhance unit and E2E tests to validate runtime-based frontend loading and fallback behavior.
- Refactor session-related API calls and incorporate runtime-configured base URLs.
2026-05-20 11:26:21 +02:00
Jeppe Bundgaard 3715da8fd3 Add release channel selector components, subject autocomplete, and related tests
- Implement components for `ReleaseChannelSelector`, including panel and sidebar variants.
- Add `ReleaseAssignmentSubjectAutocomplete` for user, subuser, and customer search functionality.
- Update i18n phrases for channel selector and subject assignment.
- Enhance unit and E2E tests for release channel availability, switching, and runtime configuration.
- Modify modules for channel-related state handling and runtime refresh mechanics.
2026-05-20 10:31:49 +02:00
Jeppe Bundgaard 100e477a95 Add "Delete" functionality for release service sets in isolated stacks
- Implement deletion of inactive isolated stacks via API and UI.
- Normalize build pack handling, defaulting frontend to "static".
- Update E2E tests to verify deletion and adjusted UI behavior.
- Refactor related release manager methods and validation logic.
2026-05-19 16:55:20 +02:00
Jeppe Bundgaard 1132c8c256 Merge remote-tracking branch 'origin/master' 2026-05-19 14:47:01 +02:00
Jeppe Bundgaard 42237fcc70 Add unit tests for release manager i18n validation and implement ReleaseReplayInspector Vue component for timeline inspection and replay functionality. 2026-05-19 14:46:42 +02:00
gold-dev ae8b3b6b64 Feat: Hvidovre only one toggler 2026-05-19 14:26:23 +03:00
Jeppe B 8378491b0b Merge pull request #51
Fix chromium mobile runner flakes
2026-05-19 13:12:11 +02:00
Jeppe Bundgaard 20274cf8eb Update i18n phrases for ssl_domain fields to clarify load balancer routing details and add new methods for release service sets and bundles in SuperUser Release Manager. 2026-05-19 13:11:17 +02:00
Jeppe Bundgaard d25726fafd Add unit and E2E tests for release channel availability and switched-notice functionality. Configure runtime mocks and implement Vue components for channel-specific handling. 2026-05-19 12:05:25 +02:00
Jeppe Bundgaard 4f1973d048 Add SuperUser Release Manager view with channel management, deployments, replay, and integrations 2026-05-19 10:04:00 +02:00
Jeppe Bundgaard d69c474655 Add multilingual glossary and phrases for i18n, including terms, attributes, and compatibility across da, de, en, no, and sv locales. 2026-05-19 08:03:39 +02:00
Jeppe Bundgaard 56078307da Update vehicles table action visibility and adjust E2E test timeouts and MinIO configuration
- Change `displayActionsDirectly` prop to `false` in vehicles table for compact mode consistency.
- Refactor E2E tests with reusable `PAGE_READY_TIMEOUT` constant to prevent flaky behavior.
- Enhance MinIO configuration in replication management and system status tests to include additional environment variables and form fields.
2026-05-18 15:07:25 +02:00
Jeppe Bundgaard e6291cdb8a Add response handling for MinIO provisioning in replication E2E test 2026-05-18 13:52:01 +02:00
Jeppe Bundgaard bf0b2152c5 Enhance replication management to support MinIO alongside database and Redis, update UI labels, and improve status reporting 2026-05-18 13:48:16 +02:00
Jeppe Bundgaard 9771723992 Fix chromium mobile runner flakes 2026-05-18 12:28:23 +02:00
Jeppe Bundgaard 156e50a864 Merge remote-tracking branch 'origin/master' 2026-05-18 11:01:12 +02:00
Jeppe Bundgaard 5081fe0776 Add Playwright PR runner and Vitest unit test fast runner scripts; integrate with test commands and update specs/tests for PR-based selection. 2026-05-18 10:59:56 +02:00
Jeppe Bundgaard 656e43d449 Add SuperUser replication management view, including UI for database and Redis hosts, replication status, provisioning, and Compose template generation 2026-05-18 09:55:41 +02:00
Jeppe Bundgaard d135a3f0af Add database and Redis replication support to System Status Dashboard, including UI updates, new routes, localization, and unit/E2E tests. 2026-05-17 15:32:45 +02:00
gold-dev 7bd880497f Feat: Disable click redirect, setup checkbox counts 2026-05-13 22:15:28 +03:00
Jeppe Bundgaard d148c0d6b9 Enhance ActionSettingsWheelButton functionality with fixed positioning logic, ResizeObserver integration, and dropdown layout syncing; refactor unit tests for improved mock structure. 2026-05-13 17:36:15 +02:00
Jeppe Bundgaard 75bab7b425 Update UI styles and logic for ActionSettingsWheelButton, invoicing period components, and desktop section layout, adding flyout positioning logic and syncing. 2026-05-13 16:47:31 +02:00
Jeppe Bundgaard 8dfaebba00 Add unit test for user vehicle actions visibility and enhancements for department 75 loading UI, default department handling, and translations. 2026-05-13 15:35:21 +02:00
Jeppe Bundgaard f7f01dcd2f Add E2E and unit tests for invoicing period flag handling, enhance duplicate groups filtering by date, and implement Excel export for department 75 distribution 2026-05-13 13:58:00 +02:00
Jeppe Bundgaard fba85a72c1 Refactor pagination components to use provide and usePaginatedList, optimize fast link handling with queuing and concurrency limits, and update XLVask content generation logic and caching. 2026-05-12 15:03:03 +02:00
Jeppe Bundgaard c657ec57ad Refactor DatePeriodSelector and invoicing period tests: adjust visibility condition for mobile update button, remove redundant test skips, and enhance logic for date shortcut handling across layouts. 2026-05-12 05:38:50 +02:00
Jeppe Bundgaard f24df440d0 Enhance ActionSettingsWheelButton and invoicing period tests with improved structure and visibility checks 2026-05-12 04:52:05 +02:00
Jeppe Bundgaard b73f83a4ab Clean up debug logs in invoicing statistics, add isDateLoading state for period paging, update date change handling with refresh logic, and enhance locale translations and tests. 2026-05-12 04:03:36 +02:00
Jeppe Bundgaard 2700ff3526 Add caching, component updates, and unit tests for invoicing period and XLVask functionality 2026-05-12 03:41:40 +02:00
Jeppe Bundgaard a261f2f3bd Add unit tests and components for invoicing period flag handling and self-wash view rendering 2026-05-11 21:40:51 +02:00
Jeppe Bundgaard 81c4ba0800 Refine safety seal normalization, enhance booking completion flow, and update E2E tests for improved validation and popup handling. 2026-05-11 04:37:34 +02:00
Jeppe Bundgaard 31e72af9bf Warm smoke route chunks before Playwright runs 2026-05-11 03:47:05 +02:00
Jeppe Bundgaard 10a52521d5 Fix invoicing period invoice action 2026-05-11 03:35:36 +02:00
Jeppe Bundgaard c9b34ba36c Fix frontend smoke test races 2026-05-11 03:15:56 +02:00
Jeppe Bundgaard 87ad8f9d31 Merge remote-tracking branch 'origin/master' 2026-05-11 01:57:06 +02:00
Jeppe Bundgaard a754cfc7bb Add delay support for booking completion flow, refine safety seal handling, and enhance popup submission logic with better action management and validation. Update related E2E tests. 2026-05-11 01:56:15 +02:00
gold-dev 29bc0e548b Feat: Header & Daily report block switcher 2026-05-07 16:39:11 +03:00
Jeppe Bundgaard 378598b161 Restrict unassigned department visibility test to desktop, refactor test selectors, and simplify department loading logic. 2026-05-07 14:12:17 +02:00
Jeppe Bundgaard e8c1dda3cc Add E2E test for unassigned department visibility in the overview list. Refactor department sorting and UI logic with priority-based filtering for improved accessibility. 2026-05-07 13:54:53 +02:00
Jeppe Bundgaard fabc4ad54a Refactor department access logic to use canAccessAssignedDepartment. Update related components, tests, and UI for consistency. Add tests for unassigned department handling and align E2E layouts. 2026-05-07 13:50:12 +02:00
Jeppe Bundgaard b89ae2bb9e Sort departments by priority order in UI and API logic. Add support for "order_priority" and normalize fields consistently throughout components and tests. 2026-05-07 13:22:07 +02:00
Jeppe Bundgaard fb162eef6c Add archived department filtering feature for superusers, including E2E tests, UI updates, and OpenAPI enhancements. Extend department table with archived toggle switch and update translations. 2026-05-07 12:54:31 +02:00
Jeppe Bundgaard dc2840e023 Merge remote-tracking branch 'origin/master' 2026-05-07 10:43:02 +02:00
Jeppe Bundgaard fe6d9e8d16 Add E2E tests for department branding management and refine supporting API mocks. Implement branding creation, editing, assignment, and clearing workflows. Extend DepartmentProfile with branding UI and logic updates. 2026-05-07 10:42:45 +02:00
Jeppe Bundgaard 7ab7d83fb5 Align invoicing period specs with formatted date contract 2026-05-07 10:15:49 +02:00
gold-dev 028dad2183 Feat: Time ago to local time hours 2026-05-06 17:08:57 +03:00
gold-dev 6b0fe60958 Feat: Change texts 2026-05-06 16:49:06 +03:00
Jeppe Bundgaard a7789cb631 Merge remote-tracking branch 'origin/master' 2026-05-06 14:15:01 +02:00
Jeppe Bundgaard da65876a64 Add responsive tests for "/qr/new-customer" layout, extend API with split_by_month logic, and update E2E/admin tests with session mocking and department sync fixes. Normalize POS department handling, improve invoicing period split logic, and enhance receipt export safety. 2026-05-06 14:14:34 +02:00
Jeppe Bundgaard f245161bfa Align stale unit specs with current self-serve and invoices views 2026-05-06 09:08:23 +02:00
Jeppe Bundgaard bdffdca420 Handle "STOP" command failures by interpreting "lane not occupied" as a safe-to-clear state. Refactor error handling, add clearActiveWashState, and extend unit/E2E tests with this scenario. 2026-04-30 14:52:40 +02:00
Jeppe Bundgaard 5d1199a133 Update unit test to include lane click event trigger 2026-04-29 17:43:29 +02:00
Jeppe Bundgaard 4752b3d096 Add self-serve feature logic, including lane filtering, normalization, and OpenAPI updates. Enhance Self-Serve Studio with progress indicators, UI controls, path projection streaming, and abort handling. Update E2E and unit tests for enhanced self-serve behaviors. 2026-04-29 17:27:53 +02:00
Jeppe Bundgaard fc9c3700a1 Add Danish self-serve labels and guided wash flow steps. Update UI with guided step actions and bottom control integration. Fix translations and refine E2E/unit test coverage for self-serve flow behavior. 2026-04-29 16:01:34 +02:00
Jeppe Bundgaard a15d6841c5 Add task attachment management to Self-Serve Studio. Extend graph, path outcomes, and flow logic. Update E2E/unit tests with attachment creation, upload, download, deletion flows, and related validations. 2026-04-29 14:41:29 +02:00
Jeppe Bundgaard 9641f67e3a Extend Self-Serve Studio with "action" nodes, supporting creation, focus, and editing workflows. Add unit tests, normalize forms, and enhance virtual binding/gateway logic. Update E2E tests and node visualization settings. 2026-04-29 10:56:40 +02:00
Jeppe Bundgaard de2fa57c63 Add audit issue navigation and task-button normalization to Self-Serve Studio. Implement node ID resolution, audit visualization logic, and reusable task button helpers. Extend E2E and unit tests. 2026-04-29 09:51:09 +02:00
Jeppe Bundgaard a658fe7863 Resume active self-serve washes for authenticated customers across devices. Update OpenAPI, UI components, and extend E2E/unit tests. 2026-04-28 18:00:09 +02:00
Jeppe Bundgaard 503fb84f10 Extend Self-Serve Studio with deferred relay logic and Bookinger-style loading states. Update OpenAPI, UI components, and related E2E/unit tests. 2026-04-28 17:09:50 +02:00
Jeppe Bundgaard 11bb8f824d Extend Self-Serve Studio with vehicle type management, mutation options, and enhanced simulation/debug tools. Update OpenAPI, UI components, and related E2E/unit tests. 2026-04-28 16:54:27 +02:00
Jeppe Bundgaard 8615cc6678 Introduce task-service logic and relay coverage to Self-Serve Studio. Add service normalization, binding service visualizations, and task-service coverage mapping. Extend E2E tests for tasks and bindings. 2026-04-28 15:42:06 +02:00
Jeppe Bundgaard 65e4c27f45 Enhance Self-Serve Studio with dynamic filters, scoped layouts, and optimized auto-layout logic. Update filtering behavior, node visibility, and edge handling. Extend E2E tests for vehicle-specific scenarios. 2026-04-28 15:01:30 +02:00
Jeppe Bundgaard f9d8b87768 Add new table and enhance studio layout logic
Introduce `department_selfserve_studio_layouts` table for department-specific layouts and implement advanced auto-layout functionality in the DepartmentSelfServeStudio module. Added custom node definitions, updated styling, and integrated new logics for sorting and visualizing nodes in the Vue Flow interface.
2026-04-28 14:21:26 +02:00
Jeppe Bundgaard 586a0cc86d Add fullscreen mode to Self-Serve Studio with improved canvas controls and responsive layout adjustments. Extend related E2E tests. 2026-04-28 13:46:47 +02:00
Jeppe Bundgaard e9cbb40559 Add all-in-one self-serve studio with flow editor, simulator, versioning, and enhanced E2E/OpenAPI support. Extend legacy edge gateway workflows. 2026-04-28 13:36:16 +02:00
Jeppe Bundgaard f2c3962749 Improve error handling for lane service updates and add comprehensive tests for edge gateway failures. Refactor route handling and enhance lane/session binding logic. 2026-04-28 12:29:56 +02:00
Jeppe Bundgaard e63e113628 Load EdgeGateway components asynchronously in SuperUserDashboard self-serve view to optimize performance. 2026-04-28 11:37:35 +02:00
Jeppe Bundgaard 4b02de7358 Add self-serve Edge Agents management with navigation, routes, and tabs. Update legacy redirects and extend unit and E2E tests. 2026-04-28 11:32:23 +02:00
Jeppe Bundgaard 61aab76e63 Add self-serve sessions management with filtering, force-stop functionality, and enhanced E2E test coverage. Extend edge gateway module configuration tests. 2026-04-28 10:03:36 +02:00
Jeppe Bundgaard e26e37d9d7 Add enhanced admin access checks and error handling for draft transaction customers and self-serve processes. Extend edge gateway diagnostics, refine terminal readiness detection, and improve live session test coverage. 2026-04-28 09:01:52 +02:00
Jeppe Bundgaard d411da2a12 Enhance edge gateway live sessions, improve WebSocket and error diagnostics, optimize lane relay testing, and extend terminal and command handling features. 2026-04-27 17:39:24 +02:00
Jeppe Bundgaard 15b3c726dc Add relay test modes (local/cloud), enhance lane and self-serve relay testing, and refine UI/UX for department hardware workspace. 2026-04-27 16:25:18 +02:00
Jeppe Bundgaard ee47c801ea Enhance edge gateway live session handling, extend telemetry updates, refine department lane bindings, and replace legacy invoice components. 2026-04-27 14:14:52 +02:00
Jeppe B d0993d2963 Merge pull request #50 from copenhagentruckwash/codex/github-actions-green-20260424-local
Stabilize frontend unit test environments
2026-04-24 21:09:18 +02:00
Jeppe Bundgaard 85a6506bfd Stabilize frontend unit test environments 2026-04-24 19:22:24 +02:00
Jeppe B 7a489e371f Merge pull request #49 from copenhagentruckwash/run-tests-on-mobile-and-desktop
Stabilize mobile/desktop Playwright smoke tests and JSON warmup
2026-04-24 18:59:29 +02:00
Jeppe B 17b973d77c Stabilize cross-device Playwright smoke coverage 2026-04-24 15:13:18 +02:00
1147 changed files with 167342 additions and 8776 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(find /c/Users/2jepp/WebstormProjects/pleno-vue -type f \\\\\\(-name *.ts -o -name *.js \\\\\\) -not -path */node_modules* -not -path */dist/* -not -path */.git/* -not -path */dev-dist/*)",
"Bash(find /c/Users/2jepp/WebstormProjects/pleno-vue/src -type f \\\\\\(-name *.ts -o -name *.js -o -name *.vue \\\\\\))",
"Bash(find /c/Users/2jepp/WebstormProjects/pleno-vue/app -type f \\\\\\(-name *.ts -o -name *.js -o -name *.java -o -name *.kt \\\\\\))",
"Bash(grep -r \"getAutomaticFlags\\\\|AutomaticFlags\\\\|automaticFlags\\\\|Automatic.*Flags\" /c/Users/2jepp/WebstormProjects/pleno-vue --include=*.ts --include=*.js --include=*.java --include=*.kt --exclude-dir=node_modules --exclude-dir=dist --exclude-dir=dev-dist --exclude-dir=output --exclude-dir=.git)",
"Bash(xargs grep:*)"
]
}
}
+17
View File
@@ -0,0 +1,17 @@
.github
.idea
.vscode
coverage
dev-dist
dist
node_modules
node_modules.*
output
playwright-report
test-results
.ai
.ai-workflow
.claude
.codex
.gradle
gradle
+15
View File
@@ -0,0 +1,15 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary
*.pdf binary
*.zip binary
*.webm binary
+122
View File
@@ -0,0 +1,122 @@
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_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\",\"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
+30 -11
View File
@@ -61,17 +61,38 @@ jobs:
env:
VITEST_BATCH_SIZE: 5
e2e-smoke:
e2e-pr:
if: github.event_name != 'schedule'
needs: build-and-unit
runs-on: [self-hosted, Linux, X64, default]
strategy:
fail-fast: false
matrix:
project: [chromium-desktop, chromium-mobile]
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
@@ -85,17 +106,15 @@ jobs:
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright smoke tests (${{ matrix.project }})
run: npx playwright test --grep @smoke --project="${{ matrix.project }}"
- name: Run Playwright PR tests
run: npm run test:e2e:pr -- --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-smoke-${{ matrix.project }}
path: |
output/playwright/report
output/playwright/test-results
name: playwright-report-pr
path: output/playwright
if-no-files-found: ignore
retention-days: 7
View File
View File
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
3.0
View File
+26
View File
@@ -0,0 +1,26 @@
FROM node:24-alpine AS build
WORKDIR /app
RUN apk add --no-cache git
COPY package*.json ./
RUN npm ci --ignore-scripts
COPY . .
ARG SOURCE_COMMIT
ARG RELEASE_COMMIT_SHA
ARG COMMIT_SHA
ARG GITHUB_SHA
ENV SOURCE_COMMIT=$SOURCE_COMMIT
ENV RELEASE_COMMIT_SHA=$RELEASE_COMMIT_SHA
ENV COMMIT_SHA=$COMMIT_SHA
ENV GITHUB_SHA=$GITHUB_SHA
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.coolify-frontend.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+22
View File
@@ -22,6 +22,28 @@ 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
```
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
+5 -6
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">
@@ -16,6 +15,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="/src/releaseBootstrap.js"></script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location ~ ^/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store";
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";
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";
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
try_files $uri =404;
}
location ~ ^/(master|beta|canary|internal)/frontend$ {
try_files /index.html =404;
}
location ~ ^/(master|beta|canary|internal)/frontend/ {
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
try_files $uri $uri/ /index.html;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+776 -3
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
@@ -3375,7 +3377,7 @@ paths:
tags:
- Departments
summary: List departments
description: Retrieve a list of all visible departments
description: Retrieve visible, active departments by default. Superuser department access may filter archived departments with `filters=archived:1`.
operationId: listDepartments
parameters:
- name: id
@@ -3386,6 +3388,11 @@ paths:
- $ref: '#/components/parameters/PageParam'
- $ref: '#/components/parameters/PerPageParam'
- $ref: '#/components/parameters/SearchParam'
- name: filters
in: query
schema:
type: string
description: Comma-separated field filters. `archived:1` is only honored for users with superuser department access.
responses:
'200':
description: Departments retrieved successfully
@@ -4644,6 +4651,22 @@ paths:
customer_id:
type: integer
nullable: true
vehicle_type:
type: integer
nullable: true
description: Optional product/vehicle type override used when refreshing the self-serve summary.
vehicle_type_id:
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
@@ -4688,6 +4711,22 @@ paths:
customer_id:
type: integer
nullable: true
vehicle_type:
type: integer
nullable: true
description: Optional product/vehicle type override used when refreshing the self-serve summary.
vehicle_type_id:
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
@@ -4740,6 +4779,7 @@ paths:
tags:
- Self-Serve
summary: Check whether self-serve is allowed for a vehicle on a lane
description: Customers with own self-serve permissions may evaluate any registration plate for their wash. Persisted self-serve answers are only applied when they are scoped to the authenticated customer.
operationId: getSelfserveVehicleAllowed
parameters:
- name: lane_id
@@ -5168,6 +5208,228 @@ paths:
'404':
$ref: '#/components/responses/NotFound'
/department/selfserve/studio/graph:
get:
tags:
- Self-Serve
summary: Get all-in-one self-serve studio graph
description: Returns the replacement studio workspace graph with nodes, edges, resolved lookup labels, validation, layout, versioning, simulator defaults, gateway workspace, and permissions. Vehicle type lookups and scope nodes are derived from selectable wash products.
operationId: getSelfserveStudioGraph
parameters:
- name: department
in: query
required: true
schema:
type: integer
responses:
'200':
description: Studio graph returned
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioGraph'
put:
tags:
- Self-Serve
summary: Bulk save all-in-one self-serve studio graph changes
description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, rules, tasks, scopes, attachments metadata, and gateway references while keeping layout separate from runtime behavior.
operationId: saveSelfserveStudioGraph
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioGraphSaveRequest'
responses:
'200':
description: Studio graph saved
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioGraph'
'422':
$ref: '#/components/responses/BadRequest'
/department/selfserve/studio/layout:
put:
tags:
- Self-Serve
summary: Save self-serve studio canvas layout
description: Persists canvas-only node positions and viewport state. Layout does not affect runtime wash behavior.
operationId: saveSelfserveStudioLayout
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioLayoutSaveRequest'
responses:
'200':
description: Layout saved
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioLayout'
/department/selfserve/studio/validate:
post:
tags:
- Self-Serve
summary: Validate self-serve studio graph
operationId: validateSelfserveStudioGraph
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [department]
properties:
department:
type: integer
responses:
'200':
description: Validation result
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioValidation'
/department/selfserve/studio/simulate:
post:
tags:
- Self-Serve
summary: Simulate self-serve studio runtime
operationId: simulateSelfserveStudioGraph
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [department, lane_id, reg]
properties:
department: { type: integer }
lane_id: { type: integer }
reg: { type: string }
customer_number: { type: integer, nullable: true }
vehicle_type_id: { type: integer, nullable: true }
config_source:
type: string
enum: [draft, published]
default: draft
answer_overrides:
type: array
items:
type: object
required: [question_id]
properties:
question_id: { type: integer }
value:
type: boolean
nullable: true
include_hardware:
type: boolean
default: true
mode:
type: string
enum: [full_dry_run]
default: full_dry_run
responses:
'200':
description: Simulator result
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioSimulationResponse'
/department/selfserve/studio/publish:
post:
tags:
- Self-Serve
summary: Publish self-serve studio draft
operationId: publishSelfserveStudioDraft
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [department]
properties:
department: { type: integer }
responses:
'200':
description: Published version
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveConfigVersion'
/department/selfserve/studio/rollback:
post:
tags:
- Self-Serve
summary: Roll back self-serve studio to an earlier version
operationId: rollbackSelfserveStudioDraft
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [department, target_version_id]
properties:
department: { type: integer }
target_version_id: { type: integer }
responses:
'200':
description: Rollback version
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveConfigVersion'
/department/selfserve/studio/gateway-action:
post:
tags:
- Self-Serve
summary: Run permission-gated edge gateway action from studio
operationId: runSelfserveStudioGatewayAction
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [department, gateway_id, action]
properties:
department: { type: integer }
gateway_id: { type: integer }
action:
type: string
enum: [discovery, discover, update, uninstall, cancel, rotate_credentials, bindings]
confirm:
type: boolean
description: Required for dangerous gateway actions such as uninstall and credential rotation.
operation_id: { type: integer, nullable: true }
request:
type: object
additionalProperties: true
bindings:
type: array
items:
type: object
additionalProperties: true
responses:
'200':
description: Gateway action result
content:
application/json:
schema:
type: object
additionalProperties: true
# Products Endpoints
/products:
get:
@@ -6133,6 +6395,34 @@ paths:
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue/monitor:
get:
tags:
- Invoices
summary: Monitor current-user visible collected-invoice e-conomic transfer queue jobs
operationId: monitorCollectedInvoiceEconomicQueueJobs
parameters:
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 100
default: 50
responses:
'200':
description: Queue monitor state retrieved
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferQueueMonitorResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue/status:
get:
tags:
@@ -6192,6 +6482,57 @@ paths:
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue/dismiss:
post:
tags:
- Invoices
summary: Clear one completed or failed collected-invoice queue job for the current user
operationId: dismissCollectedInvoiceEconomicQueueJob
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [job_id]
properties:
job_id:
type: integer
minimum: 1
responses:
'200':
description: Queue job cleared
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferQueueDismissResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'409': { $ref: '#/components/responses/Conflict' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue/dismiss-terminal:
post:
tags:
- Invoices
summary: Clear all visible completed or failed collected-invoice queue jobs for the current user
operationId: dismissCollectedInvoiceEconomicTerminalQueueJobs
responses:
'200':
description: Terminal queue jobs cleared
content:
application/json:
schema:
$ref: '#/components/schemas/EconomicTransferQueueDismissTerminalResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/queue/run:
post:
tags:
@@ -8483,6 +8824,7 @@ paths:
description: |
Send a command (e.g., start, stop, reset) to a self-serve lane.
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
Property gate command permissions are bypassed for authenticated customers with an active self-serve wash in the target department.
operationId: sendSelfServeLaneCommand
requestBody:
required: true
@@ -8502,6 +8844,13 @@ paths:
license_plate:
type: string
description: Required for START command
customer_number:
type: integer
description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients.
defer_relay_side_effects:
type: boolean
default: false
description: For START, open the entrance gate as part of the command but defer cleaner and machine relay activation to explicit relay endpoints.
responses:
'200':
description: Command sent successfully
@@ -13446,6 +13795,140 @@ components:
- meta
- includes
EconomicTransferQueueMonitorResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
jobs:
type: array
items:
$ref: '#/components/schemas/EconomicTransferQueueJob'
counts:
type: object
properties:
queued:
type: integer
minimum: 0
in_progress:
type: integer
minimum: 0
failed:
type: integer
minimum: 0
completed:
type: integer
minimum: 0
total:
type: integer
minimum: 0
required:
- queued
- in_progress
- failed
- completed
- total
progress_percent:
type: integer
minimum: 0
maximum: 100
limit:
type: integer
minimum: 1
maximum: 100
required:
- jobs
- counts
- progress_percent
- limit
meta:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
includes:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
required:
- success
- data
- meta
- includes
EconomicTransferQueueDismissResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
message:
type: string
job:
$ref: '#/components/schemas/EconomicTransferQueueJob'
required:
- message
- job
meta:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
includes:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
required:
- success
- data
- meta
- includes
EconomicTransferQueueDismissTerminalResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
message:
type: string
dismissed_count:
type: integer
minimum: 0
required:
- message
- dismissed_count
meta:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
includes:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
required:
- success
- data
- meta
- includes
CollectedInvoiceEconomicCompareResponse:
type: object
description: Result of comparing a collected invoice with its E-conomic counterpart
@@ -14561,6 +15044,203 @@ components:
enum:
- MACHINE
SelfserveStudioNode:
type: object
required: [id, position, data]
properties:
id:
type: string
type:
type: string
nullable: true
position:
type: object
required: [x, y]
properties:
x: { type: number }
y: { type: number }
data:
type: object
additionalProperties: true
properties:
kind:
type: string
enum: [question, condition, rule, task, lane, machine_type, vehicle_type, edge_gateway, relay_binding, relay, runtime_checkpoint]
object_id:
oneOf:
- type: integer
- type: string
nullable: true
label:
type: string
raw:
type: object
additionalProperties: true
SelfserveStudioEdge:
type: object
required: [id, source, target]
properties:
id: { type: string }
source: { type: string }
target: { type: string }
type: { type: string, nullable: true }
label: { type: string, nullable: true }
data:
type: object
additionalProperties: true
SelfserveStudioLayout:
type: object
properties:
nodes:
type: object
additionalProperties:
type: object
properties:
x: { type: number }
y: { type: number }
viewport:
type: object
additionalProperties: true
runtime_affecting:
type: boolean
enum: [false]
SelfserveStudioValidation:
type: object
properties:
valid:
type: boolean
errors:
type: array
items: { type: string }
warnings:
type: array
items: { type: string }
items:
type: array
items:
type: object
properties:
severity:
type: string
enum: [error, warning]
message:
type: string
stats:
type: object
additionalProperties: true
validated_at:
type: string
format: date-time
SelfserveConfigVersion:
type: object
properties:
id: { type: integer }
department_id: { type: integer }
status:
type: string
enum: [DRAFT, PUBLISHED, ARCHIVED]
version_number: { type: integer }
config:
type: object
additionalProperties: true
validation_result:
$ref: '#/components/schemas/SelfserveStudioValidation'
source_version_id: { type: integer, nullable: true }
created_by: { type: integer, nullable: true }
published_at: { type: string, nullable: true }
created_at: { type: string, nullable: true }
updated_at: { type: string, nullable: true }
SelfserveStudioGraph:
type: object
required: [nodes, edges, lookups, validation, layout, versions, simulator_defaults, gateway_workspace, permissions]
properties:
nodes:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioNode'
edges:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioEdge'
lookups:
type: object
additionalProperties: true
validation:
$ref: '#/components/schemas/SelfserveStudioValidation'
layout:
$ref: '#/components/schemas/SelfserveStudioLayout'
versions:
type: array
items:
$ref: '#/components/schemas/SelfserveConfigVersion'
active_config:
type: object
nullable: true
additionalProperties: true
draft:
type: object
additionalProperties: true
simulator_defaults:
type: object
additionalProperties: true
gateway_workspace:
type: object
additionalProperties: true
permissions:
type: object
additionalProperties:
type: boolean
meta:
type: object
additionalProperties: true
SelfserveStudioGraphOperation:
type: object
properties:
action:
type: string
enum: [create, update, delete, connect, disconnect, reorder]
entity:
type: string
enum: [question, condition, rule, task]
id:
type: integer
nullable: true
source:
type: string
nullable: true
target:
type: string
nullable: true
data:
type: object
additionalProperties: true
items:
type: array
items:
type: object
additionalProperties: true
SelfserveStudioGraphSaveRequest:
type: object
required: [department]
properties:
department: { type: integer }
operations:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioGraphOperation'
nodes:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioNode'
layout:
$ref: '#/components/schemas/SelfserveStudioLayout'
SelfserveStudioLayoutSaveRequest:
type: object
required: [department, layout]
properties:
department: { type: integer }
layout:
$ref: '#/components/schemas/SelfserveStudioLayout'
SelfserveMachineType:
type: object
properties:
@@ -14731,6 +15411,75 @@ components:
type: string
format: date-time
SelfserveStudioSimulationDebug:
type: object
required: [summary, parameters, stages, questions, conditions, rules, tasks, hardware, graph_annotations, recommendations]
properties:
summary:
type: object
additionalProperties: true
parameters:
type: object
additionalProperties: true
stages:
type: array
items:
type: object
additionalProperties: true
questions:
type: array
items:
type: object
additionalProperties: true
conditions:
type: array
items:
type: object
additionalProperties: true
rules:
type: array
items:
type: object
additionalProperties: true
tasks:
type: array
items:
type: object
additionalProperties: true
hardware:
type: object
additionalProperties: true
graph_annotations:
type: object
properties:
nodes:
type: object
additionalProperties:
type: object
additionalProperties: true
edges:
type: object
additionalProperties:
type: object
additionalProperties: true
recommendations:
type: array
items:
type: object
additionalProperties: true
SelfserveStudioSimulationResponse:
allOf:
- $ref: '#/components/schemas/SelfserveVehicleAllowedResponse'
- type: object
properties:
simulator_version: { type: integer }
dry_run: { type: boolean, enum: [true] }
mode: { type: string, enum: [full_dry_run] }
config_source: { type: string, enum: [draft, published] }
debug:
$ref: '#/components/schemas/SelfserveStudioSimulationDebug'
SelfserveVehicleAllowedResponse:
type: object
properties:
@@ -14770,10 +15519,20 @@ components:
type: boolean
allowed:
type: boolean
blocked_reason:
type: string
nullable: true
session:
allOf:
- $ref: '#/components/schemas/SelfserveWashSession'
nullable: true
config_source:
type: string
nullable: true
evaluation_trace:
type: object
nullable: true
additionalProperties: true
SelfserveWashSummary:
type: object
@@ -15113,6 +15872,8 @@ components:
type: integer
visible:
type: boolean
archived:
type: boolean
dimension:
type: integer
branding:
@@ -15168,6 +15929,8 @@ components:
type: integer
machine_available:
type: boolean
selfserve_enabled:
type: boolean
DepartmentCreate:
type: object
@@ -15183,6 +15946,8 @@ components:
type: integer
visible:
type: boolean
archived:
type: boolean
longitude:
type: number
format: float
@@ -15203,6 +15968,8 @@ components:
type: string
visible:
type: boolean
archived:
type: boolean
longitude:
type: number
format: float
@@ -15236,6 +16003,9 @@ components:
machine_type_id:
type: integer
nullable: true
selfserve_enabled:
type: boolean
default: true
status:
type: string
created_at:
@@ -15272,6 +16042,9 @@ components:
machine_type_id:
type: integer
nullable: true
selfserve_enabled:
type: boolean
default: true
DepartmentLaneUpdate:
type: object
@@ -15301,6 +16074,8 @@ components:
machine_type_id:
type: integer
nullable: true
selfserve_enabled:
type: boolean
DepartmentGate:
type: object
@@ -17254,5 +18029,3 @@ components:
success: { type: boolean, example: true }
data:
$ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendPayload'
+68 -37
View File
@@ -41,6 +41,7 @@
"chart.js": "^4.4.7",
"dayspan-vuetify": "^0.4.0",
"flow": "^0.2.3",
"html2canvas": "^1.4.1",
"qrcode": "^1.5.4",
"sass": "^1.81.1",
"sweetalert2": "11.22.4",
@@ -232,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",
@@ -1930,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",
@@ -1949,11 +1959,12 @@
}
},
"node_modules/@bubblewrap/core/node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"version": "7.24.6",
"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",
@@ -2076,7 +2087,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -2125,7 +2135,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -2563,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"
}
@@ -2807,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"
}
@@ -2844,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"
},
@@ -2857,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"
},
@@ -2882,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"
@@ -2926,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"
},
@@ -3050,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"
},
@@ -3063,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"
},
@@ -3079,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"
},
@@ -5342,7 +5342,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5377,7 +5376,6 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -5471,8 +5469,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",
@@ -5596,7 +5593,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",
@@ -5678,6 +5674,15 @@
"zxing-wasm": "1.1.3"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -5811,7 +5816,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5896,8 +5900,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",
@@ -6155,7 +6158,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"
},
@@ -6492,6 +6494,15 @@
"node": ">=8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -6569,7 +6580,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"
}
@@ -6758,7 +6768,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"
@@ -8237,6 +8246,19 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -8303,8 +8325,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",
@@ -10371,7 +10392,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",
@@ -10726,7 +10746,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"
},
@@ -10910,7 +10929,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",
@@ -10932,7 +10950,6 @@
"integrity": "sha512-Do7u6iRb6K+lrllcTkB1BXcHwOxcKe3rEfOF/GcCLE2w3WpddakRAosJOHFUR37DpsvimQXEt5abs3NzUjEIqg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@bufbuild/protobuf": "^2.5.0",
"colorjs.io": "^0.5.0",
@@ -12149,6 +12166,15 @@
"integrity": "sha512-pv4GjmramjdObhDyR1q85Td8X60Puu/lGQn7Kw2id05LLgHhAcWgnz6xSdMCSxBMWjQDmMyDXPTC2aqADdpiow==",
"license": "Apache-2.0"
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/throat": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz",
@@ -12654,6 +12680,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
@@ -12680,7 +12715,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",
@@ -14073,7 +14107,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -14133,7 +14166,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",
@@ -14821,7 +14853,6 @@
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
+23 -3
View File
@@ -16,9 +16,23 @@
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
"text:fix-encoding": "node scripts/text-encoding.mjs fix",
"text:check-encoding": "node scripts/text-encoding.mjs check",
"test:unit": "npm run text:check-encoding && node scripts/run-vitest-unit-batches.mjs",
"i18n:v2:compile": "node scripts/i18n-v2-compile-source.mjs",
"i18n:v2:source-check": "node scripts/i18n-v2-compile-source.mjs --check",
"i18n:v2:check": "npm run i18n:v2:source-check && npm run i18n:v2:global-template-audit && npm run i18n:v2:template-dedupe-audit && npm run i18n:v2:word-audit",
"i18n:v2:word-audit": "node scripts/i18n-v2-word-dedupe.mjs",
"i18n:v2:word-dedupe": "node scripts/i18n-v2-word-dedupe.mjs --apply",
"i18n:v2:global-template-audit": "node scripts/i18n-v2-globalize-templates.mjs",
"i18n:v2:globalize-templates": "node scripts/i18n-v2-globalize-templates.mjs --apply",
"i18n:v2:template-dedupe-audit": "node scripts/i18n-v2-template-dedupe.mjs",
"i18n:v2:template-dedupe": "node scripts/i18n-v2-template-dedupe.mjs --apply",
"test:unit": "npm run text:check-encoding && npm run test:unit:fast && npm run test:unit:serial",
"test:unit:fast": "node scripts/run-vitest-unit-fast.mjs",
"test:unit:serial": "node scripts/run-vitest-unit-batches.mjs --from-list tests/unit/serial-tests.txt",
"test:unit:single": "vitest run",
"test:e2e": "playwright test",
"test:e2e": "npm run test:e2e:matrix --",
"test:e2e:matrix": "playwright test",
"test:e2e:pr": "node scripts/run-playwright-pr.mjs",
"test:e2e:changed": "node scripts/run-playwright-pr.mjs --changed-only",
"test:e2e:i18n:views": "playwright test tests/e2e/i18n.views.spec.ts --project=chromium-desktop",
"test:e2e:ci": "node scripts/run-playwright-ci-parallel.mjs",
"test:e2e:full:slice": "node scripts/run-playwright-full-slice.mjs",
@@ -28,9 +42,14 @@
"test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile",
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
"test:e2e:live:public": "playwright test --config=playwright.live.config.ts --grep @public-live",
"test:e2e:live:roles": "playwright test --config=playwright.live.config.ts --grep @role-live",
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
"release:verify-upload": "node scripts/release/verify-upload.mjs",
"release:update-server-version": "node scripts/release/update-server-version.mjs",
"release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh",
"test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"",
"test:all": "npm run test:unit && npm run test:e2e:smoke",
"test:all": "npm run test:unit && npm run test:e2e:pr",
"twa:build": "bubblewrap build",
"twa:update": "bubblewrap update"
},
@@ -67,6 +86,7 @@
"chart.js": "^4.4.7",
"dayspan-vuetify": "^0.4.0",
"flow": "^0.2.3",
"html2canvas": "^1.4.1",
"qrcode": "^1.5.4",
"sass": "^1.81.1",
"sweetalert2": "11.22.4",
+60 -30
View File
@@ -1,4 +1,5 @@
import { execFile, spawn } from "node:child_process";
import { createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
@@ -11,15 +12,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;
}
@@ -342,7 +348,9 @@ function resolveExpectedContentType(specifier) {
}
if (cleanSpecifier.endsWith(".json")) {
return "json";
// Vite serves imported JSON modules as JavaScript ("?import" transform),
// so warming should verify a JavaScript content type.
return "javascript";
}
return "javascript";
@@ -366,6 +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();
@@ -384,12 +396,36 @@ async function warmModuleGraph(entryUrl, { depth = 2, timeoutMs = 120_000 } = {}
async function warmUpDevServer(url) {
const warmupTargets = await buildWarmupTargets();
const selfServeEntryModules = [
"/src/views/dashboards/userDashboard/wash/MyWash.vue",
"/src/views/dashboards/userDashboard/wash/MyWashStart.vue",
"/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue",
"/src/views/guest/book/GuestBookExteriorWash.vue",
];
const smokeRouteEntryModules = [
{ path: "/src/components/page/wrappers/LayoutV2.vue", depth: 2 },
{ path: "/src/views/pages/LandingPage.vue", depth: 2 },
{ path: "/src/views/dashboards/superUserDashboard/selfserve/EdgeAgents.vue", depth: 1 },
{ path: "/src/features/edgeGateways/EdgeGatewayManager.vue", depth: 2 },
{ path: "/src/features/edgeGateways/EdgeGatewayTerminalPage.vue", depth: 2 },
{ path: "/src/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue", depth: 1 },
{ path: "/src/features/edgeGateways/EdgeGatewayDepartmentWorkspace.vue", depth: 2 },
{ path: "/src/views/dashboards/superUserDashboard/CollectedOrderInvoices.vue", depth: 1 },
{ path: "/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionOverview.vue", depth: 2 },
{ path: "/src/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionMonthView.vue", depth: 2 },
];
for (const target of warmupTargets) {
await warmUpAsset(new URL(target.pathname, url).toString(), target.expectedContentType);
}
await warmModuleGraph(new URL("/src/main.js", url).toString());
for (const entryModule of selfServeEntryModules) {
await warmModuleGraph(new URL(entryModule, url).toString(), { depth: 2 });
}
for (const { path: entryModule, depth } of smokeRouteEntryModules) {
await warmModuleGraph(new URL(entryModule, url).toString(), { depth });
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
@@ -410,31 +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();
+53
View File
@@ -0,0 +1,53 @@
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?: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"
+185
View File
@@ -0,0 +1,185 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const sourceDirectory = path.join(projectRoot, "src", "i18n", "source");
const generatedDirectory = path.join(projectRoot, "src", "i18n", "generated");
const activeLocales = ["da", "en", "sv", "de", "no"];
const mode = process.argv.includes("--check") ? "check" : "write";
const linkTokenPattern = /@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))/gu;
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
const stableJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
const writeJson = (filePath, value) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, stableJson(value), "utf8");
};
const listJsonFiles = (directory) => {
if (!fs.existsSync(directory)) {
return [];
}
const entries = fs.readdirSync(directory, { withFileTypes: true });
return entries
.flatMap((entry) => {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
return listJsonFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith(".json") ? [entryPath] : [];
})
.sort((left, right) => left.localeCompare(right, "en", { sensitivity: "base" }));
};
const mergeMessages = (left = {}, right = {}) => {
const merged = { ...left };
for (const [key, value] of Object.entries(right ?? {})) {
if (isPlainObject(value) && isPlainObject(merged[key])) {
merged[key] = mergeMessages(merged[key], value);
} else {
merged[key] = value;
}
}
return merged;
};
const readJsonFragments = (directory) => {
let merged = {};
for (const filePath of listJsonFiles(directory)) {
merged = mergeMessages(merged, readJson(filePath));
}
return merged;
};
const mapSourcePathToRuntimePath = (keyPath) => {
if (keyPath.startsWith("terms.glossary.")) {
return `words.generated.${keyPath.slice("terms.glossary.".length)}`;
}
if (keyPath.startsWith("terms.")) {
return `words.${keyPath.slice("terms.".length)}`;
}
if (keyPath.startsWith("phrases.compat.")) {
return `templates.generated.compat.${keyPath.slice("phrases.compat.".length)}`;
}
if (keyPath.startsWith("phrases.")) {
return `templates.${keyPath.slice("phrases.".length)}`;
}
return keyPath;
};
const rewriteLinks = (value) => {
if (typeof value === "string") {
return value.replace(linkTokenPattern, (token, modifier = "", literalTarget, pathTarget) => {
const target = literalTarget ?? pathTarget;
const mappedTarget = mapSourcePathToRuntimePath(target);
const quoteTarget = literalTarget || /[^\p{L}\p{N}_.-]/u.test(mappedTarget);
return quoteTarget ? `@${modifier}:{'${mappedTarget}'}` : `@${modifier}:${mappedTarget}`;
});
}
if (Array.isArray(value)) {
return value.map((entry) => rewriteLinks(entry));
}
if (!isPlainObject(value)) {
return value;
}
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, rewriteLinks(entry)]));
};
const compileWords = (sourceTerms) => {
const { glossary, ...terms } = sourceTerms;
return {
grammar: terms.grammar ?? {},
actions: terms.actions ?? {},
entities: terms.entities ?? {},
statuses: terms.statuses ?? {},
domain: terms.domain ?? {},
services: terms.services ?? {},
units: terms.units ?? {},
replication: terms.replication ?? {},
generated: glossary ?? {},
};
};
const compileTemplates = (sourcePhrases) => {
const { compat, ...phrases } = sourcePhrases;
return {
...phrases,
generated: {
compat: compat ?? {},
},
};
};
const compileLocale = (locale) => {
const localeSourceDirectory = path.join(sourceDirectory, locale);
const sourceTerms = readJsonFragments(path.join(localeSourceDirectory, "terms"));
const sourcePhrases = readJsonFragments(path.join(localeSourceDirectory, "phrases"));
return rewriteLinks({
words: compileWords(sourceTerms),
templates: compileTemplates(sourcePhrases),
});
};
const compileGlobal = () => {
const shared = readJsonFragments(path.join(sourceDirectory, "global", "shared"));
const locales = {};
for (const locale of activeLocales) {
locales[locale] = readJsonFragments(path.join(sourceDirectory, "global", "locales", locale));
}
return rewriteLinks({
shared,
locales,
});
};
const compiledFiles = new Map([
[path.join(generatedDirectory, "global-v2.json"), compileGlobal()],
...activeLocales.map((locale) => [path.join(generatedDirectory, `${locale}-v2.json`), compileLocale(locale)]),
]);
if (mode === "check") {
const mismatches = [];
for (const [filePath, value] of compiledFiles.entries()) {
const expected = stableJson(value);
const actual = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "") : "";
if (actual !== expected) {
mismatches.push(path.relative(projectRoot, filePath));
}
}
if (mismatches.length > 0) {
console.error("Generated i18n v2 runtime files are out of date:");
for (const mismatch of mismatches) {
console.error(` ${mismatch}`);
}
console.error("Run npm run i18n:v2:compile.");
process.exit(1);
}
console.log("Generated i18n v2 runtime files are up to date.");
} else {
for (const [filePath, value] of compiledFiles.entries()) {
writeJson(filePath, value);
}
console.log(`Compiled i18n v2 runtime files to ${path.relative(projectRoot, generatedDirectory)}.`);
}
+389
View File
@@ -0,0 +1,389 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
const activeLocales = ["da", "en", "sv", "de", "no"];
const mode = process.argv.includes("--apply") ? "apply" : "check";
const templatePrefix = "templates.generated.compat";
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)/gu;
const exactLinkPattern = /^@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))$/u;
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
};
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
const flattenStringEntries = (value, prefix = "") => {
if (typeof value === "string") {
return [{ key: prefix, value }];
}
if (!isPlainObject(value)) {
return [];
}
return Object.entries(value).flatMap(([key, entry]) =>
flattenStringEntries(entry, prefix ? `${prefix}.${key}` : key)
);
};
const getValueAtPath = (value, keyPath) => {
let current = value;
for (const segment of keyPath.split(".")) {
if (!isPlainObject(current) || !(segment in current)) {
return undefined;
}
current = current[segment];
}
return current;
};
const ensureObjectAtPath = (value, keyPath) => {
let current = value;
for (const segment of keyPath.split(".")) {
if (!isPlainObject(current[segment])) {
current[segment] = {};
}
current = current[segment];
}
return current;
};
const setValueAtPath = (value, keyPath, nextValue) => {
const segments = keyPath.split(".");
const parent = ensureObjectAtPath(value, segments.slice(0, -1).join("."));
parent[segments.at(-1)] = nextValue;
};
const deleteValueAtPath = (value, keyPath) => {
const segments = keyPath.split(".");
const parents = [];
let current = value;
for (const segment of segments.slice(0, -1)) {
if (!isPlainObject(current) || !(segment in current)) {
return false;
}
parents.push([current, segment]);
current = current[segment];
}
if (!isPlainObject(current) || !(segments.at(-1) in current)) {
return false;
}
delete current[segments.at(-1)];
for (let index = parents.length - 1; index >= 0; index -= 1) {
const [parent, segment] = parents[index];
const child = parent[segment];
if (isPlainObject(child) && Object.keys(child).length === 0) {
delete parent[segment];
} else {
break;
}
}
return true;
};
const mergeMessages = (sharedMessages = {}, localeMessages = {}) => {
const mergedMessages = { ...sharedMessages };
for (const [key, value] of Object.entries(localeMessages ?? {})) {
if (isPlainObject(value) && isPlainObject(mergedMessages[key])) {
mergedMessages[key] = mergeMessages(mergedMessages[key], value);
} else {
mergedMessages[key] = value;
}
}
return mergedMessages;
};
const applyTextModifier = (value, modifier) => {
switch (modifier) {
case ".capitalize":
return `${value.charAt(0).toLocaleUpperCase()}${value.slice(1)}`;
case ".upper":
return value.toLocaleUpperCase();
case ".lower":
return value.toLocaleLowerCase();
default:
return value;
}
};
const resolveLinkedMessage = (messages, keyPath, seen = new Set()) => {
if (seen.has(keyPath)) {
return undefined;
}
seen.add(keyPath);
const value = getValueAtPath(messages, keyPath);
if (typeof value !== "string") {
return undefined;
}
const exactLink = value.trim().match(exactLinkPattern);
if (exactLink) {
const target = exactLink.groups.literal ?? exactLink.groups.path;
const resolved = resolveLinkedMessage(messages, target, seen);
return resolved === undefined ? undefined : applyTextModifier(resolved, exactLink.groups.modifier);
}
return value.replace(linkTokenPattern, (token) => {
const match = token.match(exactLinkPattern);
if (!match?.groups) {
return token;
}
const target = match.groups.literal ?? match.groups.path;
const resolved = resolveLinkedMessage(messages, target, new Set(seen));
return resolved === undefined ? token : applyTextModifier(resolved, match.groups.modifier);
});
};
const readRawLocales = () =>
Object.fromEntries(
activeLocales.map((locale) => [locale, readJson(path.join(localesDirectory, `${locale}-v2.json`))])
);
const buildMergedLocales = (globalMessages, rawLocales) =>
Object.fromEntries(
activeLocales.map((locale) => [
locale,
mergeMessages(
mergeMessages(globalMessages.shared ?? {}, globalMessages.locales?.[locale] ?? {}),
rawLocales[locale]
),
])
);
const isCatalogPhraseKey = (keyPath) => !keyPath.startsWith("words.") && !keyPath.startsWith("templates.");
const exactLinkTarget = (value) => {
const match = value.trim().match(exactLinkPattern);
return match?.groups?.literal ?? match?.groups?.path;
};
const templateKeyFor = (keyPath) => `${templatePrefix}.${keyPath}`;
const linkedTemplateFor = (keyPath) => `@:{'${templateKeyFor(keyPath)}'}`;
const sourceValueFor = (globalMessages, rawLocales, locale, keyPath) => {
const rawValue = getValueAtPath(rawLocales[locale], keyPath);
if (typeof rawValue === "string") {
return rawValue;
}
const localeValue = getValueAtPath(globalMessages.locales?.[locale] ?? {}, keyPath);
if (typeof localeValue === "string") {
return localeValue;
}
const sharedValue = getValueAtPath(globalMessages.shared ?? {}, keyPath);
return typeof sharedValue === "string" ? sharedValue : undefined;
};
const collectCandidateKeys = (globalMessages, rawLocales) => {
const keys = new Set();
for (const locale of activeLocales) {
for (const entry of flattenStringEntries(rawLocales[locale]).filter((entry) => isCatalogPhraseKey(entry.key))) {
keys.add(entry.key);
}
for (const entry of flattenStringEntries(globalMessages.locales?.[locale] ?? {}).filter((entry) =>
isCatalogPhraseKey(entry.key)
)) {
const target = exactLinkTarget(entry.value);
if (target !== templateKeyFor(entry.key)) {
keys.add(entry.key);
}
}
}
return [...keys].sort((left, right) => left.localeCompare(right, "en", { sensitivity: "base" }));
};
const reorderLocaleRoot = (localeMessages) => {
const { words, templates, ...rest } = localeMessages;
return { words, templates, ...rest };
};
const pruneLocaleMap = (locales) => {
const nextLocales = {};
for (const locale of activeLocales) {
nextLocales[locale] = isPlainObject(locales?.[locale]) ? locales[locale] : {};
}
return nextLocales;
};
const transformCatalogs = (originalGlobalMessages, originalRawLocales) => {
const globalMessages = cloneJson(originalGlobalMessages);
const rawLocales = cloneJson(originalRawLocales);
globalMessages.shared = globalMessages.shared ?? {};
globalMessages.locales = pruneLocaleMap(globalMessages.locales ?? {});
const originalMergedLocales = buildMergedLocales(originalGlobalMessages, originalRawLocales);
const candidateKeys = collectCandidateKeys(originalGlobalMessages, originalRawLocales);
let directSharedAliasCount = 0;
let generatedTemplateCount = 0;
let removedRawEntryCount = 0;
let removedLocaleEntryCount = 0;
for (const keyPath of candidateKeys) {
const valuesByLocale = Object.fromEntries(
activeLocales.map((locale) => {
const sourceValue = sourceValueFor(originalGlobalMessages, originalRawLocales, locale, keyPath);
if (typeof sourceValue === "string") {
return [locale, sourceValue];
}
const mergedValue = getValueAtPath(originalMergedLocales[locale], keyPath);
return [locale, typeof mergedValue === "string" ? mergedValue : undefined];
})
);
const values = activeLocales.map((locale) => valuesByLocale[locale]);
if (values.some((value) => typeof value !== "string")) {
continue;
}
const firstValue = values[0];
const allValuesIdentical = values.every((value) => value === firstValue);
const canUseDirectSharedAlias =
allValuesIdentical &&
!firstValue.includes("words.generated.") &&
!firstValue.includes(templatePrefix) &&
exactLinkTarget(firstValue) !== keyPath;
if (canUseDirectSharedAlias) {
setValueAtPath(globalMessages.shared, keyPath, firstValue);
directSharedAliasCount += 1;
} else {
setValueAtPath(globalMessages.shared, keyPath, linkedTemplateFor(keyPath));
for (const locale of activeLocales) {
setValueAtPath(rawLocales[locale], templateKeyFor(keyPath), valuesByLocale[locale]);
}
generatedTemplateCount += 1;
}
for (const locale of activeLocales) {
if (deleteValueAtPath(rawLocales[locale], keyPath)) {
removedRawEntryCount += 1;
}
if (deleteValueAtPath(globalMessages.locales[locale], keyPath)) {
removedLocaleEntryCount += 1;
}
}
}
for (const locale of activeLocales) {
rawLocales[locale] = reorderLocaleRoot(rawLocales[locale]);
globalMessages.locales[locale] = isPlainObject(globalMessages.locales[locale])
? globalMessages.locales[locale]
: {};
}
const nextMergedLocales = buildMergedLocales(globalMessages, rawLocales);
const mismatches = [];
for (const locale of activeLocales) {
for (const entry of flattenStringEntries(originalMergedLocales[locale])) {
const expected = resolveLinkedMessage(originalMergedLocales[locale], entry.key);
const actual = resolveLinkedMessage(nextMergedLocales[locale], entry.key);
if (actual !== expected) {
mismatches.push(
`${locale}.${entry.key}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
);
}
}
}
const remainingRawPhraseEntries = activeLocales.flatMap((locale) =>
flattenStringEntries(rawLocales[locale])
.filter((entry) => isCatalogPhraseKey(entry.key))
.map((entry) => `${locale}.${entry.key}`)
);
const remainingLocalePhraseEntries = activeLocales.flatMap((locale) =>
flattenStringEntries(globalMessages.locales?.[locale] ?? {})
.filter((entry) => isCatalogPhraseKey(entry.key))
.map((entry) => `${locale}.${entry.key}`)
);
return {
globalMessages,
rawLocales,
candidateCount: candidateKeys.length,
directSharedAliasCount,
generatedTemplateCount,
removedRawEntryCount,
removedLocaleEntryCount,
mismatches,
remainingRawPhraseEntries,
remainingLocalePhraseEntries,
};
};
const globalPath = path.join(localesDirectory, "global-v2.json");
const originalGlobalMessages = readJson(globalPath);
const originalRawLocales = readRawLocales();
const result = transformCatalogs(originalGlobalMessages, originalRawLocales);
console.log(
[
`candidate phrase keys: ${result.candidateCount}`,
`direct shared aliases: ${result.directSharedAliasCount}`,
`generated shared template aliases: ${result.generatedTemplateCount}`,
`removed raw locale entries: ${result.removedRawEntryCount}`,
`removed global locale entries: ${result.removedLocaleEntryCount}`,
].join("\n")
);
let hasErrors = false;
if (result.mismatches.length > 0) {
hasErrors = true;
console.error(`transformed output changed ${result.mismatches.length} strings`);
for (const mismatch of result.mismatches.slice(0, 30)) {
console.error(` ${mismatch}`);
}
}
if (result.remainingRawPhraseEntries.length > 0) {
hasErrors = true;
console.error(`raw locale phrase entries remain outside words/templates: ${result.remainingRawPhraseEntries.length}`);
for (const entry of result.remainingRawPhraseEntries.slice(0, 30)) {
console.error(` ${entry}`);
}
}
if (result.remainingLocalePhraseEntries.length > 0) {
hasErrors = true;
console.error(`global-v2 locale phrase entries remain: ${result.remainingLocalePhraseEntries.length}`);
for (const entry of result.remainingLocalePhraseEntries.slice(0, 30)) {
console.error(` ${entry}`);
}
}
if (hasErrors) {
process.exit(1);
}
if (mode === "apply") {
writeJson(globalPath, result.globalMessages);
for (const locale of activeLocales) {
writeJson(path.join(localesDirectory, `${locale}-v2.json`), result.rawLocales[locale]);
}
}
+341
View File
@@ -0,0 +1,341 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
const activeLocales = ["da", "en", "sv", "de", "no"];
const mode = process.argv.includes("--apply") ? "apply" : "check";
const templatePrefix = "templates.generated.compat";
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*\}/g;
const exactLinkPattern = /^@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)$/u;
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
};
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
const flattenStringEntries = (value, prefix = "") => {
if (typeof value === "string") {
return [{ key: prefix, value }];
}
if (!isPlainObject(value)) {
return [];
}
return Object.entries(value).flatMap(([key, entry]) =>
flattenStringEntries(entry, prefix ? `${prefix}.${key}` : key)
);
};
const getValueAtPath = (value, keyPath) => {
let current = value;
for (const segment of keyPath.split(".")) {
if (!isPlainObject(current) || !(segment in current)) {
return undefined;
}
current = current[segment];
}
return current;
};
const ensureObjectAtPath = (value, keyPath) => {
let current = value;
if (!keyPath) {
return current;
}
for (const segment of keyPath.split(".")) {
if (!isPlainObject(current[segment])) {
current[segment] = {};
}
current = current[segment];
}
return current;
};
const setValueAtPath = (value, keyPath, nextValue) => {
const segments = keyPath.split(".");
const parent = ensureObjectAtPath(value, segments.slice(0, -1).join("."));
parent[segments.at(-1)] = nextValue;
};
const deleteValueAtPath = (value, keyPath) => {
const segments = keyPath.split(".");
const parents = [];
let current = value;
for (const segment of segments.slice(0, -1)) {
if (!isPlainObject(current) || !(segment in current)) {
return false;
}
parents.push([current, segment]);
current = current[segment];
}
if (!isPlainObject(current) || !(segments.at(-1) in current)) {
return false;
}
delete current[segments.at(-1)];
for (let index = parents.length - 1; index >= 0; index -= 1) {
const [parent, segment] = parents[index];
const child = parent[segment];
if (isPlainObject(child) && Object.keys(child).length === 0) {
delete parent[segment];
} else {
break;
}
}
return true;
};
const normalizePhrase = (value) =>
value
.normalize("NFKC")
.toLocaleLowerCase()
.replace(placeholderPattern, "{}")
.replace(/[\p{P}\p{S}]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
const linkToTemplate = (suffix) => `@:{'${templatePrefix}.${suffix}'}`;
const canonicalPriority = (suffix) => {
if (suffix.startsWith("common.")) return 0;
if (suffix.startsWith("global.")) return 1;
if (suffix.startsWith("nav.")) return 2;
if (suffix.startsWith("tables.common.")) return 3;
if (suffix.startsWith("objects.columns.")) return 4;
if (suffix.startsWith("auth.")) return 5;
if (suffix.startsWith("products.")) return 6;
return 10;
};
const chooseCanonicalSuffix = (suffixes) =>
[...suffixes].sort(
(left, right) =>
canonicalPriority(left) - canonicalPriority(right) ||
left.split(".").length - right.split(".").length ||
left.length - right.length ||
left.localeCompare(right, "en", { sensitivity: "base" })
)[0];
const createDisjointSet = (items) => {
const parent = new Map(items.map((item) => [item, item]));
const find = (item) => {
const currentParent = parent.get(item);
if (currentParent === item) {
return item;
}
const root = find(currentParent);
parent.set(item, root);
return root;
};
const union = (left, right) => {
const leftRoot = find(left);
const rightRoot = find(right);
if (leftRoot !== rightRoot) {
parent.set(rightRoot, leftRoot);
}
};
const groups = () => {
const grouped = new Map();
for (const item of items) {
const root = find(item);
const group = grouped.get(root) ?? [];
group.push(item);
grouped.set(root, group);
}
return [...grouped.values()];
};
return { union, groups };
};
const replaceTemplatePathReferences = (value, replacements) => {
if (typeof value === "string") {
let nextValue = value;
for (const [fromSuffix, toSuffix] of replacements.entries()) {
nextValue = nextValue.replaceAll(`${templatePrefix}.${fromSuffix}`, `${templatePrefix}.${toSuffix}`);
}
return nextValue;
}
if (Array.isArray(value)) {
return value.map((entry) => replaceTemplatePathReferences(entry, replacements));
}
if (!isPlainObject(value)) {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, replaceTemplatePathReferences(entry, replacements)])
);
};
const readRawLocales = () =>
Object.fromEntries(
activeLocales.map((locale) => [locale, readJson(path.join(localesDirectory, `${locale}-v2.json`))])
);
const collectTemplateSuffixes = (rawLocales) => {
const suffixes = new Set();
for (const locale of activeLocales) {
for (const entry of flattenStringEntries(getValueAtPath(rawLocales[locale], templatePrefix) ?? {})) {
suffixes.add(entry.key);
}
}
return [...suffixes].sort((left, right) => left.localeCompare(right, "en", { sensitivity: "base" }));
};
const transformCatalogs = (originalGlobalMessages, originalRawLocales) => {
const rawLocales = cloneJson(originalRawLocales);
let globalMessages = cloneJson(originalGlobalMessages);
const suffixes = collectTemplateSuffixes(rawLocales);
const disjointSet = createDisjointSet(suffixes);
for (const locale of activeLocales) {
const groupedByLocalePhrase = new Map();
for (const suffix of suffixes) {
const value = getValueAtPath(rawLocales[locale], `${templatePrefix}.${suffix}`);
if (typeof value !== "string") {
continue;
}
const signature = normalizePhrase(value);
if (signature.length < 4) {
continue;
}
const group = groupedByLocalePhrase.get(signature) ?? [];
group.push(suffix);
groupedByLocalePhrase.set(signature, group);
}
for (const group of groupedByLocalePhrase.values()) {
if (group.length <= 1) {
continue;
}
const [first, ...rest] = group;
for (const suffix of rest) {
disjointSet.union(first, suffix);
}
}
}
const duplicateGroups = disjointSet.groups().filter((group) => group.length > 1);
const replacements = new Map();
for (const group of duplicateGroups) {
const canonicalSuffix = chooseCanonicalSuffix(group);
for (const suffix of group) {
if (suffix !== canonicalSuffix) {
replacements.set(suffix, canonicalSuffix);
}
}
}
for (const [fromSuffix, toSuffix] of [...replacements.entries()]) {
const seen = new Set([fromSuffix]);
let finalSuffix = toSuffix;
while (replacements.has(finalSuffix) && !seen.has(finalSuffix)) {
seen.add(finalSuffix);
finalSuffix = replacements.get(finalSuffix);
}
replacements.set(fromSuffix, finalSuffix);
}
if (replacements.size > 0) {
globalMessages = replaceTemplatePathReferences(globalMessages, replacements);
for (const locale of activeLocales) {
rawLocales[locale] = replaceTemplatePathReferences(rawLocales[locale], replacements);
}
}
let removedTemplateCount = 0;
let repointedAliasCount = 0;
for (const [fromSuffix, toSuffix] of replacements.entries()) {
setValueAtPath(globalMessages.shared, fromSuffix, linkToTemplate(toSuffix));
repointedAliasCount += 1;
for (const locale of activeLocales) {
if (deleteValueAtPath(rawLocales[locale], `${templatePrefix}.${fromSuffix}`)) {
removedTemplateCount += 1;
}
}
}
const remainingDuplicateGroups = [];
for (const locale of activeLocales) {
const nonLinkedTemplates = flattenStringEntries(getValueAtPath(rawLocales[locale], templatePrefix) ?? {}).filter(
(entry) => !exactLinkPattern.test(entry.value.trim())
);
const byPhrase = new Map();
for (const entry of nonLinkedTemplates) {
const signature = normalizePhrase(entry.value);
const group = byPhrase.get(signature) ?? [];
group.push(entry.key);
byPhrase.set(signature, group);
}
for (const [signature, group] of byPhrase.entries()) {
if (signature.length >= 4 && group.length > 1) {
remainingDuplicateGroups.push(`${locale}: ${signature} <- ${group.join(", ")}`);
}
}
}
return {
globalMessages,
rawLocales,
duplicateGroupCount: duplicateGroups.length,
replacementCount: replacements.size,
repointedAliasCount,
removedTemplateCount,
remainingDuplicateGroups,
};
};
const globalPath = path.join(localesDirectory, "global-v2.json");
const originalGlobalMessages = readJson(globalPath);
const originalRawLocales = readRawLocales();
const result = transformCatalogs(originalGlobalMessages, originalRawLocales);
console.log(
[
`duplicate generated template groups: ${result.duplicateGroupCount}`,
`template replacements: ${result.replacementCount}`,
`repointed shared aliases: ${result.repointedAliasCount}`,
`removed locale template entries: ${result.removedTemplateCount}`,
].join("\n")
);
if (result.remainingDuplicateGroups.length > 0) {
console.error(`near-duplicate generated templates remain: ${result.remainingDuplicateGroups.length}`);
for (const group of result.remainingDuplicateGroups.slice(0, 30)) {
console.error(` ${group}`);
}
process.exit(1);
}
if (mode === "apply") {
writeJson(globalPath, result.globalMessages);
for (const locale of activeLocales) {
writeJson(path.join(localesDirectory, `${locale}-v2.json`), result.rawLocales[locale]);
}
}
+390
View File
@@ -0,0 +1,390 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
const activeLocales = ["da", "en", "sv", "de", "no"];
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\{'[^']+'\}|[\p{L}\p{N}_.-]+)/gu;
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*\}/g;
const wordTokenPattern = /[\p{L}\p{N}]+/gu;
const linkedMessagePattern = /^@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))$/u;
const mode = process.argv.includes("--apply") ? "apply" : "check";
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
};
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
const flattenStringEntries = (value, prefix = "") => {
if (typeof value === "string") {
return [{ key: prefix, value }];
}
if (!isPlainObject(value)) {
return [];
}
return Object.entries(value).flatMap(([key, entry]) =>
flattenStringEntries(entry, prefix ? `${prefix}.${key}` : key)
);
};
const getValueAtPath = (value, keyPath) => {
let current = value;
for (const segment of keyPath.split(".")) {
if (!isPlainObject(current) || !(segment in current)) {
return undefined;
}
current = current[segment];
}
return current;
};
const setValueAtPath = (value, keyPath, nextValue) => {
const segments = keyPath.split(".");
let current = value;
for (const segment of segments.slice(0, -1)) {
current = current[segment];
}
current[segments.at(-1)] = nextValue;
};
const compareStringValues = (left, right) => left.localeCompare(right, "en", { sensitivity: "base" });
const protectSegments = (value) => {
const protectedRanges = [];
for (const pattern of [linkTokenPattern, placeholderPattern]) {
pattern.lastIndex = 0;
for (const match of value.matchAll(pattern)) {
protectedRanges.push({ start: match.index ?? 0, end: (match.index ?? 0) + match[0].length });
}
}
protectedRanges.sort((a, b) => a.start - b.start || b.end - a.end);
const mergedRanges = [];
for (const range of protectedRanges) {
const previous = mergedRanges.at(-1);
if (previous && range.start <= previous.end) {
previous.end = Math.max(previous.end, range.end);
} else {
mergedRanges.push({ ...range });
}
}
const segments = [];
let offset = 0;
for (const range of mergedRanges) {
if (offset < range.start) {
segments.push({ protected: false, value: value.slice(offset, range.start) });
}
segments.push({ protected: true, value: value.slice(range.start, range.end) });
offset = range.end;
}
if (offset < value.length) {
segments.push({ protected: false, value: value.slice(offset) });
}
return segments;
};
const normalizeToken = (token) => token.normalize("NFKC").toLocaleLowerCase();
const isAuditedToken = (token) => /\p{L}/u.test(token);
const collectTokens = (value) => {
const tokens = [];
for (const segment of protectSegments(value)) {
if (segment.protected) {
continue;
}
wordTokenPattern.lastIndex = 0;
for (const match of segment.value.matchAll(wordTokenPattern)) {
const token = match[0];
if (isAuditedToken(token)) {
tokens.push(token);
}
}
}
return tokens;
};
const stripMarks = (value) => value.normalize("NFKD").replace(/\p{M}/gu, "");
const slugify = (token, usedKeys) => {
const base =
stripMarks(token)
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "_")
.replace(/^_+|_+$/g, "")
.replace(/_{2,}/g, "_") || "word";
const prefixed = /^\p{N}/u.test(base) ? `word_${base}` : base;
let key = prefixed;
let suffix = 2;
while (usedKeys.has(key)) {
key = `${prefixed}_${suffix}`;
suffix += 1;
}
usedKeys.add(key);
return key;
};
const isCapitalizedToken = (token) => {
const lower = token.toLocaleLowerCase();
return token === `${lower.charAt(0).toLocaleUpperCase()}${lower.slice(1)}` && token !== lower;
};
const isUpperToken = (token) => token.length > 1 && token === token.toLocaleUpperCase() && token !== token.toLocaleLowerCase();
const isLowerToken = (token) => token === token.toLocaleLowerCase() && token !== token.toLocaleUpperCase();
const linkedWord = (keyPath, modifier = "") => `@${modifier}:{'${keyPath}'}`;
const applyTextModifier = (value, modifier) => {
switch (modifier) {
case ".capitalize":
return `${value.charAt(0).toLocaleUpperCase()}${value.slice(1)}`;
case ".upper":
return value.toLocaleUpperCase();
case ".lower":
return value.toLocaleLowerCase();
default:
return value;
}
};
const modifierForToken = (token, word) => {
if (normalizeToken(token) !== normalizeToken(word.value)) {
return null;
}
const candidateModifiers = ["", ".upper", ".capitalize", ".lower"];
for (const modifier of candidateModifiers) {
if (applyTextModifier(word.value, modifier) === token) {
return modifier;
}
}
return null;
};
const replaceRepeatedTokens = (value, tokenToWord) =>
protectSegments(value)
.map((segment) => {
if (segment.protected) {
return segment.value;
}
return segment.value.replace(wordTokenPattern, (token) => {
const normalized = normalizeToken(token);
const word = tokenToWord.get(normalized);
if (!word || !isAuditedToken(token)) {
return token;
}
const exactVariant = word.variants?.get(token);
if (exactVariant) {
return linkedWord(exactVariant.path);
}
const modifier = modifierForToken(token, word);
if (modifier !== null) {
return linkedWord(word.path, modifier);
}
return token;
});
})
.join("");
const applyLinkedModifier = (value, modifier) => {
return applyTextModifier(value, modifier);
};
const resolveLinkedMessage = (messages, keyPath, seen = new Set()) => {
if (seen.has(keyPath)) {
return undefined;
}
seen.add(keyPath);
const value = getValueAtPath(messages, keyPath);
if (typeof value !== "string") {
return undefined;
}
const exactLink = value.trim().match(linkedMessagePattern);
if (exactLink) {
const resolved = resolveLinkedMessage(messages, exactLink.groups.literal ?? exactLink.groups.path, seen);
return resolved === undefined ? undefined : applyLinkedModifier(resolved, exactLink.groups.modifier);
}
return value.replace(linkTokenPattern, (token) => {
const match = token.match(linkedMessagePattern);
if (!match?.groups) {
return token;
}
const resolved = resolveLinkedMessage(messages, match.groups.literal ?? match.groups.path, new Set(seen));
if (resolved === undefined) {
return token;
}
return applyLinkedModifier(resolved, match.groups.modifier);
});
};
const reorderLocaleRoot = (localeMessages) => {
const { words, templates, ...rest } = localeMessages;
return { words, templates, ...rest };
};
const transformLocale = (locale, originalMessages) => {
const messages = cloneJson(originalMessages);
const entries = flattenStringEntries(messages).filter((entry) => !entry.key.startsWith("words."));
const tokenStats = new Map();
for (const entry of entries) {
for (const token of collectTokens(entry.value)) {
const normalized = normalizeToken(token);
const stat = tokenStats.get(normalized) ?? { count: 0, casings: new Map() };
stat.count += 1;
stat.casings.set(token, (stat.casings.get(token) ?? 0) + 1);
tokenStats.set(normalized, stat);
}
}
const repeatedTokens = [...tokenStats.entries()]
.filter(([, stat]) => stat.count > 1)
.sort(([left], [right]) => compareStringValues(left, right));
const existingWords = messages.words ?? {};
const generatedWords = {};
const tokenToWord = new Map();
const usedKeys = new Set(Object.keys(existingWords.generated ?? {}));
for (const [normalized, stat] of repeatedTokens) {
const existingWordEntry = flattenStringEntries(existingWords).find(
(entry) => normalizeToken(entry.value) === normalized
);
if (existingWordEntry) {
tokenToWord.set(normalized, { path: `words.${existingWordEntry.key}`, value: existingWordEntry.value });
continue;
}
const casings = [...stat.casings.entries()].sort(
([leftToken, leftCount], [rightToken, rightCount]) =>
rightCount - leftCount || compareStringValues(leftToken, rightToken)
);
const lowerCaseToken = casings.find(([token]) => token === normalized)?.[0];
const canonical = lowerCaseToken ?? casings[0][0];
const key = slugify(normalized, usedKeys);
generatedWords[key] = canonical;
const word = { path: `words.generated.${key}`, value: canonical, variants: new Map() };
for (const [casing] of casings) {
if (modifierForToken(casing, word) !== null) {
continue;
}
const variantKey = slugify(`${normalized}_${casing}`, usedKeys);
generatedWords[variantKey] = casing;
word.variants.set(casing, { path: `words.generated.${variantKey}`, value: casing });
}
tokenToWord.set(normalized, word);
}
messages.words = {
grammar: messages.words?.grammar ?? {},
actions: messages.words?.actions ?? {},
entities: messages.words?.entities ?? {},
statuses: messages.words?.statuses ?? {},
domain: messages.words?.domain ?? {},
services: messages.words?.services ?? {},
units: messages.words?.units ?? {},
...(messages.words ?? {}),
generated: {
...(messages.words?.generated ?? {}),
...Object.fromEntries(Object.entries(generatedWords).sort(([left], [right]) => compareStringValues(left, right))),
},
};
for (const entry of entries) {
const nextValue = replaceRepeatedTokens(entry.value, tokenToWord);
if (nextValue !== entry.value) {
setValueAtPath(messages, entry.key, nextValue);
}
}
const mismatches = [];
for (const entry of entries) {
const previousResolved = resolveLinkedMessage(originalMessages, entry.key);
const resolved = resolveLinkedMessage(messages, entry.key);
if (resolved !== previousResolved) {
mismatches.push(`${entry.key}: expected ${JSON.stringify(previousResolved)}, got ${JSON.stringify(resolved)}`);
}
}
return {
messages: reorderLocaleRoot(messages),
generatedWordCount: Object.keys(generatedWords).length,
repeatedTokenCount: repeatedTokens.length,
mismatches,
};
};
const countRepeatedTokensOutsideWords = (messages) => {
const counts = new Map();
for (const entry of flattenStringEntries(messages).filter((entry) => !entry.key.startsWith("words."))) {
for (const token of collectTokens(entry.value)) {
const normalized = normalizeToken(token);
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
}
}
return [...counts.entries()].filter(([, count]) => count > 1);
};
let hasErrors = false;
for (const locale of activeLocales) {
const filePath = path.join(localesDirectory, `${locale}-v2.json`);
const originalMessages = readJson(filePath);
const result = transformLocale(locale, originalMessages);
const repeatedAfter = countRepeatedTokensOutsideWords(result.messages);
if (result.mismatches.length > 0) {
hasErrors = true;
console.error(`${locale}: transformed output changed ${result.mismatches.length} strings`);
for (const mismatch of result.mismatches.slice(0, 20)) {
console.error(` ${mismatch}`);
}
}
if (repeatedAfter.length > 0) {
hasErrors = true;
console.error(`${locale}: ${repeatedAfter.length} repeated token groups remain outside words`);
for (const [token, count] of repeatedAfter.slice(0, 20)) {
console.error(` ${token}: ${count}`);
}
}
console.log(
`${locale}: ${result.repeatedTokenCount} repeated token groups, ${result.generatedWordCount} new generated word entries`
);
if (mode === "apply" && !hasErrors) {
writeJson(filePath, result.messages);
}
}
if (hasErrors) {
process.exit(1);
}
+89
View File
@@ -0,0 +1,89 @@
export const chromiumProjects = ["chromium-desktop", "chromium-mobile"];
export const prGrep = "@pr";
export const smokeGrep = "@smoke";
export const fallbackChangePatterns = [
/^package(?:-lock)?\.json$/u,
/^vite\.config\.js$/u,
/^playwright(?:\..+)?\.config\.(?:js|ts)$/u,
/^playwright\.global-(?:setup|teardown)\.mjs$/u,
/^scripts\/run-playwright/u,
/^tests\/e2e\/(?:support|fixtures)\//u,
];
export const sourceMappings = [
{
name: "auth",
patterns: [/^src\/(?:views|components|middleware)\/.*auth/iu, /^src\/views\/auth\//u, /^src\/components\/session\//u],
specs: ["tests/e2e/auth.smoke.spec.js", "tests/e2e/userAuth.spec.ts"],
projects: chromiumProjects,
},
{
name: "public-routes",
patterns: [/^src\/views\/pages\//u, /^src\/components\/page\//u, /^src\/router\.js$/u],
specs: ["tests/e2e/navigation.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "booking",
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
specs: [
"tests/e2e/booking-selfserve.smoke.spec.js",
"tests/e2e/userBookWash.spec.ts",
"tests/e2e/userBookings.spec.ts",
],
projects: chromiumProjects,
},
{
name: "user-vehicles",
patterns: [/^src\/components\/displays\/user\/vehicles\//u, /^src\/views\/dashboards\/userDashboard\/vehicles\//u],
specs: ["tests/e2e/userVehicles.spec.ts"],
projects: chromiumProjects,
},
{
name: "pos",
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u],
specs: ["tests/e2e/pos-flow.spec.js", "tests/e2e/pos-mobile-order-flow.spec.js", "tests/e2e/admin-pos-orders.spec.ts"],
projects: chromiumProjects,
},
{
name: "edge-gateways",
patterns: [/edge[-/]?gateways?/iu, /edgeGateway/iu],
specs: ["tests/e2e/edge-gateways.smoke.spec.js", "tests/e2e/edge-gateways.routes.spec.js"],
projects: chromiumProjects,
},
{
name: "invoicing",
patterns: [/invoic/iu, /economic[-/]?queue/iu, /collected-order/iu],
specs: [
"tests/e2e/invoicing-period.smoke.spec.js",
"tests/e2e/invoice-distribution.smoke.spec.js",
"tests/e2e/invoice-transfer-queue-history.spec.js",
],
projects: chromiumProjects,
},
{
name: "workfeed",
patterns: [/workfeed/iu],
specs: ["tests/e2e/workfeed-config.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "system-status",
patterns: [/system[-/]?status/iu, /systemDatabase/iu, /replication/iu],
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "self-serve",
patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu],
specs: ["tests/e2e/self-serve-wash.spec.js", "tests/e2e/self-serve-studio-flow.spec.js"],
projects: chromiumProjects,
},
{
name: "i18n",
patterns: [/^src\/i18n\//u, /i18n/iu, /locales/iu],
specs: ["tests/e2e/i18n.smoke.spec.ts"],
projects: chromiumProjects,
},
];
+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);
});
+53
View File
@@ -0,0 +1,53 @@
#!/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://${USER_NAME}:${PASSWORD}@${HOST}"
fi
upload_file() {
local source_file="$1"
local remote_file="$2"
if [[ -f "$source_file" ]]; then
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; put -O \"$(dirname "$remote_file")\" \"$source_file\" -o \"$(basename "$remote_file")\"; bye"
fi
}
for directory in assets resources favicons icons img sounds .well-known; do
if [[ -d "$DIST_DIR/$directory" ]]; then
lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; mirror -R --only-newer --parallel=4 \"$DIST_DIR/$directory\" \"$directory\"; bye"
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 || "";
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);
});
+54 -8
View File
@@ -1,24 +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",
@@ -52,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",
@@ -61,23 +72,40 @@ 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",
"superuser-department-branding.spec.js",
"superuser-department-gates.spec.ts",
"superuser-department-lanes.spec.ts",
"superuser-departments-archive.spec.ts",
"superuser-drafts.spec.ts",
"superuser-products-layout.spec.ts",
"superuser-system-status.smoke.spec.js",
"superuser-users.spec.ts",
"superuser-vehicles.smoke.spec.js",
"workfeed-config.smoke.spec.js",
],
};
const titleRules = [
export const titleRules = [
{ role: "customer", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[User\]/u] },
{ role: "subuser", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Subuser\]/u] },
{ role: "admin", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Operator\]/u] },
@@ -104,6 +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] },
];
@@ -188,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) => {
@@ -210,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);
@@ -297,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);
@@ -330,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();
}
+448
View File
@@ -0,0 +1,448 @@
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { chromiumProjects, fallbackChangePatterns, prGrep, smokeGrep, sourceMappings } from "./playwright-pr-mapping.mjs";
const execFileAsync = promisify(execFile);
const workingDirectory = process.cwd();
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
const args = parseArgs(process.argv.slice(2));
const activeChildren = new Set();
let isShuttingDown = false;
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
void shutdown(signal).finally(() => {
process.exit(130);
});
});
}
function parseArgs(rawArgs) {
const parsed = {
help: false,
changedOnly: false,
listOnly: false,
base: "",
head: "",
projects: [],
forwardedArgs: [],
};
for (let index = 0; index < rawArgs.length; index += 1) {
const value = rawArgs[index];
if (value === "--") {
parsed.forwardedArgs.push(...rawArgs.slice(index + 1));
break;
}
if (value === "--help" || value === "-h") {
parsed.help = true;
continue;
}
if (value === "--changed-only") {
parsed.changedOnly = true;
continue;
}
if (value === "--list-only") {
parsed.listOnly = true;
continue;
}
if (value === "--base" && rawArgs[index + 1] !== undefined) {
parsed.base = rawArgs[index + 1];
index += 1;
continue;
}
if (value.startsWith("--base=")) {
parsed.base = value.slice("--base=".length);
continue;
}
if (value === "--head" && rawArgs[index + 1] !== undefined) {
parsed.head = rawArgs[index + 1];
index += 1;
continue;
}
if (value.startsWith("--head=")) {
parsed.head = value.slice("--head=".length);
continue;
}
if (value === "--project" && rawArgs[index + 1] !== undefined) {
parsed.projects.push(rawArgs[index + 1]);
index += 1;
continue;
}
if (value.startsWith("--project=")) {
parsed.projects.push(value.slice("--project=".length));
continue;
}
parsed.forwardedArgs.push(value);
}
return parsed;
}
function printUsage() {
console.log(`
Playwright PR runner
Usage:
node scripts/run-playwright-pr.mjs [options] [-- <playwright args>]
Options:
--base <ref> Base ref for changed-area detection
--head <ref> Head ref for changed-area detection. Default: HEAD
--project <name> Restrict to one Chromium Playwright project. Can be repeated.
--changed-only Run changed-area selection without the core @pr gate
--list-only List selected tests instead of running them
-h, --help Show help
Examples:
npm run test:e2e:pr
npm run test:e2e:changed -- --base=HEAD~1 --head=HEAD
`);
}
function normalizePath(value) {
return value.replace(/\\/g, "/").replace(/^\.\//u, "");
}
function isZeroSha(value) {
return /^0{40}$/u.test(value || "");
}
function getSelectedProjects(projects = args.projects) {
return projects.length > 0 ? projects : chromiumProjects;
}
function unique(values) {
return [...new Set(values)];
}
function sanitizeNamespace(value) {
return value.replace(/[^a-zA-Z0-9._-]+/g, "-");
}
function getArtifactNamespace(suffix) {
const baseNamespace = process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "pr";
return sanitizeNamespace(`${baseNamespace}-${suffix}`);
}
function buildProjectArgs(projects) {
return projects.flatMap((project) => ["--project", project]);
}
async function runPlaywright({ label, commandArgs, artifactSuffix }) {
const finalArgs = ["test", ...commandArgs, ...args.forwardedArgs];
if (args.listOnly && !finalArgs.includes("--list")) {
finalArgs.push("--list");
}
console.log(`[playwright-pr] ${args.listOnly ? "Listing" : "Running"} ${label}: playwright ${finalArgs.join(" ")}`);
return new Promise((resolve) => {
const child = spawn(process.execPath, [playwrightCliPath, ...finalArgs], {
cwd: workingDirectory,
env: {
...process.env,
PLAYWRIGHT: "1",
PLAYWRIGHT_ARTIFACT_NAMESPACE: getArtifactNamespace(artifactSuffix),
PLAYWRIGHT_REPORTER_MODE: "line-html",
PLAYWRIGHT_WORKERS: process.env.PLAYWRIGHT_WORKERS || "1",
},
stdio: "inherit",
windowsHide: true,
});
activeChildren.add(child);
child.on("close", (code) => {
activeChildren.delete(child);
resolve(code ?? 1);
});
});
}
async function tryGitDiff(rangeArgs) {
const { stdout } = await execFileAsync("git", ["diff", "--name-only", "--diff-filter=ACMR", ...rangeArgs], {
cwd: workingDirectory,
maxBuffer: 16 * 1024 * 1024,
});
return stdout
.split(/\r?\n/u)
.map((line) => normalizePath(line.trim()))
.filter(Boolean);
}
async function getChangedFiles() {
const base = args.base.trim();
const head = args.head.trim() || "HEAD";
if (base && !isZeroSha(base)) {
try {
return {
files: await tryGitDiff([`${base}...${head}`]),
source: `${base}...${head}`,
};
} catch {
try {
return {
files: await tryGitDiff([`${base}..${head}`]),
source: `${base}..${head}`,
};
} catch {
return {
files: [],
source: `${base}...${head}`,
unavailable: true,
};
}
}
}
try {
return {
files: await tryGitDiff(["HEAD~1...HEAD"]),
source: "HEAD~1...HEAD",
};
} catch {
return {
files: [],
source: "HEAD~1...HEAD",
unavailable: true,
};
}
}
function addSpec(selection, spec, projects) {
const normalizedSpec = normalizePath(spec);
const existingProjects = selection.specProjects.get(normalizedSpec) || new Set();
for (const project of projects) {
existingProjects.add(project);
}
selection.specProjects.set(normalizedSpec, existingProjects);
}
function isE2eSpec(file) {
return /^tests\/e2e\/.+\.spec\.(?:js|ts)$/u.test(file);
}
function shouldFallback(file) {
return fallbackChangePatterns.some((pattern) => pattern.test(file));
}
function isFrontendSource(file) {
return /^src\//u.test(file) || /^public\//u.test(file) || /^index\.html$/u.test(file);
}
function selectChangedTests(changedFiles) {
const selection = {
specProjects: new Map(),
mappedFiles: [],
unmappedFiles: [],
fallback: false,
};
const selectedProjects = getSelectedProjects();
for (const rawFile of changedFiles) {
const file = normalizePath(rawFile);
if (isE2eSpec(file)) {
addSpec(selection, file, selectedProjects);
selection.mappedFiles.push(file);
continue;
}
if (shouldFallback(file)) {
selection.fallback = true;
selection.unmappedFiles.push(file);
continue;
}
if (!isFrontendSource(file)) {
continue;
}
const matches = sourceMappings.filter((mapping) => mapping.patterns.some((pattern) => pattern.test(file)));
if (matches.length === 0) {
selection.fallback = true;
selection.unmappedFiles.push(file);
continue;
}
selection.mappedFiles.push(file);
for (const mapping of matches) {
const projects = mapping.projects.filter((project) => selectedProjects.includes(project));
for (const spec of mapping.specs) {
addSpec(selection, spec, projects.length > 0 ? projects : selectedProjects);
}
}
}
return selection;
}
function groupSpecsByProjects(specProjects) {
const groups = new Map();
for (const [spec, projects] of specProjects.entries()) {
const projectList = unique([...projects]).sort();
const key = projectList.join(",");
const existing = groups.get(key) || {
projects: projectList,
specs: [],
};
existing.specs.push(spec);
groups.set(key, existing);
}
return [...groups.values()].map((group) => ({
...group,
specs: group.specs.sort(),
}));
}
async function runCorePrGate() {
const projects = getSelectedProjects();
for (const project of projects) {
const code = await runPlaywright({
label: `core ${prGrep} gate (${project})`,
artifactSuffix: `core-${project}`,
commandArgs: ["--grep", prGrep, "--project", project],
});
if (code !== 0) {
return code;
}
}
return 0;
}
async function runChangedSelection(selection) {
const projects = getSelectedProjects();
if (selection.fallback) {
console.log(
`[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(", ")}`
);
for (const project of projects) {
const code = await runPlaywright({
label: `fallback ${smokeGrep} gate (${project})`,
artifactSuffix: `smoke-fallback-${project}`,
commandArgs: ["--grep", smokeGrep, "--grep-invert", prGrep, "--project", project],
});
if (code !== 0) {
return code;
}
}
return 0;
}
const groups = groupSpecsByProjects(selection.specProjects);
if (groups.length === 0) {
console.log("[playwright-pr] No changed-area E2E specs selected.");
return 0;
}
for (const [index, group] of groups.entries()) {
for (const project of group.projects) {
const code = await runPlaywright({
label: `changed-area specs ${index + 1}/${groups.length} (${project})`,
artifactSuffix: `changed-${index + 1}-${project}`,
commandArgs: [...group.specs, "--project", project],
});
if (code !== 0) {
return code;
}
}
}
return 0;
}
async function shutdown(signal) {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
console.error(`Received ${signal}. Stopping active Playwright PR children...`);
await Promise.all(
[...activeChildren].map(
(child) =>
new Promise((resolve) => {
if (!child.pid) {
resolve();
return;
}
if (process.platform === "win32") {
const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
killer.on("close", resolve);
return;
}
child.kill("SIGTERM");
resolve();
})
)
);
}
async function main() {
if (args.help) {
printUsage();
return;
}
await fs.access(playwrightCliPath);
if (!args.changedOnly) {
const coreCode = await runCorePrGate();
if (coreCode !== 0) {
process.exitCode = coreCode;
return;
}
}
const changed = await getChangedFiles();
if (changed.unavailable) {
console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`);
return;
}
console.log(`[playwright-pr] Changed-area diff ${changed.source}: ${changed.files.length} file(s).`);
const selection = selectChangedTests(changed.files);
const changedCode = await runChangedSelection(selection);
if (changedCode !== 0) {
process.exitCode = changedCode;
}
}
await main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
+31 -2
View File
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
const workingDirectory = process.cwd();
const workingDirectory = await fs.realpath(process.cwd());
const vitestCliPath = path.join(workingDirectory, "node_modules", "vitest", "vitest.mjs");
const unitTestsRoot = path.join(workingDirectory, "tests", "unit");
const args = parseArgs(process.argv.slice(2));
@@ -23,6 +23,7 @@ function parseArgs(rawArgs) {
const parsed = {
help: false,
batchSize: null,
fromList: null,
specFilters: [],
forwardedArgs: [],
};
@@ -51,6 +52,17 @@ function parseArgs(rawArgs) {
continue;
}
if (value === "--from-list" && rawArgs[index + 1]) {
parsed.fromList = rawArgs[index + 1];
index += 1;
continue;
}
if (value.startsWith("--from-list=")) {
parsed.fromList = value.slice("--from-list=".length);
continue;
}
if (value.startsWith("-")) {
parsed.forwardedArgs.push(...rawArgs.slice(index));
break;
@@ -80,15 +92,31 @@ Usage:
Options:
--batch-size <n> Spec files per batch. Default: 5
--from-list <file> Read additional spec filters from a newline-delimited file
-h, --help Show help
Examples:
npm run test:unit
npm run test:unit:serial
npm run test:unit -- tests/unit/select-vehicle-form-pos.spec.js
npm run test:unit -- tests/unit/select-vehicle-form-pos.spec.js -- --reporter=verbose
`);
}
async function readSpecFilterList(listFile) {
if (!listFile) {
return [];
}
const listPath = path.isAbsolute(listFile) ? listFile : path.join(workingDirectory, listFile);
const contents = await fs.readFile(listPath, "utf8");
return contents
.split(/\r?\n/u)
.map((line) => line.replace(/#.*/u, "").trim())
.filter(Boolean);
}
async function collectSpecFiles(targetDirectory) {
const directoryEntries = await fs.readdir(targetDirectory, { withFileTypes: true });
const files = [];
@@ -234,7 +262,8 @@ async function main() {
await fs.access(vitestCliPath);
const discoveredFiles = await collectSpecFiles(unitTestsRoot);
const selectedFiles = applyFilters(discoveredFiles, args.specFilters);
const listFilters = await readSpecFilterList(args.fromList);
const selectedFiles = applyFilters(discoveredFiles, [...listFilters, ...args.specFilters]);
if (selectedFiles.length === 0) {
throw new Error("No unit spec files matched the provided filters.");
+219
View File
@@ -0,0 +1,219 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
const workingDirectory = await fs.realpath(process.cwd());
const vitestCliPath = path.join(workingDirectory, "node_modules", "vitest", "vitest.mjs");
const unitTestsRoot = path.join(workingDirectory, "tests", "unit");
const serialListPath = path.join(unitTestsRoot, "serial-tests.txt");
const args = parseArgs(process.argv.slice(2));
let activeChild = null;
let isShuttingDown = false;
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
void shutdown(signal).finally(() => {
process.exit(130);
});
});
}
function parseArgs(rawArgs) {
const parsed = {
help: false,
specFilters: [],
forwardedArgs: [],
};
for (let index = 0; index < rawArgs.length; index += 1) {
const value = rawArgs[index];
if (value === "--") {
parsed.forwardedArgs.push(...rawArgs.slice(index + 1));
break;
}
if (value === "--help" || value === "-h") {
parsed.help = true;
continue;
}
if (value.startsWith("-")) {
parsed.forwardedArgs.push(...rawArgs.slice(index));
break;
}
parsed.specFilters.push(value);
}
return parsed;
}
function normalizePathSegments(value) {
return value.replace(/\\/g, "/");
}
function printUsage() {
console.log(`
Vitest fast unit runner
Usage:
node scripts/run-vitest-unit-fast.mjs [spec-filter ...] [-- <vitest args>]
Runs all unit specs except entries in tests/unit/serial-tests.txt.
Environment:
VITEST_FAST_MAX_WORKERS Optional max workers for the fast pass
Examples:
npm run test:unit:fast
npm run test:unit:fast -- tests/unit/workfeed
npm run test:unit:fast -- --reporter=verbose
`);
}
async function collectSpecFiles(targetDirectory) {
const directoryEntries = await fs.readdir(targetDirectory, { withFileTypes: true });
const files = [];
for (const entry of directoryEntries) {
const absolutePath = path.join(targetDirectory, entry.name);
if (entry.isDirectory()) {
files.push(...(await collectSpecFiles(absolutePath)));
continue;
}
if (entry.isFile() && entry.name.endsWith(".spec.js")) {
files.push(absolutePath);
}
}
return files.sort((left, right) => left.localeCompare(right));
}
async function readSerialAllowlist() {
const contents = await fs.readFile(serialListPath, "utf8");
return new Set(
contents
.split(/\r?\n/u)
.map((line) => line.replace(/#.*/u, "").trim())
.filter(Boolean)
.map(normalizePathSegments)
);
}
function matchesFilter(relativePath, rawFilter) {
const normalizedRelativePath = normalizePathSegments(relativePath);
const normalizedFilter = normalizePathSegments(rawFilter.trim());
if (!normalizedFilter) {
return false;
}
return (
normalizedRelativePath === normalizedFilter ||
normalizedRelativePath.endsWith(normalizedFilter) ||
normalizedRelativePath.includes(normalizedFilter)
);
}
function applyFilters(files, specFilters) {
if (specFilters.length === 0) {
return files;
}
return files.filter((absolutePath) => {
const relativePath = path.relative(workingDirectory, absolutePath);
return specFilters.some((filter) => matchesFilter(relativePath, filter));
});
}
function hasForwardedArg(name) {
return args.forwardedArgs.some((value) => value === name || value.startsWith(`${name}=`));
}
function resolveMaxWorkers() {
return (process.env.VITEST_FAST_MAX_WORKERS || "").trim();
}
async function runVitest(files) {
const enforcedArgs = [];
const configuredMaxWorkers = resolveMaxWorkers();
if (configuredMaxWorkers && !hasForwardedArg("--maxWorkers")) {
enforcedArgs.push(`--maxWorkers=${configuredMaxWorkers}`);
}
return new Promise((resolve) => {
activeChild = spawn(process.execPath, [vitestCliPath, "run", ...files, ...enforcedArgs, ...args.forwardedArgs], {
cwd: workingDirectory,
env: process.env,
stdio: "inherit",
windowsHide: true,
});
activeChild.on("close", (code) => {
activeChild = null;
resolve(code ?? 1);
});
});
}
async function shutdown(signal) {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
console.error(`Received ${signal}. Stopping active Vitest fast run...`);
if (activeChild?.pid) {
if (process.platform === "win32") {
spawn("taskkill", ["/PID", String(activeChild.pid), "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
} else {
activeChild.kill("SIGTERM");
}
}
}
async function main() {
if (args.help) {
printUsage();
return;
}
await fs.access(vitestCliPath);
const serialAllowlist = await readSerialAllowlist();
const discoveredFiles = await collectSpecFiles(unitTestsRoot);
const fastFiles = discoveredFiles.filter((absolutePath) => {
const relativePath = normalizePathSegments(path.relative(workingDirectory, absolutePath));
return !serialAllowlist.has(relativePath);
});
const selectedFiles = applyFilters(fastFiles, args.specFilters);
if (selectedFiles.length === 0) {
console.log("No parallel-safe unit spec files matched the provided filters.");
return;
}
console.log(
`Running ${selectedFiles.length} parallel-safe unit spec file(s); ${serialAllowlist.size} spec file(s) remain in the serial allowlist.`
);
const code = await runVitest(selectedFiles);
if (code !== 0) {
process.exitCode = code;
}
}
await main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
+46 -25
View File
@@ -1,41 +1,51 @@
<script setup>
import { computed, defineAsyncComponent, onMounted, watch } from "vue";
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, watch } from "vue";
// Set the favicon to the /favicon.ico file
import { useHead } from "@vueuse/head";
import favicon from '@/assets/favicon.ico';
import {useRoute} from "vue-router";
import LayoutV2 from "@/components/page/wrappers/LayoutV2.vue";
import favicon from "@/assets/favicon.ico";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { hasStoredSessionToken } from "@/services/sessionStorage.js";
import {
releaseChannelSwitchedStatus,
releaseChannelUnavailableStatus,
startReleaseChannelAvailabilityClock,
stopReleaseChannelAvailabilityClock,
} from "@/services/releaseChannelAvailability.js";
const route = useRoute();
const { t, te, locale } = useI18n({ useScope: "global" });
const APP_TITLE = "Truck Wash";
const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/LayoutV2.vue"));
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
const VersionCheck = defineAsyncComponent(() => import("@/components/global/VersionCheck.vue"));
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
const ErrorReportLauncher = defineAsyncComponent(() => import("@/components/global/ErrorReportLauncher.vue"));
const ReleaseChannelUnavailable = defineAsyncComponent(() =>
import("@/components/release/ReleaseChannelUnavailable.vue")
);
const ReleaseChannelSwitched = defineAsyncComponent(() => import("@/components/release/ReleaseChannelSwitched.vue"));
const routeTitleI18nKeys = {
"about-us": ["header.about_us"],
"book-a-demo": ["about_us.solutions.operator.book_demo"],
"customer-creation": ["header.customer_creation"],
"login": ["header.customer_login"],
"subuserlogin": ["header.customer_login"],
"adminlogin": ["header.operator_login"],
"guestHome": ["guest.home.title"],
"PwaDownload": ["guest.pwa.title"],
login: ["header.customer_login"],
subuserlogin: ["header.customer_login"],
adminlogin: ["header.operator_login"],
guestHome: ["guest.home.title"],
PwaDownload: ["guest.pwa.title"],
"connectivity-issue": ["connectivity.title"],
"selfservestudio": ["admin.self_serve.title"]
selfservestudio: ["admin.self_serve.title"],
};
const toReadableTitle = (value) => {
if (!value) return "";
return value
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (char) => char.toUpperCase());
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (char) => char.toUpperCase());
};
const resolveRouteTitle = () => {
@@ -49,7 +59,7 @@ const resolveRouteTitle = () => {
}
const routeName = typeof route.name === "string" ? route.name : "";
const knownKeys = routeName ? (routeTitleI18nKeys[routeName] || []) : [];
const knownKeys = routeName ? routeTitleI18nKeys[routeName] || [] : [];
for (const key of knownKeys) {
if (te(key)) return t(key);
}
@@ -63,6 +73,13 @@ const updateDocumentTitle = () => {
};
const shouldRenderRequestQueueProgress = computed(() => hasStoredSessionToken());
const shouldRenderReleaseChannelUnavailable = computed(() => releaseChannelUnavailableStatus.value.shouldBlock);
const shouldRenderReleaseChannelSwitched = computed(
() => !shouldRenderReleaseChannelUnavailable.value && releaseChannelSwitchedStatus.value.shouldShow
);
const shouldHideGlobalOverlays = computed(
() => shouldRenderReleaseChannelUnavailable.value || shouldRenderReleaseChannelSwitched.value
);
const initiateStoredSession = async () => {
if (!hasStoredSessionToken()) {
@@ -78,19 +95,24 @@ const initiateStoredSession = async () => {
};
useHead({
link: [
{ rel: 'icon', type: 'image/x-icon', href: favicon }
]
link: [{ rel: "icon", type: "image/x-icon", href: favicon }],
});
onMounted(() => {
startReleaseChannelAvailabilityClock();
void initiateStoredSession();
});
onBeforeUnmount(() => {
stopReleaseChannelAvailabilityClock();
});
watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
</script>
<template>
<LayoutV2 v-if="route.meta?.template === 'with-header'">
<ReleaseChannelUnavailable v-if="shouldRenderReleaseChannelUnavailable" />
<ReleaseChannelSwitched v-else-if="shouldRenderReleaseChannelSwitched" />
<LayoutV2 v-else-if="route.meta?.template === 'with-header'">
<router-view />
</LayoutV2>
<main v-else-if="route.meta?.template === 'clear-main'">
@@ -100,13 +122,12 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
<header></header>
<main>
<DefaultPageWrapper>
<VersionCheck/>
<router-view />
</DefaultPageWrapper>
</main>
</template>
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress" />
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
</template>
<style scoped>
</style>
<style scoped></style>
+3
View File
@@ -760,6 +760,7 @@
.pos-step-one-insights {
display: grid;
align-items: stretch;
gap: 0.9rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
}
@@ -903,6 +904,7 @@
.pos-step-one-insight-card__section {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0.55rem;
min-width: 0;
@@ -970,6 +972,7 @@
.pos-step-one-empty-state,
.pos-step-one-loading-state {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
min-height: 4rem;
-3
View File
@@ -74,10 +74,7 @@ watch(locale, (newLocale) => {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background-color: transparent;
border: 1.5px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
color: #fff;
cursor: pointer;
font-size: 14px;
@@ -1,18 +1,10 @@
<script setup>
import CustomerComplaintsPagination from "@/components/displays/pagination/models/SuperUserDashboard/CustomerComplaintsPagination.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
</script>
<template>
<div data-testid="superuser-complaints-page">
<PageTitle
:title="t('superuser.pages.complaints.title')"
:subtitle="t('superuser.pages.complaints.subtitle')"
/>
<CustomerComplaintsPagination auto-load="true" />
<CustomerComplaintsPagination :auto-load="true" />
</div>
</template>
+2 -13
View File
@@ -1,26 +1,15 @@
<script setup>
import { ref } from 'vue'
import { getDepartmentListData } from "@/components/session/Session.vue";
import { showCreateDepartmentForm } from "@/components/forms/superUser/createDepartmentForm.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import DepartmentsPagination from "@/components/displays/pagination/models/SuperUserDashboard/DepartmentsPagination.vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const tableData = ref([]);
getDepartmentListData().then((response) => {
tableData.value = response.data.data;
});
const redirect = (path) => {
window.location = path;
}
</script>
<template>
<div>
<PageTitle :title="t('superuser.pages.departments.title')" :subtitle="t('superuser.pages.departments.subtitle')">
<PageTitle :title="t('common.departments')" :subtitle="t('superuser.pages.departments.subtitle')">
<template #buttons>
<button class="button is-dark" @click="showCreateDepartmentForm">
<span class="icon">
@@ -36,4 +25,4 @@ const redirect = (path) => {
<style scoped>
</style>
</style>
+1 -1
View File
@@ -11,7 +11,7 @@ const { t } = useI18n();
<template>
<div>
<PageTitle :title="t('superuser.pages.products.title')" :subtitle="t('superuser.pages.products.subtitle')">
<PageTitle :title="t('common.products')" :subtitle="t('superuser.pages.products.subtitle')">
<template #buttons>
<button class="button is-dark" @click="SessionUser.objects.products.showCreateObjectForm(loadList)">
<span class="icon">
+33 -7
View File
@@ -6,15 +6,35 @@ import SubusersPagination from "@/components/displays/pagination/models/SuperUse
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue";
const props = defineProps({
endpoint: {
type: String,
default: "/subusers",
},
showCustomer: {
type: Boolean,
default: false,
},
superuserPage: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const canInvite = computed(() => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_ADD"));
const canInvite = computed(() =>
props.superuserPage
? SessionUser.canAccessSuperUser()
: SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_ADD")
);
const requiresGrantSelection = computed(
() => SessionUser.isSubuser.value && !SessionUser.subuser.selectedGrantCustomerNumber.value
() => !props.superuserPage && SessionUser.isSubuser.value && !SessionUser.subuser.selectedGrantCustomerNumber.value
);
const paginationVersion = ref(0);
const paginationKey = computed(() =>
SessionUser.isSubuser.value
props.superuserPage
? `subusers-superuser-${paginationVersion.value}`
: SessionUser.isSubuser.value
? `subusers-${SessionUser.subuser.selectedGrantCustomerNumber.value || "none"}-${paginationVersion.value}`
: `subusers-user-${paginationVersion.value}`
);
@@ -22,13 +42,13 @@ const paginationKey = computed(() =>
const onInviteClick = async () => {
await SessionUser.objects.subusers.functions.showInviteForm(() => {
paginationVersion.value += 1;
});
}, { superuser: props.superuserPage });
};
</script>
<template>
<div>
<PageTitle :title="t('superuser.pages.subusers.title')" :subtitle="t('superuser.pages.subusers.subtitle')">
<PageTitle :title="SessionUser.objects.subusers.meta.title" :subtitle="t('superuser.pages.subusers.subtitle')">
<template #buttons>
<button v-if="canInvite" class="button is-dark" type="button" @click="onInviteClick">
<span class="icon">
@@ -39,7 +59,7 @@ const onInviteClick = async () => {
</template>
</PageTitle>
<div v-if="SessionUser.isSubuser.value" class="mb-5">
<div v-if="SessionUser.isSubuser.value && !superuserPage" class="mb-5">
<SubuserGrantSelector />
<p class="help">
Vælg den kunde, du vil administrere chauffører for. Listen og rettighederne følger det valgte kundenummer.
@@ -50,7 +70,13 @@ const onInviteClick = async () => {
Vælg først en kunde for at se og administrere chauffører.
</div>
<SubusersPagination v-else :key="paginationKey" auto-load="true" />
<SubusersPagination
v-else
:key="paginationKey"
:endpoint="endpoint"
:show-customer="showCustomer"
auto-load="true"
/>
</div>
</template>
@@ -51,6 +51,10 @@ const props = defineProps({
hasBorder: {
type: Boolean,
default: false
},
headerTestId: {
type: String,
default: ""
}
});
/**
@@ -95,7 +99,7 @@ watch(() => props.forceState, (newVal) => {
</div>
</div>
<!-- Default slot for header -->
<div class="card-header is-radiusless" @click="toggle" :class="{ 'is-clickable': props.toggleable }" v-if="$slots.header && !props.hideHeader">
<div class="card-header is-radiusless" @click="toggle" :class="{ 'is-clickable': props.toggleable }" v-if="$slots.header && !props.hideHeader" :data-testid="props.headerTestId || undefined">
<slot name="header"></slot>
</div>
<!-- Default slot for any content -->
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,10 @@ const props = defineProps({
icon: String,
label: String,
disabled: Boolean,
testId: {
type: String,
default: "",
},
template: String // The style of the button (default, danger, success, warning, info, light)
});
const emit = defineEmits(['selected']);
@@ -131,6 +135,7 @@ const getLabelColor = () => {
@click.stop.prevent="click"
:class="{'is-disabled': isDisabled()}"
:disabled="isDisabled()"
:data-testid="props.testId || undefined"
>
<span class="icon">
<i :class="getIcon() + ' ' + getIconColor()"></i>
@@ -119,9 +119,25 @@ const formatDateInputValue = (date: Date) => {
return "";
}
return date.toISOString().split("T")[0];
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const parseDateInputValue = (value: string) => {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return new Date(value);
}
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
};
const isSameDateInputValue = (left: Date, right: Date) => (
formatDateInputValue(left) === formatDateInputValue(right)
);
const updateSelection = () => {
emits("update:selection", { startDate: startDate.value, endDate: endDate.value });
props.onSelectionChange(startDate.value, endDate.value);
@@ -177,25 +193,38 @@ const isSelectionValid = computed(() => (
));
const handleStartDateChange = (event) => {
const newDate = new Date(event.target.value);
const newDate = parseDateInputValue(event.target.value);
if (Number.isNaN(newDate.getTime())) {
console.warn("Invalid start date:", event.target.value);
return;
}
if (isSameDateInputValue(newDate, startDate.value)) {
return;
}
applyDateRange(newDate, endDate.value);
};
const handleEndDateChange = (event) => {
const newDate = new Date(event.target.value);
const newDate = parseDateInputValue(event.target.value);
if (Number.isNaN(newDate.getTime())) {
console.warn("Invalid end date:", event.target.value);
return;
}
if (isSameDateInputValue(newDate, endDate.value)) {
return;
}
applyDateRange(startDate.value, newDate);
};
const getFullMonthRange = (year: number, month: number): DateRange => ({
startDate: new Date(year, month - 1, 1, 0, 0, 0, 0),
endDate: new Date(year, month, 0, 23, 59, 59, 999),
});
const setMonth = (month: number) => {
if (month < 1 || month > 12) {
console.warn("Invalid month selection:", month);
@@ -203,9 +232,8 @@ const setMonth = (month: number) => {
}
const year = startDate.value.getFullYear();
const startOfMonth = new Date(year, month - 1, 1);
const endOfMonth = new Date(year, month, 0);
applyDateRange(startOfMonth, endOfMonth);
const range = getFullMonthRange(year, month);
applyDateRange(range.startDate, range.endDate);
};
const handleMonthChange = (event) => {
@@ -217,6 +245,10 @@ const handleMonthChange = (event) => {
setMonth(month);
};
const setCurrentSelectionEntireMonth = () => {
setMonth(currentMonthSelection.value);
};
const handleYearChange = (event) => {
const year = parseInt(event.target.value, 10);
if (Number.isNaN(year)) {
@@ -307,24 +339,14 @@ const shortcuts = computed<Shortcut[]>(() => {
label: SessionUser.objects.global.language.text.this_month,
getRange: () => {
const today = new Date();
const firstDayOfThisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const lastDayOfThisMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return {
startDate: new Date(firstDayOfThisMonth.setHours(23, 59, 59, 999)),
endDate: new Date(lastDayOfThisMonth.setHours(23, 59, 59, 999)),
};
return getFullMonthRange(today.getFullYear(), today.getMonth() + 1);
},
},
{
label: SessionUser.objects.global.language.text.last_month,
getRange: () => {
const today = new Date();
const firstDayOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const lastDayOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
return {
startDate: new Date(firstDayOfLastMonth.setHours(23, 59, 59, 999)),
endDate: new Date(lastDayOfLastMonth.setHours(23, 59, 59, 999)),
};
return getFullMonthRange(today.getFullYear(), today.getMonth());
},
},
{
@@ -346,16 +368,7 @@ const shortcuts = computed<Shortcut[]>(() => {
label: SessionUser.objects.global.language.text.same_month_last_year,
getRange: () => {
const today = new Date();
const firstDayOfThisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const lastDayOfThisMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
const firstDayOfLastYearSameMonth = new Date(firstDayOfThisMonth);
firstDayOfLastYearSameMonth.setFullYear(firstDayOfThisMonth.getFullYear() - 1);
const lastDayOfLastYearSameMonth = new Date(lastDayOfThisMonth);
lastDayOfLastYearSameMonth.setFullYear(lastDayOfThisMonth.getFullYear() - 1);
return {
startDate: new Date(firstDayOfLastYearSameMonth.setHours(23, 59, 59, 999)),
endDate: new Date(lastDayOfLastYearSameMonth.setHours(23, 59, 59, 999)),
};
return getFullMonthRange(today.getFullYear() - 1, today.getMonth() + 1);
},
},
];
@@ -402,7 +415,7 @@ const handleShortcutSelection = (event) => {
:disabled="props.isDisabled || props.isReadonly"
@change="handleShortcutSelection"
>
<option value="" disabled>Vaelg periode</option>
<option value="" disabled>Vælg periode</option>
<option v-for="shortcut in shortcuts" :key="shortcut.label" :value="shortcut.label">
{{ shortcut.label }}
</option>
@@ -419,6 +432,7 @@ const handleShortcutSelection = (event) => {
class="input"
data-testid="date-period-start"
:value="formatDateInputValue(startDate)"
@input="handleStartDateChange"
@change="handleStartDateChange"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
@@ -434,6 +448,7 @@ const handleShortcutSelection = (event) => {
class="input"
data-testid="date-period-end"
:value="formatDateInputValue(endDate)"
@input="handleEndDateChange"
@change="handleEndDateChange"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
@@ -494,9 +509,10 @@ const handleShortcutSelection = (event) => {
</div>
</div>
<div class="date-period-selector__mobile-actions" v-if="visibility.showUpdateButton">
<div class="date-period-selector__mobile-actions">
<slot name="right"></slot>
<button
v-if="visibility.showUpdateButton"
class="button is-primary is-fullwidth"
type="button"
@click="updateSelection"
@@ -516,6 +532,7 @@ const handleShortcutSelection = (event) => {
class="input"
data-testid="date-period-start"
:value="formatDateInputValue(startDate)"
@input="handleStartDateChange"
@change="handleStartDateChange"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
@@ -530,6 +547,7 @@ const handleShortcutSelection = (event) => {
class="input"
data-testid="date-period-end"
:value="formatDateInputValue(endDate)"
@input="handleEndDateChange"
@change="handleEndDateChange"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
@@ -648,7 +666,7 @@ const handleShortcutSelection = (event) => {
iconPack="fas"
has-icon
>
Selection is not an entire month <span class="has-text-weight-bold is-clickable" @click="setMonth(currentMonthSelection)">Click to set entire month</span>.
Selection is not an entire month <span class="has-text-weight-bold is-clickable" data-testid="date-period-set-entire-month" @click="setCurrentSelectionEntireMonth">Click to set entire month</span>.
When a partial month selection is made, it may not accurately represent the intended time period. Consider selecting an entire month for more precise results.
</b-message>
</div>
@@ -28,10 +28,10 @@ if (departments.value.length === 0) {
<th>{{ $t('objects.columns.department') }}</th>
<th>{{ $t('tables.bookings.reg_tractor') }}</th>
<th>{{ $t('tables.bookings.reg_trailer') }}</th>
<th>{{ $t('objects.columns.reference') }}</th>
<th>{{ $t('common.reference') }}</th>
<th>{{ $t('objects.columns.notes') }}</th>
<th>{{ $t('objects.columns.date') }}</th>
<th>{{ $t('objects.columns.status') }}</th>
<th>{{ $t('common.date') }}</th>
<th>{{ $t('common.status') }}</th>
<th>{{ $t('objects.bookings.columns.contact_email') }}</th>
<th>{{ $t('objects.bookings.columns.wash_certificate_email') }}</th>
<th>{{ $t('tables.actions') }}</th>
@@ -53,7 +53,7 @@ if (departments.value.length === 0) {
<td>{{ booking.washCertificateEmail }}</td>
<td>
<div class="buttons">
<button class="button is-small is-dark" @click="redirectDepartmentOrderPage(booking.id, booking.department_id)">{{ $t('global.show') }}</button>
<button class="button is-small is-dark" @click="redirectDepartmentOrderPage(booking.id, booking.department_id)">{{ $t('common.show') }}</button>
</div>
</td>
</tr>
@@ -94,6 +94,7 @@ const modules = computed(() => [
},
count: 0,
hasSwitch: true,
switchLoading: isLoadingSelfServeEnabled.value || isSavingSelfServeEnabled.value,
switchValue: selfServeEnabled,
switchDisabled: isLoadingSelfServeEnabled.value || isSavingSelfServeEnabled.value,
onToggle: async (newValue) => {
@@ -118,8 +119,8 @@ const modules = computed(() => [
},
{
id: 6,
name: 'Self-Serve Studio',
description: 'Configure questions, conditions, rules and tasks',
name: 'Døgnvask konfiguration',
description: 'Gå til konfiguration af Døgnvask',
icon: "fas fa-project-diagram",
onClick: () => {
router.push(buildDepartmentModulePath('/modules/self-serve/studio'));
@@ -225,9 +226,17 @@ setInterval(() => {
<i :class="module.icon"></i>
</span>
</div>
<p class="card-header-title">{{ module.name }}</p>
<div class="is-pulled-right card-header-title pr-0">
<p class="card-header-title department-module-card__header-title">{{ module.name }}</p>
<div class="department-module-card__actions" data-testid="department-module-card-actions">
<span class="button is-small is-dark department-module-card__count" @click.stop="module.onClickCount" v-if="module.count > 0">{{ module.count }}</span>
<span
v-else-if="module?.switchLoading"
class="tag is-rounded is-small department-module-card__state-loading"
data-testid="department-module-self-serve-state-loading"
role="status"
:aria-label="t('global.loading')"
@click.stop
></span>
<span class="button is-small is-outlined department-module-card__switch" v-else-if="module?.hasSwitch" @click.stop>
<b-switch
size="is-small"
@@ -253,19 +262,72 @@ setInterval(() => {
height: 100%;
}
.department-module-card .card-header-title {
.department-module-card__header-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.department-module-card__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
margin-left: auto;
padding-right: 0.75rem;
}
.department-module-card__count,
.department-module-card__switch {
.department-module-card__switch,
.department-module-card__state-loading {
position: relative;
z-index: 1;
}
.department-module-card__state-loading {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
min-height: 1.25rem;
padding: 0 0.5rem !important;
background-color: #e5e7eb !important;
border: 1px solid #d1d5db !important;
color: transparent !important;
background-image:
radial-gradient(circle, rgba(156, 163, 175, 0.95) 0.16rem, transparent 0.18rem),
radial-gradient(circle, rgba(156, 163, 175, 0.95) 0.16rem, transparent 0.18rem),
radial-gradient(circle, rgba(156, 163, 175, 0.95) 0.16rem, transparent 0.18rem);
background-repeat: no-repeat;
background-size: 0.4rem 0.4rem;
background-position:
calc(50% - 0.5rem) 50%,
50% 50%,
calc(50% + 0.5rem) 50%;
animation: department-module-state-loading-pulse 1.1s ease-in-out infinite;
}
@keyframes department-module-state-loading-pulse {
0%,
100% {
opacity: 0.68;
transform: scale(0.98);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.department-module-card__state-loading {
animation: none;
}
}
@media (max-width: 768px) {
.department-module-card__title {
display: none;
@@ -70,7 +70,7 @@ const hasUpdatePermission = () => {
:editFunction="su_object.showEditObjectFieldForm"
column="enabled"
:parse-function="(value) => {
return value ? t('global.yes') : t('global.no');
return value ? t('common.yes') : t('common.no');
}"
:permission-check-function="hasUpdatePermission"
/>
@@ -1,7 +1,16 @@
<script setup>
import { watch } from 'vue';
import { useRouter } from "vue-router";
import { getCurrentStep, setDepartment, getOrderId, setStep, setOrderId, searchAndSelectCustomer, loadOrderItems } from "@/components/shop/POSDepartmentProcess.vue";
import {
clearActivePosOrderContext,
getCurrentStep,
setDepartment,
getOrderId,
setStep,
setOrderId,
searchAndSelectCustomer,
loadOrderItems,
} from "@/components/shop/POSDepartmentProcess.vue";
import PosDepartmentStep1 from "@/components/displays/department/pos/steps/PosDepartmentStep1.vue";
import PosDepartmentStep2 from "@/components/displays/department/pos/steps/PosDepartmentStep2.vue";
import PosDepartmentStep3 from "@/components/displays/department/pos/steps/PosDepartmentStep3.vue";
@@ -41,20 +50,33 @@ const debugVehicles = () => {
}
);
};
setDepartment();
watch(() => router.currentRoute.value.params.departmentId, (nextDepartmentId) => {
if (nextDepartmentId) {
setDepartment(nextDepartmentId);
}
});
applyPosRouteSearch(window.location.search, {
const routeStateHandlers = {
setOrderId,
loadOrderItems,
setStep,
searchAndSelectCustomer,
});
clearActivePosOrderContext,
resetMobilePos: () => pos.reset.pos(),
};
const getRouteSearch = (route) => {
const fullPath = route?.fullPath || "";
const queryIndex = fullPath.indexOf("?");
return queryIndex >= 0 ? fullPath.slice(queryIndex) : "";
};
const applyCurrentPosRoute = () => {
const currentRoute = router.currentRoute.value;
if (currentRoute.params.departmentId) {
setDepartment(currentRoute.params.departmentId);
} else {
setDepartment();
}
applyPosRouteSearch(getRouteSearch(currentRoute), routeStateHandlers);
};
watch(() => router.currentRoute.value.fullPath, applyCurrentPosRoute, { immediate: true });
/** Define the createOrder function */
</script>
@@ -246,7 +246,7 @@ const getScanBadges = (scan) => {
if (!scan.seen_before) {
badges.push({
key: 'unknown',
text: t('admin.pos.status_unknown'),
text: t('common.new'),
className: 'pos-scan-badge--unknown',
});
}
@@ -560,7 +560,7 @@ setTimeout(() => {
<span class="icon">
<i class="fas fa-redo"></i>
</span>
<span>{{ $t('admin.pos.try_again') }}</span>
<span>{{ $t('common.retry') }}</span>
</button>
</div>
</article>
@@ -1,5 +1,6 @@
<script setup>
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import { showEditOrderItemForm } from "@/components/forms/superUser/editOrderItemForm.vue";
import { removeOrderItem } from "@/components/shop/OrdersItems.vue";
import { computed, ref, watch, useSlots } from "vue";
@@ -26,6 +27,11 @@ const props = defineProps({
},
});
const emits = defineEmits(["update:orderItems", "onDestroyItem"]);
const route = useRoute();
const highlightOrderItemId = computed(() => {
const parsed = Number.parseInt(String(route.query.highlightOrderItem || ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
});
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
@@ -69,6 +75,10 @@ const handleCustomerWishesFieldSaved = async (fieldKey) => {
await loadOrder();
};
const handleOrderNoteSaved = (savedValue) => {
order_notes.value = savedValue;
};
// Check if the size is large
const isLarge = props.isLarge !== undefined ? props.isLarge : false;
const isOrderDetailVariant = computed(() => props.variant === "order-detail");
@@ -502,6 +512,7 @@ const deleteOrderItem = async (orderItemId) => {
:total-price="totalPrice"
:show-actions="Boolean(editPossible)"
:can-edit-order-items="canEditOrderItems"
:highlight-order-item-id="highlightOrderItemId"
@edit="openOrderItemEditor"
@delete="deleteOrderItem"
/>
@@ -518,6 +529,7 @@ const deleteOrderItem = async (orderItemId) => {
:total-price="totalPrice"
:show-actions="Boolean(editPossible)"
:can-edit-order-items="canEditOrderItems"
:highlight-order-item-id="highlightOrderItemId"
@edit="openOrderItemEditor"
@delete="deleteOrderItem"
/>
@@ -528,14 +540,14 @@ const deleteOrderItem = async (orderItemId) => {
<table v-if="false" class="table is-fullwidth pos-order-items__table">
<thead>
<tr>
<th>{{ $t("admin.pos.product") }}</th>
<th>{{ $t("common.product") }}</th>
<th style="width: 20%; text-align: right">{{ $t("admin.pos.price_dkk") }}</th>
<th
v-if="editPossible"
class="pos-order-items__actions-header"
:style="{ width: isOrderDetailVariant ? '18%' : '15%' }"
>
{{ isOrderDetailVariant ? $t("global.actions") : "" }}
{{ isOrderDetailVariant ? $t("common.actions") : "" }}
</th>
</tr>
</thead>
@@ -920,7 +932,12 @@ const deleteOrderItem = async (orderItemId) => {
data-testid="pos-order-metadata-note"
>
<p class="pos-order-metadata-label">{{ $t("admin.pos.order_note") }}</p>
<POSOrderNote v-bind:order_id="activeOrderId" v-bind:loadOrder="loadOrder" v-bind:note="order_notes" />
<POSOrderNote
v-bind:order_id="activeOrderId"
v-bind:loadOrder="loadOrder"
v-bind:note="order_notes"
v-bind:onSaved="handleOrderNoteSaved"
/>
</section>
</div>
</div>
@@ -33,6 +33,7 @@ import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentB
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
const { t } = useI18n();
const emit = defineEmits(["update:activeTab"]);
const panel_tabs = ref([
{
@@ -151,6 +152,7 @@ const rootClasses = computed(() => ({
}));
const isSidebarVariant = computed(() => props.variant === "sidebar");
const isRulesTabActive = computed(() => panel_tabs.value[1]?.active === true);
const activeTabKey = computed(() => panel_tabs.value.find((tab) => tab.active)?.key ?? "details");
const setActiveTab = (selectedIndex) => {
panel_tabs.value.forEach((tab, index) => {
@@ -171,6 +173,14 @@ const hasAttribute = (prop) => {
return customer_attributes.value.some((attribute) => attribute.attribute === prop);
};
watch(
activeTabKey,
(tabKey) => {
emit("update:activeTab", tabKey);
},
{ immediate: true }
);
watch(
isRulesTabActive,
(isActive) => {
@@ -255,52 +265,62 @@ const customer_data_has_empty_details = () => {
/>
<!-- Attributes -->
<RequiresPermission permission="list_customer_attributes">
<div
class="panel-block pos-selected-customer__row"
v-if="panel_tabs[1].active"
v-for="attribute in attributes"
:key="attribute.attribute"
>
<span class="panel-icon pos-selected-customer__icon">
<i :class="attribute.icon" aria-hidden="true"></i>
</span>
<span
class="pos-selected-customer__label"
@mouseover="showPopper(popperBox(attribute.name, attribute.description), $event.target)"
@mouseleave="removePopperIfOpen()"
>{{ attribute.name }}</span
<template v-if="panel_tabs[1].active">
<div
v-for="attribute in attributes"
:key="attribute.attribute"
class="panel-block pos-selected-customer__row"
data-testid="pos-customer-rule-row"
:data-rule-attribute="attribute.attribute"
>
<span class="pos-selected-customer__value">
<span class="panel-icon pos-selected-customer__icon">
<i :class="attribute.icon" aria-hidden="true"></i>
</span>
<span
v-if="hasAttribute(attribute.prop)"
@click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())"
class="pos-selected-customer__label"
@mouseover="showPopper(popperBox(attribute.name, attribute.description), $event.target)"
@mouseleave="removePopperIfOpen()"
>{{ attribute.name }}</span
>
<span class="field">
<input
:id="attribute.prop"
type="checkbox"
:name="attribute.prop"
class="switch"
checked="checked"
:disabled="!SessionUser.hasPermission('delete_customer_attribute')"
/>
<label :for="attribute.prop"></label>
<span class="pos-selected-customer__value">
<span
v-if="hasAttribute(attribute.prop)"
@click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())"
>
<span class="field">
<input
:id="attribute.prop"
type="checkbox"
:name="attribute.prop"
class="switch"
checked="checked"
:disabled="!SessionUser.hasPermission('delete_customer_attribute')"
/>
<label :for="attribute.prop"></label>
</span>
</span>
<span v-else @click="createAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
<span class="field">
<input
:id="attribute.prop"
type="checkbox"
:name="attribute.prop"
class="switch"
:disabled="!SessionUser.hasPermission('add_customer_attribute')"
/>
<label :for="attribute.prop"></label>
</span>
</span>
</span>
<span v-else @click="createAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
<span class="field">
<input
:id="attribute.prop"
type="checkbox"
:name="attribute.prop"
class="switch"
:disabled="!SessionUser.hasPermission('add_customer_attribute')"
/>
<label :for="attribute.prop"></label>
</span>
</span>
</span>
</div>
</div>
<div
v-if="$slots['rules-footer']"
class="pos-selected-customer__rules-footer"
data-testid="pos-customer-rules-footer"
>
<slot name="rules-footer"></slot>
</div>
</template>
</RequiresPermission>
<!-- Shortcuts -->
<template v-if="panel_tabs[2].active" v-for="shortcut in shortcuts" :key="shortcut.name">
@@ -425,6 +445,14 @@ const customer_data_has_empty_details = () => {
padding: 1rem;
}
.pos-selected-customer__rules-footer {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border-top: 1px solid #eef3f8;
}
.pos-selected-customer--sidebar .pos-selected-customer__title,
.pos-selected-customer--order-detail .pos-selected-customer__title {
font-size: 1.05rem;
@@ -121,7 +121,7 @@ const createCustomerWishField = ({
const referenceField = createCustomerWishField({
key: "reference",
label: computed(() => t("pos.order.reference")),
label: computed(() => t("common.reference")),
source: () => props.reference,
saveValue: (value) => SessionUser.objects.orders.set.reference(props.order_id, value),
testIdBase: "pos-order-customer-wishes-reference",
@@ -16,6 +16,10 @@ const props = defineProps({
loadOrder: {
type: Function,
required: true
},
onSaved: {
type: Function,
default: null
}
});
@@ -29,6 +33,11 @@ const autosave = useOrderMetadataAutosave({
await SessionUser.objects.orders.set.notes(props.order_id, value);
return value;
},
onSaved: async (savedValue, requestedValue) => {
if (typeof props.onSaved === "function") {
await props.onSaved(savedValue, requestedValue);
}
},
});
const previewValue = computed(() => autosave.draft.value);
@@ -186,7 +186,7 @@ watch(normalizedOrderId, async (nextOrderId, previousOrderId) => {
class="pos-inline-tag"
data-testid="pos-order-inline-created"
>
<span class="pos-inline-tag__label">{{ $t('pos.order.created') }}:</span>
<span class="pos-inline-tag__label">{{ $t('common.created') }}:</span>
<span class="pos-inline-tag__value">{{ resolvedCreatedAt }}</span>
</span>
</div>
@@ -19,6 +19,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
highlightOrderItemId: {
type: Number,
default: 0,
},
});
const emits = defineEmits(['edit', 'delete']);
@@ -42,6 +46,7 @@ const getRowClass = (row) => {
'pos-order-items__row--related': row.isRelated,
'pos-order-items__row--group-parent': row.hasRelatedItems,
'pos-order-items__row--group-middle': row.isRelated && !row.isLastRelatedItem,
'pos-order-items__row--highlight': Number(row?.orderItem?.id) === Number(props.highlightOrderItemId),
};
};
@@ -102,7 +107,7 @@ const getActionsCellAttrs = () => ({
>
<b-table-column
field="product"
:label="$t('admin.pos.product')"
:label="$t('common.product')"
v-slot="tableProps"
>
<div class="pos-order-items__product-cell" :data-testid="`pos-order-item-name-${tableProps.row.orderItem.id}`">
@@ -187,7 +192,7 @@ const getActionsCellAttrs = () => ({
<b-table-column
v-if="showActions"
field="actions"
:label="$t('global.actions')"
:label="$t('common.actions')"
custom-key="actions"
header-class="pos-order-items__actions-header"
:th-attrs="getActionsHeaderAttrs"
@@ -293,6 +298,11 @@ const getActionsCellAttrs = () => ({
border-bottom-color: transparent;
}
.pos-order-items__detail-table :deep(.table tbody tr.pos-order-items__row--highlight td) {
background: #fff3cd !important;
box-shadow: inset 3px 0 0 #ffdd57;
}
.pos-order-items__detail-table :deep(.table tfoot td) {
font-weight: 600;
border-top: 1px solid #dfe7f1;
@@ -116,12 +116,12 @@ const saveChanges = async () => {
>
<div class="pos-order-item-edit-modal__header">
<p class="title is-5 mb-1">{{ t('admin.pos.edit') }}</p>
<p class="is-size-7 has-text-grey mb-0">{{ t('admin.pos.product') }} #{{ orderItem.id }}</p>
<p class="is-size-7 has-text-grey mb-0">{{ t('common.product') }} #{{ orderItem.id }}</p>
</div>
<div class="columns is-multiline">
<div class="column is-12">
<label class="label" for="pos-order-item-edit-name">{{ t('admin.pos.product') }}</label>
<label class="label" for="pos-order-item-edit-name">{{ t('common.product') }}</label>
<input
id="pos-order-item-edit-name"
class="input"
@@ -133,7 +133,7 @@ const saveChanges = async () => {
</div>
<div class="column is-12-mobile is-6-tablet">
<label class="label" for="pos-order-item-edit-price">{{ t('global.price') }}</label>
<label class="label" for="pos-order-item-edit-price">{{ t('common.price') }}</label>
<input
id="pos-order-item-edit-price"
v-model="form.price"
@@ -147,7 +147,7 @@ const saveChanges = async () => {
</div>
<div class="column is-12-mobile is-6-tablet">
<label class="label" for="pos-order-item-edit-quantity">{{ t('global.quantity') }}</label>
<label class="label" for="pos-order-item-edit-quantity">{{ t('common.quantity') }}</label>
<input
id="pos-order-item-edit-quantity"
v-model="form.quantity"
@@ -173,7 +173,7 @@ const saveChanges = async () => {
</div>
<div class="column is-12">
<label class="label" for="pos-order-item-edit-reference">{{ t('objects.columns.reference') }}</label>
<label class="label" for="pos-order-item-edit-reference">{{ t('common.reference') }}</label>
<input
id="pos-order-item-edit-reference"
v-model="form.reference"
@@ -21,6 +21,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
highlightOrderItemId: {
type: Number,
default: 0,
},
});
const emits = defineEmits(['edit', 'delete']);
@@ -44,6 +48,7 @@ const getRowClass = (row) => {
'pos-order-items__row--related': row.isRelated,
'pos-order-items__row--group-parent': row.hasRelatedItems,
'pos-order-items__row--group-middle': row.isRelated && !row.isLastRelatedItem,
'pos-order-items__row--highlight': Number(row?.orderItem?.id) === Number(props.highlightOrderItemId),
};
};
</script>
@@ -59,7 +64,7 @@ const getRowClass = (row) => {
>
<b-table-column
field="product"
:label="$t('admin.pos.product')"
:label="$t('common.product')"
v-slot="tableProps"
>
<div class="pos-order-items__product-cell" :data-testid="`pos-order-item-name-${tableProps.row.orderItem.id}`">
@@ -156,7 +161,9 @@ const getRowClass = (row) => {
>
<div class="pos-order-items__action-cell">
<div class="buttons is-narrow">
<ActionSettingsWheelButton>
<ActionSettingsWheelButton
:flag-target="{ target_type: 'order_item', target_id: tableProps.row.orderItem.id }"
>
<template #actions>
<ActionSettingsWheelItem
:label="$t('admin.pos.delete')"
@@ -232,6 +239,11 @@ const getRowClass = (row) => {
border-bottom-color: transparent;
}
.pos-order-items__default-table :deep(.table tbody tr.pos-order-items__row--highlight td) {
background: #fff3cd !important;
box-shadow: inset 3px 0 0 #ffdd57;
}
.pos-order-items__default-table :deep(.table tfoot td) {
font-weight: 600;
}
@@ -44,6 +44,10 @@ const props = defineProps({
console.warn('setItemValue function not provided', orderItem, column, value);
},
},
highlightOrderItemId: {
type: Number,
default: 0,
},
});
const calculateTax = (price) => {
@@ -76,9 +80,9 @@ const getOrderItemBasePrice = (orderItem) => {
<tr>
<th>{{ $t('tables.products.name') }}</th>
<th>{{ $t('global.note') }}</th>
<th>{{ $t('objects.columns.reference') }}</th>
<th>{{ $t('global.quantity') }}</th>
<th style="text-align: right;">{{ $t('global.price') }}</th>
<th>{{ $t('common.reference') }}</th>
<th>{{ $t('common.quantity') }}</th>
<th style="text-align: right;">{{ $t('common.price') }}</th>
</tr>
</thead>
<tbody>
@@ -88,7 +92,13 @@ const getOrderItemBasePrice = (orderItem) => {
<div class="skeleton-lines"><div style="width: 100%;"></div></div>
</td>
</tr>
<tr v-for="orderItem in props.orderItems" :key="orderItem.id" v-else>
<tr
v-for="orderItem in props.orderItems"
:key="orderItem.id"
v-else
:id="`order-item-${orderItem.id}`"
:class="{ 'order-item-highlight': Number(orderItem.id) === Number(props.highlightOrderItemId) }"
>
<td>{{ getOrderItemProductName(orderItem) }}</td>
<td>
<input type="text" v-if="props.allowEdit" v-model="orderItem.notes" @input="props.setItemValue(orderItem, 'notes', orderItem.notes)" class="input is-small" />
@@ -111,7 +121,7 @@ const getOrderItemBasePrice = (orderItem) => {
<tfoot>
<!-- Tax -->
<tr v-if="props.taxPercentage > 0">
<td colspan="4">{{ $t('global.tax') }} ({{ props.taxPercentage }}%)</td>
<td colspan="4">{{ $t('common.tax') }} ({{ props.taxPercentage }}%)</td>
<td style="text-align: right;" v-if="props.isLoading">
<!-- Skeleton-lines 1 row -->
<div class="skeleton-lines"><div style="width: 100%;"></div></div>
@@ -147,5 +157,8 @@ const getOrderItemBasePrice = (orderItem) => {
</template>
<style scoped>
.order-item-highlight > td {
background: #fff3cd !important;
box-shadow: inset 3px 0 0 #ffdd57;
}
</style>
@@ -294,7 +294,7 @@ const uploadOrderAttachmentFile = async (file) => {
} catch (error) {
console.error("Error uploading order attachment:", error);
Swal.fire({
title: t("admin.pos.settings_wheel.error"),
title: t("common.error"),
text: t("admin.pos.attachments_upload_error"),
icon: "error",
});
@@ -6,6 +6,7 @@ const { t } = useI18n();
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import OrderAttachmentsActionButton from "@/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue";
import AssignDraftOrderCustomerModal from "@/components/displays/modals/AssignDraftOrderCustomerModal.vue";
import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue";
const props = defineProps({
orders: {
@@ -44,13 +45,19 @@ const props = defineProps({
type: Boolean,
default: false,
},
invoicePeriodFlags: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["flagStatusChanged", "flagCreated"]);
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { departments, getDepartments, isLoading, getDepartmentName } from "@/components/pagination/departmentTabs.vue";
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { orderBy, orderDirection, setOrder, loadList } from "@/components/pagination/paginatedList.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
const { orderBy, orderDirection, setOrder, loadList } = usePaginatedListInstance();
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
import InvoiceMultipleCollectionsModal from "@/components/displays/modals/InvoiceMultipleCollectionsModal.vue";
@@ -397,6 +404,20 @@ const tableHeaders = ref([
},
]);
const isTableHeaderVisible = (header) => {
return header.visible === undefined || header.visible(null) === true;
};
const invoiceCollectionSummaryColumnSpan = computed(() => {
const invoiceControlColumns =
(props.invoiceView && props.allowSelectMultiple ? 1 : 0) + (props.invoiceView ? 1 : 0);
const leadingOrderColumns = tableHeaders.value.filter(
(header) => isTableHeaderVisible(header) && !["total_net_amount", "actions"].includes(header.name)
).length;
return invoiceControlColumns + leadingOrderColumns;
});
const active_dropdown_object_ids = ref([]);
const toggleDropdown = (object) => {
// Check if the object is already in the active dropdown list
@@ -471,6 +492,18 @@ const invoiceCollectionQueueSuccess = invoiceQueue.invoiceCollectionQueueSuccess
const invoiceCollectionQueue = invoiceQueue.invoiceCollectionQueue;
const collectionQueueLog = invoiceQueue.collectionQueueLog;
const isInvoiceMultipleCollectionsModalOpen = ref(false);
const isSuperuserInvoicesRoute = computed(() => (
route.path === "/superuser/invoices" || route.path.startsWith("/superuser/invoices/")
));
const legacyInvoiceProgressCount = computed(() => (
invoiceCollectionQueueSuccess.value.length
+ invoiceCollectionQueueFailed.value.length
+ invoiceCollectionQueueInProgress.value.length
+ invoiceCollectionQueue.value.length
));
const shouldShowLegacyInvoiceProgressBar = computed(() => {
return !isSuperuserInvoicesRoute.value && legacyInvoiceProgressCount.value > 0;
});
const isInvoiceQueueBusy = computed(() => {
return invoiceCollectionQueue.value.length > 0 || invoiceCollectionQueueInProgress.value.length > 0;
});
@@ -666,6 +699,66 @@ const canSeePoField = () => {
//return (SessionUser.hasAttribute('usePONumbers') || SessionUser.hasPermission('department'))
};
const sortInvoicePeriodFlags = (flags = []) => [...flags].sort((left, right) => {
const sourceOrder = { manual: 0, automatic: 1 };
const leftSource = sourceOrder[left?.source] ?? 99;
const rightSource = sourceOrder[right?.source] ?? 99;
if (leftSource !== rightSource) {
return leftSource - rightSource;
}
return String(left?.created_at || left?.fingerprint || "").localeCompare(
String(right?.created_at || right?.fingerprint || "")
);
});
const isActiveInvoicePeriodFlag = (flag) => String(flag?.status || "active") === "active";
const getOrderLineFlags = (order) => sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
if (!isActiveInvoicePeriodFlag(flag)) {
return false;
}
const targetType = String(flag?.target_type || "");
if (!["order", "order_field"].includes(targetType)) {
return false;
}
const flagOrderId = Number(flag?.order_id || flag?.target_id || 0);
return flagOrderId > 0 && flagOrderId === Number(order?.id || 0);
}));
const getOrderItemLineFlags = (order) => sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
if (!isActiveInvoicePeriodFlag(flag)) {
return false;
}
const targetType = String(flag?.target_type || "");
if (!["order_item", "order_item_field"].includes(targetType)) {
return false;
}
return Number(flag?.order_id || flag?.context?.order_id || 0) === Number(order?.id || 0);
}));
const getOrderContainedFlags = (order) => sortInvoicePeriodFlags([
...getOrderLineFlags(order),
...getOrderItemLineFlags(order),
]);
const getOrderIndicatorIconClass = (order) => {
if (getOrderContainedFlags(order).length > 0) {
return "fas fa-flag";
}
return isPendingHandheld(order) ? "fas fa-mobile-alt" : "fas fa-circle";
};
const getOrderIndicatorColorClass = (order) => {
const flags = getOrderContainedFlags(order);
if (flags.length === 0) {
return getOrderInvoiceStatusBarColor(order);
}
return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning";
};
const emitFlagStatusChanged = (flag) => emit("flagStatusChanged", flag);
const emitFlagCreated = (flag) => emit("flagCreated", flag);
/**
* Order actions modal
*/
@@ -764,7 +857,7 @@ const formatCashierName = (order) => {
@click="invoiceSelectedCollections()"
:disabled="selectedInvoiceCollections.length === 0 || isInvoiceQueueBusy"
>
{{ $t("global.invoice") }} {{ $t("global.selected_multiple") }} ({{
{{ $t("common.invoice") }} {{ $t("global.selected_multiple") }} ({{
selectedInvoiceCollections.length
}})
</button>
@@ -777,8 +870,8 @@ const formatCashierName = (order) => {
'is-dark': isInvoiceCollectionSelectedAll(),
}"
>
{{ isInvoiceCollectionSelectedAll() ? $t("global.unselect") : $t("global.select") }}
{{ $t("global.all").toLowerCase() }}
{{ isInvoiceCollectionSelectedAll() ? $t("global.unselect") : $t("common.select") }}
{{ $t("common.all").toLowerCase() }}
</button>
<!-- Expand / Collapse all invoice collections -->
<button
@@ -790,7 +883,7 @@ const formatCashierName = (order) => {
}"
>
{{ isAutoExpandAll() ? $t("global.collapse") : $t("global.expand") }}
{{ $t("global.all").toLowerCase() }}
{{ $t("common.all").toLowerCase() }}
</button>
</div>
</td>
@@ -847,8 +940,11 @@ const formatCashierName = (order) => {
isFirstOrderInCollection(order.invoice_collection_id, order)
"
>
<tr>
<td colspan="13">
<tr :data-testid="`pos-order-invoice-collection-row-${order.invoice_collection_id}`">
<td
:colspan="invoiceCollectionSummaryColumnSpan"
:data-testid="`pos-order-invoice-collection-summary-${order.invoice_collection_id}`"
>
<div class="has-text-weight-bold">
Faktura samling ID: {{ order.invoice_collection_id }}
<span class="has-text-grey"
@@ -860,8 +956,10 @@ const formatCashierName = (order) => {
>
</div>
</td>
<!-- Spacer -->
<td colspan="2">
<td
class="has-text-right"
:data-testid="`pos-order-invoice-collection-total-${order.invoice_collection_id}`"
>
<!-- Total net amount for the invoice collection -->
<span class="has-text-weight-bold">
{{
@@ -927,7 +1025,11 @@ const formatCashierName = (order) => {
</template>
<template v-if="props.invoiceView">
<td class="is-narrow">
<button class="button is-small" @click="toggleDropdown(order)">
<button
class="button is-small"
:data-testid="`pos-order-expand-${order.id}`"
@click="toggleDropdown(order)"
>
<span
class="icon"
:class="isDropdownActive(order) ? 'has-text-link' : ''"
@@ -941,8 +1043,8 @@ const formatCashierName = (order) => {
</template>
<td>
<ColorIndicator
v-bind:color_class="getOrderInvoiceStatusBarColor(order)"
v-bind:icon_class="isPendingHandheld(order) ? 'fas fa-mobile-alt' : 'fas fa-circle'"
v-bind:color_class="getOrderIndicatorColorClass(order)"
v-bind:icon_class="getOrderIndicatorIconClass(order)"
v-bind:is_narrow="true"
v-bind:label="{
text: order.id,
@@ -1158,9 +1260,15 @@ const formatCashierName = (order) => {
v-bind:user_id="order.user_id"
v-bind:order_id="order.id"
v-bind:invoice_collection_id="order.invoice_collection_id"
v-bind:customer_number="order.customer_id"
v-bind:department_id="order.department_id"
v-bind:reg_1="order.reg_1"
v-bind:reg_2="order.reg_2"
v-bind:reg_3="order.reg_3"
v-bind:order_booking_id="order.booking_id"
:refreshFunction="loadList"
@deleted="loadList()"
@flag-created="emitFlagCreated"
:data-testid="`pos-order-list-settings-${order.id}`"
>
<template v-slot:actions>
@@ -1185,13 +1293,29 @@ const formatCashierName = (order) => {
</div>
</td>
</tr>
<tr
v-if="getOrderLineFlags(order).length > 0"
:data-testid="`pos-order-flags-row-${order.id}`"
>
<td colspan="100%">
<InvoicingPeriodFlagList
compact
:flags="getOrderLineFlags(order)"
@statusChanged="emitFlagStatusChanged"
/>
</td>
</tr>
<!-- Dropdown content (if the invoice view is enabled,and the dropdown is active) -->
<tr v-if="props.invoiceView && isDropdownActive(order)">
<!-- Order line items -->
<td colspan="100%">
<!-- Content -->
<div class="content">
<OrderContentTable :order-id="order.id" />
<OrderContentTable
:order-id="order.id"
:invoice-period-flags="getOrderItemLineFlags(order)"
@flagStatusChanged="emitFlagStatusChanged"
/>
</div>
</td>
</tr>
@@ -1347,6 +1471,11 @@ const formatCashierName = (order) => {
</div>
</div>
</div>
<InvoicingPeriodFlagList
compact
:flags="getOrderLineFlags(order)"
@statusChanged="emitFlagStatusChanged"
/>
</template>
<template #content>
<div>
@@ -1377,9 +1506,15 @@ const formatCashierName = (order) => {
v-bind:user_id="order.user_id"
v-bind:order_id="order.id"
v-bind:invoice_collection_id="order.invoice_collection_id"
v-bind:customer_number="order.customer_id"
v-bind:department_id="order.department_id"
v-bind:reg_1="order.reg_1"
v-bind:reg_2="order.reg_2"
v-bind:reg_3="order.reg_3"
v-bind:order_booking_id="order.booking_id"
:refreshFunction="loadList"
@deleted="loadList()"
@flag-created="emitFlagCreated"
:displayActionsDirectly="true"
>
<template #actions></template>
@@ -1392,7 +1527,11 @@ const formatCashierName = (order) => {
</div>
<!-- Order line items -->
<div class="content" v-if="props.invoiceView && isAutoExpandAll()">
<OrderContentTable :order-id="order.id" />
<OrderContentTable
:order-id="order.id"
:invoice-period-flags="getOrderItemLineFlags(order)"
@flagStatusChanged="emitFlagStatusChanged"
/>
</div>
</div>
</template>
@@ -1457,7 +1596,47 @@ const formatCashierName = (order) => {
</div>
<!-- Orders as cards -->
<template v-for="order in getVisibleOrders" :key="order.id">
<template
v-for="order in props.groupInvoiceCollection ? getVisibleOrdersSortedByInvoiceCollection : getVisibleOrders"
:key="order.id"
>
<!-- If grouping by invoice collection is enabled, show the invoice collection id -->
<template
v-if="
props.groupInvoiceCollection &&
doesOrderHaveInvoiceCollection(order) &&
isFirstOrderInCollection(order.invoice_collection_id, order)
"
>
<div
class="column is-full"
:data-testid="`pos-order-invoice-collection-row-${order.invoice_collection_id}`"
>
<div class="message is-light mb-0">
<div
class="message-body is-flex is-justify-content-space-between is-align-items-center p-2"
:data-testid="`pos-order-invoice-collection-summary-${order.invoice_collection_id}`"
>
<div class="has-text-weight-bold">
Faktura samling ID: {{ order.invoice_collection_id }}
</div>
<div class="has-text-weight-bold">
<span :data-testid="`pos-order-invoice-collection-total-${order.invoice_collection_id}`"
>{{
SessionUser.functions.currency.toLocal(
getOrdersByInvoiceCollection(order.invoice_collection_id)
.reduce((sum, order) => sum + order.total_net_amount, 0)
.toFixed(2)
)
}}
kr.</span
>
</div>
</div>
</div>
</div>
</template>
<div class="column is-full">
<WhiteBoxCard
:forceStateFooter="true"
@@ -1468,6 +1647,7 @@ const formatCashierName = (order) => {
:has-hover-effect="true"
:hasSelectionStyle="false"
:data-testid="`pos-order-list-card-${order.id}`"
:headerTestId="`pos-order-expand-${order.id}`"
>
<template #header>
<div
@@ -1478,9 +1658,17 @@ const formatCashierName = (order) => {
<div>
<small>
<!-- ID and status icon -->
<span class="icon mr-1" :class="getOrderInvoiceStatusBarColor(order)">
<i :class="isPendingHandheld(order) ? 'fas fa-mobile-alt' : 'fas fa-circle'"></i>
</span>
<ColorIndicator
class="mr-1"
style="display: inline-flex"
v-bind:color_class="getOrderIndicatorColorClass(order)"
v-bind:icon_class="getOrderIndicatorIconClass(order)"
v-bind:is_narrow="true"
v-bind:visibility="{
icon: true,
dropdown: false,
}"
/>
{{ order.reg_1 }}<span v-if="order.reg_2">, {{ order.reg_2 }}</span
><span v-if="order.reg_3">, {{ order.reg_3 }}</span>
<span class="has-text-grey-light">#{{ order.id }}</span></small
@@ -1596,6 +1784,8 @@ const formatCashierName = (order) => {
:order-id="order.id"
:summaryMode="true"
:display-price="SessionUser.canAccessAdmin()"
:invoice-period-flags="getOrderItemLineFlags(order)"
@flagStatusChanged="emitFlagStatusChanged"
/>
</div>
<div class="column is-12 has-text-centered">
@@ -1605,7 +1795,7 @@ const formatCashierName = (order) => {
<br />
<!-- Cashier • Time ago -->
<small class="has-text-grey"
>{{ order.cashier_name }} • {{ SessionUser.functions.date.timeAgo(order.created_at) }}</small
>{{ order.cashier_name }} • {{ SessionUser.functions.date.toLocalTimeHours(order.created_at) }}</small
>
<!-- Reg 1 • Reg 2 • Reg 3 -->
<br />
@@ -1703,6 +1893,11 @@ const formatCashierName = (order) => {
</div>
</div>
</div>
<InvoicingPeriodFlagList
compact
:flags="getOrderLineFlags(order)"
@statusChanged="emitFlagStatusChanged"
/>
</template>
<template #content> </template>
<template #footer>
@@ -1765,6 +1960,7 @@ const formatCashierName = (order) => {
/>
<!-- bottom progress bar -->
<InvoicingBillingPeriodInvoiceProgressBar
v-if="shouldShowLegacyInvoiceProgressBar"
v-bind:max-progress="
invoiceCollectionQueueSuccess.length +
invoiceCollectionQueueFailed.length +
@@ -1806,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"
@@ -1813,6 +2010,7 @@ const formatCashierName = (order) => {
v-bind:department_id="selectedOrderForActionsMenu.department_id"
:refreshFunction="loadList"
@deleted="loadList()"
@flag-created="emitFlagCreated"
:displayActionsDirectly="true"
>
<template #actions></template>
@@ -15,6 +15,10 @@ import {
getCurrentStep,
clearCache,
nextStep,
copyLastWashItemsToCurrentOrder,
order_id,
customer_id,
step,
setDesktopStep1PreflightHandler,
clearDesktopStep1PreflightHandler,
} from "@/components/shop/POSDepartmentProcess.vue";
@@ -24,6 +28,7 @@ import SelectVehicleFormPOS from "@/components/forms/department/pos/SelectVehicl
import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/PosLastScannedLicensePlatesV2.vue";
import PosDesktopOrderBookingSelectorModal from "@/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue";
import PosDesktopCustomerConflictModal from "@/components/displays/department/pos/steps/elements/PosDesktopCustomerConflictModal.vue";
import PosDesktopDuplicateWarning from "@/components/displays/department/pos/steps/elements/PosDesktopDuplicateWarning.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
import {
@@ -58,6 +63,8 @@ const right_tabs = ref([
const getPreferredRailTab = () => (customer_name.value ? "customer" : "license_plates");
const forceActiveTab = ref(getPreferredRailTab());
const activeRailTab = ref(getPreferredRailTab());
const customerPanelActiveTab = ref("details");
const selectVehicleFormRef = ref(null);
const createEmptyDesktopStep1Context = () => ({
@@ -83,17 +90,28 @@ const duplicateOrders = ref([]);
const duplicateWarningKey = ref("");
const duplicateDetailsExpanded = ref(false);
const pendingNextResolution = ref(false);
const isDesktopLastWashCopying = ref(false);
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
const isDesktopStep1Active = computed(() => getCurrentStep() === 1);
const setTab = (tab) => {
forceActiveTab.value = tab;
activeRailTab.value = tab;
};
const setCustomerPanelTab = (tab) => {
customerPanelActiveTab.value = tab || "details";
};
watch(
customer_name,
(newValue) => {
forceActiveTab.value = newValue ? "customer" : "license_plates";
const nextRailTab = newValue ? "customer" : "license_plates";
forceActiveTab.value = nextRailTab;
activeRailTab.value = nextRailTab;
if (!newValue) {
customerPanelActiveTab.value = "details";
}
},
{ immediate: true }
);
@@ -249,10 +267,10 @@ const bookingSelectionObjects = computed(() => {
const contentSegments = [
`${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`,
`${t("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`,
`${t("admin.pos.order_booking_selector.reference_label")}: ${
`${t("common.reference")}: ${
getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")
}`,
`${t("admin.pos.order_booking_selector.services_label")}: ${
`${t("common.services")}: ${
getOrderBookingServiceText(booking) || t("admin.pos.not_found")
}`,
];
@@ -266,7 +284,7 @@ const bookingSelectionObjects = computed(() => {
content: contentSegments.join(" • "),
buttons: [
{
label: t("admin.pos.order_booking_selector.use_booking"),
label: t("common.select"),
action: () => handleBookingSelection(booking),
color: "primary",
testId: `pos-desktop-order-booking-use-${booking.id}`,
@@ -307,6 +325,18 @@ const isInlineDuplicateWarningVisible = computed(() => {
return duplicateWarningKey.value === getDuplicateStateKey(normalizedContext);
});
const isCustomerRulesPanelActive = computed(
() => activeRailTab.value === "customer" && customerPanelActiveTab.value === "rules"
);
const shouldShowDuplicateWarningInRulesFooter = computed(
() => isInlineDuplicateWarningVisible.value && isCustomerRulesPanelActive.value
);
const shouldShowDuplicateWarningInActionRail = computed(
() => isInlineDuplicateWarningVisible.value && !isCustomerRulesPanelActive.value
);
const shouldShowRulesFooterActions = computed(() => isCustomerRulesPanelActive.value);
const shouldShowActionRailControls = computed(() => !isCustomerRulesPanelActive.value);
const duplicateDetailsToggleLabel = computed(() =>
duplicateDetailsExpanded.value ? t("admin.pos.hide_order_details") : t("admin.pos.show_order_details")
);
@@ -604,6 +634,48 @@ const handleCustomerConflictCancel = async () => {
}
};
const pushDesktopStepTwoRoute = () => {
if (!order_id.value) {
return;
}
window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
};
const handleDesktopLastWashCopy = async (payload = {}) => {
if (isDesktopLastWashCopying.value) {
return;
}
isDesktopLastWashCopying.value = true;
try {
await desktopStep1CoordinationPromise;
desktopStep1CoordinationPromise = coordinateDesktopStep1({
reason: "copy_last_wash",
finalizeReg1Input: true,
});
const result = await desktopStep1CoordinationPromise;
if (!result.canProceed) {
return;
}
const didCopy = await copyLastWashItemsToCurrentOrder(payload?.items || [], {
sourceReference: payload?.order?.reference,
});
if (!didCopy) {
return;
}
step.value = 2;
pushDesktopStepTwoRoute();
} catch (error) {
console.error("Unable to copy last wash to current order:", error);
} finally {
isDesktopLastWashCopying.value = false;
}
};
onMounted(() => {
setDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
});
@@ -631,8 +703,10 @@ watch(
<template #default>
<SelectVehicleFormPOS
ref="selectVehicleFormRef"
:is-desktop-last-wash-copying="isDesktopLastWashCopying"
@update:desktopStep1Context="setDesktopStep1Context"
@commit:desktopStep1="handleDesktopStep1Commit"
@copy-last-wash="handleDesktopLastWashCopy"
/>
</template>
</WhiteBox>
@@ -649,7 +723,23 @@ watch(
@update:activeTab="setTab"
>
<template #customer>
<PosSelectedCustomer variant="sidebar" />
<PosSelectedCustomer variant="sidebar" @update:activeTab="setCustomerPanelTab">
<template v-if="shouldShowRulesFooterActions" #rules-footer>
<PosDesktopDuplicateWarning
v-if="shouldShowDuplicateWarningInRulesFooter"
:title="t('admin.pos.warning')"
:message="t('admin.pos.duplicate_order_warning')"
:toggle-label="duplicateDetailsToggleLabel"
:details-expanded="duplicateDetailsExpanded"
:duplicate-orders="duplicateDetailsObjects"
@toggle-details="toggleDuplicateDetails"
/>
<NextStepError class="is-fullwidth" :clear-automatically="true" :clear-delay="8000" />
<ButtonsBox class="pos-actions pos-actions--stacked">
<NextStep class="is-fullwidth" tabindex="6" />
</ButtonsBox>
</template>
</PosSelectedCustomer>
</template>
<template #license_plates>
<PosLastScannedLicensePlatesV2 />
@@ -662,62 +752,20 @@ watch(
<ButtonsBox class="pos-actions pos-actions--stacked">
<Cancel tabindex="2" class="is-fullwidth" />
</ButtonsBox>
<div class="pos-shell-actions__rail">
<div
v-if="isInlineDuplicateWarningVisible"
class="notification is-warning is-light pos-duplicate-warning"
data-testid="pos-desktop-duplicate-warning-inline"
>
<div class="pos-duplicate-warning__header">
<div class="pos-duplicate-warning__header-copy">
<span class="icon pos-duplicate-warning__icon" aria-hidden="true">
<i class="fas fa-triangle-exclamation"></i>
</span>
<p class="pos-duplicate-warning__title">{{ t("admin.pos.warning") }}</p>
</div>
<button
type="button"
class="button is-small is-warning is-light"
data-testid="pos-desktop-duplicate-warning-toggle-details"
@click="toggleDuplicateDetails"
>
{{ duplicateDetailsToggleLabel }}
</button>
</div>
<p class="pos-duplicate-warning__message">
{{ t("admin.pos.duplicate_order_warning") }}
</p>
<div
v-if="duplicateDetailsExpanded"
class="pos-duplicate-warning__details"
data-testid="pos-desktop-duplicate-warning-details-inline"
>
<div
v-for="duplicateOrder in duplicateDetailsObjects"
:key="duplicateOrder.id"
class="pos-duplicate-warning__detail-card"
:data-testid="`pos-desktop-duplicate-warning-detail-${duplicateOrder.id}`"
>
<div class="pos-duplicate-warning__detail-copy">
<p class="pos-duplicate-warning__detail-title">{{ duplicateOrder.label }}</p>
<p class="pos-duplicate-warning__detail-content">{{ duplicateOrder.content }}</p>
</div>
<div class="buttons pos-duplicate-warning__detail-actions">
<button
v-for="button in duplicateOrder.buttons"
:key="button.label"
type="button"
class="button is-small"
:class="`is-${button.color || 'dark'}`"
:data-testid="button.testId"
@click="button.action()"
>
{{ button.label }}
</button>
</div>
</div>
</div>
</div>
<div
v-if="shouldShowActionRailControls"
class="pos-shell-actions__rail"
data-testid="pos-step-1-action-rail"
>
<PosDesktopDuplicateWarning
v-if="shouldShowDuplicateWarningInActionRail"
:title="t('admin.pos.warning')"
:message="t('admin.pos.duplicate_order_warning')"
:toggle-label="duplicateDetailsToggleLabel"
:details-expanded="duplicateDetailsExpanded"
:duplicate-orders="duplicateDetailsObjects"
@toggle-details="toggleDuplicateDetails"
/>
<NextStepError class="is-fullwidth" :clear-automatically="true" :clear-delay="8000" />
<ButtonsBox class="pos-actions pos-actions--stacked">
<NextStep class="is-fullwidth" tabindex="6" />
@@ -126,7 +126,7 @@ const closeModal = () => {
data-testid="pos-desktop-customer-conflict-cancel"
@click="emit('cancel')"
>
{{ t("admin.pos.customer_conflict.cancel") }}
{{ t("common.cancel") }}
</button>
<button
class="button is-primary is-light"
@@ -0,0 +1,83 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
message: {
type: String,
required: true,
},
toggleLabel: {
type: String,
required: true,
},
detailsExpanded: {
type: Boolean,
default: false,
},
duplicateOrders: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(["toggle-details"]);
</script>
<template>
<div
class="notification is-warning is-light pos-duplicate-warning"
data-testid="pos-desktop-duplicate-warning-inline"
>
<div class="pos-duplicate-warning__header">
<div class="pos-duplicate-warning__header-copy">
<span class="icon pos-duplicate-warning__icon" aria-hidden="true">
<i class="fas fa-triangle-exclamation"></i>
</span>
<p class="pos-duplicate-warning__title">{{ title }}</p>
</div>
<button
type="button"
class="button is-small is-warning is-light"
data-testid="pos-desktop-duplicate-warning-toggle-details"
@click="emit('toggle-details')"
>
{{ toggleLabel }}
</button>
</div>
<p class="pos-duplicate-warning__message">
{{ message }}
</p>
<div
v-if="detailsExpanded"
class="pos-duplicate-warning__details"
data-testid="pos-desktop-duplicate-warning-details-inline"
>
<div
v-for="duplicateOrder in duplicateOrders"
:key="duplicateOrder.id"
class="pos-duplicate-warning__detail-card"
:data-testid="`pos-desktop-duplicate-warning-detail-${duplicateOrder.id}`"
>
<div class="pos-duplicate-warning__detail-copy">
<p class="pos-duplicate-warning__detail-title">{{ duplicateOrder.label }}</p>
<p class="pos-duplicate-warning__detail-content">{{ duplicateOrder.content }}</p>
</div>
<div class="buttons pos-duplicate-warning__detail-actions">
<button
v-for="button in duplicateOrder.buttons"
:key="button.label"
type="button"
class="button is-small"
:class="`is-${button.color || 'dark'}`"
:data-testid="button.testId"
@click="button.action()"
>
{{ button.label }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -8,8 +8,13 @@ const props = defineProps({
type: [Number, String],
default: null,
},
copying: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["copy"]);
const { locale, t } = useI18n();
const order = ref(null);
@@ -78,12 +83,52 @@ const groupedOrderItems = computed(() => {
}));
});
const getOrderItemProductId = (item) => parsePositiveInteger(item?.product?.id ?? item?.product_id);
const getOrderItemQuantity = (item) => {
const parsedQuantity = Number.parseInt(String(item?.quantity ?? 1), 10);
return Number.isInteger(parsedQuantity) && parsedQuantity > 0 ? parsedQuantity : null;
};
const hasUsableOrderItems = computed(() => {
return groupedOrderItems.value.some((group) => getOrderItemProductId(group.item) && getOrderItemQuantity(group.item));
});
const isCopyDisabled = computed(() => loadState.value !== "loaded" || props.copying || !hasUsableOrderItems.value);
const copyButtonLabel = computed(() => {
const lastWashLabel = SessionUser.objects.global.language.last_wash || t("common.last_wash");
return `${SessionUser.objects.global.language.copy || t("common.copy")} ${lastWashLabel.toLowerCase()}`;
});
const copyButtonText = computed(() => (props.copying ? t("pos.copying_last_wash") : copyButtonLabel.value));
const copyButtonState = computed(() => {
if (props.copying) {
return "copying";
}
if (isCopyDisabled.value) {
return "disabled";
}
return "ready";
});
const onCopy = () => {
if (isCopyDisabled.value) {
return;
}
emit("copy", {
order: order.value,
orderId: normalizedOrderId.value,
items: orderItems.value,
});
};
const lastWashMetadata = computed(() => {
const currentOrder = order.value || {};
return [
{
key: "created",
label: t("pos.order.created"),
label: t("common.created"),
value: formatDateTime(currentOrder.created_at),
},
{
@@ -145,7 +190,7 @@ watch(normalizedOrderId, (nextOrderId, previousOrderId) => {
</div>
</dl>
<section class="pos-step-one-insight-card__section">
<section class="pos-step-one-insight-card__section" data-testid="pos-desktop-last-wash-section">
<div class="pos-step-one-insight-card__section-header">
<h4 class="pos-step-one-insight-card__section-title">Indhold</h4>
</div>
@@ -181,12 +226,15 @@ watch(normalizedOrderId, (nextOrderId, previousOrderId) => {
type="button"
class="button is-small is-light pos-step-one-copy-action"
data-testid="pos-desktop-last-wash-copy"
disabled
:data-state="copyButtonState"
:disabled="isCopyDisabled"
:aria-busy="props.copying ? 'true' : 'false'"
@click="onCopy"
>
<span class="icon is-small">
<i class="fas fa-copy" aria-hidden="true"></i>
<i :class="props.copying ? 'fas fa-spinner fa-spin' : 'fas fa-copy'" aria-hidden="true"></i>
</span>
<span>{{ `${SessionUser.objects.global.language.copy} ${SessionUser.objects.global.language.last_wash.toLowerCase()}` }}</span>
<span>{{ copyButtonText }}</span>
</button>
</template>
</section>
@@ -394,7 +394,7 @@ const closeModal = () => {
class="tag pos-desktop-order-booking-card__today-tag"
:data-testid="`pos-desktop-order-booking-today-${booking.id}`"
>
{{ t("global.time.today") }}
{{ t("common.today") }}
</span>
<span v-if="didBookingDetailFail(booking)" class="tag is-light pos-desktop-order-booking-card__tag">
{{ t("admin.pos.not_found") }}
@@ -413,6 +413,7 @@ const closeModal = () => {
:refreshFunction="emitRefreshBookings"
:icon="'fas fa-ellipsis-v'"
trigger-button-variant="text"
allow-booking-completion
>
<template #actions />
</ActionSettingsWheelButton>
@@ -430,7 +431,7 @@ const closeModal = () => {
<dd>{{ getBookingPlates(booking) || t("admin.pos.not_found") }}</dd>
</div>
<div class="pos-desktop-order-booking-card__meta-item">
<dt>{{ t("admin.pos.order_booking_selector.reference_label") }}</dt>
<dt>{{ t("common.reference") }}</dt>
<dd>{{ getBookingReference(booking) || "—" }}</dd>
</div>
<div v-if="getBookingPo(booking)" class="pos-desktop-order-booking-card__meta-item">
@@ -454,7 +455,7 @@ const closeModal = () => {
>
<div class="pos-desktop-order-booking-card__section-header">
<p class="pos-desktop-order-booking-card__section-title">
{{ t("admin.pos.order_booking_selector.services_label") }}
{{ t("common.services") }}
</p>
<div
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
@@ -475,8 +476,8 @@ const closeModal = () => {
class="pos-desktop-order-booking-card__item-preview"
>
<div class="pos-desktop-order-booking-card__item-preview-head">
<span>{{ t("global.quantity") }}</span>
<span>{{ t("admin.pos.order_booking_selector.services_label") }}</span>
<span>{{ t("common.quantity") }}</span>
<span>{{ t("common.services") }}</span>
<span>{{ t("admin.pos.price_dkk") }}</span>
</div>
<div
@@ -528,7 +529,7 @@ const closeModal = () => {
:data-testid="`pos-desktop-order-booking-use-${booking.id}`"
@click="onSelect(booking)"
>
{{ t("admin.pos.order_booking_selector.use_booking") }}
{{ t("common.select") }}
</button>
</div>
</article>
@@ -2,12 +2,21 @@
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
getOrderBookingItemPreviewRows,
getOrderBookingReferenceValue,
getOrderBookingServiceLabels,
} from "@/components/displays/department/pos/utils/orderBookingDisplay.js";
const props = defineProps({
vehicle: {
type: Object,
default: null,
},
booking: {
type: Object,
default: null,
},
});
const { t } = useI18n();
@@ -19,6 +28,8 @@ const parsePositiveInteger = (value) => {
const normalizeStatus = (value) => String(value ?? "").trim().toLowerCase();
const selectedBooking = computed(() => (props.booking?.id ? props.booking : null));
const getVehicleStatusLabel = (vehicle) => {
if (!vehicle) {
return SessionUser.objects.global.language.no_data;
@@ -78,6 +89,11 @@ const getVehicleStatusTone = (vehicle) => {
};
const vehicleTypeName = computed(() => {
const bookingPrimaryService = bookingSummaryRows.value.find((row) => !row.isAddon)?.label || bookingSummaryRows.value[0]?.label;
if (bookingPrimaryService) {
return bookingPrimaryService;
}
const vehicleTypeId = parsePositiveInteger(props.vehicle?.type);
if (!vehicleTypeId) {
return SessionUser.objects.global.language.no_data;
@@ -105,17 +121,24 @@ const vehicleMetadata = computed(() => [
{
key: "reg",
label: SessionUser.objects.orders.columns.reg_1.label,
value: String(props.vehicle?.reg ?? "").trim() || SessionUser.objects.global.language.no_data,
value:
String(selectedBooking.value?.reg_1 ?? props.vehicle?.reg ?? "").trim() ||
SessionUser.objects.global.language.no_data,
},
{
key: "customer",
label: t("admin.pos.customer"),
value: String(props.vehicle?.customer_name ?? "").trim() || SessionUser.objects.global.language.no_data,
value:
String(selectedBooking.value?.customer_name ?? props.vehicle?.customer_name ?? "").trim() ||
SessionUser.objects.global.language.no_data,
},
{
key: "reference",
label: SessionUser.objects.orders.columns.reference.label,
value: String(props.vehicle?.reference ?? "").trim() || SessionUser.objects.global.language.no_data,
value:
getOrderBookingReferenceValue(selectedBooking.value) ||
String(props.vehicle?.reference ?? "").trim() ||
SessionUser.objects.global.language.no_data,
},
{
key: "status",
@@ -124,7 +147,36 @@ const vehicleMetadata = computed(() => [
},
]);
const bookingSummaryRows = computed(() => {
if (!selectedBooking.value) {
return [];
}
const previewRows = getOrderBookingItemPreviewRows(selectedBooking.value);
if (previewRows.length > 0) {
return previewRows.map((row) => ({
key: row.key,
label: row.label,
quantity: row.quantity,
isAddon: row.depth > 0,
includeInInvoice: true,
}));
}
return getOrderBookingServiceLabels(selectedBooking.value).map((label, index) => ({
key: `booking-service-${index}`,
label,
quantity: 1,
isAddon: index > 0,
includeInInvoice: true,
}));
});
const vehicleSummaryRows = computed(() => {
if (bookingSummaryRows.value.length > 0) {
return bookingSummaryRows.value;
}
if (!props.vehicle) {
return [];
}
@@ -166,7 +218,7 @@ const hasVehicleSummaryRows = computed(() => vehicleSummaryRows.value.length > 0
<p class="pos-step-one-insight-card__eyebrow">{{ SessionUser.objects.vehicles.meta.labels.single }}</p>
<h3 class="pos-step-one-insight-card__title">{{ vehicleTypeName }}</h3>
<p class="pos-step-one-insight-card__subtitle">
{{ String(vehicle?.reg ?? "").trim() || SessionUser.objects.global.language.no_data }}
{{ String(selectedBooking?.reg_1 ?? vehicle?.reg ?? "").trim() || SessionUser.objects.global.language.no_data }}
</p>
</div>
<div class="pos-step-one-insight-card__pills">
@@ -196,7 +248,7 @@ const hasVehicleSummaryRows = computed(() => vehicleSummaryRows.value.length > 0
</div>
</dl>
<section class="pos-step-one-insight-card__section">
<section class="pos-step-one-insight-card__section" data-testid="pos-desktop-vehicle-summary-section">
<div class="pos-step-one-insight-card__section-header">
<h4 class="pos-step-one-insight-card__section-title">Ydelse og tilvalg</h4>
</div>
@@ -320,16 +320,23 @@ const noop = () => {};
.vehicle-card__media {
align-items: center;
background: rgba(255, 255, 255, 0.66);
border: 1px solid rgba(31, 91, 183, 0.12);
border-radius: 1rem;
display: flex;
flex: 0 0 5.25rem;
height: 5.25rem;
justify-content: center;
padding: 0.75rem;
width: 5.25rem;
}
.vehicle-card__image {
height: 100%;
height: auto;
max-height: 100%;
max-width: 100%;
object-fit: contain;
width: 100%;
width: auto;
}
.vehicle-card__details {
@@ -14,7 +14,7 @@ type attachment = {
image: string | null;
document: string | null;
relation: string | null;
other: string | null;
other: unknown;
src: string | null; // For document preview (e.g., PDF URL) // THIS IS NEVER STORED, JUST FOR PREVIEW PURPOSES
};
created_at: string;
@@ -22,6 +22,8 @@ type attachment = {
deleted_at: string | null;
}
const SELF_SERVE_WASH_ATTACHMENT_TYPE = 'SELF_SERVE_WASH';
const getAttachmentContent = (attachmentEntry: attachment) => {
if (!attachmentEntry.content) {
return {
@@ -43,7 +45,19 @@ const getAttachmentContent = (attachmentEntry: attachment) => {
};
const getAttachmentOtherText = (attachmentEntry: attachment) => {
return getAttachmentContent(attachmentEntry).other || '';
const other = getAttachmentContent(attachmentEntry).other;
if (typeof other === 'string') {
return other;
}
if (isSelfServeWashAttachment(attachmentEntry)) {
const customerNumber = getSelfServeWashPayload(attachmentEntry)?.customer_number;
return customerNumber
? `${t('admin.pos.settings_wheel.self_serve_wash_attachment')} #${customerNumber}`
: t('admin.pos.settings_wheel.self_serve_wash_attachment');
}
return other && typeof other === 'object' ? JSON.stringify(other) : '';
};
const props = defineProps({
attachments: {
@@ -79,6 +93,26 @@ const determineAttachmentType = (attachment: attachment): 'image' | 'document' |
return 'unknown';
};
const getSelfServeWashPayload = (attachmentEntry: attachment): Record<string, any> | null => {
const other = getAttachmentContent(attachmentEntry).other;
return other && typeof other === 'object' && (other as Record<string, any>).type === SELF_SERVE_WASH_ATTACHMENT_TYPE
? other as Record<string, any>
: null;
};
const isSelfServeWashAttachment = (attachmentEntry: attachment): boolean => {
return getSelfServeWashPayload(attachmentEntry) !== null;
};
const formatSelfServeDriver = (payload: Record<string, any>): string => {
return payload.subuser?.name || payload.subuser?.username || (payload.subuser_id ? `#${payload.subuser_id}` : '-');
};
const formatElapsedMinutes = (seconds: unknown): string => {
const parsed = Number(seconds);
return Number.isFinite(parsed) && parsed > 0 ? `${Math.ceil(parsed / 60)} min` : '-';
};
const getAttachmentTypeIcon = (attachment: attachment): string => {
const type = determineAttachmentType(attachment);
switch (type) {
@@ -309,14 +343,35 @@ const onClickAttachWashCertificate = () => {
</template>
<!-- OTHER PREVIEW -->
<template v-else-if="determineAttachmentType(attachment) === 'other' && getAttachmentContent(attachment).other">
<template v-if="isSelfServeWashAttachment(attachment)">
<div class="content is-size-7">
<p class="has-text-weight-semibold">{{ t('admin.pos.settings_wheel.self_serve_wash_attachment') }}</p>
<p>
<strong>{{ t('admin.pos.settings_wheel.self_serve_customer') }}:</strong>
#{{ getSelfServeWashPayload(attachment)?.customer_number || '-' }}
</p>
<p>
<strong>{{ t('admin.pos.settings_wheel.self_serve_driver') }}:</strong>
{{ formatSelfServeDriver(getSelfServeWashPayload(attachment) || {}) }}
</p>
<p>
<strong>{{ t('pos.license_plate') }}:</strong>
{{ getSelfServeWashPayload(attachment)?.license_plate || '-' }}
</p>
<p>
<strong>{{ t('admin.pos.settings_wheel.self_serve_elapsed') }}:</strong>
{{ formatElapsedMinutes(getSelfServeWashPayload(attachment)?.elapsed_wash_time_seconds) }}
</p>
</div>
</template>
<!-- If the other type is a URL, you can create a link -->
<template v-if="getAttachmentContent(attachment).other.startsWith('http')">
<a :href="getAttachmentContent(attachment).other" target="_blank" rel="noopener noreferrer">
<template v-else-if="typeof getAttachmentContent(attachment).other === 'string' && getAttachmentContent(attachment).other.startsWith('http')">
<a :href="String(getAttachmentContent(attachment).other)" target="_blank" rel="noopener noreferrer">
{{ getAttachmentContent(attachment).other }}
</a>
</template>
<template v-else>
<span>{{ getAttachmentContent(attachment).other }}</span>
<span>{{ getAttachmentOtherText(attachment) }}</span>
</template>
</template>
<!-- NO PREVIEW -->
@@ -326,7 +381,7 @@ const onClickAttachWashCertificate = () => {
</template>
<template #footer>
<div class="card-footer-item">
{{ t('admin.pos.created') }}: {{ new Date(attachment.created_at).toLocaleString() }}
{{ t('common.created') }}: {{ new Date(attachment.created_at).toLocaleString() }}
</div>
<a class="card-footer-item" v-if="!attachment.deleted_at" @click="onClickDelete(attachment.id)">
<!-- Delete button or action can be placed here -->
@@ -179,24 +179,23 @@ const resetAll = () => {
reset_all_values();
};
const onClickClearAllButton = () => {
const onClickClearAllButton = async () => {
// Check if the session storage contains the pos_order_id
if (localStorage.getItem("pos_order_id")) {
order_id.value = parseInt(localStorage.getItem("pos_order_id") || "0");
}
// 1. Delete the order (If any)
if (order_id.value) {
SessionUser.objects.orders.delete
.single(order_id.value)
.then(() => {
console.warn("Order deleted successfully");
})
.catch((error) => {
console.error("Error deleting order:", error);
})
.finally(() => {
resetAll();
});
try {
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(order_id.value);
if (!deleted) {
return;
}
console.warn("Order deleted successfully");
} catch (error) {
console.error("Error deleting order:", error);
}
resetAll();
} else {
resetAll();
}
@@ -294,7 +293,7 @@ const isAttachmentsVisible = () => {
</div>
<div class="column is-narrow pl-0">
<p class="is-size-7 has-text-weight-semibold">
{{ t("admin.pos.reference") }}
{{ t("common.reference") }}
</p>
</div>
</div>
@@ -1,46 +1,38 @@
<script setup lang="ts">
import {
reset_all_values,
customer_name,
nextStep,
searchAndSelectCustomer,
isCustomerSelected,
order_id,
customer_id,
step,
reg_1,
reg_2,
reg_3,
reference,
order_notes,
setDepartment,
getDepartment,
getStoredPosOrderId,
} from "@/components/shop/POSDepartmentProcess.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import { resetPos } from "../objects/PosDepartmentStepMobileFlow.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
const onClickClearAll = () => {
// 1. Delete the order (If any)
// Check localstorage for pos_order_id
if (localStorage.getItem("pos_order_id")) {
order_id.value = parseInt(localStorage.getItem("pos_order_id") || "0");
}
if (order_id.value) {
SessionUser.objects.orders.delete
.single(order_id.value)
.then(() => {
// 2. Reset the POS
resetPos();
// 3. Reset all values
reset_all_values();
})
.catch((error) => {
console.error("Error deleting order:", error);
resetPos();
reset_all_values();
});
const toPositiveInteger = (value: unknown) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const onClickClearAll = async () => {
const storedOrderId = getStoredPosOrderId();
const currentOrderId = toPositiveInteger(order_id.value);
const activeDraftOrderId = storedOrderId && currentOrderId === storedOrderId ? storedOrderId : null;
// 1. Delete only the active mobile draft order, never a historical route-loaded order.
if (activeDraftOrderId) {
try {
order_id.value = activeDraftOrderId;
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(activeDraftOrderId);
if (!deleted) {
return;
}
} catch (error) {
console.error("Error deleting order:", error);
}
}
// 2. Reset the POS
resetPos();
// 3. Reset all values
reset_all_values();
};
</script>
@@ -54,7 +46,7 @@ const onClickClearAll = () => {
>
<div class="columns is-mobile is-vcentered has-text-centered">
<!-- Clear all -->
<div class="column is-12 has-text-centered" @click="onClickClearAll()">
<div class="column is-12 has-text-centered">
<div class="columns is-mobile is-vcentered is-centered">
<div class="column is-narrow pr-1">
<span class="icon is-small">
@@ -132,7 +132,7 @@ const promptForReference = async (initialValue = "") => {
title: t("admin.pos.reference_required_title"),
text: t("admin.pos.reference_required_text"),
input: "text",
inputLabel: t("admin.pos.reference"),
inputLabel: t("common.reference"),
inputValue: initialValue,
showCancelButton: true,
confirmButtonText: t("admin.pos.confirm"),
@@ -177,7 +177,6 @@ const validateReferenceRequirements = async () => {
reference.value = enteredRef;
metadata.setReference(enteredRef);
console.warn("New reference set:", enteredRef);
onClick(); // Retry the click action
return true;
}
console.warn("Reference number is required but was not provided. Aborting operation.");
@@ -188,7 +187,7 @@ const validateReferenceRequirements = async () => {
}
};
const step1 = () => {
const step1 = async () => {
/** This function can be used to perform any specific actions for step 1 */
// Set registration numbers
reg_1.value = vehicles.vehicle_1.value?.reg || "";
@@ -202,18 +201,14 @@ const step1 = () => {
mergeSafetySeal();
// We only need to check if the customer requires reference, if the field is empty.
if (!isNonEmptyString(reference.value)) {
validateReferenceRequirements().then((isValid) => {
if (!isValid) {
// The requirements were not met, do not proceed.
return;
}
// Proceed with setting reference and notes
finalizeStep1();
});
} else {
// Reference is already filled, just finalize step 1
finalizeStep1();
const isValid = await validateReferenceRequirements();
if (!isValid) {
return false;
}
}
// Reference is resolved, persist metadata and pending attachments.
return finalizeStep1();
};
const mergeReferences = () => {
if (metadata.getReference() && metadata.getReference().length >= 1) {
@@ -246,25 +241,34 @@ const mergeSafetySeal = () => {
syncMobileSafetySealState(resolvedSafetySeal);
updateSafetySeal();
};
const finalizeStep1 = () => {
const finalizeStep1 = async () => {
mergeReferences();
mergeNotes();
mergeSafetySeal();
// Upload the attachments if there are any (And the order id has been created)
if (attachments.getBase64().length > 0 && order_id.value > 0) {
for (const attachment of attachments.getBase64()) {
uploadAttachment(attachment)
.then(() => {
console.warn("Attachment uploaded successfully");
// Remove the attachment from the list
attachments.removeBase64(attachment);
})
.catch((error: any) => {
const uploadResults = await Promise.all(
attachments.getBase64().map(async (attachment: any) => {
try {
const didUpload = await uploadAttachment(attachment);
if (didUpload) {
console.warn("Attachment uploaded successfully");
attachments.removeBase64(attachment);
}
return didUpload;
} catch (error: any) {
errors.value.push(error);
console.warn("An error occurred while uploading the attachment:", error);
});
return false;
}
})
);
if (uploadResults.some((didUpload) => didUpload === false)) {
console.warn("One or more attachments could not be uploaded before completion.");
}
}
return true;
};
const addTransactionToHistory = async (orderId: number) => {
@@ -320,10 +324,21 @@ const step2 = async () => {
const selectedBookingId = getSelectedBookingId();
const hasWashCertificateInBasket = Boolean(transactionItems.containsWashCertificate?.());
const resolvedSafetySeal = getResolvedMobileSafetySeal();
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
try {
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
syncMobileSafetySealState(resolvedSafetySeal);
await completeStep2Order({
bookingSafetySeal: resolvedSafetySeal,
markOrderCompleted: false,
});
return;
}
if (requiresBookingCompletionPopup) {
const onCompleteBookingWithSafetySeal = async ({ safetySeal = "" } = {}) => {
syncMobileSafetySealState(safetySeal);
@@ -401,12 +416,15 @@ const onClick = async () => {
// Proceed to the next step
switch (step.value) {
case 1:
step1();
if (!(await step1())) {
return;
}
const restoredOrderId = await restoreStoredPosOrderId({
validateOrder: true,
customerId: getCustomerId(),
departmentId: POSDepartmentProcess.getDepartment(),
allowCompleted: false,
syncDepartment: true,
});
if (restoredOrderId) {
console.warn("Order ID retrieved from local storage:", restoredOrderId);
@@ -424,7 +442,9 @@ const onClick = async () => {
});
break;
case 2:
step1();
if (!(await step1())) {
return;
}
await step2();
break;
case 3:
@@ -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>
@@ -4,11 +4,15 @@ import { metadata, popups } from "../objects/PosDepartmentStepMobileFlow.vue";
import { getResolvedMobileSafetySeal } from "../objects/mobileOrderCompletion.js";
import SessionUser from "@/components/session/token/SessionUser.vue";
const emit = defineEmits<{
(e: 'close'): void;
(e: "close"): void;
}>();
const popupProps = computed(() => popups.get()?.props || {});
const isSubmitting = ref(false);
type SubmitAction = "without_certificate" | "with_certificate";
const submittingAction = ref<SubmitAction | null>(null);
const isSubmitting = computed(() => submittingAction.value !== null);
const isSubmittingAction = (action: SubmitAction) => submittingAction.value === action;
const resolveExistingSafetySeal = () => {
const popupSafetySeal = popupProps.value?.safetySeal;
if (popupSafetySeal !== null && popupSafetySeal !== undefined) {
@@ -20,11 +24,17 @@ const resolveExistingSafetySeal = () => {
const safetySealNumber = ref(resolveExistingSafetySeal());
const closePopup = () => {
emit('close');
emit("close");
};
const runPopupCompletionCallback = async (callback: any, safetySeal: string) => {
isSubmitting.value = true;
const closePopupIfStillActive = () => {
if (popups.get()?.id === "complete_booking") {
closePopup();
}
};
const runPopupCompletionCallback = async (callback: any, safetySeal: string, action: SubmitAction) => {
submittingAction.value = action;
try {
await Promise.resolve(
callback({
@@ -32,26 +42,26 @@ const runPopupCompletionCallback = async (callback: any, safetySeal: string) =>
safetySeal,
})
);
closePopup();
closePopupIfStillActive();
} catch (error) {
console.warn("Failed to complete booking from popup callback:", error);
} finally {
isSubmitting.value = false;
submittingAction.value = null;
}
};
const submitBookingCompletionFallback = async (safetySeal: string) => {
isSubmitting.value = true;
const submitBookingCompletionFallback = async (safetySeal: string, action: SubmitAction) => {
submittingAction.value = action;
try {
await SessionUser.objects.order_bookings.functions.complete(
metadata.getBookingId(),
safetySeal === "" ? null : safetySeal
);
closePopup();
closePopupIfStillActive();
} catch (error) {
console.warn("Failed to complete booking from popup fallback action:", error);
} finally {
isSubmitting.value = false;
submittingAction.value = null;
}
};
@@ -62,10 +72,10 @@ const completeWithoutCertificate = async () => {
const callback = popupProps.value?.onCompleteWithoutCertificate || popupProps.value?.onComplete;
if (typeof callback === "function") {
await runPopupCompletionCallback(callback, "");
await runPopupCompletionCallback(callback, "", "without_certificate");
return;
}
await submitBookingCompletionFallback("");
await submitBookingCompletionFallback("", "without_certificate");
};
const completeWithCertificate = async () => {
@@ -76,10 +86,10 @@ const completeWithCertificate = async () => {
const normalizedSafetySeal = String(safetySealNumber.value ?? "").trim() || resolveExistingSafetySeal();
const callback = popupProps.value?.onCompleteWithCertificate || popupProps.value?.onComplete;
if (typeof callback === "function") {
await runPopupCompletionCallback(callback, normalizedSafetySeal);
await runPopupCompletionCallback(callback, normalizedSafetySeal, "with_certificate");
return;
}
await submitBookingCompletionFallback(normalizedSafetySeal);
await submitBookingCompletionFallback(normalizedSafetySeal, "with_certificate");
};
</script>
@@ -104,29 +114,22 @@ const completeWithCertificate = async () => {
<button
class="button is-light"
data-testid="pos-mobile-booking-complete-without-certificate"
:class="{ 'is-loading': isSubmitting }"
:class="{ 'is-loading': isSubmittingAction('without_certificate') }"
:disabled="isSubmitting"
@click="completeWithoutCertificate"
>
Intet vaskecertifikat
</button>
<button
class="button is-primary"
data-testid="pos-mobile-booking-complete-with-certificate"
:class="{ 'is-loading': isSubmitting }"
:disabled="isSubmitting"
@click="completeWithCertificate"
class="button is-primary"
data-testid="pos-mobile-booking-complete-with-certificate"
:class="{ 'is-loading': isSubmittingAction('with_certificate') }"
:disabled="isSubmitting"
@click="completeWithCertificate"
>
Generer vaskecertifikat
</button>
</div>
<!--<FormDisplay
:form_identifier="'GENERATE_BOOKING_WASH_CERTIFICATE'"
v-bind:validator_options="{validateBookingId: {default: metadata.getBookingId(), locked: true, visible: false}}"
:onSuccessfulSubmission="onSuccessfulCertificateSubmission"
:showSuccessSubmitAlert="false"
:showResetButton="false"
/>-->
</div>
</template>
@@ -152,7 +155,7 @@ input {
width: 100%;
height: 42px;
background: #FFFFFF;
background: #ffffff;
border-color: transparent;
border-radius: 4px;
@@ -185,8 +188,8 @@ input:focus-visible {
gap: 10px;
width: 100%;
height: 42px;
background: #FFFFFF;
border: 1px solid #A6A5A5;
background: #ffffff;
border: 1px solid #a6a5a5;
border-radius: 4px;
/* Inside auto layout */
@@ -211,7 +214,7 @@ input.is-searched {
width: 100%;
height: 42px;
background: #FFFFFF;
background: #ffffff;
border: 1px solid #000000;
border-radius: 4px;
border-bottom: none;
@@ -221,6 +224,5 @@ input.is-searched {
order: 1;
align-self: stretch;
flex-grow: 0;
}
</style>
@@ -339,7 +339,7 @@ const isBookingScheduledForToday = (booking: any) => {
class="tag booking-option__today-tag"
:data-testid="`pos-mobile-order-booking-today-${booking.id}`"
>
{{ t("global.time.today") }}
{{ t("common.today") }}
</span>
<span v-if="didBookingDetailFail(booking)" class="tag is-light booking-option__tag">
{{ t("admin.pos.not_found") }}
@@ -355,6 +355,7 @@ const isBookingScheduledForToday = (booking: any) => {
:refreshFunction="emitRefreshBookings"
:icon="'fas fa-ellipsis-v'"
trigger-button-variant="text"
allow-booking-completion
>
<template #actions />
</ActionSettingsWheelButton>
@@ -372,7 +373,7 @@ const isBookingScheduledForToday = (booking: any) => {
<dd>{{ getBookingPlates(booking) || t("admin.pos.not_found") }}</dd>
</div>
<div class="booking-option__meta-item">
<dt>{{ t("admin.pos.order_booking_selector.reference_label") }}</dt>
<dt>{{ t("common.reference") }}</dt>
<dd>{{ getBookingReference(booking) || "-" }}</dd>
</div>
<div v-if="getBookingPo(booking)" class="booking-option__meta-item">
@@ -391,7 +392,7 @@ const isBookingScheduledForToday = (booking: any) => {
:data-testid="`pos-mobile-order-booking-services-${booking.id}`"
>
<div class="booking-option__section-header">
<p class="booking-option__section-title">{{ t("admin.pos.order_booking_selector.services_label") }}</p>
<p class="booking-option__section-title">{{ t("common.services") }}</p>
<div
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
class="booking-option__section-total"
@@ -408,8 +409,8 @@ const isBookingScheduledForToday = (booking: any) => {
</div>
<div v-else-if="getBookingItemPreviewRows(booking).length > 0" class="booking-option__item-preview">
<div class="booking-option__item-preview-head">
<span>{{ t("global.quantity") }}</span>
<span>{{ t("admin.pos.order_booking_selector.services_label") }}</span>
<span>{{ t("common.quantity") }}</span>
<span>{{ t("common.services") }}</span>
<span>{{ t("admin.pos.price_dkk") }}</span>
</div>
<div
@@ -455,7 +456,7 @@ const isBookingScheduledForToday = (booking: any) => {
:data-testid="`pos-mobile-order-booking-use-${booking.id}`"
@click="onSelect(booking)"
>
{{ t("admin.pos.order_booking_selector.use_booking") }}
{{ t("common.select") }}
</button>
</div>
</article>
@@ -4,8 +4,10 @@ export type PosDepartment = {
id: number;
name: string;
description?: string;
order_priority?: number | null;
priority_order?: number | null;
branding?: number;
latitude?: number;
longitude?: number;
}
</script>
};
</script>
@@ -133,7 +133,7 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
return {
/** General purpose action buttons (used by PosAddons)*/
confirm: {
label: t("admin.pos.action_buttons.confirm"),
label: t("common.confirm"),
description: t("admin.pos.action_buttons.confirm_desc"),
onClick: () => {
clearPopup(); // Close the popup
@@ -141,7 +141,7 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
color: "primary",
},
cancel: {
label: t("admin.pos.action_buttons.cancel"),
label: t("common.cancel"),
description: t("admin.pos.action_buttons.cancel_desc"),
onClick: () => {
clearPopup(); // Close the popup
@@ -149,7 +149,7 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
color: "light",
},
close: {
label: t("admin.pos.action_buttons.close"),
label: t("common.close"),
description: t("admin.pos.action_buttons.close_desc"),
onClick: () => {
clearPopup(); // Close the popup
@@ -788,7 +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",
@@ -4,6 +4,7 @@ import {
order_notes,
order_safety_seal,
reference,
clearStoredPosOrderId,
} from "@/components/shop/POSDepartmentProcess.vue";
import { metadata } from "./PosDepartmentStepMobileFlow.vue";
@@ -115,5 +116,6 @@ export const finalizeCurrentMobileOrder = async (options = {}) => {
if (normalizedOptions.markOrderCompleted) {
await SessionUser.objects.orders.functions.mark_as_completed(normalizedOrderId);
}
clearStoredPosOrderId();
return normalizedOrderId;
};
@@ -183,7 +183,7 @@ const handleMouseWheel = (e: WheelEvent) => {
<span class="icon is-small">
<i class="fas fa-times"></i>
</span>
<small>{{ t("global.close") }}</small>
<small>{{ t("common.close") }}</small>
</button>
</div>
</div>
@@ -286,7 +286,7 @@ const tableHeaders = ref([
},
{
name: 'reference',
title: t('objects.columns.reference'),
title: t('common.reference'),
sortable: true,
},
{
@@ -296,7 +296,7 @@ const tableHeaders = ref([
},
{
name: 'created_at',
title: t('objects.columns.date'),
title: t('common.date'),
sortable: true,
},
{
@@ -0,0 +1,120 @@
export const XLVASK_USAGE_AMOUNT_CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_PREFIX = "xlvask-usage-amount:";
const memoryCache = new Map();
const safeSessionStorage = () => {
try {
if (typeof window === "undefined" || !window.sessionStorage) {
return null;
}
return window.sessionStorage;
} catch {
return null;
}
};
const normalizeUsageLogId = (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;
};
const isFreshEntry = (entry) => {
const storedAt = Number(entry?.storedAt ?? 0);
return storedAt > 0 && Date.now() - storedAt <= XLVASK_USAGE_AMOUNT_CACHE_TTL_MS;
};
export const buildXlvaskUsageAmountCacheKey = (objectOrId) => {
const usageLogId = normalizeUsageLogId(objectOrId);
return usageLogId > 0 ? String(usageLogId) : "";
};
export const getCachedXlvaskUsageAmount = (objectOrId) => {
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
if (!cacheKey) {
return null;
}
const memoryEntry = memoryCache.get(cacheKey);
if (isFreshEntry(memoryEntry)) {
return memoryEntry.data;
}
if (memoryEntry) {
memoryCache.delete(cacheKey);
}
const storage = safeSessionStorage();
if (!storage) {
return null;
}
const storageKey = `${CACHE_PREFIX}${cacheKey}`;
try {
const parsed = JSON.parse(storage.getItem(storageKey) || "null");
if (!isFreshEntry(parsed)) {
storage.removeItem(storageKey);
return null;
}
memoryCache.set(cacheKey, parsed);
return parsed.data;
} catch {
storage.removeItem(storageKey);
return null;
}
};
export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
if (!cacheKey) {
return;
}
const entry = {
storedAt: Date.now(),
data,
};
memoryCache.set(cacheKey, entry);
const storage = safeSessionStorage();
if (!storage) {
return;
}
try {
storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
} catch {
// Best-effort cache. Quota errors should not block XL Vask usage rows.
}
};
export const clearCachedXlvaskUsageAmount = (objectOrId = null) => {
const storage = safeSessionStorage();
const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId);
if (cacheKey) {
memoryCache.delete(cacheKey);
try {
storage?.removeItem(`${CACHE_PREFIX}${cacheKey}`);
} catch {
// Best-effort cache cleanup.
}
return;
}
memoryCache.clear();
if (!storage) {
return;
}
try {
Object.keys(storage)
.filter((key) => key.startsWith(CACHE_PREFIX))
.forEach((key) => storage.removeItem(key));
} catch {
// Best-effort cache cleanup.
}
};
@@ -1,4 +1,19 @@
const hasLinkedOrder = (object) => Number(object?.linked_order_id) > 0;
export const isUsageOrderAttachedToOrder = (object) => {
if (hasLinkedOrder(object)) {
return true;
}
const automation = object?.automation;
const automationStatus = automation?.status;
if (
["auto_accepted", "accepted"].includes(automationStatus) &&
["attach_order", "create_order"].includes(automation?.action)
) {
return true;
}
const duplicates = Array.isArray(object?.duplicates) ? object.duplicates : [];
return duplicates.some((duplicate) => duplicate?.wash_id === object?.wash_id);
};
@@ -14,6 +14,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
highlightUsageLogId: {
type: Number,
default: 0,
},
});
import {computed, ref, watch} from 'vue';
import { departments, getDepartments, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
@@ -28,6 +32,11 @@ import {
filterUsageOrdersByAttachment,
isUsageOrderAttachedToOrder
} from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
import {
clearCachedXlvaskUsageAmount,
getCachedXlvaskUsageAmount,
setCachedXlvaskUsageAmount
} from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
const redirectDepartmentOrderPage = (orderId, departmentId) => {
// Send the user to the order page (In a new tab)
@@ -204,25 +213,57 @@ const humanReadableTimeAgo = (dateString) => {
return 'lige nu';
};
const applyGeneratedContent = (object, data) => {
const orderItems = Array.isArray(data?.order_items) ? data.order_items : [];
const potentialDuplicates = Array.isArray(data?.potential_duplicates) ? data.potential_duplicates : [];
object.items = orderItems;
if (potentialDuplicates.length > 0) {
object.selectedDuplicateId = parseInt(potentialDuplicates[0].id);
object.duplicates = potentialDuplicates;
}
if (data?.automation !== undefined) {
object.automation = data.automation;
}
object.fast_link_key = undefined;
object.loading = false;
};
const applyCachedGeneratedContent = (object) => {
const cached = getCachedXlvaskUsageAmount(object);
if (!cached) {
return false;
}
applyGeneratedContent(object, cached);
return true;
};
const generateContent = (object) => {
if (applyCachedGeneratedContent(object)) {
return Promise.resolve();
}
if (!object.fast_link_key || object.loading) {
return; // If no fast link key, do not generate content
return Promise.resolve(); // If no fast link key, do not generate content
}
object.loading = true; // Set loading state to true
// Generate content based on the object
SessionUser.request(
return SessionUser.request(
'/modules/xlvask/services/usage/orders/fast-link?fast_link_key=' + object.fast_link_key,
'GET'
).then((response) => {
const data = response.data.data;
object.fast_link_key = undefined; // Clear the fast link key after use
object.items = data.order_items;
object.selectedDuplicateId = data.potential_duplicates?.length ? parseInt(data.potential_duplicates[0].id) : null;
object.duplicates = data.potential_duplicates;
object.loading = false; // Set loading state to false
applyGeneratedContent(object, data);
setCachedXlvaskUsageAmount(object, {
order_items: object.items,
potential_duplicates: object.duplicates,
automation: object.automation,
});
}
).catch((error) => {
console.error('Error generating content:', error);
object.loading = false;
});
};
@@ -249,9 +290,20 @@ const getPriceFromOrderItems = (orderItems) => {
}, 0);
};
const getObjectPrice = (object) => {
if (doesObjectHaveItems(object)) {
return getPriceFromOrderItems(object.items);
}
const parsedAmount = Number(object?.total_net_amount ?? 0);
return Number.isFinite(parsedAmount) ? parsedAmount : 0;
};
const doesObjectHaveAmount = (object) => getObjectPrice(object) > 0;
const doesObjectPriceMatchPotentialDuplicate = (object, potentialDuplicate) => {
// Check if the price of the object matches the potential duplicate
return getPriceFromOrderItems(object.items) === potentialDuplicate.total_net_amount ?? 0;
return getObjectPrice(object) === Number(potentialDuplicate?.total_net_amount ?? 0);
};
const getPrimaryItemFromOrderItems = (orderItems) => {
@@ -276,6 +328,14 @@ const getProductName = (orderItem) => {
return product ? product.name : 'Ukendt produkt';
};
const getObjectPrimaryProductName = (object) => {
if (doesObjectHaveItems(object)) {
return getProductName(getPrimaryItemFromOrderItems(object.items));
}
return object?.xlvask_primary_product_name || object?.primary_product_name || '';
};
const doesObjectHaveItems = (object) => {
// Check if the object has items
return object.items && Array.isArray(object.items) && object.items.length > 0;
@@ -288,12 +348,12 @@ const automaticallyLoadContent = (object) => {
}
}
// Automatically load content for each object when the objects prop changes
const isHighlightedUsageLog = (object) => Number(object?.id || 0) === Number(props.highlightUsageLogId || 0);
// Reuse cached detail payloads immediately, but only fetch one-time fast-link details on demand.
watch(() => props.objects, (newObjects) => {
newObjects.forEach(object => {
if (object.fast_link_key && !object.loading) {
generateContent(object);
}
applyCachedGeneratedContent(object);
});
}, { immediate: true, deep: true });
@@ -416,7 +476,7 @@ const isObjectAttachedToOrder = (object) => {
const doesObjectHaveExactMatch = (object) => {
// Check if the object has any duplicates with the same order price
return object.duplicates?.some(duplicate => {
return getPriceFromOrderItems(object.items) === duplicate.total_net_amount;
return getObjectPrice(object) === Number(duplicate.total_net_amount ?? 0);
}) || false;
};
@@ -425,6 +485,151 @@ const doesObjectHaveDuplicates = (object) => {
return object.duplicates && Array.isArray(object.duplicates) && object.duplicates.length > 0;
};
const hasAutomationState = (object) => {
const status = object?.automation?.status;
return !!status && status !== 'none' && status !== 'failed';
};
const isAutomationActionable = (object) => {
return object?.automation?.can_accept || object?.automation?.can_deny;
};
const getAutomationOrderId = (object) => {
return object?.automation?.matched_order_id
?? object?.automation?.created_order_id
?? object?.automation?.candidate_order?.id
?? null;
};
const getAutomationLabel = (object) => {
const automation = object?.automation ?? {};
const orderId = getAutomationOrderId(object);
const orderSuffix = orderId ? ` #${orderId}` : '';
if (automation.status === 'suggested') {
return automation.action === 'create_order'
? 'Foreslået: Opret ordre'
: `Foreslået: Tilknyt ordre${orderSuffix}`;
}
if (automation.status === 'auto_accepted') {
return automation.action === 'create_order'
? `Oprettet automatisk${orderSuffix}`
: `Tilknyttet automatisk${orderSuffix}`;
}
if (automation.status === 'accepted') {
return automation.action === 'create_order'
? `Oprettet${orderSuffix}`
: `Accepteret${orderSuffix}`;
}
if (automation.status === 'denied') {
return 'Afvist';
}
return '';
};
const getAutomationReason = (object) => {
return object?.automation?.reason || object?.automation?.error || '';
};
const getAutomationConfidence = (object) => {
const confidence = Number(object?.automation?.confidence ?? 0);
if (!Number.isFinite(confidence) || confidence <= 0) {
return '';
}
return `${Math.round(confidence * 100)}% sikkerhed`;
};
const getAutomationTagClass = (object) => {
const status = object?.automation?.status;
if (status === 'auto_accepted' || status === 'accepted') {
return 'is-success';
}
if (status === 'denied') {
return 'is-light';
}
if (status === 'failed') {
return 'is-danger';
}
return object?.automation?.source === 'openai' ? 'is-info' : 'is-warning';
};
const getAutomationMessageClass = (object) => {
const status = object?.automation?.status;
if (status === 'auto_accepted' || status === 'accepted') {
return 'is-success';
}
if (status === 'denied') {
return 'is-light';
}
if (status === 'failed') {
return 'is-danger';
}
return object?.automation?.source === 'openai' ? 'is-info' : 'is-warning';
};
const getAutomationIconClass = (object) => {
const automation = object?.automation ?? {};
if (automation.status === 'denied') {
return 'fa-ban';
}
if (automation.status === 'failed') {
return 'fa-exclamation-triangle';
}
if (automation.action === 'create_order') {
return 'fa-plus-circle';
}
return 'fa-link';
};
const setAutomationDecision = (object, decision) => {
if (!object?.id || object.automationLoading) {
return;
}
object.automationLoading = true;
SessionUser.request(
`/modules/xlvask/services/usage/orders/${object.id}/automation/${decision}`,
'POST',
{
suggestion_id: object.automation?.id ?? null,
}
).then((response) => {
object.automation = response.data.data;
clearCachedXlvaskUsageAmount(object);
window.dispatchEvent(new CustomEvent('xlvask-usage-order-updated', {
detail: {
usageLogId: object.id,
decision,
},
}));
}).catch((error) => {
Swal.fire({
title: 'Fejl',
text: decision === 'accept'
? 'Forslaget kunne ikke accepteres.'
: 'Forslaget kunne ikke afvises.',
icon: 'error',
confirmButtonText: 'OK',
});
console.error(error);
}).finally(() => {
object.automationLoading = false;
});
};
const acceptAutomation = (object) => {
setAutomationDecision(object, 'accept');
};
const denyAutomation = (object) => {
setAutomationDecision(object, 'deny');
};
const filteredObjects = computed(() => {
return filterUsageOrdersByAttachment(props.objects, props.showOnlyUnattachedVehicle);
});
@@ -539,47 +744,100 @@ const filteredObjects = computed(() => {
</tfoot>
</table>
<template v-for="object in filteredObjects" :key="object.id">
<WhiteBoxCard v-bind:loading="object.loading ?? false" :toggleable="true" @mouseover="automaticallyLoadContent(object)" @click="automaticallyLoadContent(object)" :default-open="object.isOpen ?? false">
<WhiteBoxCard
v-bind:loading="object.loading ?? false"
:toggleable="true"
:class="{ 'xlvask-usage-card--highlighted': isHighlightedUsageLog(object) }"
@mouseover="automaticallyLoadContent(object)"
@click="automaticallyLoadContent(object)"
:default-open="isHighlightedUsageLog(object) || (object.isOpen ?? false)"
>
<template #header>
<p class="card-header-title">
<!-- Registration Number -->
<span class="has-text-weight-bold">{{ object.reg_1 }}</span>
<!-- Time Ago -->
<span class="tag is-light is-size-7 ml-2">
{{ humanReadableTimeAgo(object.created_at) }}
</span>
<!-- Primary Item Name -->
<span class="tag is-light is-size-7 ml-2" v-if="doesObjectHaveItems(object)">
{{ getProductName(getPrimaryItemFromOrderItems(object.items)) }}
</span>
<!-- Price -->
<span class="tag is-light is-size-7 ml-2" v-if="doesObjectHaveItems(object)">
{{ SessionUser.functions.currency.toLocal(getPriceFromOrderItems(object.items).toFixed(2)) }}
</span>
<!-- Linked to order status -->
<span class="tag is-light is-size-7 ml-2" :class="{ 'is-grey': !isObjectAttachedToOrder(object), 'is-success': isObjectAttachedToOrder(object) }">
<span class="icon is-small"><i class="fas" :class="isObjectAttachedToOrder(object) ? 'fa-link' : 'fa-unlink'"></i></span>
<span class="has-text-weight-bold">{{ isObjectAttachedToOrder(object) ? 'Tilknyttet ordre' : 'Ikke tilknyttet ordre' }}</span>
</span>
<!-- Potential duplicates found?->Exact match found ?-->
<span class="tag is-light is-size-7 ml-2" :class="{ 'is-success': doesObjectHaveExactMatch(object), 'is-warning': !doesObjectHaveExactMatch(object) }" v-if="doesObjectHaveDuplicates(object)">
<span class="icon is-small"><i class="fas" :class="doesObjectHaveExactMatch(object) ? 'fa-check' : 'fa-exclamation-triangle'"></i></span>
<span class="has-text-weight-bold">{{ (doesObjectHaveExactMatch(object) ? SessionUser.objects.global.language.price_match : SessionUser.objects.global.language.price_unmatch) }}</span>
</span>
<!-- Department and Lane -->
<span class="tags has-addons ml-2">
<span class="tag">{{ getDepartmentName(object.department_id) }}</span>
<span class="tag is-info is-light">{{ object.lane }}</span>
</span>
</p>
<p class="card-header-title is-justify-content-end">
<span class="has-text-weight-bold">{{ object.customer_name }}</span>
</p>
<div class="card-header-title xlvask-usage-card-header">
<div class="xlvask-usage-card-main">
<div class="xlvask-usage-card-primary">
<span class="has-text-weight-bold xlvask-usage-registration">{{ object.reg_1 }}</span>
<span class="tag is-light is-size-7 xlvask-usage-chip">
{{ humanReadableTimeAgo(object.created_at) }}
</span>
<span class="tag is-light is-size-7 xlvask-usage-chip" v-if="getObjectPrimaryProductName(object)">
{{ getObjectPrimaryProductName(object) }}
</span>
<span class="tag is-light is-size-7 xlvask-usage-chip xlvask-usage-price" v-if="doesObjectHaveAmount(object)">
{{ SessionUser.functions.currency.toLocal(getObjectPrice(object).toFixed(2)) }}
</span>
</div>
<div class="xlvask-usage-card-status">
<span class="tag is-light is-size-7 xlvask-usage-chip" :class="{ 'is-grey': !isObjectAttachedToOrder(object), 'is-success': isObjectAttachedToOrder(object) }">
<span class="icon is-small"><i class="fas" :class="isObjectAttachedToOrder(object) ? 'fa-link' : 'fa-unlink'"></i></span>
<span class="has-text-weight-bold xlvask-usage-chip-text">{{ isObjectAttachedToOrder(object) ? 'Tilknyttet ordre' : 'Ikke tilknyttet ordre' }}</span>
</span>
<span
v-if="hasAutomationState(object)"
class="tag is-light is-size-7 xlvask-usage-chip xlvask-usage-automation-chip"
:class="getAutomationTagClass(object)"
:data-testid="'xlvask-automation-suggestion-' + object.id"
>
<span class="icon is-small"><i class="fas" :class="getAutomationIconClass(object)"></i></span>
<span class="has-text-weight-bold xlvask-usage-chip-text">{{ getAutomationLabel(object) }}</span>
</span>
<span class="tag is-light is-size-7 xlvask-usage-chip xlvask-usage-warning-chip" :class="{ 'is-success': doesObjectHaveExactMatch(object), 'is-warning': !doesObjectHaveExactMatch(object) }" v-if="doesObjectHaveDuplicates(object)">
<span class="icon is-small"><i class="fas" :class="doesObjectHaveExactMatch(object) ? 'fa-check' : 'fa-exclamation-triangle'"></i></span>
<span class="has-text-weight-bold xlvask-usage-chip-text">{{ (doesObjectHaveExactMatch(object) ? SessionUser.objects.global.language.price_match : SessionUser.objects.global.language.price_unmatch) }}</span>
</span>
<span class="tags has-addons xlvask-usage-location-tags">
<span class="tag xlvask-usage-chip">{{ getDepartmentName(object.department_id) }}</span>
<span class="tag is-info is-light xlvask-usage-chip">{{ object.lane }}</span>
</span>
</div>
</div>
<div class="xlvask-usage-card-customer" :title="object.customer_name">
{{ object.customer_name }}
</div>
</div>
</template>
<template #content>
<div class="columns">
<div class="column is-eight">
<div class="columns xlvask-usage-content-columns">
<div class="column is-10 xlvask-usage-main-column">
<div class="content">
<article
v-if="hasAutomationState(object)"
class="message mb-3"
:class="getAutomationMessageClass(object)"
>
<div class="message-body">
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
<div>
<p class="has-text-weight-bold mb-1">
<span class="icon is-small"><i class="fas" :class="getAutomationIconClass(object)"></i></span>
<span>{{ getAutomationLabel(object) }}</span>
</p>
<p v-if="getAutomationReason(object)" class="mb-1">{{ getAutomationReason(object) }}</p>
<p v-if="getAutomationConfidence(object)" class="is-size-7 has-text-grey">{{ getAutomationConfidence(object) }}</p>
</div>
<div class="buttons mb-0" v-if="isAutomationActionable(object)">
<button
class="button is-small is-success"
:class="{ 'is-loading': object.automationLoading }"
:disabled="object.automationLoading"
:data-testid="'xlvask-automation-accept-' + object.id"
@click.stop="acceptAutomation(object)"
>
Accepter
</button>
<button
class="button is-small is-light"
:class="{ 'is-loading': object.automationLoading }"
:disabled="object.automationLoading"
:data-testid="'xlvask-automation-deny-' + object.id"
@click.stop="denyAutomation(object)"
>
Afvis
</button>
</div>
</div>
</div>
</article>
<div class="message" :class="{ 'is-warning': (!isObjectAttachedToOrder(object)), 'is-success': isObjectAttachedToOrder(object) }">
<div class="message-body">
<template v-if="object.wash_id">
@@ -615,9 +873,8 @@ const filteredObjects = computed(() => {
</template>
</div>
</div>
<span v-if="!object.loading">{{generateContent(object)}}</span>
<template v-if="object.items">
<div class="columns">
<div class="columns xlvask-order-comparison-columns">
<div class="column" :class="{ 'is-12': (!object.duplicates?.length && !isComparingOrders(object)), 'is-6': (object.duplicates?.length && isComparingOrders(object)) }">
<div class="field">
<input class="input" type="text" readonly :value="getDepartmentName(object.department_id) + ' - ' + object.created_at + ' (' + SessionUser.objects.global.language.generated + ')'"/>
@@ -666,7 +923,7 @@ const filteredObjects = computed(() => {
<span><br>{{ object.wash_id }}</span>
</div>
</div>
<div class="column is-2">
<div class="column is-2 xlvask-usage-actions-column">
<div class="content">
<div class="buttons">
<template v-if="isObjectAttachedToOrder(object)">
@@ -729,4 +986,187 @@ const filteredObjects = computed(() => {
</template>
<style scoped>
.xlvask-usage-card--highlighted {
border-left: 4px solid #3273dc;
box-shadow: 0 0 0 2px rgba(50, 115, 220, 0.16);
}
.xlvask-usage-card-header {
align-items: start;
display: grid;
gap: 0.6rem 1rem;
grid-template-columns: minmax(0, 1fr) minmax(9rem, 15rem);
min-width: 0;
width: 100%;
}
.xlvask-usage-card-main {
display: flex;
flex-direction: column;
gap: 0.45rem;
min-width: 0;
}
.xlvask-usage-card-primary,
.xlvask-usage-card-status {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
min-width: 0;
}
.xlvask-usage-registration {
flex: 0 0 auto;
line-height: 1.45;
white-space: nowrap;
}
.xlvask-usage-card-primary > .tag,
.xlvask-usage-card-status > .tag,
.xlvask-usage-card-status > .tags {
margin: 0 !important;
}
.xlvask-usage-chip {
height: auto;
line-height: 1.2;
max-width: 100%;
min-height: 1.6rem;
min-width: 0;
padding-bottom: 0.25rem;
padding-top: 0.25rem;
white-space: normal;
}
.xlvask-usage-chip .icon {
flex: 0 0 auto;
}
.xlvask-usage-chip-text {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.xlvask-usage-automation-chip .xlvask-usage-chip-text {
max-width: 18rem;
}
.xlvask-usage-warning-chip .xlvask-usage-chip-text {
max-width: 15rem;
}
.xlvask-usage-location-tags {
align-items: center;
display: inline-flex;
flex: 0 1 auto;
flex-wrap: nowrap;
max-width: 100%;
min-width: 0;
}
.xlvask-usage-location-tags .tag:first-child {
display: block;
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.xlvask-usage-card-customer {
font-weight: 700;
line-height: 1.25;
min-width: 0;
overflow-wrap: anywhere;
text-align: left;
}
.xlvask-usage-content-columns,
.xlvask-order-comparison-columns {
min-width: 0;
width: 100%;
}
.xlvask-usage-main-column,
.xlvask-usage-actions-column,
.xlvask-order-comparison-columns > .column {
min-width: 0;
}
.xlvask-usage-main-column .content {
min-width: 0;
}
.xlvask-order-comparison-columns .input,
.xlvask-order-comparison-columns :deep(table) {
max-width: 100%;
}
.xlvask-order-comparison-columns :deep(.table-container) {
overflow-x: auto;
}
@media screen and (max-width: 1500px) {
.xlvask-usage-card-header {
grid-template-columns: minmax(0, 1fr) minmax(8rem, 12rem);
}
.xlvask-usage-automation-chip .xlvask-usage-chip-text {
max-width: 13rem;
}
.xlvask-usage-warning-chip .xlvask-usage-chip-text,
.xlvask-usage-location-tags .tag:first-child {
max-width: 9rem;
}
}
@media screen and (max-width: 1350px) {
.xlvask-usage-card-header {
grid-template-columns: minmax(0, 1fr);
}
.xlvask-usage-card-customer {
max-width: 100%;
}
.xlvask-order-comparison-columns {
display: block;
}
.xlvask-order-comparison-columns > .column {
flex: none;
width: 100%;
}
}
@media screen and (max-width: 1215px) {
.xlvask-usage-content-columns {
display: block;
}
.xlvask-usage-actions-column .buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.xlvask-usage-actions-column .button {
flex: 1 1 10rem;
margin-bottom: 0;
width: auto;
}
}
@media screen and (max-width: 768px) {
.xlvask-usage-chip-text,
.xlvask-usage-automation-chip .xlvask-usage-chip-text,
.xlvask-usage-warning-chip .xlvask-usage-chip-text,
.xlvask-usage-location-tags .tag:first-child {
max-width: 100%;
}
}
</style>
@@ -50,7 +50,7 @@ const handleFileUpload = async (event) => {
await fetchAttachments();
Swal.fire({
icon: "success",
title: t('modals.success'),
title: t('common.success'),
text: t('modals.file_uploaded'),
timer: 1500,
showConfirmButton: false,
@@ -59,7 +59,7 @@ const handleFileUpload = async (event) => {
console.error("Error uploading attachment:", error);
Swal.fire({
icon: "error",
title: t('modals.error'),
title: t('common.error'),
text: t('modals.upload_error'),
});
} finally {
@@ -96,7 +96,7 @@ const deleteAttachment = async (attachmentId) => {
console.error("Error deleting attachment:", error);
Swal.fire({
icon: "error",
title: t('modals.error'),
title: t('common.error'),
text: t('modals.delete_error'),
});
}
@@ -117,7 +117,7 @@ const downloadAttachment = async (attachmentId) => {
console.error("Error downloading attachment:", error);
Swal.fire({
icon: "error",
title: t('modals.error'),
title: t('common.error'),
text: t('modals.download_error'),
});
}
@@ -145,7 +145,7 @@ onMounted(() => {
<thead>
<tr>
<th>{{ t('modals.filename') }}</th>
<th class="has-text-right">{{ t('modals.actions') }}</th>
<th class="has-text-right">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
@@ -8,6 +8,10 @@ const { loadList, loadSwitch, metaCurrentPage, metaItemsPerPage, setList } = use
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { ref, watch } from "vue";
import SelfServeTaskAttachmentsModal from "@/components/displays/department/tables/SelfServeTaskAttachmentsModal.vue";
import {
SELF_SERVE_TASK_BUTTON_OPTIONS,
normalizeSelfServeTaskButtons,
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
// Define the props
const props = defineProps({
@@ -36,24 +40,15 @@ const closeAttachmentsModal = () => {
const editButtonsFunction = (id, column, value, onAfterSubmit) => {
editingButtonsId.value = id;
try {
const parsed = JSON.stringify(value);
if (Array.isArray(parsed)) {
currentButtonsSelection.value = parsed;
} else {
console.warn('Parsed buttons value is not an array:', parsed);
currentButtonsSelection.value = [];
}
} catch (e) {
console.warn('Error parsing buttons value:', e);
currentButtonsSelection.value = [];
}
currentButtonsSelection.value = normalizeSelfServeTaskButtons(value);
showButtonsEditModal.value = true;
};
const saveButtonsEdit = async () => {
console.warn('Saving buttons with selection:', currentButtonsSelection.value);
await SessionUser.objects.self_serve_tasks.set.buttons(editingButtonsId.value, currentButtonsSelection.value);
await SessionUser.objects.self_serve_tasks.set.buttons(
editingButtonsId.value,
normalizeSelfServeTaskButtons(currentButtonsSelection.value)
);
showButtonsEditModal.value = false;
loadList();
};
@@ -195,7 +190,7 @@ const getScopeLabel = (object) => {
<td>{{ object.order_priority }}</td>
<td>{{ object.task }}</td>
<td>{{ object.description }}</td>
<td>{{ object.condition_id ? SessionUser.objects.self_serve_conditions.functions.getConditionName(object.condition_id) : $t('global.all') }}</td>
<td>{{ object.condition_id ? SessionUser.objects.self_serve_conditions.functions.getConditionName(object.condition_id) : $t('common.all') }}</td>
<td>{{ getScopeLabel(object) }}</td>
<td>{{ SessionUser.objects.self_serve_tasks.columns.services.parse(object.services) }}</td>
<td>{{ SessionUser.objects.self_serve_tasks.columns.buttons.parse(object.buttons) }}</td>
@@ -237,15 +232,15 @@ const getScopeLabel = (object) => {
<button class="delete" @click="cancelButtonsEdit" aria-label="close"></button>
</header>
<section class="modal-card-body">
<div v-for="n in 12" :key="n" class="field mb-3">
<div v-for="option in SELF_SERVE_TASK_BUTTON_OPTIONS" :key="String(option.id)" class="field mb-3">
<input
class="is-checkradio"
type="checkbox"
:id="`btn-${n-1}`"
:id="`btn-${option.id}`"
v-model="currentButtonsSelection"
:value="n-1"
:value="option.id"
>
<label :for="`btn-${n-1}`">Button {{ n-1 }}</label>
<label :for="`btn-${option.id}`">{{ option.name }}</label>
</div>
</section>
<footer class="modal-card-foot">
@@ -6,7 +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 { API_URL } from "@/config";
import {
buildSelfServeDynamicImageUrl,
getSelfServeDynamicImageButtonsToPress,
getSelfServeDynamicImageThumbPosition,
} from "@/services/selfServeDynamicImage.js";
const { t: $t } = useI18n();
@@ -61,6 +65,7 @@ const {
syncVehicleAnswer,
clearVehicleAnswers,
evaluateCondition,
downloadAttachment,
} = useSelfServeLogic();
const emptyCompletedTasks = computed(() => ({}));
@@ -136,43 +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 Set();
activeTasks.value.forEach((task) => {
if (!Array.isArray(task?.buttons)) {
return;
}
task.buttons.forEach((button) => {
const buttonId = parseInt(button);
if (!Number.isNaN(buttonId) && buttonId >= 0 && buttonId <= 11) {
buttonIds.add(buttonId);
}
});
});
return [...buttonIds];
});
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
@@ -185,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 `${API_URL}/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(() => (
@@ -266,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",
@@ -321,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>
@@ -338,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>
@@ -350,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>
@@ -362,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>
@@ -372,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 }})
@@ -384,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>
@@ -401,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
@@ -412,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"
@@ -435,14 +398,14 @@ watch(dynamicImageUrl, () => {
:completedTasks="emptyCompletedTasks"
:show-checkboxes="false"
@toggle-task="() => {}"
@download-attachment="() => {}"
@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>
@@ -453,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"
@@ -54,7 +54,7 @@ const props = defineProps({
:loadList="loadList"
column="value"
:edit-function="SessionUser.objects.self_serve_vehicle_conditions.showEditObjectFieldForm"
:parse-function="(val) => val ? t('global.yes') : t('global.no')"
:parse-function="(val) => val ? t('common.yes') : t('common.no')"
:permission-check-function="SessionUser.canAccessAdmin"
/>
<EditableTableColumn
@@ -34,10 +34,10 @@ if (departments.value.length === 0) {
<th>{{ $t('objects.columns.department') }}</th>
<th>{{ $t('tables.bookings.reg_tractor') }}</th>
<th>{{ $t('tables.bookings.reg_trailer') }}</th>
<th>{{ $t('objects.columns.reference') }}</th>
<th>{{ $t('common.reference') }}</th>
<th>{{ $t('objects.columns.notes') }}</th>
<th>{{ $t('objects.columns.date') }}</th>
<th>{{ $t('objects.columns.status') }}</th>
<th>{{ $t('common.date') }}</th>
<th>{{ $t('common.status') }}</th>
<th>{{ $t('objects.bookings.columns.contact_email') }}</th>
<th>{{ $t('objects.bookings.columns.wash_certificate_email') }}</th>
<th>{{ $t('tables.actions') }}</th>
@@ -59,7 +59,7 @@ if (departments.value.length === 0) {
<td>{{ booking.washCertificateEmail }}</td>
<td>
<div class="buttons">
<button class="button is-small is-dark" @click="redirectDepartmentOrderPage(booking.id, booking.department_id)">{{ $t('global.show') }}</button>
<button class="button is-small is-dark" @click="redirectDepartmentOrderPage(booking.id, booking.department_id)">{{ $t('common.show') }}</button>
</div>
</td>
</tr>
@@ -598,7 +598,7 @@ onBeforeUnmount(() => {
data-testid="draft-order-assign-cancel"
@click="closeModal"
>
{{ t("global.cancel") }}
{{ t("common.cancel") }}
</button>
<button
class="button is-link"
@@ -107,7 +107,7 @@ loadUser();
<div class="modal-background" @click="closeModal"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{getUserKey('customer_name', t('modals.unknown_customer'), t('modals.loading'))}}</p>
<p class="modal-card-title">{{getUserKey('customer_name', t('modals.unknown_customer'), t('common.loading'))}}</p>
<button class="delete" aria-label="close" @click="closeModal"></button>
</header>
<section class="modal-card-body">
@@ -150,7 +150,7 @@ loadUser();
<p>{{ t('modals.fetching_data') }}</p>
<p>{{ t('modals.please_wait') }}</p>
</div>
<progress class="progress is-small is-dark" max="100">{{ t('modals.loading') }}</progress>
<progress class="progress is-small is-dark" max="100">{{ t('common.loading') }}</progress>
</template>
</section>
<footer class="modal-card-foot">
@@ -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;
@@ -8,8 +8,20 @@ const { t } = useI18n();
import SessionUser from "@/components/session/token/SessionUser.vue";
const canExport = computed(() => typeof paginatedList?.exportToExcel === 'function');
const props = defineProps({
hideSearch: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: "",
},
});
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);
@@ -54,25 +66,25 @@ const handleDocumentClick = (event: MouseEvent) => {
};
onMounted(() => {
document.addEventListener('click', handleDocumentClick);
document.addEventListener("click", handleDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick);
document.removeEventListener("click", handleDocumentClick);
});
</script>
<template>
<!-- Top row: Reload, Search -->
<div class="columns is-vcentered is-multiline pagination-general-search-reload">
<div class="column pagination-general-search-reload__search-column">
<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">
@@ -127,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";
@@ -23,6 +15,22 @@ const props = defineProps({
type: String,
required: true,
},
showFilters: {
type: Boolean,
default: true,
},
hideSearch: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: "",
},
hidePagination: {
type: Boolean,
default: false,
},
});
const isSmall = ref(window.innerWidth < 1024);
</script>
@@ -31,18 +39,31 @@ const isSmall = ref(window.innerWidth < 1024);
<div data-disable-auto-excel-export="1">
<!-- Label -->
<h2 class="title is-4 mb-4">{{ label }}</h2>
<slot name="description"></slot>
<!-- Search & reload -->
<PaginationDisplayGeneralSearchReload>
<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>
</template>
</PaginationDisplayGeneralSearchReload>
<slot name="afterSearch"></slot>
<!-- Filters -->
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
<PaginationDisplay
v-if="showFilters"
data-testid="table-labeled-pagination-filters"
:metaItemsPerPage="metaItemsPerPage"
:loadFunction="loadList"
:isLoading="isLoading"
:setMetaItemsPerPage="setMetaItemsPerPage"
>
<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>
@@ -54,9 +75,15 @@ const isSmall = ref(window.innerWidth < 1024);
<slot name="default"></slot>
</section>
<!-- Pagination -->
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
<PaginationNavigation
v-if="!props.hidePagination"
:currentPage="metaCurrentPage"
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
:loadFunction="loadList"
:setPage="setPage"
:isLoading="isLoading"
/>
</div>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -24,8 +24,17 @@ const props = defineProps({
}
})
import { provide } from "vue";
import { useRouter } from "vue-router";
import {
usePaginatedList,
PaginatedListKey,
} from "@/components/pagination/paginatedList.vue";
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
isLoaded,
isLoading,
list,
@@ -42,7 +51,7 @@ import {
setOrder,
hideSearchField,
setHideSearchField,
} from "@/components/pagination/paginatedList.vue";
} = paginatedList;
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
@@ -7,9 +7,21 @@ const props = defineProps({
type: Number, default: 0
}, hideSearch: {
type: Boolean, default: false
}, title: {
type: String, default: ""
}, initialDateFrom: {
type: String, default: ""
}, initialDateTo: {
type: String, default: ""
}, inheritPeriodFilters: {
type: Boolean, default: false
}, loadAllAtOnce: {
type: Boolean, default: false
}, highlightUsageLogId: {
type: Number, default: 0
}
})
import {computed, ref } from "vue";
import {computed, ref, watch } from "vue";
import { useRouter } from "vue-router";
import {
isLoading,
@@ -34,13 +46,40 @@ import PaginationDisplay from "@/components/displays/pagination/PaginationDispla
import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
import PaginationDisplayTemplateDates
from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue";
import ShowErrorField from "@/components/global/ShowErrorField.vue";
import { useI18n } from 'vue-i18n'
import { BSwitch } from "buefy";
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
const { t } = useI18n()
const router = useRouter();
const SELFWASH_PERIOD_ALL_LIMIT = 10000;
setEndpoint("/modules/xlvask/services/usage/orders", false);
if (props.loadAllAtOnce) {
setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false);
}
const parseInitialDate = (value) => {
if (!value) {
return new Date();
}
const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
};
const applyInitialPeriodFilters = () => {
if (props.initialDateFrom) {
setFilter("StartTime-date_from", props.initialDateFrom, false);
}
if (props.initialDateTo) {
setFilter("StartTime-date_to", props.initialDateTo, false);
}
};
const shouldShowLocalFilters = computed(() => !props.inheritPeriodFilters);
const titleText = computed(() => props.title || SessionUser.objects.orders.meta.title);
// If the customer filter is set, filter the orders by the customer number
if (props.setCustomerFilter > 0) {
setFilter("customer_number", props.setCustomerFilter);
@@ -62,10 +101,6 @@ if (router.currentRoute.value.params.departmentId) {
} else {
setOrder("StartTime", "desc");
}
// Load the list automatically if the autoLoad prop is set
if (props.autoLoad) {
loadList();
}
const isImportLoading = ref(false);
const importUsageButton = async () => {
@@ -90,8 +125,8 @@ const importUsageButton = async () => {
isImportLoading.value = false;
}
};
const dateFrom = ref(new Date());
const dateTo = ref(new Date());
const dateFrom = ref(parseInitialDate(props.initialDateFrom));
const dateTo = ref(parseInitialDate(props.initialDateTo));
const parsedDate = (date) => {
return new Date(date);
@@ -127,6 +162,29 @@ const actions = {
}
};
applyInitialPeriodFilters();
// Load the list automatically if the autoLoad prop is set
if (props.autoLoad) {
loadList();
}
watch(
() => [props.initialDateFrom, props.initialDateTo],
([nextDateFrom, nextDateTo], [previousDateFrom, previousDateTo]) => {
if (nextDateFrom === previousDateFrom && nextDateTo === previousDateTo) {
return;
}
dateFrom.value = parseInitialDate(nextDateFrom);
dateTo.value = parseInitialDate(nextDateTo);
applyInitialPeriodFilters();
if (props.autoLoad) {
loadList();
}
}
);
const showOnlyUnattachedVehicle = ref(false);
const visibleObjectsCount = computed(() => {
const currentList = Array.isArray(list.value) ? list.value : [];
@@ -142,7 +200,7 @@ const visibleObjectsCount = computed(() => {
<div class="level mb-3">
<div class="level-left">
<div class="level-item">
<h1 class="title is-4">{{SessionUser.objects.orders.meta.title}} ({{SessionUser.objects.global.language.synchronized}})</h1>
<h1 class="title is-4">{{ titleText }} ({{SessionUser.objects.global.language.synchronized}})</h1>
</div>
<div class="level-item">
<h2 class="subtitle is-6">{{SessionUser.objects.global.language.showing}} {{ visibleObjectsCount }} {{SessionUser.objects.global.language.showing_of_separator}} {{ metaTotalItems }} {{SessionUser.objects.orders.meta.title.toLowerCase()}}</h2>
@@ -157,8 +215,8 @@ const visibleObjectsCount = computed(() => {
</div>
</div>
</div>
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_transactions')" v-if="!hideSearchField"/>
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_transactions')" v-if="!hideSearchField && shouldShowLocalFilters"/>
<PaginationDisplay v-if="shouldShowLocalFilters" :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
<template #paginationColumns>
<!-- Sort by created_at -->
<div class="column is-narrow my-3">
@@ -186,7 +244,7 @@ const visibleObjectsCount = computed(() => {
type="is-link"
v-model="showOnlyUnattachedVehicle"
>
Vis kun ikke tilknyttet køretøj
Vis kun ikke tilknyttede vaske
</b-switch>
</div>
</div>
@@ -204,11 +262,20 @@ const visibleObjectsCount = computed(() => {
-->
</template>
</PaginationDisplay>
<ShowErrorField v-else error="paginatedGetRequest"/>
<XlvaskUsageOrdersTable
:objects="list"
:show-only-unattached-vehicle="showOnlyUnattachedVehicle"
:highlight-usage-log-id="props.highlightUsageLogId"
/>
<PaginationNavigation
v-if="!props.loadAllAtOnce"
:currentPage="metaCurrentPage"
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
:loadFunction="loadList"
:setPage="setPage"
:isLoading="isLoading"
/>
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
</template>
<style scoped>
@@ -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">
@@ -93,7 +91,7 @@ if (props.autoLoad) {
data-testid="superuser-complaints-department-filter"
@change="onDepartmentFilterChange($event.target.value)"
>
<option value="*">{{ t('global.all') }}</option>
<option value="*">{{ t('common.all') }}</option>
<option
v-for="department in departments"
:key="department.id"
@@ -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>
@@ -1,51 +1,41 @@
<script setup>
import { computed, provide, ref } from "vue";
import { useI18n } from "vue-i18n";
import DepartmentsTable from "@/components/displays/superuser/tables/departmentsTable.vue";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
let props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
import { useRouter } from "vue-router";
import {
usePaginatedList,
PaginatedListKey
} from "@/components/pagination/paginatedList.vue";
import { provide, computed } from "vue";
const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
const { t } = useI18n();
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 { useI18n } from 'vue-i18n'
const { t } = useI18n()
const router = useRouter();
setEndpoint("/departments", false);
setOrder('order_priority', 'asc');
setOrder("order_priority", "asc");
const archivedFilter = ref("0");
const sortedList = computed(() => {
return [...list.value].sort((a, b) => (a.order_priority || 0) - (b.order_priority || 0));
});
// Hide the search field, if the hideSearch prop is set
const handleArchivedFilterChange = () => {
setFilter("archived", archivedFilter.value);
};
setFilter("archived", archivedFilter.value, false);
if (props.hideSearch) {
setHideSearchField(true);
}
@@ -53,17 +43,39 @@ if (props.hideSearch) {
if (props.autoLoad) {
loadList();
}
</script>
<template>
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_department')" v-if="!hideSearchField"/>
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage"/>
<DepartmentsTable :objects="sortedList" />
<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('common.departments')"
:hide-search="hideSearchField"
>
<template #paginationDisplayFiltersElement>
<div class="column is-narrow my-3 department-status-filter">
<label class="label is-small" for="department-archive-filter">{{
t("common.status")
}}</label>
<div class="control">
<div class="select">
<select
id="department-archive-filter"
v-model="archivedFilter"
data-testid="superuser-departments-archive-filter"
@change="handleArchivedFilterChange"
>
<option value="0">{{ t("objects.departments.filters.active") }}</option>
<option value="1">{{ t("objects.departments.filters.archived") }}</option>
</select>
</div>
</div>
</div>
</template>
<DepartmentsTable :objects="sortedList" />
</TableLabeledPagination>
</template>
<style scoped>
</style>
.department-status-filter {
min-width: 14rem;
}
</style>
@@ -74,11 +74,24 @@ const props = defineProps({
showDraftAssignmentActions: {
type: Boolean,
default: false
},
invoicePeriodFlags: {
type: Array,
default: () => []
}
})
import { ref } from "vue";
const emit = defineEmits(["flagStatusChanged", "flagCreated"]);
import { computed, ref, provide } from "vue";
import { useRouter } from "vue-router";
import {
usePaginatedList,
PaginatedListKey,
} from "@/components/pagination/paginatedList.vue";
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
isLoading,
list,
loadList,
@@ -91,7 +104,7 @@ import {
setAdditionalQueryParameters,
setOrder,
setHideSearchField,
} from "@/components/pagination/paginatedList.vue";
} = paginatedList;
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
@@ -101,6 +114,11 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const router = useRouter();
const areFiltersVisible = ref(!props.hideFilter);
const filterToggleLabel = computed(() => (
areFiltersVisible.value ? t("pagination.hide_filters") : t("pagination.show_filters")
));
const filterToggleIcon = computed(() => (areFiltersVisible.value ? "fa-chevron-up" : "fa-filter"));
setHideSearchField(props.hideSearch);
setEndpoint("/orders", false);
// If the customer filter is set, filter the orders by the customer number
@@ -177,7 +195,28 @@ const doesEndpointMatch = (matcher) => {
<template>
<template v-if="doesEndpointMatch('/orders')">
<TableLabeledPagination :label="SessionUser.objects.orders.meta.labels.multiple">
<TableLabeledPagination
:label="SessionUser.objects.orders.meta.labels.multiple"
:show-filters="areFiltersVisible"
:hide-search="props.hideSearch"
:hide-pagination="props.hidePagination"
>
<template #afterSearch v-if="props.hideFilter">
<div class="invoice-orders-filter-toggle">
<button
type="button"
class="button is-text is-small"
data-testid="invoice-orders-toggle-filters"
:aria-expanded="areFiltersVisible ? 'true' : 'false'"
@click="areFiltersVisible = !areFiltersVisible"
>
<span class="icon is-small">
<i class="fas" :class="filterToggleIcon" aria-hidden="true"></i>
</span>
<span>{{ filterToggleLabel }}</span>
</button>
</div>
</template>
<template #paginationDisplayFiltersElement>
<PaginationDisplayFilters
:departmentColumn="'department_id'"
@@ -202,7 +241,7 @@ const doesEndpointMatch = (matcher) => {
<div class="control">
<div class="select">
<select @change="setFilter('booked_invoice_id', $event.target.value, true);">
<option value="*" :selected="!props.applyDefaultFilters">{{ t('pagination.all') }}</option>
<option value="*" :selected="!props.applyDefaultFilters">{{ t('common.all') }}</option>
<option value="not null">{{ t('pagination.booked') }}</option>
<option value="is_null" :selected="props.applyDefaultFilters">{{ t('pagination.not_booked') }}</option>
</select>
@@ -215,9 +254,9 @@ const doesEndpointMatch = (matcher) => {
<div class="control">
<div class="select">
<select @change="setFilter('error_message', $event.target.value, true);">
<option value="*" selected>{{ t('pagination.all') }}</option>
<option value="not null">{{ t('pagination.yes') }}</option>
<option value="is_null">{{ t('pagination.no') }}</option>
<option value="*" selected>{{ t('common.all') }}</option>
<option value="not null">{{ t('common.yes') }}</option>
<option value="is_null">{{ t('common.no') }}</option>
</select>
</div>
</div>
@@ -228,7 +267,7 @@ const doesEndpointMatch = (matcher) => {
<div class="control">
<div class="select">
<select @change="setFilter('processor', $event.target.value, true);">
<option value="*" selected>{{ t('pagination.all') }}</option>
<option value="*" selected>{{ t('common.all') }}</option>
<option value="1">E-conomic</option>
<option value="2">Stripe</option>
<option value="3">{{ t('pagination.other_no_tracking') }}</option>
@@ -243,7 +282,7 @@ const doesEndpointMatch = (matcher) => {
<div class="control">
<div class="select">
<select @change="setFilter('completed_at', $event.target.value, true);">
<option value="*" selected>{{ t('pagination.all') }}</option>
<option value="*" selected>{{ t('common.all') }}</option>
<option value="not null">{{ t('pagination.completed') }}</option>
<option :value="'is_null'">{{ t('pagination.not_completed') }}</option>
</select>
@@ -256,7 +295,7 @@ const doesEndpointMatch = (matcher) => {
<div class="control">
<div class="select">
<select @change="setFilter('customer_id-has_attribute', $event.target.value, true);">
<option value="*" selected>{{ t('pagination.all') }}</option>
<option value="*" selected>{{ t('common.all') }}</option>
<option value="invoiceAllOrdersIndividually">{{ t('pagination.invoice_per_order') }}</option>
<option value="!invoiceAllOrdersIndividually">{{ t('pagination.invoice_per_month') }}</option>
</select>
@@ -269,7 +308,7 @@ const doesEndpointMatch = (matcher) => {
<div class="control">
<div class="select">
<select @change="setFilter('customer_id-has_key-OtherSpecialArrangement', $event.target.value, true);">
<option value="*" :selected="!props.applyDefaultFilters">{{ t('pagination.all') }}</option>
<option value="*" :selected="!props.applyDefaultFilters">{{ t('common.all') }}</option>
<option value="OtherSpecialArrangement">{{ t('pagination.has_special_agreement') }}</option>
<option value="!OtherSpecialArrangement" :selected="props.applyDefaultFilters">{{ t('pagination.no_special_agreement') }}</option>
</select>
@@ -282,7 +321,7 @@ const doesEndpointMatch = (matcher) => {
<div class="control">
<div class="select">
<select @change="setFilter('customer_id-has_key-OtherVaskeabonnement', $event.target.value, true);">
<option value="*" :selected="!props.applyDefaultFilters">{{ t('pagination.all') }}</option>
<option value="*" :selected="!props.applyDefaultFilters">{{ t('common.all') }}</option>
<option value="OtherVaskeabonnement">{{ t('pagination.has_wash_subscription') }}</option>
<option value="!OtherVaskeabonnement" :selected="props.applyDefaultFilters">{{ t('pagination.no_wash_subscription') }}</option>
</select>
@@ -334,6 +373,9 @@ const doesEndpointMatch = (matcher) => {
v-bind:showOnlyWithIds="props.showOnlyWithIds"
v-bind:autoExpandAll="props.autoExpandAll"
v-bind:excludedOrderIds="props.excludedOrderIds"
:invoice-period-flags="props.invoicePeriodFlags"
@flagStatusChanged="(flag) => emit('flagStatusChanged', flag)"
@flagCreated="(flag) => emit('flagCreated', flag)"
/>
</template>
</TableLabeledPagination>
@@ -341,5 +383,8 @@ const doesEndpointMatch = (matcher) => {
</template>
<style scoped>
.invoice-orders-filter-toggle {
margin-top: -0.35rem;
margin-bottom: 0.25rem;
}
</style>
@@ -116,7 +116,7 @@ if (props.autoLoad) {
</div>
<!-- Filter by status -->
<div class="column is-narrow my-3">
<label class="label">{{ t('pagination.status') }}</label>
<label class="label">{{ t('common.status') }}</label>
<div class="control">
<div class="select">
<select @change="setShowClosedInvoices($event.target.value === 'true')" v-model="showClosedInvoices">
@@ -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>

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