Compare commits

..
Author SHA1 Message Date
Jeppe Bundgaard c06808af33 Update e2e-full workflow to use default_branch for conditional checks 2026-04-15 12:41:22 +02:00
Jeppe Bundgaard a9a5137515 Add pre-commit formatting hooks for test files and configure Husky integration:
- Added Husky pre-commit hook to enforce formatting on JavaScript and TypeScript test files.
- Implemented `pre-commit-format-tests.mjs` to check for unstaged changes, format test files using Prettier, and re-stage them.
- Included `prepare-husky.mjs` to verify Husky installation in the git worktree.
- Updated `package-lock.json` to include `husky` as a dependency.
2026-04-15 12:18:49 +02:00
Jeppe Bundgaard a0c8574399 Add edge agent update handling and verification mechanisms
Refactored the edge agent to include robust update handling, verification, and rollback procedures. Enhanced test coverage for critical update flows and introduced support for background edge gateway refresh without disrupting current user actions.
2026-04-15 12:07:32 +02:00
Jeppe Bundgaard 71b8272eb8 Add tests and functionality for edge gateway updates and lifecycle
This commit introduces unit tests, E2E tests, and implementation updates related to edge gateway lifecycle management, including update handling, artifact validation, and rollback mechanisms. It also refines routing, component interaction, and backend methods to improve update tracking, status transitions, and artifact management.
2026-04-15 11:53:45 +02:00
Jeppe Bundgaard 95a8f114bc Refactor e2e test fixtures and formatting for consistency:
- Removed unnecessary string quotes from numeric keys in `cvrSearchResponses` across `mobilePos.js` and related test files.
- Reformatted multiline function chaining and object initialization for improved readability.
- Enhanced fixture setup logic to ensure consistent mock data handling across test scenarios.
2026-04-15 09:23:25 +02:00
Jeppe Bundgaard 6b91097f98 Add unit and e2e tests for auto-selecting non-barred customer suggestions and update POS vehicle forms:
- Implemented unit tests for `VehicleCustomerSuggestionsPos` to validate auto-selection and manual override behaviors.
- Added e2e scenarios to verify auto-application of non-barred customer suggestions in desktop POS.
- Refactored customer suggestion handling logic to support new `selectionSource`.
- Updated POS and booking components to improve multi-department booking handling and prevent cross-department conflicts.
- Enhanced booking match validation and streamlined customer suggestion methods.
2026-04-15 09:16:43 +02:00
Jeppe Bundgaard 00151ec237 Add unit tests and e2e coverage for POS mobile add-customer autofill functionality:
- Added unit tests for `applyPosAddCustomerSearchResult` to handle autofill field updates, manual overrides, and stale response rejection.
- Implemented a new e2e scenario for preserving manual edits during autofill updates and ignoring stale delayed responses.
- Refactored `mobilePos.js` fixture setup to include CVR search and customer registration mock logic.
- Added and integrated `PosDesktopCustomerConflictModal.vue` for UI handling of customer conflicts.
2026-04-15 08:56:30 +02:00
Jeppe Bundgaard b5a8199472 Reduce default Vitest batch size to 5, enforce single-threaded execution, and refactor unit test wait conditions:
- Adjusted `run-vitest-unit-batches.mjs` to lower default batch size from 10 to 5 and enforce single-threaded execution for more predictable test behavior.
- Updated GitHub workflows to align with new batch size defaults.
- Introduced `waitForCondition` helper in unit tests to replace arbitrary delays with predicate-based waiting.
- Refactored `useEdgeGatewayTerminal` for improved session handling, including idle delay for shell polling and cleanup of request queuing logic.
2026-04-14 17:55:38 +02:00
Jeppe Bundgaard c8f917ac30 Add batch support for Vitest unit tests and update workflows:
- Introduced `run-vitest-unit-batches.mjs` script for running unit tests in batches with configurable batch size.
- Updated `package.json` to replace `test:unit` command with the batch runner and added `test:unit:single` for direct execution.
- Tweaked GitHub workflows (`tests.yml`, `code_quality.yml`) to use updated actions and support batch testing.
- Minor formatting improvements in Vue components and workflows for consistent style.
2026-04-14 17:46:00 +02:00
Jeppe Bundgaard 586aba8531 Refactor collectedOrderInvoiceManageStripe.vue to integrate economic queue job for Stripe invoice booking:
- Replaced refs with computed properties for status determination (paid, draft, booked, etc.).
- Integrated `useEconomicQueueJob` for stripe transfer management, including enqueue, retry, and transport error handling.
- Streamlined Stripe payment data mapping with `computed` for enhanced maintainability.
- Updated template to include improved UI elements for transfer status and error feedback.
- Added progress indicators and notifications for queue operations.
2026-04-14 17:34:46 +02:00
Jeppe Bundgaard bf7c01ce25 Update Vite SCSS config: enable quietDeps and expand silenceDeprecations list 2026-04-14 17:30:39 +02:00
Jeppe Bundgaard 1236770513 Refactor e2e tests for improved readability and formatting consistency:
- Reformatted callback and function parameters across booking flow, POS, and visual test files.
- Enhanced readability by aligning multiline arguments with consistent indentation.
- Updated test messages for clarity and better context.
2026-04-14 17:24:54 +02:00
Jeppe Bundgaard 3d8203945d Skip unstable test scenarios for Firefox desktop and WebKit mobile; update visual snapshots. 2026-04-14 17:10:28 +02:00
Jeppe Bundgaard 2be33c89a7 Add Edge Broker service and configuration defaults
Introduced the Edge Broker service in the Docker Compose setup, configured to run on port 4300. Updated `.env.example` to include default environment variables for the Edge Broker and adjusted tests to validate the new configuration. Updated visual snapshot tests to reflect related UI changes.
2026-04-14 16:48:40 +02:00
Jeppe Bundgaard 631e9c4488 Enhance POS mobile e2e tests:
- Added fallback actions to handle missing customer selection and attachment toggles.
- Introduced visibility checks for crucial elements (e.g., "Take Picture" and "Close" actions).
- Updated visual test snapshots for duplicate warnings on desktop.
2026-04-14 16:32:16 +02:00
Jeppe Bundgaard 1afdda2650 Refactor POS workflows and improve booking flow:
- Updated `PayWithStripeButton` to handle setup requirements and improved error state handling.
- Enhanced POS mobile popup completion flow with added callback support and fallback actions.
- Introduced booking safety seal and improved step navigation logic.
- Added new e2e tests for booking confirmation, order completion, and error scenarios.
- Adjusted POS e2e and visual tests for updated workflows and UI changes.
- Refactored POS component logic for consistency and improved readability.
2026-04-14 16:19:11 +02:00
Jeppe Bundgaard 6f23941ea4 Add unit tests for ActionSettingsWheelButton i18n key coverage:
- Introduced validation to ensure all literal translation keys in the component are present across active locales.
- Enhanced coverage by integrating locale JSONs and adding missing key detection.
- Refactored button test setup with extracted `SETTINGS_WHEEL_TRANSLATION_KEYS` and reusable helper methods.
2026-04-14 14:32:01 +02:00
Jeppe Bundgaard 983585ede7 Refactor and cleanup:
- Migrate postinstall script to `postinstall-sync-playwright-root-links.mjs` for streamlined path resolution.
- Replace `axios` v1.15.0 with v1.13.5 and downgrade `vite` from v8.0.5 to v7.1.11.
- Update testing code for consistent formatting and enhanced readability (e.g., `poll` and `catch` calls).
- Remove unused or redundant dependency flags and align `package-lock.json` with new configuration.
2026-04-14 14:17:28 +02:00
Jeppe Bundgaard b01f400d2c Add e2e visual snapshots for Edge Gateways and POS workflows. 2026-04-14 13:50:25 +02:00
Jeppe Bundgaard ecec624aea Update axios to v1.15.0 and vite to v8.0.5; modify dependency flags for improved build configuration. 2026-04-14 13:48:43 +02:00
Jeppe Bundgaard bbbad2c634 Remove dependency on the ws module.
Deleted the entire `ws` library and its associated files. This likely reflects a move away from the WebSocket library, possibly signaling an alternate implementation or unused/legacy code cleanup.
2026-04-14 13:45:15 +02:00
Jeppe Bundgaard 4963fed0b5 Add unit and e2e tests for POS mobile popups:
- Introduced `headerCloseTestId` and `showHeaderClose` properties in visible-header popups for improved testability.
- Added unit tests to validate header close button behavior across popups.
- Enhanced e2e tests to verify popup visibility and header close functionality in mobile workflows.
2026-04-14 10:58:27 +02:00
Jeppe Bundgaard a4676f8f87 Add e2e and unit tests for i18n coverage and POS workflows:
- Introduced i18n key usage validation with `tests/e2e/i18n.views.spec.ts` for deterministic locale coverage.
- Added `viewI18nKeyScanner` utility to scan and validate view translation keys.
- Created `PosDepartmentStep1` unit tests for duplicate warnings and booking selection flow.
- Enhanced POS mobile popup with `SelectOrderBookingPopupProps` and new header close options.
- Updated e2e tests with scenarios to verify booking selections, duplicate handling, and locale alignment.
- Added new `test:e2e:i18n:views` npm script for targeted i18n test execution.
2026-04-14 10:49:20 +02:00
Jeppe Bundgaard 7533622fb1 Please provide the corresponding diff so I can generate an appropriate commit message. 2026-04-14 10:28:46 +02:00
Jeppe Bundgaard 90518308f8 Add e2e tests for POS desktop card payment flow:
- Added tests for handling misconfigured terminal locations with superuser and operator guidance.
- Verified behavior for zero readers, existing payment intents (`requires_capture` and `succeeded`), and happy path payment flows.
- Introduced `bootDesktopCardPayment` helper for streamlined test setup.
2026-04-14 10:28:29 +02:00
Jeppe Bundgaard e88c2d14d5 Refactor POS booking flow and error handling:
- Improved error state management with `normalizeErrorState` and `clearErrorState` utilities.
- Enhanced Stripe payment flow with clear status messaging and support for terminal setup requirements.
- Updated POS component templates (`PosDepartmentStep3.vue`, `PayWithStripeButton.vue`) for better error messages and UX.
- Added `pos-step-4` navigation and tests in e2e flows.
- Introduced utility `toPositiveInteger` for robust numeric validation in bookings.
2026-04-14 10:27:29 +02:00
Jeppe Bundgaard c14f0cc1c4 Add utilities and tests for mobile order management and Subuser navigation:
- Introduced `mobileOrderCompletion.js` for mobile order metadata handling with safety seal syncing, metadata persistence, and booking linking utilities.
- Added tests for Subuser navigation menu (`navigation-menu-items-user-subusers.spec.js`) to validate links and permission gating.
- Created unit and end-to-end tests for Subuser grant permissions and management, including permission nodes and invites (`subuser-grant-permission-nodes.spec.js`, `subuser-management.spec.ts`).
- Implemented `SubuserCompleteRegistrationPage.vue` with validation, token handling, and registration flow for Subuser setup.
- Enhanced Playwright e2e tests for Subuser features (`subuserCompleteRegistration.spec.ts`).
2026-04-14 10:09:10 +02:00
Jeppe Bundgaard a93b0dc954 Add tests and utilities for dynamic booking preload handling in POS flow:
- Introduced logic and utilities to fetch exact plate bookings beyond initial preload (`ensureVehiclePlateBookingsLoaded`, `mergePendingBookings`).
- Enhanced booking dropdown and chooser to handle dynamically loaded data for desktop POS.
- Updated e2e tests (`pos-flow.spec.js`) with scenarios for exact and partial plate matches in booking preload handling.
- Refactored `network.js` to support paginated and filtered responses for bookings based on registration fields.
- Improved UX and display logic in booking selectors (`LicensePlateReg1Input.vue`, `PosDepartmentProcess.vue`).
2026-04-13 21:53:16 +02:00
Jeppe Bundgaard 6549eb5015 Refactor POS booking flow and dropdown logic:
- Enhanced order booking sorting with improved date handling and validation (`getOrderBookingSortDateValue`, `hasOrderBookingSortDateValue`).
- Updated dropdown interaction in `LicensePlateReg1Input.vue` for better selection behavior and UX.
- Revamped test data and assertions in e2e tests with added coverage for new booking scenarios.
- Improved dropdown styling and responsiveness in `LicensePlateReg1Input.vue`.
2026-04-13 21:42:18 +02:00
Jeppe Bundgaard 0f37b0632a Add POS order booking modal and utilities with tests:
- Introduced `PosDesktopOrderBookingSelectorModal.vue` to streamline booking selection for desktop POS.
- Added `orderBookingDisplay.js` utility for handling booking display and reference value normalization.
- Created unit test `select-vehicle-form-pos.spec.js` to validate booking selection flow and registration matching.
- Implemented `run-playwright-ci-parallel.mjs` script to optimize Playwright CI with parallel testing.
- Updated styles and responsiveness for enhanced booking modal UX.
2026-04-13 21:12:34 +02:00
Jeppe Bundgaard 7d9d928a2d Normalize license plate input value handling in LicensePlateInput.vue:
- Ensured input field values are updated consistently with `props.actualValue`.
- Replaced raw `newValue` usage with normalized string values for better data integrity.
- Improved checks for synchronization and validation logic (`checkLicensePlateValid`, `searchCustomerRegistrationNumbers`).
2026-04-13 18:02:06 +02:00
Jeppe Bundgaard ce049d9196 Enhance POS booking flow logic and tests:
- Added support for trailers (`reg_2`) in booking selection and vehicle matching logic.
- Implemented logic to prioritize bookings from registered vehicles and trailers.
- Enhanced booking dropdown with date labels and validation for booked vehicles.
- Improved e2e tests to cover booking date, trailer registration, and vehicle-search scenarios.
- Refactored Vue components for consistency in booking handling and dropdown interaction.
2026-04-13 17:56:19 +02:00
Jeppe Bundgaard 8f3ec2cf09 Add booking selection popup and refactor POS booking flow logic:
- Introduced `PosDepartmentStepMobilePopupSelectOrderBooking.vue` for mobile booking selection.
- Refactored vehicle and booking handling in `SelectVehicleFormPOS.vue` with better plate matching, booking persistence, and customer association logic.
- Updated `PosVehicle` object type to include booking matches.
- Enhanced POS transaction history and booking views with improved readability, functionality, and `data-testid` attributes.
- Adjusted `PosPopup.vue` components to support the new booking popup.
2026-04-13 17:27:37 +02:00
Jeppe Bundgaard ecfe27571c Refactor booking flow tests and enhance product category filtering logic:
- Improved vehicle type selection handling with better validation and polling in `bookingFlow.ts`.
- Updated product card selectors for enhanced visibility and interaction in booking flow tests.
- Added `matchesProductCategory` utility in `network.js` for flexible and accurate product category filtering.
2026-04-13 15:44:15 +02:00
Jeppe Bundgaard 53d1ae114f Enhance booking flow and add customer validation handling:
- Introduced `ensureVehicleTypeSelection` utility to streamline vehicle type selection in `bookingFlow.ts`.
- Updated booking flow to conditionally handle vehicle type selection for desktop wizards.
- Added customer number validation logic in `network.js` to simulate backend responses for `/guest/validation/customer-number`.
2026-04-13 15:34:17 +02:00
Jeppe Bundgaard 653aa63899 Refactor POS components and implement new features:
- Replaced `POSOrderReference.vue` with `OrderAttachmentsActionButton.vue` and added new features like attachment preview, upload, and management.
- Introduced `POSOrderCustomerWishes.vue` for handling customer wishes fields with autosave behavior.
- Added Playwright global setup/teardown scripts for managing dev server lifecycle during tests.
- Enhanced booking flow test utilities and adjusted visibility rules in `bookingFlow.ts`.
2026-04-13 15:27:34 +02:00
Jeppe Bundgaard 8a25dcd97d Update attachment actions in orders table and e2e tests:
- Modified attachment controls to display "+" button when no attachments exist and ensured proper alignment with settings button.
- Refactored `shouldShowOrderAttachmentsButton` logic for improved clarity and access determination.
- Enhanced Vue component (`ordersTable.vue`) to handle attachment addition and visibility, adding support for dynamic labels and icons.
- Updated e2e tests (`admin-pos-orders.spec.ts`) to verify alignment, visibility, and interaction of attachment controls in desktop order list.
- Added CSS rules to improve positioning and responsiveness of order actions.
2026-04-13 14:45:29 +02:00
Jeppe Bundgaard 3e8a7ee3db Update POS styling and refactor Vue component for consistency:
- **CSS Enhancements:** Added `.pos-registration-control` class, improved styling for registration fields, and enhanced responsiveness for `.pos-order-items--order-detail`.
- **Vue Refactor:** Standardized imports, object keys, and quotes in `ActionSettingsWheelButton.vue` to align with consistent formatting and readability.
2026-04-13 14:28:12 +02:00
Jeppe Bundgaard 295f69ee41 Implement POS registration field and autosave utility:
- Added `PosOrderRegistrationField.vue` for managing inline registration field inputs with autosave behavior.
- Introduced `useOrderMetadataAutosave.js` composable for seamless value normalization, dirty tracking, and delayed persistence.
- Refactored mobile and desktop POS components to integrate `useOrderMetadataAutosave` for improved field management (e.g., `POSOrderReference.vue`, `PosOrderLicensePlates.vue`).
- Updated e2e tests to cover registration field scenarios, including input normalization and persistence (`pos-mobile-order-flow.spec.js`, `admin-pos-orders.spec.ts`).
- Enhanced styling and responsiveness for registration fields in `pos.css`.
2026-04-13 14:20:36 +02:00
Jeppe Bundgaard ade565cbca Introduce completeBookingCreationFlow utility and refactor booking flow tests:
- Added `completeBookingCreationFlow` utility to streamline booking creation logic in `bookingFlow.ts`.
- Refactored `userBookings.spec.ts` to replace inline booking creation steps with the new utility for improved readability and consistency.
- Applied `data-testid` attributes across booking-related components for enhanced testability and test coverage.
- Enhanced Playwright navigation error handling with `isBenignNavigationError` and `settleAuthenticatedNavigation` utilities to improve resilience and test robustness.
2026-04-13 13:39:48 +02:00
Jeppe Bundgaard 828e2fd312 Add test cases for internationalization, orders table rendering, and error handling:
- **Internationalization:** Added end-to-end test `i18n.smoke.spec.ts` to validate Danish locale translations. Ensured normalization and removed unexpected strings in the `da` locale file.
- **Orders Table:** Created unit test `orders-table.spec.js` to verify sparse data handling, invoice collection preloading, and rendering correctness.
- **Error Handling:** Enhanced connectivity issue UI (`ConnectivityIssue.vue`) with `data-testid` attributes for improved testability.
- **E2E Playwright Tests:** Updated and simplified e2e test utilities. Replaced `seedAuthenticatedState` with `primeMockSession` for session preparation. Added new tests for `/user/orders` and `/user/invoices` pages focusing on edge cases and maintaining structure consistency.
2026-04-13 12:44:06 +02:00
Jeppe Bundgaard 2969305dd5 Refactor POS desktop order workspace layout and add phone normalization utilities:
- Improved structure and layout in `PosDesktopOrderWorkspace.vue` with refined column handling and responsiveness.
- Added `resolveNormalizedPhoneNumber` utility in `POSDepartmentProcess.vue` for consistent phone number normalization across customer records.
- Updated e2e tests to validate layout changes and phone handling behavior.
- Upgraded Playwright to version 1.58.2 and adjusted related configurations.
- Ensured consistent formatting and added `postinstall` script for syncing Playwright root links.
- Strengthened POS customer rule tests with edge case validations for phone number objects.
2026-04-13 09:44:50 +02:00
Jeppe Bundgaard b39b458f4f Add .prettierrc.json and refactor test files for improved formatting consistency:
- Introduced `.prettierrc.json` to enforce consistent code formatting across the project.
- Updated unit and e2e test files to address formatting issues, improve readability, and ensure alignment with the new Prettier configuration.
2026-04-13 09:13:27 +02:00
Jeppe Bundgaard f040614f46 Refactor module paths, layout responsiveness, and testing configurations:
- Replaced repetitive department module URL logic with `buildDepartmentModulePath` utility in `DepartmentModulesDisplay.vue`.
- Enhanced layouts and responsive behavior across components, including `PosDepartmentStep1MobileTransactionHistory.vue` and `DepartmentDailyReport.vue`.
- Removed unused `SuperuserInvoicingLocalStore.vue` and refactored to `SuperuserInvoicingLocalStore.ts`.
- Updated media query handling and card layout adjustments in `DepartmentDailyReport.vue`.
- Added new Playwright configurations (`playwright.prod.config.ts` and `playwright.live.config.ts`) for e2e release workflows.
- Extended e2e and unit test coverage for mobile POS and department modules.
2026-04-09 16:32:25 +02:00
Jeppe Bundgaard e8bd2f5478 Add employee fixtures, mobile POS customer note popup, and alignment updates:
- Introduced employee fixtures in `mobilePos.js` for testing workflows involving employee data.
- Enhanced `PosDepartmentStep1MobileCustomerNotes.vue` with translated labels and data-test attributes.
- Added e2e tests for customer notes popup and translated label validation.
- Improved alignment and responsiveness for `PaginationDisplayGeneralSearchReload.vue` and related e2e tests.
- Updated button logic and styles in `GenericButton.vue` and other mobile POS components for consistent UI behavior.
2026-04-09 14:47:43 +02:00
Jeppe Bundgaard 64aa94ebc3 Add alignment utilities and enhance mobile POS e2e tests:
- Introduced helper functions (`expectHorizontallyCentered`, `expectSameRow`, `expectAboveFixedActions`) for alignment checks in `pos-mobile-order-flow.spec.js`.
- Enhanced e2e tests with layout validations for titles, subtitles, and form inputs.
- Added `reserveSpace` prop and CSS variable handling to `PosDepartmentStepMobileFixedBottomControl.vue` for better spacing control.
- Improved layouts and responsiveness in mobile POS components like `PosDepartmentStepMobile2.vue` and `ScannerInstructions.vue`.
2026-04-09 14:14:56 +02:00
Jeppe Bundgaard 157c3dbf60 Add enhanced POS desktop workspace with customer rail and order summary:
- Introduced `PosDesktopOrderWorkspace.vue` for improved order and customer management.
- Added `PosOrderItemsDefaultTable.vue` with detailed row actions, price tooltips, and order item grouping.
- Enhanced `LicensePlateReg1Input.vue` to preserve customer selection when switching license plates.
- Improved `PosDepartmentStep3.vue` with updated desktop layout using the new POS workspace.
- Added e2e test for customer rules tab to verify dynamic attribute reloading.
2026-04-09 14:03:46 +02:00
Jeppe Bundgaard 2bf1f5b385 Add enhanced department complaint functionality with category and date support alongside POS improvements:
- Introduced `departmentDailyReportComplaintCategories.js` for managing complaint categories with validation utilities.
- Added category and wash date fields to the complaint editing interface with validation in `DepartmentDailyReportComplaints.vue`.
- Developed `PosOrderDetailItemsTable.vue` for displaying order details with inline actions and price highlighting.
- Implemented `PosOrderItemEditModal.vue` for editable order items with validations for price and quantity.
- Created unit tests for `PosLastScannedLicensePlatesV2.vue` to verify scanning, caching, and error handling behaviors.
2026-04-09 12:55:30 +02:00
Jeppe Bundgaard 78648a0b7f Add customer complaints, edge gateway terminal, and mobile POS functionalities with e2e and unit test coverage:
- Implemented `departmentDailyReportComplaintCustomers.js` module for handling customer complaint data, including search, normalization, and API integration.
- Added `EdgeGatewayTerminal.vue` for root shell access with session approval, dynamic status handling, and user feedback.
- Developed `mobilePos.js` for simulating mobile POS fixtures and workflows in e2e tests.
- Introduced relevant e2e and unit tests to ensure functionality and error handling across modules.
2026-04-09 11:35:18 +02:00
Jeppe Bundgaard 90427c631f Add customer complaints page for superusers with pagination, filtering, and e2e test coverage:
- Implemented `CustomerComplaints.vue` and `CustomerComplaintsPagination.vue` for managing complaints with department filters, search functionality, and sorting.
- Added `CustomerComplaintsTable.vue` for displaying complaint details with edit and delete actions.
- Introduced `DepartmentDailyReportComplaints` module for managing CRUD operations and validation.
- Added e2e tests for complaint workflows, including pagination, filtering, and customer interaction validation (`superuser-customer-complaints.spec.ts`).
- Included validation logic and error handling for phone call gate configurations (`DepartmentGates.vue`).
- Enhanced department gate e2e tests with validation scenarios (`superuser-department-gates.spec.ts`).
2026-04-08 20:41:14 +02:00
Jeppe Bundgaard 29c6c9d135 Add e2e tests for admin module functionalities:
- Introduced tests for admin daily report features, including tile rendering, complaint management, and stale response handling.
- Added department visibility tests to ensure hidden departments are excluded from UI controls.
- Implemented night wash overview tests for breakdown validation, missing hours detection, and stable range updates.
2026-04-08 19:00:24 +02:00
Jeppe Bundgaard 8eb315f439 Add comprehensive unit and e2e tests for invoice collection, queue logic, and modal interactions:
- Introduced e2e test for "Change Invoice Collection" in `change-invoice-collection.spec.ts`.
- Added unit tests for `CollectedOrderInvoiceOverview.vue` including edit flow for `closed_at`.
- Implemented tests for queue reliability, state handling, and customer actions:
  - `CollectedOrderInvoicesQueueHistory.vue`: polling, error handling, richer diagnostics, and retry logic.
  - `InvoicingBillingPeriod` views: refresh and queue state handling.
- Enhanced test coverage for Stripe queue functionality and related actions.
2026-04-08 15:53:52 +02:00
Jeppe Bundgaard 2b6129db29 Add economic queue functionality with Vue component, unit tests, and e2e tests:
- Introduced `CollectedOrderInvoicesQueueHistory.vue` for displaying and managing job queue history.
- Added queue management features: polling with active status detection, error handling, retry logic, and pagination controls.
- Implemented e2e tests for economic queue workflows, including export polling, retries, validation errors, and component unmount behavior.
- Added unit tests for queue logic, payload normalization, and retry scenarios.
- Created `EconomicQueuePlaywrightHarness.vue` to support e2e testing with mock workflows.
2026-04-08 11:19:16 +02:00
Jeppe Bundgaard 1705d0d0ed Add unit tests and enhance logic for department gate capabilities and XLVask usage filtering:
- Introduced `getDepartmentOutsideGateCapabilities` with caching, input normalization, and error handling.
- Added tests for department gate detection, normalization, and fallback scenarios.
- Implemented `XLVaskUsageFilters` to filter and detect attached usage orders.
- Enhanced `XLVaskUsagePagination` with computed visible object counts and filtering functionality.
2026-04-07 14:02:34 +02:00
Jeppe Bundgaard beaf95a5b1 Add outside gate controls to SelfServeMachineRelayControls with comprehensive tests:
- Introduced `openOutsideEntranceGate` and `openOutsideExitGate` actions.
- Added dynamic button rendering based on department gate capabilities.
- Implemented unit tests for gate visibility and action triggers.
2026-04-01 17:12:00 +02:00
Jeppe Bundgaard 19501896e1 Add comprehensive test coverage for department gates and self-serve wash actions:
- Introduced unit tests for `DepartmentGates` config modal and gate commands in `useWashSessionActions`.
- Added E2E tests to verify gate commands and button functionality in self-serve in-progress flow.
- Updated localization files to include gate control labels (`open_property_access_gate`, `open_property_exit_gate`).
- Enhanced `DepartmentGates.vue` with config modal logic, field validation, and dynamic input rendering.
2026-03-26 16:32:34 +01:00
Jeppe Bundgaard ce9a209d73 Add property gate controls to self-serve wash functionality:
- Introduced `openPropertyAccessGate` and `openPropertyExitGate` actions in `useWashSessionActions.js`.
- Added Vue button components for gate control in `MyWashStart.vue`.
- Updated localization files with translations for gate control labels.
2026-03-26 16:00:32 +01:00
Jeppe Bundgaard 0fe2e19211 Extend MachineStatusType and enhance self-serve machine status logic:
- Added `UNKNOWN` and `VALID` statuses to `MachineStatusType` with corresponding icon and style mappings.
- Updated `SelfServeMachineRelayControls` to use `UNKNOWN` and `VALID` statuses for machine and customer displays.
- Introduced `decimalRound` function for precise rounding in `DepartmentWeather.vue` data rendering.
- Applied `decimalRound` to display hours with two decimal precision in department reports.
2026-03-26 15:31:28 +01:00
Jeppe Bundgaard d354ce7737 Enhance self-serve functionality with dynamic images, gate handling, and machine-scoped vehicle type filtering:
- Added dynamic image previews with query parameter handling and error recovery in `SelfServeTryModal.vue`.
- Introduced outside gate controls (`Entrance` and `Exit`) in `SelfServeMachineRelayControls.vue` with corresponding command handling logic.
- Enhanced vehicle type scoping logic in `SelfServeTasksPagination.vue` and `SelfServeConditionsPagination.vue` to ensure machine-type filtering consistency.
- Updated related unit tests to verify new dynamic image logic, gate controls, and scoping behaviors.
2026-03-26 14:13:15 +01:00
Jeppe Bundgaard 3e84421913 Introduce drag-and-drop task reordering in DepartmentSelfServeStudio.vue:
- Added drag-and-drop functionality for task prioritization, with visual indicators and reordering logic.
- Updated UI to support draggable tasks and ensure accessibility for admin users.
- Included unit tests to verify drag-and-drop behavior, task priority updates, and error handling.
2026-03-26 11:40:01 +01:00
Jeppe Bundgaard 7368c476f1 Add task priority, gate type normalization, and vehicle type logic to self-serve functionalities:
- Introduced task order priority, dynamic image thumb position, and improved task gate type handling in the self-serve studio.
- Enhanced task payload normalization with nullable fields and parameter verification.
- Updated UI with order priority controls, dynamic images configuration, and machine type-specific scoping options.
- Added and refined unit tests for task sorting, form controls, and validation rules.
2026-03-26 11:35:24 +01:00
Jeppe Bundgaard 21be6c0efd Add unit tests for legacy and machine-type scoping in DepartmentSelfServeStudio.vue:
- Introduced tests for legacy scope selectors and task submission validation.
- Added guards for vehicle type, lane, and department scoping in questions and tasks.
- Verified cache clearing and consistent data loading for self-serve studio.
- Updated navigation and localization for new self-serve paths.
2026-03-26 09:39:03 +01:00
Jeppe Bundgaard 173a0becdf Add unit tests for DepartmentSelfServeStudio.vue condition and vehicle type scoping:
- Added tests to verify legacy scope selectors for lanes and vehicle types.
- Ensured condition submission is guarded for both machine type and legacy scopes.
- Validated vehicle type scoping with product query filtering and new record defaults.
2026-03-25 19:33:08 +01:00
Jeppe Bundgaard 4d810247c5 Add vehicle type scoping to DepartmentSelfServeStudio.vue:
- Introduced `scopedProductId` for vehicle-specific filtering and configuration adjustments.
- Added `Vehicle Type` dropdown for filtering questions, conditions, and tasks.
- Enhanced validation for department, lane, and vehicle type scoping.
- Updated UI to include vehicle type scoping indicators and selection components.
- Added alignment fixes for action buttons and corresponding unit test coverage.
2026-03-25 19:32:59 +01:00
Jeppe Bundgaard 30d23aeaf8 Add DepartmentSelfServeStudio.vue for advanced self-serve configuration management:
- Introduced a new Vue component supporting versioned configuration authoring for questions, conditions, tasks, and rules.
- Implemented scoped editing with lane and machine type-specific configurations.
- Added version tracking features, including validation, publication, and rollback.
- Enhanced UI with tabbed navigation, modals for adding/editing records, and notifications for actions.
2026-03-25 17:55:17 +01:00
Jeppe Bundgaard 2c22a548e1 Extend self-serve functionality with new machine statuses, config version tracking, and detailed session displays:
- Added `INCLUDED` and `BILLABLE` statuses to `MachineStatusType` for enhanced session tracking.
- Introduced `configVersionId` and `evaluationTrace` support in self-serve logic and UI.
- Enhanced `SelfServeMachineRelayControls` with billable session parsing and UI updates.
- Updated `SelfServeMachineStatus` to reflect new statuses with appropriate icons and labels.
- Improved session summary displays with detailed evaluation trace output.
2026-03-25 16:15:19 +01:00
Jeppe Bundgaard 5b181087ff Upgrade dependencies in package-lock.json:
- Updated `@vitejs/plugin-vue`, `@vitejs/plugin-vue-jsx`, `@asamuzakjp/dom-selector`, `@azure/msal-browser`, and various `@babel` packages.
- Introduced new sub-dependencies and licenses.
- Removed legacy nested dependencies.
2026-03-25 15:23:17 +01:00
Jeppe Bundgaard c184054380 Merge remote-tracking branch 'origin/master' 2026-03-25 15:12:05 +01:00
Jeppe Bundgaard 5b440a6948 Add tooltips to relay controls for improved self-serve machine guidance:
- Enhanced `SelfServeMachineRelayControls` with detailed `b-tooltip` descriptions for Machine, Program Picker, and Cleaner relays.
- Added `b-tooltip` to `SelfServeMachine` for the "Allow Self-Serve" toggle, explaining its impact on machine accessibility.
- Updated imports to include `BTooltip` from Buefy.
2026-03-25 15:11:34 +01:00
Jeppe Bundgaard 844d57b1a5 Add unit tests and logic for department self-serve enablement:
- Implemented department self-serve status management in `SelfServeMachine` with fetching, updating, and error handling.
- Added unit tests for status retrieval, toggle behavior, and error scenarios.
- Enhanced UI with `b-switch` for self-serve enablement control.
2026-03-25 14:50:07 +01:00
Jeppe Bundgaard caec066c6c Add department self-serve management with API integration and tests:
- Introduced `DepartmentModulesDisplay` and `DepartmentModulesSetup` components for self-serve enablement management.
- Implemented `getDepartmentSelfServeEnabled` and `setDepartmentSelfServeEnabled` helpers.
- Added unit tests for self-serve status fetching, updating, and UI synchronization.
- Enhanced connectivity and relay logic with in-progress wash session handling and detailed displays.
2026-03-25 14:06:22 +01:00
gold-dev 2877979660 Feat: Charts dashboard 2026-03-25 15:00:57 +02:00
Jeppe Bundgaard d03b7d86bf Add shared gate control logic and in-progress wash API integration:
- Implement shared gate relay handling in `SelfServeMachineRelayControls` with unified button for entrance/exit.
- Add unit tests for gate control variations (shared vs. separate relays).
- Extend OpenAPI with endpoints for in-progress wash session details and gate operations.
- Update `MachineType` with relay configurations for comprehensive gate handling.
2026-03-25 13:39:38 +01:00
Jeppe Bundgaard 2c0f012446 Add gate control functionality to self-serve connectivity:
- Implemented `openEntranceGate` and `openExitGate` actions in connectivity relay.
- Added gate normalization logic and error handling for invalid gate types.
- Updated components and unit tests to support entrance/exit gate opening.
2026-03-25 13:25:20 +01:00
Jeppe Bundgaard 620c4f0859 Add comprehensive unit tests for connectivity relays and machine store:
- Implemented tests for self-serve connectivity relay API wrappers, covering status retrieval, set operations, and lane commands.
- Added tests for the machine connectivity store, including relay toggling, polling, force operations, and error handling.
- Refactored relay functionality to improve maintainability and testability, introducing helper functions for lane and relay commands.
- Updated machine and connectivity-related components to align with new relay definitions and shared store logic.
2026-03-25 13:20:51 +01:00
Jeppe Bundgaard de28f2cd49 Add MachineRelayType and refactor relay handling for self-serve dashboard:
- Added `MachineRelayType` for defining relay types and statuses.
- Refactored relay logic into `SelfServeConnectivityRelay` for cleaner separation of concerns.
- Updated `MachineType` to include relay configurations for machine, program picker, and cleaner.
- Replaced inline relay handling in `SelfServeMachineConnectivity` with the reusable `SelfServeConnectivityRelay` module.
2026-03-25 12:38:34 +01:00
Jeppe Bundgaard 81bd4c8583 Add interval-based status polling and toggle functionality to SelfServeMachineRelayControls:
- Refactored relay status retrieval with a polling mechanism using `setInterval`.
- Added relay toggle controls (`b-switch`) for machine, program picker, and cleaner relays.
- Improved error handling for relay status fetch and toggle operations.
- Updated lifecycle hooks to clean up polling on component unmount.
2026-03-25 12:24:10 +01:00
Jeppe Bundgaard 97bcf73db8 Add relay machine controls and program picker integration:
- Added `relay_machine_program_picker_id` and `relay_machine_cleaner_id` fields across components, views, and localization files.
- Updated relay control logic to include program picker and cleaner relays.
- Extended e2e and unit tests to validate new relay functionalities.
- Refactored related components to handle expanded relay configurations.
2026-03-25 11:42:02 +01:00
Jeppe Bundgaard c6531076ae Add Department Self-Serve dashboard with machine relay controls and API integration:
- Implemented `DepartmentSelfServe` dashboard for managing machine statuses and relays.
- Added components for machine display, controls, and relay connectivity (`SelfServeMachine`, `SelfServeMachineControls`, `SelfServeMachineRelayControls`).
- Integrated machine relay API endpoints (`/modules/self-serve/lane/relay/machine/*`) for status retrieval and updates.
- Updated router configuration with a new route for the self-serve dashboard.
- Extended OpenAPI definition to include machine relay APIs and enhance department weather targets documentation.
2026-03-25 11:24:20 +01:00
Jeppe Bundgaard 1b97332c38 Add unit tests for shared date range synchronization and refactor DepartmentDashboardOverviewNavigation components:
- Added behavior tests covering shared date range updates across multiple components, including `DepartmentDailyReportThisWeek` and `DepartmentDailyReportSmall`.
- Updated `DepartmentDashboardOverviewNavigation` to route date updates via the shared daily report store.
- Refactored components to use shared `DepartmentDailyReportObject` for date state management.
- Adjusted e2e tests to validate date range synchronization and department goal progress tracking.
2026-03-24 16:53:16 +01:00
Jeppe Bundgaard 477688d16e Add cadence target calculation for timeframe-based goal progress resolution:
- Implemented `computeCadenceTargetForTimeframe` utility to calculate dynamic targets based on timeframe and cadence criteria.
- Extended tests to validate timeframe-specific progress key selection, slice resolution, and cadence-based targets.
- Refactored `DepartmentGoals` to integrate cadence-aware target resolution and fallback logic.
- Enhanced e2e tests to cover bi-weekly cadence goals and dynamic target updates across timeframes.
2026-03-24 16:32:35 +01:00
Jeppe Bundgaard bc043b95f9 Refactor DepartmentWeather status computation with work-hour-based thresholds: add health categories and fallback for zero hours. 2026-03-24 15:07:55 +01:00
Jeppe Bundgaard 1e6590e2df Add goal progress resolver utility with extensive tests and DepartmentGoals refactor.
- Implemented `goalProgressResolver` utility for resolving progress keys, slices, and periods.
- Refactored `DepartmentGoals` to integrate `goalProgressResolver` for improved clarity and modularity.
- Updated progress calculations with cadence-aware key handling and fallback resolution.
- Add isLoading handling and loader animations to `DepartmentWeather` and improve `GET` request concurrency.
2026-03-24 14:58:48 +01:00
Jeppe Bundgaard 8b8780bbaf Refactor DepartmentWeather for key-based rendering and summary toggle improvements:
- Added `getEntryKey` function to generate unique keys for dynamic entries.
- Enhanced `isMultiDaySelection` watcher with immediate execution option.
- Updated DOM structure for action buttons with inline export button support.
- Adjusted unit tests to cover functional changes and new test case for summary mode toggling.
2026-03-24 13:59:38 +01:00
Jeppe Bundgaard 5a0d1b7712 Add daily summary toggle to DepartmentWeather with tests and refactor:
- Implemented a daily summary toggle for multi-day selections in `DepartmentWeather`.
- Refactored state computation to support hourly and daily views with weather/status aggregation.
- Added unit tests for summary toggle functionality and entry summarization logic.
2026-03-24 13:39:15 +01:00
Jeppe Bundgaard 3cc0a5bb15 Add tests for DepartmentWeather and update daily report wiring:
- Added unit tests for `DepartmentWeather` component to validate weather API calls, state handling, and edge cases (e.g., no departments, stale responses, hidden hours).
- Updated `DepartmentDailyReport` wiring to pass `department_ids`, `date_from`, and `date_to` to `DepartmentWeather`.
- Enhanced OpenAPI definition for weather timeline API with refined query parameters (`date_from`, `date_to`) and updated descriptions.
- Improved `GoalFormModal` layout, adding `department-grid--single-scroll` for better scrolling behavior and responsiveness.
2026-03-24 13:33:17 +01:00
Jeppe Bundgaard ff944ad1f2 Refactor goal form modal, update internationalization strings, and enhance API parameters:
- Restructured goal form modal with new tabbed layout for better usability.
- Updated and corrected i18n strings across multiple languages.
- Enhanced API descriptions for department weather timeline, added support for multiple department IDs, and refined query parameter logic.
2026-03-24 13:07:26 +01:00
Jeppe Bundgaard 1ee2da7ad8 Add advanced goal criteria handling with input normalization, duration modes, and improved validation:
- Introduced target duration modes (`weeks`, `months`, `years`, `legacy`) with optional cadence.
- Implemented input normalization for department daily targets and progress alerts.
- Added validations for duration cadence and date ranges.
- Updated internationalization strings for new form fields and validation messages.
- Extended tests for goal criteria serialization, normalization, and API payload generation.
2026-03-24 12:48:54 +01:00
Jeppe Bundgaard f372e59a07 Enhance Workfeed diagnostics and localization:
- Updated `listShifts` diagnostic modal to include new filters (date range, employee, release state) with improved UI/UX.
- Simplified employee and department lookup diagnostics by removing unnecessary search inputs.
- Standardized error handling and validation messages for shift diagnostics.
- Extended localization files across all supported languages with new diagnostic strings.
- Adjusted Workfeed module logic to reflect updated query parameters (`startFrom`, `startTo`, `employeeID`, `released`).
- Updated and added unit and e2e tests to validate new diagnostic flows and parameter constraints.
2026-03-24 11:52:15 +01:00
Jeppe Bundgaard eeef782705 Simplify Workfeed schemas and endpoint descriptions:
- Removed unnecessary query parameters and pagination fields from `employees`, `shifts`, and `departments` endpoints.
- Standardized `description` fields with explicit API request details.
- Consolidated schema components (e.g., `WorkfeedPagination`) and enhanced examples for clarity.
- Updated API URL from `https://api.workfeed.io` to the new production endpoint.
- Improved shift schemas with granular fields for approvals, breaks, and comments.
2026-03-24 11:37:16 +01:00
Jeppe Bundgaard 44031da7b1 Add support for CompanyID in Workfeed configuration and update localization files:
- Extend i18n locales to include `company_id` and `company_id_desc` strings for API synchronization.
- Add `CompanyID` field to Workfeed configuration UI in `ConfigurationWorkfeed.vue`.
- Update unit and e2e tests to validate `CompanyID` functionality.
- Adjust Workfeed module logic to handle `CompanyID` get/set operations.
2026-03-24 11:22:36 +01:00
Jeppe Bundgaard 943bc8ec97 Add Workfeed module with configuration management, diagnostics, and API integration:
- Implement `Workfeed` module with endpoints for employees, shifts, departments, and diagnostics.
- Add configuration UI for enabling Workfeed, API settings (`api_url`, `api_key`), and integration diagnostics.
- Add unit tests for module schema validation, API endpoints, and diagnostics contract.
- Introduce smoke test for Workfeed configuration and modal diagnostics.
- Extend i18n locales to support Workfeed integration.
2026-03-24 11:19:09 +01:00
Jeppe Bundgaard 0949d861f9 Add detailed examples and extend schema definitions for Workfeed configurations:
- Introduced examples for `WorkfeedConfigResponse` to demonstrate configurations like `enabled`, `api_url`, `api_key`, and `CompanyID`.
- Split `WorkfeedConfigEntry` into specific schema components with updated discriminator logic for easier validation and understanding.
- Enhanced OpenAPI documentation for `Workfeed` module configuration endpoints.
2026-03-24 10:56:45 +01:00
Jeppe Bundgaard 2903cdf57d Add Workfeed module endpoints and schemas to OpenAPI:
- Introduced `Workfeed` API endpoints for employees, shifts, and departments with optional filtering and pagination.
- Added configuration endpoints for Workfeed including `getWorkfeedConfig` and `updateWorkfeedConfig`.
- Defined schema components for `WorkfeedEmployee`, `WorkfeedShift`, `WorkfeedDepartment`, and related list/pagination responses.
- Updated tags to include new `Security` and `Modules` categories.
2026-03-24 10:37:05 +01:00
Jeppe Bundgaard 66682040e7 Merge remote-tracking branch 'origin/master' 2026-03-20 13:18:39 +01:00
Jeppe Bundgaard 2685888e24 Add unit tests for ActionSettingsWheelButton and refactor user resolution logic:
- Add unit tests covering `ActionSettingsWheelButton` behavior for resolving user ID and customer lookups.
- Refactor `ActionSettingsWheelButton` to use `watch` for reactive user ID and customer number changes.
- Replace `getUserId` with `resolveUserId` for improved error handling and cancellation of outdated requests.
- Simplify `hasUser` logic using a computed property and update dependent logic accordingly.
2026-03-20 13:16:33 +01:00
Jeppe Bundgaard 43cf2b302d Add tests for EditableTableColumn and fix tooltip multiline logic:
- Add unit tests for `EditableTableColumn` covering tooltip multiline behavior with `hoverHtml`.
- Update tooltip logic to use `isTooltipMultiline` computed property for slot checks.
- Adjust `OrderBookingsTable` to fix incorrect usage of `permissionCheckFunction` arguments.
- Optimize `RequestQueueProgress` state handling by removing unused visibility logic.
- Add unit test to ensure no recursive update errors occur during large request processing.
2026-03-20 13:10:35 +01:00
gold-dev add9839916 Feat: 202603 - truckwash.io dashboard V3 - slides 2-5 2026-03-20 11:23:01 +02:00
Jeppe Bundgaard f9bde54956 Add component-level permission tracking and visibility handling:
- Introduce `RequiresPermission` component for permission-based UI controls.
- Add missing permission reporting logic with throttling to `requestQueueState`.
- Update `DepartmentGoals.vue` to wrap actions with `RequiresPermission`.
- Extend unit and e2e tests to validate permission handling scenarios.
2026-03-19 18:29:00 +01:00
Jeppe Bundgaard 6ea51a70d8 Add system-search:close event handling and triple-Shift dispatch for superuser:
- Handle `system-search:close` events in `NavigationMenuGlobalSearch` to close modal.
- Implement `closeSystemSearchForSuperuser` dispatch from `RequestQueueProgress` on triple-Shift.
- Update booking API logic in `NavigationMenuItemsAdmin` for refined date filtering.
- Extend unit tests to validate `system-search:close` behavior and triple-Shift handling.
2026-03-19 16:36:58 +01:00
Jeppe Bundgaard 9cccf49a4d Enhance network tracking and insights in RequestQueue and RequestQueueProgress:
- Add network totals tracking (requests, responses, and bandwidth) in `requestQueueState`.
- Introduce byte estimation functions for requests and responses.
- Update `RequestQueueProgress` layout to display network metrics and runtime insights.
- Add dedicated bottom panel for recent request latency insights (e.g., Ping, Bookings).
- Extend unit tests to verify runtime metrics and insight behavior.
2026-03-19 16:23:53 +01:00
Jeppe Bundgaard af7d894f5c Update RequestQueueProgress layout: adjust alignment and add flexbox styling for improved responsiveness 2026-03-19 15:37:08 +01:00
Jeppe Bundgaard 3ac49b4c65 Add Escape key support to close RequestQueueProgress and corresponding unit test:
- Update keyboard handler to close and reset state when Escape is pressed.
- Add unit test to validate Escape key behavior.
2026-03-19 15:33:45 +01:00
Jeppe Bundgaard cb81058611 Replace double-Alt shortcut with triple-Shift for expanding and displaying RequestQueueProgress:
- Update shortcut detection logic to use triple-Shift within a configurable time window.
- Rename variables and constants for clarity (`lastShiftKeyPressedAtMs`, `SHIFT_MULTI_PRESS_WINDOW_MS`, etc.).
- Adjust unit tests to validate new triple-Shift behavior and visibility changes.
2026-03-19 15:27:54 +01:00
Jeppe Bundgaard 23f5fdfcbf Add double-Alt shortcut to expand and display RequestQueueProgress:
- Update visibility logic with a double-Alt keypress detection.
- Introduce `isShortcutActivated` and `lastAltKeyPressedAtMs` for shortcut tracking.
- Adjust template rendering to depend on the shortcut activation state.
- Add unit tests to validate double-Alt behavior and its impact on visibility and expansion.
2026-03-19 15:25:30 +01:00
Jeppe Bundgaard 159da161d7 Add runtime metadata and session user details to RequestQueueProgress:
- Display runtime information (API URL, environment, commit hash, version, build time).
- Introduce session user details with support for subusers, including grants and customer numbers.
- Update layout with new dedicated side panels for runtime and user information.
- Extend tests to validate new sections and session user behavior.
2026-03-19 15:22:43 +01:00
Jeppe Bundgaard 076bddab90 Enhance request tracking and error management in RequestQueue:
- Add missing permissions tracking with configurable limits.
- Introduce `clearMissingPermissions` and `pushMissingPermissions` functions.
- Update `RequestQueueProgress` layout to display missing permissions.
- Improve error handling with permission extraction from response payloads.
- Update tests for new functionality and expanded edge cases.
2026-03-19 15:18:31 +01:00
Jeppe Bundgaard 2011887a4b Redesign and improve RequestQueueProgress layout and functionality:
- Introduce a new expanded layout using a responsive grid structure.
- Add separate side panels to display errors and missing permissions.
- Reorganize and enhance error handling with detailed payload previews.
- Adjust styling for better alignment and scalability.
- Update unit tests to validate new structure and functionality.
2026-03-19 15:02:04 +01:00
Jeppe Bundgaard b4c5c8a62f Update RequestQueueProgress to remain visible when errors occur and add corresponding tests 2026-03-19 14:39:35 +01:00
Jeppe Bundgaard ae75a954a4 Expand request queue functionality with enhanced tracking and error management:
- Add request tracking with active, recent, and error request histories, along with configurable retention limits.
- Introduce payload sanitization tools, including request/response redaction, truncation, and safe text handling.
- Update `RequestQueueProgress` to display detailed request stats (ping latency, active requests, errors with payload previews).
- Enhance `installAxiosRequestQueue` to standardize URLs and include request metadata.
- Add tests for updated queue behavior, detailed request tracking, and error storage limits.
2026-03-19 14:30:57 +01:00
Jeppe Bundgaard 861e263dfa Add configurable retry logic to request queue:
- Introduce retry settings for specific HTTP status codes with exponential backoff and jitter.
- Refactor `requestQueue` to support method-based concurrency limits and retry configuration.
- Update `RequestQueueProgress` to respect minimum batch size for progress visibility.
- Add unit tests for retry logic, progress visibility, and updated queue behavior.
- Centralize request queue configuration in `REQUEST_QUEUE_CONFIG`.
2026-03-19 14:15:12 +01:00
Jeppe Bundgaard 73b838cf28 Add support for separate max concurrency limits for GET and non-GET requests:
- Update `requestQueue` to differentiate between GET and other request types with configurable limits.
- Refactor `enqueueRequest` and queue draining logic for type-based concurrent handling.
- Add unit tests to validate GET-specific concurrency and proper queue functionality.
2026-03-19 14:07:59 +01:00
Jeppe Bundgaard 3f1502184a Add axios request queue interceptor with unit tests:
- Introduce `installAxiosRequestQueue` to manage and queue requests globally.
- Update `main.js` to install the interceptor at app initialization.
- Add `__skipRequestQueue` configuration in `authenticatedRequest` for bypassing the queue.
- Create unit tests to validate queue behavior and ensure request sequencing.
2026-03-19 14:01:25 +01:00
Jeppe Bundgaard a73c28795b Add request queue system with progress tracking:
- Introduce `enqueueRequest` function and reactive state for managing request queues.
- Add `RequestQueueProgress` Vue component to display request queue states (active, queued, completed).
- Update `authenticatedRequest` to utilize the new request queue system for sequential request handling.
- Add unit tests for `RequestQueueProgress` and enhanced `authenticatedRequest` behavior.
- Remove debug `console.log` statements from affected components.
2026-03-19 13:52:03 +01:00
Jeppe Bundgaard 17036a0ae7 Refactor navigation route handling for admin menu:
- Replace `router` reference with reactive `routeContext` to remove router dependency.
- Add `syncAdminNavigationRoute` for syncing route data.
- Update `NavigationMenuItems.vue` to utilize the new syncing approach.
- Adjust unit tests to validate route context handling changes.
2026-03-19 13:25:42 +01:00
Jeppe Bundgaard 1493261ef7 Refactor department ID handling across components:
- Update `NavigationMenuItemsAdmin` and `NavigationMenuDepartment` to improve route-based department ID resolution.
- Enhance `authenticatedRequest` to suppress specific errors with custom error responses.
2026-03-19 13:23:05 +01:00
Jeppe Bundgaard cef187956e Add unit tests for authenticatedRequest error handling and NavigationMenuDepartment functionality:
- `authenticatedRequest`: Test suppression of specific errors, rejection of non-suppressed errors, and general error handling.
- `NavigationMenuDepartment`: Test dynamic department label updates based on route changes.
- Add contract tests for `NavigationMenuItemsAdmin` reactive route context usage.
2026-03-19 13:22:55 +01:00
Jeppe Bundgaard 9745b106e6 Update translations for booking statuses in locales (sv, de, no) and remove redundant console.log statements in dashboard components. 2026-03-19 13:13:24 +01:00
Jeppe Bundgaard 27bd9df995 Remove debug console.log and console.warn statements, and update translations for booking statuses in locales (en, da). 2026-03-19 13:06:06 +01:00
Jeppe Bundgaard 70440e8855 Optimize attachment preview handling in POS:
- Add `createEmbeddablePreviewUrl` utility to generate blob URLs for attachment previews.
- Introduce `releaseObjectUrl` and `onBeforeUnmount` cleanup to manage and revoke blob URLs.
- Enhance error handling and URL parsing logic for preview link generation in `POSDepartmentProcess`.
2026-03-19 12:50:00 +01:00
Jeppe Bundgaard 60742bf947 Refactor and migrate Playwright E2E tests:
- Remove `playwright.config.js` to clean up configuration.
- Add new E2E test suites (`admin-pos-orders`, `admin-module-goals`, `admin-module-pos-mobile-order-flow`) to structure Admin module tests.
- Introduce reusable authentication and data seeding utilities in `fixtures` for streamlined test flows and maintainability.
2026-03-19 12:46:47 +01:00
Jeppe Bundgaard acbeef438b Add "add_to_cart" translations for multiple locales (sv, de, no, en, da) in POS strings. 2026-03-19 12:17:05 +01:00
Jeppe Bundgaard 5b411de9e6 Refactor SelectProductsFormPOS.vue to use optional chaining for route parameter access and improve department ID fallback logic. 2026-03-19 12:12:20 +01:00
Jeppe Bundgaard 79e6ec61c2 Remove debug console.log and console.warn statements across multiple components to clean up logging and improve production readiness. 2026-03-19 12:09:52 +01:00
Jeppe Bundgaard 87b8814bcf Remove redundant console.log and console.warn statements across components to improve production readiness and clean up logging. 2026-03-19 12:00:20 +01:00
Jeppe Bundgaard 2e6b420913 Remove debug console.log and console.warn statements across POS-related components to improve production readiness and streamline logging. 2026-03-19 11:51:20 +01:00
Jeppe Bundgaard 23a72b08cf Add restoreStoredPosOrderId functionality with validations, update dependent POS logic, and introduce unit tests for order restoration scenarios. 2026-03-19 11:43:17 +01:00
Jeppe Bundgaard 8db78ec937 Clean up service workers and caches in development mode:
- Add `unregisterDevServiceWorkers` utility to remove service worker registrations and stale caches during development.
- Disable service worker registration injection in `vite.config.js` for non-production environments.
2026-03-19 11:30:35 +01:00
Jeppe Bundgaard 835438049c Expand allowed origins in configuration to support local environments 2026-03-19 11:25:36 +01:00
Jeppe Bundgaard 6a8e19b3e2 Refactor and optimize component logic and tests:
- Consolidate route search handling in `PosDepartmentMVP.vue` using `applyPosRouteSearch` for improved maintainability.
- Refactor tab visibility logic in `ElementTabsBox.vue` with reusable `isHidden` utility, removing redundant code.
- Enhance `SessionUser.vue` and `superUserObject.vue` modules with lazy bindings for improved initialization and performance.
- Update `NavigationMenuItemsAdmin.vue` to validate department IDs with `hasValidDepartmentId` and watchers for reactive booking count updates.
- Improve error handling logic in `HandleGlobalError.vue` and `loadOrderItems` for robustness against invalid inputs.
- Add and update regression tests for session and superuser initialization to ensure compliance with binding contracts.
- Refactor POS step components to streamline order-related logic and support immediate reactivity.
2026-03-19 11:25:17 +01:00
Jeppe Bundgaard a02ee3a700 Add unit tests for POS department processes, route states, and session token handling:
- Add tests for `POSDepartmentProcess.loadOrderItems` to validate order item loading, skipping invalid IDs, and handling malformed responses.
- Add tests for `applyPosRouteSearch` and `parsePosRouteSearch` to ensure correct state parsing and application from route search params.
- Introduce contract tests for session token and superuser initialization to verify decoupling and lazy binding logic.
2026-03-19 11:19:00 +01:00
Jeppe Bundgaard 95a371375f Introduce origin migration handling with outdated route guards, redirect logic, and migration-related error views.
- Add `originMigration.js` for handling allowed origins, standalone contexts, and redirect routes.
- Create `OutdatedGateway.vue` and `OutdatedInstallation.vue` error views with timer-based redirection and user instructions.
- Update router to include `/outdated-installation` and `/outdated-gateway` paths.
- Implement unit tests for migration utility methods and route guards.
- Remove debug `console.log` statements across multiple components.
2026-03-19 10:51:45 +01:00
Jeppe Bundgaard efed5ac557 Introduce origin migration handling with outdated route guards, redirect logic, and migration-related error views.
- Add `originMigration.js` for handling allowed origins, standalone contexts, and redirect routes.
- Create `OutdatedGateway.vue` and `OutdatedInstallation.vue` error views with timer-based redirection and user instructions.
- Update router to include `/outdated-installation` and `/outdated-gateway` paths.
- Implement unit tests for migration utility methods and route guards.
- Remove debug `console.log` statements across multiple components.
2026-03-19 10:44:10 +01:00
Jeppe Bundgaard 7e9489573a Add E2E tests for POS and Superuser modules; refactor POS step components for consistent data-test attributes. 2026-03-18 23:11:59 +01:00
Jeppe Bundgaard 8f7e8b565b Refactor and enhance order item functionality:
- Add unit tests for `createOrderItem` to validate behavior under various conditions (e.g., missing token, invalid order ID).
- Introduce `getAttachmentContent` utility in `PosDepartmentStepMobileAttachment.vue` for streamlined attachment property handling.
- Refactor vehicle wash configuration logic in `VehicleDisplay.vue` with null-safe checks.
- Improve invoice-related logic in `ordersSyncTable.vue` using utility functions for consistent property access.
- Enhance computed properties for customer and product handling in order displays (e.g., `MyOrder.vue`, `ProductBox.vue`).
- Consolidate department lookup logic in `departmentTabs.vue` with safer handling of missing data.
- Add utility functions in `orderItemsTable.vue` for consistent product property access and computation.
2026-03-18 22:25:28 +01:00
Jeppe Bundgaard bd0f212662 Refactor lane selection and restoration logic in MyWashStart.vue:
- Introduce `normalizeLaneId` utility for consistent lane ID parsing.
- Enhance `getEffectiveLaneId` and `ensureEffectiveLaneSelection` to dynamically handle stale lanes and effective selection.
- Modify `fetchSelfServeData` to validate and normalize license plate inputs.
- Add `isRestoring` handling throughout workflow to improve transitions and prevent unnecessary data fetches.
- Update unit tests to cover restoration scenarios and stale lane fallback behavior.
2026-03-18 16:06:38 +01:00
Jeppe Bundgaard 8917893f43 Enhance Self-Serve progress display in MyWashStart.vue and SelfServeDepartmentHeader.vue:
- Add `progressRegistration` and `progressVehicleProduct` props for improved progress details.
- Update `SelfServeDepartmentHeader.vue` template and styles for better layout and item wrapping.
- Refactor computed properties in `MyWashStart.vue` to include `selectedVehicleTypeProductName`.
2026-03-18 15:06:12 +01:00
Jeppe Bundgaard d3ed03a4a1 Refactor Self-Serve progress and department header components:
- Consolidate progress display logic into `SelfServeDepartmentHeader.vue` for better reusability.
- Remove redundant progress headers and styles in `MyWashStart.vue` and `SelfServeTasksStep.vue`.
- Update scoped styles in `SelfServeTasksStep.vue` and `SelfServeDepartmentHeader.vue` to improve layout and readability.
- Fix API URL in `vite.config.js` for version update endpoint.
2026-03-18 14:35:42 +01:00
Jeppe Bundgaard e785275c24 Refactor Self-Serve task handling and enhance UI components:
- Update `SelfServeTaskList.vue` to handle image and non-image task attachments separately and improve task card styling.
- Add utility methods for filtering task services, attachments, and images.
- Modify `MyWashStart.vue` and `SelfServeTasksStep.vue` headers for better readability, styling, and alignment.
- Improve layout responsiveness with scoped styles for task cards and progress headers.
2026-03-18 13:56:39 +01:00
Jeppe Bundgaard 65b173094f Refactor task description handling in SelfServeTaskList.vue:
- Add `hasTaskDescription` utility to validate and handle task descriptions more robustly.
- Update template to conditionally render task descriptions using the new utility.
2026-03-18 13:49:28 +01:00
Jeppe Bundgaard 9a62417981 Refactor Self-Serve logic and POS order creation handling:
- Enhance `useSelfServeLogic.js` with `setSummaryVisibleQuestions` for streamlined question visibility and ordering updates.
- Update logic to retain preview questions when summary data is unavailable.
- Add unit test to validate fallback behavior and preview question handling.
- Prevent duplicate POS orders by introducing `isCreatingOrder` flag in `POSDepartmentProcess.vue`.
- Adjust `PosDepartmentStepMobileButtonNextStep.vue` to reflect button loading state during order creation.
2026-03-18 13:42:15 +01:00
Jeppe Bundgaard ed1a23bac6 Improve lane selection logic and guided instructions in Self-Serve module:
- Refactor `MyWashStart.vue` to ensure effective lane selection is applied dynamically when transitioning steps.
- Enhance `SelfServeGuidedInstructions.vue` by normalizing step management, simplifying the layout, and improving computed property usage.
- Update `useSelfServeLogic.js` to handle missing questions via fallback to lane/registration summary.
- Add and update unit tests for lane selection, step transitions, guided instructions rendering, and fallback logic.
2026-03-18 13:29:56 +01:00
Jeppe Bundgaard dc130229b5 Refactor ViewportResponsiveWrapper to use defineComponent and simplify slot rendering logic. 2026-03-18 13:10:49 +01:00
Jeppe Bundgaard e53b751f81 Refactor Self-Serve logic and improve question visibility handling:
- Refactor `useSelfServeLogic.js` to ensure summary-driven question and condition synchronization.
- Update tests to validate correct question visibility, ordering, and condition evaluation.
- Replace hardcoded logic with dynamic IDs for conditional handling.
- Introduce `summaryVisibleQuestionIds` and `summaryQuestionOrder` to manage question filtering and sorting.
- Enhance workflow for task visibility and activation based on updated rules.
- Migrate dropdown and action handling to `PaginationDisplayGeneralSearchReload.vue` for better readability and feature expansion.
2026-03-18 13:05:06 +01:00
Jeppe Bundgaard f619487f6f Implement automatic table Excel exports with unit tests:
- Add `AutoTableExportService` for dynamic Excel export button generation and visible row extraction.
- Integrate auto-export initialization in `main.js` for legacy non-paginated tables.
- Extend `PaginationDisplay.vue` with support for paginated Excel export.
- Add export functionality to vehicle views and dependencies, including full-table export handling.
- Add tests for `TableExcelExportService.js`, export workflows, and normalization logic.
- Enhance localization with `download_excel` translation key.
2026-03-18 12:46:40 +01:00
Jeppe Bundgaard 25f9d7c071 Add unit tests and services for Self-Serve Vehicle Step and Excel export:
- Introduce `TableExcelExportService` with functions for workbook creation and export (`normalizeExcelValue`, `createWorkbookFromRows`, `downloadWorkbook`).
- Add unit tests for `SelfServeVehicleStep` to test vehicle selection persistence, registration normalization, and emission behavior.
- Extend logic and tests for Self-Serve vehicle management with error handling, dynamic state updates, and step transitions (`MyWashStart.vue`).
- Integrate Excel export in `PaginatedList.vue` with user-defined transformations and error resilience.
2026-03-18 12:05:40 +01:00
Jeppe Bundgaard ad33cfedf8 Merge remote-tracking branch 'origin/master' 2026-03-18 11:06:11 +01:00
Jeppe Bundgaard 36dfed1c3c Improve department display and loading states across components:
- Add computed properties for loading data messages and department visibility in `MyWashStart.vue` to enhance user feedback and state management.
- Introduce department list feature to `GuestQRNewCustomer.vue` and `GuestQRNewDriver.vue`, including support for addresses, lanes, and self-serve availability.
- Refactor shared department-related methods (e.g., navigation links, lane descriptions) for reusability.
- Update UI styles for better consistency and user experience in department and registration views.
- Add unit test for self-serve disabled warning in `MyWashStart.vue` to ensure accurate feedback rendering.
2026-03-18 10:53:15 +01:00
Jeppe Bundgaard b6f6cfd0aa Modularize registration forms and refactor MyWashStart component:
- Extract customer and driver registration logic into reusable `CustomerRegistrationForm` and `DriverRegistrationForm` components for improved modularity and reuse.
- Replace inline forms with new components in `CustomerCreationPage`, `GuestQRNewCustomer`, and `GuestQRNewDriver`.
- Update `MyWashStart.vue` to include developer access conditions alongside superuser checks.
- Improve UI consistency and styling across registration views.
2026-03-18 09:39:34 +01:00
Jeppe Bundgaard abc033bb05 Remove self-serve-logic.spec.js and related integration tests.
- Delete outdated tests for `useSelfServeLogic` composable, step navigation, and validation related to the Self-Serve module.
- These tests have been deprecated in favor of newer, streamlined testing strategies and refactored logic.
2026-03-17 16:49:36 +01:00
gold-dev 6223fc8af4 Feat: 202603 - truckwash.io dashboard V3 - slides 2-5 2026-03-17 13:44:14 +02:00
gold-dev 356bf7147e Feat: 202603 - truckwash.io dashboard V3 - slide 6 2026-03-17 12:37:13 +02:00
Jeppe Bundgaard c68215a451 Add unit tests for Self-Serve alignment features and refactor logic for task normalization, configuration management, and lane services:
- Introduce comprehensive unit tests for machine types, lane wiring, config management, rule conditions, and runtime data integration in the Self-Serve module.
- Refactor configuration handling in `ConfigurationSelfServe.vue` to use normalized mappings and improve payload parsing.
- Enhance `useSelfServeLogic.js` with functions for task preview, summary synchronization, attachment handling, and service updates.
- Optimize wash start flow in `MyWashStart.vue` with streamlined condition saving and endpoint integrations.
2026-03-16 14:01:35 +01:00
Jeppe Bundgaard 5b3e59bbb4 Introduce scope filtering and machine type integration for Self-Serve module:
- Add scope label computation for tasks, questions, and conditions based on department, lane, product, and machine type.
- Introduce filters for machine type, lane, and department in Self-Serve pagination.
- Replace `EditableTableColumn` with scoped rendering for improved flexibility.
- Extend API and schema to support machine type integration in department redistribution.
- Optimize UI for task, question, and condition management with machine type-aware workflows.
2026-03-16 13:43:08 +01:00
Jeppe Bundgaard f6050740ec Introduce machine types management for Self-Serve module:
- Add `DepartmentSelfServeMachineTypes` component with pagination and table for machine types.
- Extend Self-Serve objects to support machine type attributes and CRUD operations.
- Update UI flows for managing machine types, tasks, and conditions.
- Implement reusable forms for creating and editing machine types, tasks, and conditions.
- Add API integration for machine type operations.
- Enhance translations and component reuse across Self-Serve views.
2026-03-16 13:39:43 +01:00
Jeppe Bundgaard 260fb8d8e9 Introduce machine type support for Self-Serve module:
- Add `DepartmentSelfServeMachineTypes` routes and components.
- Extend condition and rule objects to support machine type attributes.
- Update UI flows for managing machine types and conditions.
- Adjust creation forms to handle machine type associations.
- Implement API changes for machine type retrieval and assignment.
- Add utility functions and async data parsing for machine type handling.
2026-03-16 13:39:25 +01:00
Jeppe Bundgaard b7b19d1ad9 Add comprehensive documentation for Self-Serve Module.
- Describe architecture, domain models, eligibility rules, API endpoints, runtime status, and lane control.
- Include end-to-end examples for happy paths and failure scenarios.
- Provide PHP and Bash usage samples for integration.
- Add detailed flow diagrams and entity definitions for clarity.
2026-03-16 13:08:12 +01:00
Jeppe Bundgaard 16eb2730b8 Merge remote-tracking branch 'origin/master' 2026-03-16 13:07:51 +01:00
Jeppe Bundgaard 412339744f Introduce footer totals for department allocations:
- Add `buildDepartmentAllocationFooterTotals` utility to compute department footer totals, excluding customer prices from the total column.
- Update department allocation summaries to prioritize actual distributions for fixed and subscription amounts.
- Adjust distribution metrics calculation logic and related tests for consistency.
- Enhance footer visuals with clearly marked totals and accompanying notes.
- Expand `buildDepartmentAllocations` to properly account for totals in UI rendering.
- Update associated unit tests to cover footer totals and exclude customer price impacts.
2026-03-16 13:04:56 +01:00
Jeppe Bundgaard aba9f63788 Introduce department goal support in global search UI:
- Add department goal progress retrieval and display in visible result cards.
- Include progress bar, period, and metadata for department goals in the UI.
- Implement caching and lazy loading for department goal data to optimize performance.
- Extend unit tests to cover department goal overview functionality.
- Update translations for department goals in English, Danish, and German locales.
2026-03-16 09:28:32 +01:00
Jeppe Bundgaard cef7a38b0e Refactor global search UI and enhance keyboard navigation:
- Add "shouldBypassTextEntryForSystemSearchKey" utility for improved keyboard event handling.
- Implement "scrollSelectedResultIntoView" for precise result focus management.
- Remove grouped summary views and associated settings (e.g., "showGrouped").
- Refactor entity label resolution with "humanize" and "resolveEntityTypeLabel" utilities.
- Simplify result toolbar with a compact layout and improved accessibility.
- Expand locale updates for entity type translations in English, Danish, and German.
- Update unit tests for new navigation logic, label resolution, and result handling.
2026-03-13 14:01:51 +01:00
Jeppe Bundgaard b28b95d40f Add unit tests and keyboard navigation enhancements for system search UI:
- Add unit tests for result sorting, grouping, and keyboard state synchronization.
- Implement keyboard navigation improvements for selections and group/result highlighting.
- Update sidebar and result pane interaction with visual indicators and scroll handling.
- Refactor payload extraction logic for nested keys and improve related action routing.
2026-03-13 13:47:16 +01:00
Jeppe Bundgaard cc260cf6c3 Add sidebar and results keyboard navigation with visual indicators:
- Implement navigation pane switching and active selection states.
- Add visual markers for sidebar and result pane interaction.
- Display object IDs and restructure result footer for clarity.
- Extend unit tests to validate navigation and UI updates.
2026-03-13 13:09:22 +01:00
Jeppe Bundgaard 489a64d5ee Enhance global search with keyboard navigation and improved selection:
- Introduce keyboard navigation for sidebar and results, including pane switching and item selection.
- Add visual indicators for selected sidebar groups and results.
- Refine result sorting and grouping logic with utility methods.
- Update modal accessibility with focus management and keyboard event handling.
- Extend unit tests to cover keyboard navigation and UI state sync.
- Apply scoped styles for interactive states and selected elements.
2026-03-13 13:06:33 +01:00
gold-dev 6ee08b6333 Merge branch 'master' of https://github.com/copenhagentruckwash/pleno-vue 2026-03-13 11:03:21 +00:00
gold-dev 7515db2d80 Feat: 202603 - truckwash.dk and truckwash.io edits - V10 8-13 2026-03-13 10:58:24 +00:00
Jeppe Bundgaard 2b3cf2010a Enhance global search functionality with shortcut support and extended result actions:
- Add double-shift keyboard shortcut for quick modal search access.
- Extend visible result cards with related targets and action handling for orders, departments, vehicles, and customers.
- Add tools for copying key identifiers like references and registrations to clipboard.
- Improve attachment handling with download actions and caching for order-related files.
- Update UI to include contextual action panels and accessibility improvements.
- Add additional unit tests for related targets and action handling.
2026-03-13 01:52:02 +01:00
Jeppe Bundgaard dfe8eaa882 Refactor global search UI and functionality:
- Enhance entity alias resolution with extended mappings and alias normalization.
- Add display labels, icons, created timestamps, and cancellation status to result cards.
- Update styles to highlight cancelled results and improve visual hierarchy.
- Expand i18n support for new labels in multiple locales, including `created`, `status`, and `cancelled`.
2026-03-13 00:50:54 +01:00
Jeppe Bundgaard 16baa28b85 Refactor global search UI:
- Replace `<b-tag>` components with simplified HTML elements for better control over styling and layout.
- Introduce utility methods and scoped styles for improved badge handling and consistent theming.
- Update group and result layouts to enhance readability and reduce visual noise.
- Remove unused imports for cleaner component structure.
2026-03-13 00:37:00 +01:00
Jeppe Bundgaard 96fdb4573d Refactor global search UI structure and styling:
- Introduce improved layout, with structured toolbars, responsive design, and grid-based result lists.
- Add scoped styles with consistent theming using CSS variables.
- Enhance accessibility and readability through defined interactive states and typography updates.
2026-03-13 00:29:03 +01:00
Jeppe Bundgaard 53ee2a0271 Correct casing and improve descriptions in Danish locale JSON for search-related UI labels. 2026-03-13 00:22:28 +01:00
Jeppe Bundgaard 93bf309626 Fix encoding issues and correct terminology in Danish locale JSON. 2026-03-13 00:17:54 +01:00
Jeppe Bundgaard 965e79e5f5 Expand system search support for new object types and aliases, improve routing logic, and add unit tests:
- Enhance alias resolution with expanded mappings for objects like `attachment`, `item`, `discount`, `task`, and more.
- Refactor navigation resolver logic for better routing of `department_variable` and attachment-like payloads.
- Add unit tests to ensure registry coverage, alias routing behavior, and fallback handling.
2026-03-13 00:16:58 +01:00
Jeppe Bundgaard bc62040670 Add inline editing functionality for advanced search results and update German locale translations:
- Introduce inline editor types for `order_item`, `customer_discount`, `customer_fixed_price`, `department_variable`, and `module_config`.
- Implement inline editing logic, including entity resolution, value validation, and API integration.
- Add "Edit" button for search result cards with permission checks.
- Update German translations for improved encoding and terminology consistency.
2026-03-13 00:16:38 +01:00
Jeppe Bundgaard e259b20548 Fix encoding and translation issues in Danish locale JSON. 2026-03-13 00:16:01 +01:00
Jeppe Bundgaard 3c4fdaeb3a Add Danish i18n translations for advanced system search, including UI labels, settings, actions, errors, and diagnostic tools. 2026-03-13 00:10:04 +01:00
Jeppe Bundgaard 024125ff1f Update German i18n translations to fix encoding and correct terminology. 2026-03-13 00:07:10 +01:00
Jeppe Bundgaard 77ecd5e656 Update German i18n translations to fix encoding and correct terminology. 2026-03-13 00:04:30 +01:00
Jeppe Bundgaard 843ab12ffa Update Danish i18n translations to fix encoding and correct terminology. 2026-03-13 00:04:25 +01:00
Jeppe Bundgaard d42fb2da18 Update Swedish i18n translations to fix encoding and correct terminology. 2026-03-12 23:58:55 +01:00
Jeppe Bundgaard 8b631898b2 Add unit tests for superuser-vehicle-analytics, superuser-vehicle-view, and advanced system search components:
- Cover query builders, data normalization, and analytics aggregations for vehicle view utilities.
- Validate route contracts, tab structure, and OpenAPI integration for `superuser-vehicle-view`.
- Test advanced search modal logic, route registration, registry compliance, and UI behavior.
- Ensure system search registry and navigation resolvers provide complete type coverage and fallback handling.
2026-03-12 23:57:01 +01:00
Jeppe Bundgaard 880031de1a Expand system search support to include users entity type:
- Add `users` to `SYSTEM_SEARCH_ENTITY_TYPES` and related navigation resolvers.
- Implement alias resolution for `users` and associated delegation strategies for search results.
- Extend support definitions, title/description keys, and navigation strategies for `users`.
- Refactor fallback handling and unknown entity resolution logic.
2026-03-12 23:49:40 +01:00
Jeppe Bundgaard 2977523d0b Update Swedish i18n translations to fix encoding and correct terminology 2026-03-12 23:49:30 +01:00
Jeppe Bundgaard 31c0aa739e Fix encoding and translation issues in English locale JSON:
- Replace invalid characters with standard ASCII equivalents.
- Correct translations for booking-related terms and general text.
- Update system configuration options and user-facing labels.
2026-03-12 23:40:02 +01:00
Jeppe Bundgaard 6c3e04e8b8 Update OpenAPI schema to include users resource in SystemSearchRequest 2026-03-12 23:34:16 +01:00
Jeppe Bundgaard 253dc1d357 Add unit tests for superuser-vehicle-analytics, superuser-vehicle-view, and advanced system search components:
- Cover query builders, data normalization, and analytics aggregations for vehicle view utilities.
- Validate route contracts, tab structure, and OpenAPI integration for `superuser-vehicle-view`.
- Test advanced search modal logic, route registration, registry compliance, and UI behavior.
- Ensure system search registry and navigation resolvers provide complete type coverage and fallback handling.
2026-03-12 23:10:20 +01:00
Jeppe Bundgaard bade01b92a Update i18n translations for Swedish locale to fix incorrect encoding issues. 2026-03-12 22:57:48 +01:00
Jeppe Bundgaard b1a2d4bcda Enhance NavigationMenuGlobalSearch with multi-page fetching and loading progress:
- Introduce multi-page result fetching for large data sets with progress tracking.
- Add progress bar UI to show loading status and fetched results count.
- Refactor offsets, pagination logic, and result grouping for improved scalability.
- Update UI elements and computed properties to handle enhanced result handling and pagination.
2026-03-12 22:13:41 +01:00
Jeppe Bundgaard a80422dab8 Add pagination to NavigationMenuGlobalSearch with b-pagination component:
- Replace previous offset-based navigation with paginated navigation.
- Adjust layout and styles for improved usability and flexibility.
- Refactor result handling to integrate pagination events and model updates.
2026-03-12 22:00:27 +01:00
Jeppe Bundgaard 70bba3f77c Refactor NavigationMenuGlobalSearch: add input focus handling, enhance entity type selectors with checklists, and improve responsive layout. 2026-03-12 21:46:53 +01:00
Jeppe Bundgaard a9972c5203 Expand NavigationMenuGlobalSearch with advanced filtering, grouping, and settings management:
- Add new entity types for enhanced filtering.
- Introduce result grouping, configurable settings, and group-specific visible results.
- Refactor sidebar and main panel for improved user experience.
- Enhance settings UI with pagination, filters, and manage tools.
2026-03-12 21:33:04 +01:00
Jeppe Bundgaard 4e65aad0fc Add Vehicle Analytics Chart component and utility functions:
- Implement `VehicleAnalyticsChart.vue` to support line/bar chart displays for vehicle data.
- Add `vehicleViewUtils.js` with reusable utility functions for data normalization, query building, and aggregation.
2026-03-12 21:20:51 +01:00
Jeppe Bundgaard 2a9a2ea93d Update OpenAPI schema to include additional resource types in SystemSearchRequest 2026-03-12 21:20:30 +01:00
Jeppe Bundgaard 5cd7598e7e Integrate advanced system-wide search feature:
- Implement an advanced search component with filters, grouped results, and caching capabilities.
- Add modal-based UI for user-friendly interaction.
- Enable cache management tools for superusers, including clearing and rebuilding caches.
- Update desktop navigation to include the new search functionality.
2026-03-12 21:10:39 +01:00
Jeppe Bundgaard d197f7f8cf Add system-wide search endpoints and integrate compare visibility filter:
- Add new search endpoints to OpenAPI schema with cache management functionality.
- Extend invoice distribution functionality with `compareVisibility` filter for result categorization.
- Update unit tests and UI components for compare visibility options.
2026-03-12 20:30:09 +01:00
Jeppe Bundgaard c97d5c09d7 Update i18n translations for Swedish, German, and Norwegian locales; refactor invoice distribution API with enhanced fallback logic for v2 split and legacy endpoints; and add unit tests for distribution methods and UI integration. 2026-03-12 19:53:52 +01:00
Jeppe Bundgaard 75b7aad56a Add unit tests for invoice distribution API, enhance v2 bulk comparison handling, and update UI/locale for customer price metrics. 2026-03-12 18:50:54 +01:00
Jeppe Bundgaard e1e3187d69 Add unit and e2e tests for superuser invoice distribution, update i18n translations, and implement distribution API/logic helpers. 2026-03-12 18:03:44 +01:00
Jeppe Bundgaard db2040570e Add superuser invoice distribution module:
- Integrate departmental distribution view with monthly breakdowns, filters, and metrics.
- Add support for comparisons, source blends, and allocation trends.
- Update i18n translations for English, Swedish, German, and Norwegian.
- Extend router with distribution-specific routes.
- Add unit tests for route mapping, tabs, and view integrations.
- Update e2e tests for API routes.
2026-03-12 17:34:26 +01:00
Jeppe Bundgaard 203123e73b Add invoicing billing period calculations module with sum, net amount, and view total functions; include comprehensive unit tests 2026-03-12 16:03:22 +01:00
Jeppe Bundgaard 80fcf8e4ce Add unit tests for invoice orders pagination, introduce safe items-per-page logic in list pagination, and refactor InvoicingBillingPeriod views for improved date handling and modularity. 2026-03-12 15:10:16 +01:00
Jeppe Bundgaard 4852a8cc6b Add unit tests for period view navigation, runtime view switching, and view existence checks; fix incorrect view keys in special arrangements and per-order views; extend global object setup in tests. 2026-03-12 14:39:08 +01:00
Jeppe Bundgaard e48c52a9b7 Add unit tests for superuser invoices route wiring, tabs, and API contracts; enhance calculations and logic in InvoicingBillingPeriodStatistics.vue; update OpenAPI schema with WeatherAPI and fixed pricing distribution functionality. 2026-03-12 14:24:12 +01:00
Jeppe Bundgaard 7c383a1663 Merge remote-tracking branch 'origin/master' 2026-03-12 11:29:02 +01:00
gold-dev 7196bd1ca8 Feat: 202603 - truckwash.dk and truckwash.io edits - V10 2-7 2026-03-12 12:03:49 +02:00
Jeppe Bundgaard 0ce9dc85ce Add tooltip and booked amount column to InvoicingBillingPeriodStatistics table 2026-03-12 09:35:21 +01:00
Jeppe Bundgaard 0d8de69fed Add unit tests for WeatherAPI module contracts and superuser wiring 2026-03-12 09:19:08 +01:00
600 changed files with 128064 additions and 24958 deletions
+21 -21
View File
@@ -1,28 +1,28 @@
name: Qodana Configuration Upload
on:
push:
branches: [ main, dev ]
pull_request:
branches: [ main ]
workflow_dispatch:
push:
branches: [main, dev]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
upload-qodana-config:
runs-on: ubuntu-latest
upload-qodana-config:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Run Qodana Configuration Uploader
env:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: |
docker run --rm \
-v $(pwd):/workspace \
-w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader:latest \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
- name: Run Qodana Configuration Uploader
env:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: |
docker run --rm \
-v $(pwd):/workspace \
-w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader:latest \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
+21 -21
View File
@@ -1,28 +1,28 @@
name: Qodana Configuration Upload
on:
push:
branches: [ main, dev ]
pull_request:
branches: [ main ]
workflow_dispatch:
push:
branches: [main, dev]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
upload-qodana-config:
runs-on: ubuntu-latest
upload-qodana-config:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Run Qodana Configuration Uploader
env:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: |
docker run --rm \
-v $(pwd):/workspace \
-w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader:latest \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
- name: Run Qodana Configuration Uploader
env:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: |
docker run --rm \
-v $(pwd):/workspace \
-w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader:latest \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
+38 -11
View File
@@ -3,20 +3,45 @@ name: Automated Tests
on:
pull_request:
push:
branches: [main, dev]
workflow_dispatch:
schedule:
- cron: "0 2 * * *"
permissions:
contents: read
concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-unit:
format-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Check frontend test formatting
run: npm run format:tests:check
build-and-unit:
needs: format-tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
@@ -29,6 +54,8 @@ jobs:
- name: Unit tests
run: npm run test:unit
env:
VITEST_BATCH_SIZE: 5
e2e-smoke:
if: github.event_name != 'schedule'
@@ -40,10 +67,10 @@ jobs:
project: [chromium-desktop, chromium-mobile]
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
@@ -58,7 +85,7 @@ jobs:
run: npx playwright test --grep @smoke --project="${{ matrix.project }}"
- name: Upload Playwright report
if: always()
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-smoke-${{ matrix.project }}
@@ -69,15 +96,15 @@ jobs:
retention-days: 7
e2e-full:
if: github.event_name == 'schedule' || github.ref == 'refs/heads/main'
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
needs: build-and-unit
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
@@ -92,7 +119,7 @@ jobs:
run: npm run test:e2e:ci
- name: Upload Playwright report
if: always()
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-full
+2
View File
@@ -0,0 +1,2 @@
cd -- "$(dirname -- "$0")/.."
npm run format:tests:commit
+6
View File
@@ -0,0 +1,6 @@
{
"endOfLine": "lf",
"tabWidth": 2,
"printWidth": 120,
"semi": true
}
+35
View File
@@ -28,6 +28,41 @@ npm run dev
npm run build
```
## Playwright Batched Chromium Runs
Run default e2e tests in deterministic 25-test shards across `chromium-desktop` and `chromium-mobile`:
```sh
npm run test:e2e:batched:chromium
```
Run the same flow and automatically re-run failed shards with `PLAYWRIGHT_WORKERS=1`:
```sh
npm run test:e2e:batched:chromium:rerun-failed
```
Optional overrides:
```sh
PLAYWRIGHT_BATCH_SIZE=25
PLAYWRIGHT_BATCH_WORKERS=2
PLAYWRIGHT_BATCH_DEV_PORT=5193
```
You can also forward Playwright args:
```sh
npm run test:e2e:batched:chromium -- --grep @smoke
```
Artifacts and summaries:
- `output/playwright/batched-chromium/last-run.json`
- `output/playwright/batched-chromium/failed-shards.json`
- `output/playwright/batched-chromium/report-index.html`
- `output/playwright/batched-chromium-shard-<i>-of-<n>/report/index.html`
- `output/playwright/batched-chromium-rerun-shard-<i>-of-<n>/report/index.html`
## Bubblewrap (TWA) Build and Install
To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands:
+7212 -207
View File
File diff suppressed because it is too large Load Diff
+4162 -2011
View File
File diff suppressed because it is too large Load Diff
+31 -6
View File
@@ -7,11 +7,26 @@
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"format:tests": "prettier --write \"tests/**/*.{js,ts}\"",
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
"prepare": "node scripts/prepare-husky.mjs",
"postinstall": "node scripts/postinstall-sync-playwright-root-links.mjs",
"preview": "vite preview",
"test:unit": "vitest run",
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
"test:unit": "node scripts/run-vitest-unit-batches.mjs",
"test:unit:single": "vitest run",
"test:e2e": "playwright test",
"test:e2e:ci": "playwright test --reporter=line,html",
"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:ci:serial": "playwright test --reporter=line,html",
"test:e2e:batched:chromium": "node scripts/run-playwright-batched-chromium.mjs",
"test:e2e:batched:chromium:rerun-failed": "node scripts/run-playwright-batched-chromium.mjs --rerun-failed",
"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:release": "npm run test:e2e:prod && npm run test:e2e:live",
"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",
"twa:build": "bubblewrap build",
"twa:update": "bubblewrap update"
@@ -33,7 +48,9 @@
"@vueuse/core": "^12.7.0",
"@vueuse/head": "^2.0.0",
"@vueuse/integrations": "^13.7.0",
"@xterm/addon-fit": "^0.11.0",
"animate.css": "^4.1.1",
"apexcharts": "^5.10.4",
"axios": "1.13.5",
"buefy": "^3.0.3",
"bulma": "^1.0.2",
@@ -61,16 +78,24 @@
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^4.5.0",
"vue-toast-notification": "^3.1.3",
"vue3-apexcharts": "^1.11.1",
"vue3-cookies": "^1.0.6",
"vuex": "^4.1.0"
"vuex": "^4.1.0",
"xlsx": "^0.18.5",
"xterm": "^5.3.0"
},
"devDependencies": {
"@playwright/test": "^1.54.2",
"@creativebulma/bulma-divider": "^1.1.0",
"@event-calendar/core": "^4.1.0",
"@playwright/test": "^1.58.2",
"@types/event-calendar__core": "^3.7.0",
"@vitejs/plugin-vue": "^5.1.4",
"@vitejs/plugin-vue-jsx": "^4.1.2",
"@vitejs/plugin-vue": "^6.0.5",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/test-utils": "^2.4.6",
"husky": "^9.1.7",
"jsdom": "^29.0.0",
"otpauth": "^9.5.0",
"prettier": "2.8.8",
"sass-embedded": "^1.81.0",
"vite": "7.1.11",
"vite-plugin-pwa": "^1.0.2",
-56
View File
@@ -1,56 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:4173";
const isCI = !!process.env.CI;
export default defineConfig({
testDir: "./tests/e2e",
timeout: 60_000,
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: isCI ? 2 : undefined,
reporter: [
["list"],
["html", { open: "never", outputFolder: "output/playwright/report" }]
],
outputDir: "output/playwright/test-results",
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure"
},
webServer: {
command: "npm run dev -- --host 127.0.0.1 --port 4173",
port: 4173,
timeout: 120_000,
reuseExistingServer: !isCI
},
projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"]
}
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 7"]
}
},
{
name: "firefox-desktop",
use: {
...devices["Desktop Firefox"]
}
},
{
name: "webkit-mobile",
use: {
...devices["iPhone 13"]
}
}
]
});
+80
View File
@@ -0,0 +1,80 @@
import path from "node:path";
import { defineConfig, devices } from "@playwright/test";
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${devPort}`;
const isCI = !!process.env.CI;
const artifactNamespace = (process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "").trim();
const artifactRoot = artifactNamespace
? path.join("output", "playwright", artifactNamespace)
: path.join("output", "playwright");
const htmlReportOutputFolder = path.join(artifactRoot, "report");
const configuredWorkers = Number(process.env.PLAYWRIGHT_WORKERS || 2);
const workers = Number.isFinite(configuredWorkers) && configuredWorkers > 0 ? configuredWorkers : 2;
const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim();
const reporter =
reporterMode === "line-html"
? [
["line"],
["html", { open: "never", outputFolder: htmlReportOutputFolder }],
]
: [
["list"],
["html", { open: "never", outputFolder: htmlReportOutputFolder }],
];
export default defineConfig({
testDir: "./tests/e2e",
testIgnore: ["**/release/**"],
timeout: 60_000,
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers,
...(process.env.PLAYWRIGHT_BASE_URL
? {}
: {
globalSetup: "./playwright.global-setup.mjs",
globalTeardown: "./playwright.global-teardown.mjs",
}),
reporter,
outputDir: path.join(artifactRoot, "test-results"),
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure"
},
projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"]
}
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 5"]
}
},
{
name: "firefox-desktop",
use: {
...devices["Desktop Firefox"]
}
},
{
name: "webkit-desktop",
use: {
...devices["Desktop Safari"]
}
},
{
name: "webkit-mobile",
use: {
...devices["iPhone 12"]
}
}
]
});
+158
View File
@@ -0,0 +1,158 @@
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
const baseURL = `http://localhost:${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`);
async function getListeningProcessOnWindows(port) {
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], {
cwd: process.cwd(),
});
const match = stdout.match(new RegExp(`^\\s*TCP\\s+[^\\s]+:${port}\\s+[^\\s]+\\s+LISTENING\\s+(\\d+)\\s*$`, "mi"));
if (!match) {
return null;
}
const pid = Number(match[1]);
const command = `Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object -ExpandProperty CommandLine`;
const processResult = await execFileAsync("powershell", ["-NoProfile", "-Command", command], {
cwd: process.cwd(),
}).catch(() => ({ stdout: "" }));
return {
Id: pid,
CommandLine: processResult.stdout.trim(),
};
}
async function getListeningProcessOnUnix(port) {
try {
const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp", "-Fc"], {
cwd: process.cwd(),
});
const pidMatch = stdout.match(/^p(\d+)$/m);
const commandMatch = stdout.match(/^c(.+)$/m);
if (!pidMatch) {
return null;
}
return {
Id: Number(pidMatch[1]),
CommandLine: commandMatch ? commandMatch[1] : "",
};
} catch {
return null;
}
}
async function getListeningProcess(port) {
if (process.platform === "win32") {
return getListeningProcessOnWindows(port);
}
return getListeningProcessOnUnix(port);
}
async function killProcessTree(pid) {
if (!pid) {
return;
}
if (process.platform === "win32") {
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"], { cwd: process.cwd() }).catch(() => {});
return;
}
try {
process.kill(-pid, "SIGTERM");
} catch {
try {
process.kill(pid, "SIGTERM");
} catch {
// ignore
}
}
}
async function waitForServerReady(url, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url, { redirect: "manual" });
if (response.status < 500) {
return;
}
} catch {
// keep polling until ready
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error(`Timed out waiting for the local Playwright dev server at ${url}.`);
}
export default async function globalSetup() {
if (process.env.PLAYWRIGHT_BASE_URL) {
return;
}
await fs.mkdir(path.dirname(pidFile), { recursive: true });
const existing = await getListeningProcess(devPort);
if (existing) {
if (!/vite(?:\.js)?/i.test(existing.CommandLine || "")) {
throw new Error(`Port ${devPort} is already in use by a non-Vite process: ${existing.CommandLine}`);
}
await killProcessTree(existing.Id);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const serverProcess =
process.platform === "win32"
? spawn(
"cmd.exe",
["/d", "/s", "/c", `npm.cmd run dev -- --host localhost --port ${devPort} --strictPort`],
{
cwd: process.cwd(),
detached: true,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: "ignore",
windowsHide: true,
}
)
: spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], {
cwd: process.cwd(),
detached: true,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: "ignore",
});
serverProcess.unref();
await fs.writeFile(pidFile, JSON.stringify({ pid: serverProcess.pid, port: devPort }), "utf8");
try {
await waitForServerReady(baseURL);
} catch (error) {
await killProcessTree(serverProcess.pid);
throw error;
}
}
+48
View File
@@ -0,0 +1,48 @@
import fs from "node:fs/promises";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
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`);
async function killProcessTree(pid) {
if (!pid) {
return;
}
if (process.platform === "win32") {
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"], { cwd: process.cwd() }).catch(() => {});
return;
}
try {
process.kill(-pid, "SIGTERM");
} catch {
try {
process.kill(pid, "SIGTERM");
} catch {
// ignore
}
}
}
export default async function globalTeardown() {
if (process.env.PLAYWRIGHT_BASE_URL) {
return;
}
try {
const file = await fs.readFile(pidFile, "utf8");
const { pid } = JSON.parse(file);
await killProcessTree(pid);
} catch {
// ignore missing or malformed state
}
await fs.rm(pidFile, { force: true }).catch(() => {});
}
+37
View File
@@ -0,0 +1,37 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.PLAYWRIGHT_BASE_URL;
const isCI = !!process.env.CI;
if (!baseURL) {
throw new Error("PLAYWRIGHT_BASE_URL is required for the live smoke gate.");
}
export default defineConfig({
testDir: "./tests/e2e/release",
testMatch: /.*\.live-smoke\.spec\.ts/,
timeout: 60_000,
fullyParallel: false,
forbidOnly: isCI,
retries: isCI ? 1 : 0,
workers: 1,
reporter: [
["list"],
["html", { open: "never", outputFolder: "output/playwright/live/report" }],
],
outputDir: "output/playwright/live/test-results",
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
},
},
],
});
View File
+54
View File
@@ -0,0 +1,54 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = "http://localhost:4173";
const isCI = !!process.env.CI;
process.env.PLAYWRIGHT_BASE_URL = baseURL;
export default defineConfig({
testDir: "./tests/e2e/release",
testMatch: /.*\.local-prod\.spec\.ts/,
timeout: 60_000,
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: isCI ? 2 : 3,
reporter: [
["list"],
["html", { open: "never", outputFolder: "output/playwright/prod/report" }],
],
outputDir: "output/playwright/prod/test-results",
use: {
baseURL,
serviceWorkers: "block",
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
webServer: {
command: "npm run preview:prod",
url: baseURL,
timeout: 240_000,
reuseExistingServer: !isCI,
},
projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 5"],
},
},
{
name: "webkit-desktop",
use: {
...devices["Desktop Safari"],
},
},
],
});
@@ -0,0 +1,22 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const helperScriptPath = path.resolve(process.cwd(), "..", "scripts", "sync-playwright-root-links.mjs");
if (!fs.existsSync(helperScriptPath)) {
console.log(
`Skipping root Playwright link sync: helper script not found at ${helperScriptPath}.`
);
process.exit(0);
}
const result = spawnSync(process.execPath, [helperScriptPath], {
stdio: "inherit",
});
if (typeof result.status === "number") {
process.exit(result.status);
}
process.exit(1);
+111
View File
@@ -0,0 +1,111 @@
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..");
const gitPathspecs = [":(glob)tests/**/*.js", ":(glob)tests/**/*.ts"];
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: repoRoot,
encoding: "utf8",
...options,
});
if (result.error) {
throw result.error;
}
return result;
}
function fail(message) {
console.error(message);
process.exit(1);
}
function runNpmScript(scriptName) {
if (process.env.npm_execpath) {
return run(process.execPath, [process.env.npm_execpath, "run", scriptName], { stdio: "inherit" });
}
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
return run(npmCommand, ["run", scriptName], {
shell: process.platform === "win32",
stdio: "inherit",
});
}
function parseLines(output) {
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
const worktreeResult = run("git", ["rev-parse", "--show-toplevel"]);
if (worktreeResult.status !== 0) {
fail("Pre-commit formatting must run inside the front-end-vue Git worktree.");
}
const gitRoot = path.resolve(worktreeResult.stdout.trim());
if (gitRoot !== repoRoot) {
fail(`Expected front-end-vue to be the Git root, but git reported: ${gitRoot}`);
}
const unstagedTrackedResult = run("git", ["diff", "--name-only", "--", ...gitPathspecs]);
if (unstagedTrackedResult.status !== 0) {
fail("Unable to inspect unstaged test-file changes before running Prettier.");
}
const untrackedResult = run("git", ["ls-files", "--others", "--exclude-standard", "--", ...gitPathspecs]);
if (untrackedResult.status !== 0) {
fail("Unable to inspect untracked test files before running Prettier.");
}
const unsafePaths = [
...parseLines(unstagedTrackedResult.stdout),
...parseLines(untrackedResult.stdout),
];
if (unsafePaths.length > 0) {
const formattedPaths = unsafePaths.map((filePath) => ` - ${filePath}`).join("\n");
fail(
[
"Pre-commit formatting aborted because tests contain unstaged changes.",
"Stage or discard these files before committing so the hook does not add unrelated work:",
formattedPaths,
].join("\n")
);
}
console.log("Running Prettier for frontend test files...");
const formatResult = runNpmScript("format:tests");
if (formatResult.status !== 0) {
process.exit(formatResult.status ?? 1);
}
console.log("Restaging formatted frontend test files...");
const knownFilesResult = run("git", ["ls-files", "--cached", "--others", "--exclude-standard", "--", ...gitPathspecs]);
if (knownFilesResult.status !== 0) {
fail("Prettier completed, but the hook could not resolve test files for restaging.");
}
const filesToStage = parseLines(knownFilesResult.stdout);
if (filesToStage.length === 0) {
process.exit(0);
}
const addResult = run("git", ["add", "--", ...filesToStage], { stdio: "inherit" });
if (addResult.status !== 0) {
fail("Prettier completed, but git add failed while restaging formatted test files.");
}
+38
View File
@@ -0,0 +1,38 @@
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..");
const huskyBin = path.join(repoRoot, "node_modules", "husky", "bin.js");
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: repoRoot,
encoding: "utf8",
...options,
});
if (result.error) {
throw result.error;
}
return result;
}
const worktreeResult = run("git", ["rev-parse", "--show-toplevel"]);
if (worktreeResult.status !== 0) {
console.log("Skipping Husky install because front-end-vue is not attached to a Git worktree.");
process.exit(0);
}
const gitRoot = path.resolve(worktreeResult.stdout.trim());
if (gitRoot !== repoRoot) {
console.log(`Skipping Husky install because Git root is ${gitRoot}, not ${repoRoot}.`);
process.exit(0);
}
const huskyResult = run(process.execPath, [huskyBin], { stdio: "inherit" });
process.exit(huskyResult.status ?? 1);
+747
View File
@@ -0,0 +1,747 @@
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const workingDirectory = process.cwd();
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
const projects = ["chromium-desktop", "chromium-mobile"];
const outputDirectory = path.join(workingDirectory, "output", "playwright", "batched-chromium");
const testListDirectory = path.join(outputDirectory, "test-lists");
const args = parseArgs(process.argv.slice(2));
const batchSize = normalizePositiveInt(args.batchSize ?? process.env.PLAYWRIGHT_BATCH_SIZE, 25);
const shardWorkers = normalizePositiveInt(
args.workers ?? process.env.PLAYWRIGHT_BATCH_WORKERS ?? process.env.PLAYWRIGHT_WORKERS,
2
);
const devPort = normalizePositiveInt(args.port ?? process.env.PLAYWRIGHT_BATCH_DEV_PORT ?? process.env.PLAYWRIGHT_DEV_PORT, 5193);
const baseURL = `http://localhost:${devPort}`;
let activePlaywrightChild = null;
let activeDevServerProcess = 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 = {
rerunFailed: false,
dryRun: false,
help: false,
batchSize: null,
workers: null,
port: null,
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 === "--rerun-failed") {
parsed.rerunFailed = true;
continue;
}
if (value === "--dry-run") {
parsed.dryRun = true;
continue;
}
if (value === "--help" || value === "-h") {
parsed.help = true;
continue;
}
if (value === "--batch-size" && rawArgs[index + 1]) {
parsed.batchSize = rawArgs[index + 1];
index += 1;
continue;
}
if (value.startsWith("--batch-size=")) {
parsed.batchSize = value.slice("--batch-size=".length);
continue;
}
if (value === "--workers" && rawArgs[index + 1]) {
parsed.workers = rawArgs[index + 1];
index += 1;
continue;
}
if (value.startsWith("--workers=")) {
parsed.workers = value.slice("--workers=".length);
continue;
}
if (value === "--port" && rawArgs[index + 1]) {
parsed.port = rawArgs[index + 1];
index += 1;
continue;
}
if (value.startsWith("--port=")) {
parsed.port = value.slice("--port=".length);
continue;
}
parsed.forwardedArgs.push(value);
}
return parsed;
}
function normalizePositiveInt(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function stripAnsi(input) {
return input.replace(/\u001b\[[0-9;]*m/g, "");
}
function formatDuration(durationMs) {
return `${(durationMs / 1000).toFixed(1)}s`;
}
function chunkTests(testLines, size) {
const chunks = [];
for (let index = 0; index < testLines.length; index += size) {
const shard = Math.floor(index / size) + 1;
const lines = testLines.slice(index, index + size);
chunks.push({
shard,
start: index + 1,
end: index + lines.length,
lines,
});
}
return chunks;
}
function printUsage() {
console.log(`
Playwright batched chromium runner
Usage:
node scripts/run-playwright-batched-chromium.mjs [options] [-- <playwright args>]
Options:
--batch-size <n> Tests per chunk. Default: 25
--workers <n> PLAYWRIGHT_WORKERS for primary runs. Default: 2
--port <n> Vite dev server port. Default: 5193
--rerun-failed Re-run failed chunks with PLAYWRIGHT_WORKERS=1
--dry-run Compute and print chunk plan without executing tests
-h, --help Show help
`);
}
function validateForwardedArgs(forwardedArgs) {
for (const value of forwardedArgs) {
if (value === "--list" || value.startsWith("--list=")) {
throw new Error("--list is managed by this script and cannot be forwarded.");
}
if (value === "--project" || value.startsWith("--project=")) {
throw new Error("--project is managed by this script and cannot be forwarded.");
}
if (value === "--shard" || value.startsWith("--shard=")) {
throw new Error("--shard is managed by this script and cannot be forwarded.");
}
if (value === "--test-list" || value.startsWith("--test-list=")) {
throw new Error("--test-list is managed by this script and cannot be forwarded.");
}
if (value === "--test-list-invert" || value.startsWith("--test-list-invert=")) {
throw new Error("--test-list-invert is managed by this script and cannot be forwarded.");
}
}
}
function prefixStream(stream, prefix) {
const lineReader = readline.createInterface({ input: stream });
lineReader.on("line", (line) => {
process.stdout.write(`[${prefix}] ${line}\n`);
});
}
async function getListeningProcessOnWindows(port) {
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], { cwd: workingDirectory });
const match = stdout.match(new RegExp(`^\\s*TCP\\s+[^\\s]+:${port}\\s+[^\\s]+\\s+LISTENING\\s+(\\d+)\\s*$`, "mi"));
if (!match) {
return null;
}
const pid = Number(match[1]);
const command = `Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object -ExpandProperty CommandLine`;
const processResult = await execFileAsync("powershell", ["-NoProfile", "-Command", command], {
cwd: workingDirectory,
}).catch(() => ({ stdout: "" }));
return {
id: pid,
commandLine: processResult.stdout.trim(),
};
}
async function getListeningProcessOnUnix(port) {
try {
const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp", "-Fc"], {
cwd: workingDirectory,
});
const pidMatch = stdout.match(/^p(\d+)$/m);
const commandMatch = stdout.match(/^c(.+)$/m);
if (!pidMatch) {
return null;
}
return {
id: Number(pidMatch[1]),
commandLine: commandMatch ? commandMatch[1] : "",
};
} catch {
return null;
}
}
async function getListeningProcess(port) {
if (process.platform === "win32") {
return getListeningProcessOnWindows(port);
}
return getListeningProcessOnUnix(port);
}
async function killProcessTree(pid) {
if (!pid) {
return;
}
if (process.platform === "win32") {
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"], { cwd: workingDirectory }).catch(() => {});
return;
}
try {
process.kill(-pid, "SIGTERM");
} catch {
try {
process.kill(pid, "SIGTERM");
} catch {
// ignore
}
}
}
async function waitForServerReady(url, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url, { redirect: "manual" });
if (response.status < 500) {
return;
}
} catch {
// keep polling
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error(`Timed out waiting for Vite server at ${url}.`);
}
async function startDevServer() {
const existingProcess = await getListeningProcess(devPort);
if (existingProcess) {
if (!/vite(?:\.js)?/i.test(existingProcess.commandLine || "")) {
throw new Error(`Port ${devPort} is already in use by a non-Vite process: ${existingProcess.commandLine}`);
}
await killProcessTree(existingProcess.id);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
activeDevServerProcess =
process.platform === "win32"
? spawn(
"cmd.exe",
["/d", "/s", "/c", `npm.cmd run dev -- --host localhost --port ${devPort} --strictPort`],
{
cwd: workingDirectory,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
}
)
: spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], {
cwd: workingDirectory,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: ["ignore", "pipe", "pipe"],
});
prefixStream(activeDevServerProcess.stdout, "dev-server");
prefixStream(activeDevServerProcess.stderr, "dev-server:err");
await waitForServerReady(baseURL);
console.log(`Vite dev server is ready at ${baseURL}.`);
}
async function stopDevServer() {
if (!activeDevServerProcess) {
return;
}
await killProcessTree(activeDevServerProcess.pid);
activeDevServerProcess = null;
}
async function runPlaywrightCommand(commandArgs, envOverrides = {}, inheritOutput = false) {
if (inheritOutput) {
return new Promise((resolve) => {
activePlaywrightChild = spawn(process.execPath, [playwrightCliPath, ...commandArgs], {
cwd: workingDirectory,
env: {
...process.env,
...envOverrides,
},
stdio: "inherit",
windowsHide: true,
});
activePlaywrightChild.on("close", (code) => {
activePlaywrightChild = null;
resolve({
code: code ?? 1,
stdout: "",
stderr: "",
});
});
});
}
try {
const result = await execFileAsync(process.execPath, [playwrightCliPath, ...commandArgs], {
cwd: workingDirectory,
env: {
...process.env,
...envOverrides,
},
maxBuffer: 30 * 1024 * 1024,
windowsHide: true,
});
return {
code: 0,
stdout: result.stdout,
stderr: result.stderr,
};
} catch (error) {
const stdout = typeof error.stdout === "string" ? error.stdout : "";
const stderr = typeof error.stderr === "string" ? error.stderr : "";
const code = typeof error.code === "number" ? error.code : 1;
return {
code,
stdout,
stderr,
};
}
}
async function preflightCollectTests(forwardedArgs) {
const commandArgs = [
"test",
...projects.flatMap((project) => ["--project", project]),
"--list",
...forwardedArgs,
];
const result = await runPlaywrightCommand(
commandArgs,
{
PLAYWRIGHT_BASE_URL: baseURL,
PLAYWRIGHT: "1",
},
false
);
const combinedOutput = stripAnsi(`${result.stdout}\n${result.stderr}`);
const totalMatch = combinedOutput.match(/Total:\s+(\d+)\s+tests?/i);
if (!totalMatch) {
throw new Error(`Failed to parse Playwright test count from --list output.\n${combinedOutput}`);
}
const listedTests = combinedOutput
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.startsWith("["));
const parsedTotal = Number(totalMatch[1]);
if (listedTests.length === 0) {
throw new Error("Playwright --list returned zero runnable tests.");
}
if (listedTests.length !== parsedTotal) {
console.warn(
`Warning: --list reported ${parsedTotal} tests but ${listedTests.length} test lines were parsed. Using parsed lines.`
);
}
return {
totalTests: listedTests.length,
listedTests,
};
}
async function writeTestListFile(passLabel, chunk, totalShards) {
await fs.mkdir(testListDirectory, { recursive: true });
const filename = `${passLabel}-shard-${chunk.shard}-of-${totalShards}.txt`;
const targetPath = path.join(testListDirectory, filename);
await fs.writeFile(targetPath, `${chunk.lines.join("\n")}\n`, "utf8");
return targetPath;
}
async function runShard({ chunk, totalShards, workers, passLabel, forwardedArgs }) {
const testListPath = await writeTestListFile(passLabel, chunk, totalShards);
const commandArgs = [
"test",
...projects.flatMap((project) => ["--project", project]),
"--test-list",
testListPath,
...forwardedArgs,
];
const isRerun = passLabel === "rerun";
const artifactNamespace = isRerun
? `batched-chromium-rerun-shard-${chunk.shard}-of-${totalShards}`
: `batched-chromium-shard-${chunk.shard}-of-${totalShards}`;
console.log(
`[${passLabel}] Running shard ${chunk.shard}/${totalShards} (${chunk.start}-${chunk.end}, ${chunk.lines.length} tests, workers=${workers})`
);
const startedAt = Date.now();
const result = await runPlaywrightCommand(
commandArgs,
{
PLAYWRIGHT_BASE_URL: baseURL,
PLAYWRIGHT_DEV_PORT: String(devPort),
PLAYWRIGHT_WORKERS: String(workers),
PLAYWRIGHT_ARTIFACT_NAMESPACE: artifactNamespace,
PLAYWRIGHT_REPORTER_MODE: "line-html",
PLAYWRIGHT: "1",
},
true
);
return {
pass: passLabel,
shard: chunk.shard,
totalShards,
tests: chunk.lines.length,
start: chunk.start,
end: chunk.end,
workers,
code: result.code,
durationMs: Date.now() - startedAt,
artifactNamespace,
testListPath: path.relative(workingDirectory, testListPath),
};
}
function printSummary(primaryResults, rerunResults) {
const rows = [...primaryResults, ...rerunResults];
console.log("\nBatched Chromium Summary");
console.log("pass\tshard\tstatus\ttests\tworkers\tduration\tartifact");
for (const row of rows) {
const status = row.code === 0 ? "passed" : "failed";
console.log(
`${row.pass}\t${row.shard}/${row.totalShards}\t${status}\t${row.start}-${row.end} (${row.tests})\t${row.workers}\t${formatDuration(row.durationMs)}\t${row.artifactNamespace}`
);
}
}
async function writeRunArtifacts({ totalTests, totalShards, chunks, primaryResults, rerunResults }) {
const failedPrimaryShards = primaryResults.filter((result) => result.code !== 0).map((result) => result.shard);
const failedRerunShards = rerunResults.filter((result) => result.code !== 0).map((result) => result.shard);
await fs.mkdir(outputDirectory, { recursive: true });
const runReport = {
generatedAt: new Date().toISOString(),
baseURL,
projects,
batchSize,
totalTests,
totalShards,
primaryWorkers: shardWorkers,
rerunFailed: args.rerunFailed,
forwardedArgs: args.forwardedArgs,
chunks: chunks.map((chunk) => ({
shard: chunk.shard,
start: chunk.start,
end: chunk.end,
tests: chunk.lines.length,
})),
primaryResults,
rerunResults,
failedPrimaryShards,
failedRerunShards,
};
await fs.writeFile(path.join(outputDirectory, "last-run.json"), JSON.stringify(runReport, null, 2), "utf8");
await fs.writeFile(path.join(outputDirectory, "failed-shards.json"), JSON.stringify(failedPrimaryShards, null, 2), "utf8");
const primaryRows = primaryResults
.map((result) => {
const status = result.code === 0 ? "passed" : "failed";
const color = result.code === 0 ? "#166534" : "#991b1b";
return `<tr>
<td>${result.shard}/${result.totalShards}</td>
<td>${result.start}-${result.end} (${result.tests})</td>
<td style="color:${color};font-weight:600">${status}</td>
<td>${result.workers}</td>
<td>${formatDuration(result.durationMs)}</td>
<td>${result.testListPath}</td>
<td><a href="../${result.artifactNamespace}/report/index.html">Open report</a></td>
</tr>`;
})
.join("\n");
const rerunRows =
rerunResults.length === 0
? "<tr><td colspan=\"7\">No rerun shards executed.</td></tr>"
: rerunResults
.map((result) => {
const status = result.code === 0 ? "passed" : "failed";
const color = result.code === 0 ? "#166534" : "#991b1b";
return `<tr>
<td>${result.shard}/${result.totalShards}</td>
<td>${result.start}-${result.end} (${result.tests})</td>
<td style="color:${color};font-weight:600">${status}</td>
<td>${result.workers}</td>
<td>${formatDuration(result.durationMs)}</td>
<td>${result.testListPath}</td>
<td><a href="../${result.artifactNamespace}/report/index.html">Open report</a></td>
</tr>`;
})
.join("\n");
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Batched Chromium Playwright Report Index</title>
<style>
body { font-family: Segoe UI, Arial, sans-serif; margin: 32px; color: #111827; }
h1, h2 { margin-bottom: 8px; }
p { color: #4b5563; }
table { border-collapse: collapse; width: 100%; margin: 16px 0 28px; }
th, td { border: 1px solid #d1d5db; padding: 8px 10px; text-align: left; }
th { background: #f3f4f6; }
a { color: #1d4ed8; text-decoration: none; }
a:hover { text-decoration: underline; }
code { background: #f3f4f6; padding: 2px 6px; border-radius: 4px; }
</style>
</head>
<body>
<h1>Batched Chromium Playwright Reports</h1>
<p>Total tests: <strong>${totalTests}</strong> | Batch size: <strong>${batchSize}</strong> | Shards: <strong>${totalShards}</strong></p>
<p>JSON summary: <code>output/playwright/batched-chromium/last-run.json</code></p>
<h2>Primary pass</h2>
<table>
<thead>
<tr>
<th>Shard</th>
<th>Test range</th>
<th>Status</th>
<th>Workers</th>
<th>Duration</th>
<th>Test list</th>
<th>Report</th>
</tr>
</thead>
<tbody>
${primaryRows}
</tbody>
</table>
<h2>Rerun pass</h2>
<table>
<thead>
<tr>
<th>Shard</th>
<th>Test range</th>
<th>Status</th>
<th>Workers</th>
<th>Duration</th>
<th>Test list</th>
<th>Report</th>
</tr>
</thead>
<tbody>
${rerunRows}
</tbody>
</table>
</body>
</html>`;
await fs.writeFile(path.join(outputDirectory, "report-index.html"), html, "utf8");
}
async function shutdown(signal) {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
console.error(`Received ${signal}. Stopping active Playwright chunk and dev server...`);
await Promise.all([
killProcessTree(activePlaywrightChild?.pid),
killProcessTree(activeDevServerProcess?.pid),
]);
activePlaywrightChild = null;
activeDevServerProcess = null;
}
async function main() {
if (args.help) {
printUsage();
return;
}
validateForwardedArgs(args.forwardedArgs);
await fs.access(playwrightCliPath);
const preflight = await preflightCollectTests(args.forwardedArgs);
const chunks = chunkTests(preflight.listedTests, batchSize);
const totalShards = chunks.length;
console.log(
`Batched Chromium preflight: ${preflight.totalTests} tests across ${projects.join(", ")} -> ${totalShards} shards at ${batchSize} tests/chunk.`
);
if (args.dryRun) {
await fs.mkdir(outputDirectory, { recursive: true });
const dryRunFile = path.join(outputDirectory, "dry-run.json");
await fs.writeFile(
dryRunFile,
JSON.stringify(
{
generatedAt: new Date().toISOString(),
baseURL,
projects,
batchSize,
shardWorkers,
totalTests: preflight.totalTests,
totalShards,
rerunFailed: args.rerunFailed,
forwardedArgs: args.forwardedArgs,
shards: chunks.map((chunk) => ({
shard: chunk.shard,
start: chunk.start,
end: chunk.end,
tests: chunk.lines.length,
})),
},
null,
2
),
"utf8"
);
console.log(`Dry run complete. Summary written to ${path.relative(workingDirectory, dryRunFile)}.`);
return;
}
await startDevServer();
const primaryResults = [];
for (const chunk of chunks) {
primaryResults.push(
await runShard({
chunk,
totalShards,
workers: shardWorkers,
passLabel: "primary",
forwardedArgs: args.forwardedArgs,
})
);
}
const failedPrimaryShards = primaryResults.filter((result) => result.code !== 0).map((result) => result.shard);
const rerunResults = [];
if (args.rerunFailed && failedPrimaryShards.length > 0) {
console.log(`\nRe-running failed chunks with workers=1: ${failedPrimaryShards.join(", ")}`);
for (const shard of failedPrimaryShards) {
const chunk = chunks[shard - 1];
rerunResults.push(
await runShard({
chunk,
totalShards,
workers: 1,
passLabel: "rerun",
forwardedArgs: args.forwardedArgs,
})
);
}
}
printSummary(primaryResults, rerunResults);
await writeRunArtifacts({
totalTests: preflight.totalTests,
totalShards,
chunks,
primaryResults,
rerunResults,
});
await stopDevServer();
const failedRerunShards = rerunResults.filter((result) => result.code !== 0).map((result) => result.shard);
if (failedPrimaryShards.length === 0) {
console.log("\nAll batched Chromium chunks passed.");
return;
}
console.error(`\nPrimary failed shards: ${failedPrimaryShards.join(", ")}`);
if (args.rerunFailed) {
if (failedRerunShards.length > 0) {
console.error(`Rerun failed shards: ${failedRerunShards.join(", ")}`);
} else {
console.error("All failed shards passed during rerun. Treating this as flaky and returning non-zero.");
}
}
process.exitCode = 1;
}
await main().catch(async (error) => {
console.error(error instanceof Error ? error.message : error);
await stopDevServer();
process.exitCode = 1;
});
+232
View File
@@ -0,0 +1,232 @@
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
import { promisify } from "node:util";
const forwardedArgs = process.argv.slice(2);
const workingDirectory = process.cwd();
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
const basePort = normalizePositiveInt(process.env.PLAYWRIGHT_PARALLEL_BASE_PORT, 5191);
const perProcessWorkers = normalizePositiveInt(process.env.PLAYWRIGHT_PARALLEL_WORKERS, 1);
const reportIndexDirectory = path.join(workingDirectory, "output", "playwright", "ci-parallel-report");
const execFileAsync = promisify(execFile);
const activeChildren = new Set();
let isShuttingDown = false;
const groups = [
{
name: "chromium",
projects: ["chromium-desktop", "chromium-mobile"],
},
{
name: "firefox",
projects: ["firefox-desktop"],
},
{
name: "webkit",
projects: ["webkit-desktop", "webkit-mobile"],
},
];
function normalizePositiveInt(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function getArtifactNamespace(group) {
return `ci-parallel-${group.name}`;
}
function getDevServerPidFile(artifactNamespace) {
return path.join(workingDirectory, "output", "playwright", `dev-server-${artifactNamespace}.json`);
}
async function killProcessTree(pid) {
if (!pid) {
return;
}
if (process.platform === "win32") {
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"]).catch(() => {});
return;
}
try {
process.kill(-pid, "SIGTERM");
} catch {
try {
process.kill(pid, "SIGTERM");
} catch {
// ignore
}
}
}
async function cleanupDevServerArtifacts(artifactNamespace) {
const pidFile = getDevServerPidFile(artifactNamespace);
try {
const file = await fs.readFile(pidFile, "utf8");
const { pid } = JSON.parse(file);
await killProcessTree(pid);
} catch {
// ignore missing pid files or already-exited processes
}
await fs.rm(pidFile, { force: true }).catch(() => {});
}
function prefixStream(stream, prefix) {
const lineReader = readline.createInterface({ input: stream });
lineReader.on("line", (line) => {
process.stdout.write(`[${prefix}] ${line}\n`);
});
}
function spawnGroup(group, index) {
const devPort = basePort + index;
const artifactNamespace = getArtifactNamespace(group);
const args = [
"test",
...group.projects.flatMap((project) => ["--project", project]),
...forwardedArgs,
];
const child = spawn(process.execPath, [playwrightCliPath, ...args], {
cwd: workingDirectory,
env: {
...process.env,
PLAYWRIGHT_BASE_URL: "",
PLAYWRIGHT_DEV_PORT: String(devPort),
PLAYWRIGHT_WORKERS: String(perProcessWorkers),
PLAYWRIGHT_ARTIFACT_NAMESPACE: artifactNamespace,
PLAYWRIGHT_REPORTER_MODE: "line-html",
PLAYWRIGHT: "1",
},
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
activeChildren.add(child);
prefixStream(child.stdout, group.name);
prefixStream(child.stderr, `${group.name}:err`);
return new Promise((resolve) => {
child.on("close", (code) => {
activeChildren.delete(child);
resolve({
name: group.name,
code: code ?? 1,
artifactNamespace,
projects: group.projects,
port: devPort,
});
});
});
}
async function writeCombinedReportIndex(results) {
await fs.rm(reportIndexDirectory, { recursive: true, force: true });
await fs.mkdir(reportIndexDirectory, { recursive: true });
const rows = results
.map((result) => {
const status = result.code === 0 ? "passed" : "failed";
const statusColor = result.code === 0 ? "#166534" : "#991b1b";
const reportHref = `../${result.artifactNamespace}/report/index.html`;
return `
<tr>
<td>${result.name}</td>
<td>${result.projects.join(", ")}</td>
<td>${result.port}</td>
<td style="color: ${statusColor}; font-weight: 600;">${status}</td>
<td><a href="${reportHref}">Open report</a></td>
</tr>`;
})
.join("\n");
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Playwright Parallel Reports</title>
<style>
body { font-family: Segoe UI, Arial, sans-serif; margin: 32px; color: #111827; }
h1 { margin-bottom: 8px; }
p { color: #4b5563; }
table { border-collapse: collapse; width: 100%; margin-top: 24px; }
th, td { border: 1px solid #d1d5db; padding: 10px 12px; text-align: left; }
th { background: #f3f4f6; }
a { color: #1d4ed8; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h1>Playwright Parallel Reports</h1>
<p>Each child run used its own Vite port, dev-server pid file, and artifact directory.</p>
<table>
<thead>
<tr>
<th>Group</th>
<th>Projects</th>
<th>Port</th>
<th>Status</th>
<th>Report</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
</body>
</html>`;
await fs.writeFile(path.join(reportIndexDirectory, "index.html"), html, "utf8");
}
async function shutdown(signal) {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
process.stderr.write(`Received ${signal}. Stopping parallel Playwright children...\n`);
await Promise.all([...activeChildren].map((child) => killProcessTree(child.pid)));
await Promise.all(groups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group))));
}
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
void shutdown(signal).finally(() => {
process.exit(130);
});
});
}
async function main() {
console.log(
`Starting Playwright CI in parallel with ${groups.length} processes and ${perProcessWorkers} worker(s) per process.`
);
await Promise.all(groups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group))));
const results = await Promise.all(groups.map((group, index) => spawnGroup(group, index)));
await writeCombinedReportIndex(results);
await Promise.all(groups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group))));
const failedRuns = results.filter((result) => result.code !== 0);
if (failedRuns.length > 0) {
console.error(
`Parallel Playwright CI failed for: ${failedRuns.map((result) => `${result.name} (${result.code})`).join(", ")}`
);
process.exit(1);
}
console.log(
`Parallel Playwright CI passed. Combined report index: ${path.relative(workingDirectory, path.join(reportIndexDirectory, "index.html"))}`
);
}
await main();
+265
View File
@@ -0,0 +1,265 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
const workingDirectory = 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));
const batchSize = normalizePositiveInt(args.batchSize ?? process.env.VITEST_BATCH_SIZE, 5);
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,
batchSize: null,
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 === "--batch-size" && rawArgs[index + 1]) {
parsed.batchSize = rawArgs[index + 1];
index += 1;
continue;
}
if (value.startsWith("--batch-size=")) {
parsed.batchSize = value.slice("--batch-size=".length);
continue;
}
if (value.startsWith("-")) {
parsed.forwardedArgs.push(...rawArgs.slice(index));
break;
}
parsed.specFilters.push(value);
}
return parsed;
}
function normalizePositiveInt(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function normalizePathSegments(value) {
return value.replace(/\\/g, "/");
}
function printUsage() {
console.log(`
Vitest unit batch runner
Usage:
node scripts/run-vitest-unit-batches.mjs [spec-filter ...] [options] [-- <vitest args>]
Options:
--batch-size <n> Spec files per batch. Default: 5
-h, --help Show help
Examples:
npm run test:unit
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 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));
}
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 chunkFiles(files, size) {
const chunks = [];
for (let index = 0; index < files.length; index += size) {
chunks.push({
batch: Math.floor(index / size) + 1,
files: files.slice(index, index + size),
});
}
return chunks;
}
function formatDuration(durationMs) {
return `${(durationMs / 1000).toFixed(1)}s`;
}
function hasForwardedArg(name) {
return args.forwardedArgs.some((value) => value === name || value.startsWith(`${name}=`));
}
async function runBatch(chunk, totalBatches) {
console.log(`[vitest-batch] Running batch ${chunk.batch}/${totalBatches} with ${chunk.files.length} spec file(s).`);
const startedAt = Date.now();
const enforcedArgs = [];
if (!hasForwardedArg("--maxWorkers")) {
enforcedArgs.push("--maxWorkers=1");
}
if (!hasForwardedArg("--fileParallelism") && !hasForwardedArg("--no-file-parallelism")) {
enforcedArgs.push("--no-file-parallelism");
}
return new Promise((resolve) => {
activeChild = spawn(
process.execPath,
[vitestCliPath, "run", ...chunk.files, ...enforcedArgs, ...args.forwardedArgs],
{
cwd: workingDirectory,
env: process.env,
stdio: "inherit",
windowsHide: true,
}
);
activeChild.on("close", (code) => {
activeChild = null;
resolve({
batch: chunk.batch,
files: chunk.files.length,
code: code ?? 1,
durationMs: Date.now() - startedAt,
});
});
});
}
function printSummary(results, totalFiles) {
console.log("\nVitest batch summary");
console.log(`Total spec files: ${totalFiles}`);
console.log("batch\tstatus\tfiles\tduration");
for (const result of results) {
const status = result.code === 0 ? "passed" : "failed";
console.log(`${result.batch}\t${status}\t${result.files}\t${formatDuration(result.durationMs)}`);
}
}
async function shutdown(signal) {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
console.error(`Received ${signal}. Stopping active Vitest batch...`);
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 discoveredFiles = await collectSpecFiles(unitTestsRoot);
const selectedFiles = applyFilters(discoveredFiles, args.specFilters);
if (selectedFiles.length === 0) {
throw new Error("No unit spec files matched the provided filters.");
}
const chunks = chunkFiles(selectedFiles, batchSize);
console.log(
`Running ${selectedFiles.length} unit spec file(s) in ${chunks.length} batch(es) at ${batchSize} file(s) per batch.`
);
const results = [];
for (const chunk of chunks) {
results.push(await runBatch(chunk, chunks.length));
}
printSummary(results, selectedFiles.length);
const failedBatches = results.filter((result) => result.code !== 0);
if (failedBatches.length > 0) {
throw new Error(`Vitest batches failed: ${failedBatches.map((result) => result.batch).join(", ")}`);
}
}
await main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
+45
View File
@@ -0,0 +1,45 @@
$ErrorActionPreference = "Stop"
param(
[int]$Port = 5173
)
function Get-ListeningProcess {
param([int]$TargetPort)
$listener = Get-NetTCPConnection -LocalPort $TargetPort -State Listen -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $listener) {
return $null
}
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $($listener.OwningProcess)" -ErrorAction SilentlyContinue
if (-not $process) {
return $null
}
return [PSCustomObject]@{
Id = $listener.OwningProcess
CommandLine = [string]($process.CommandLine ?? "")
}
}
$existing = Get-ListeningProcess -TargetPort $Port
if ($existing) {
if ($existing.CommandLine -notmatch "vite(?:\.js)?") {
throw "Port $Port is already in use by a non-Vite process: $($existing.CommandLine)"
}
Stop-Process -Id $existing.Id -Force
Start-Sleep -Seconds 1
}
$npm = if (Get-Command npm.cmd -ErrorAction SilentlyContinue) {
"npm.cmd"
} else {
"npm"
}
& $npm run dev -- --host localhost --port $Port --strictPort
exit $LASTEXITCODE
+1035
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -6,6 +6,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useHead } from "@vueuse/head";
import favicon from '@/assets/favicon.ico';
import VersionCheck from "@/components/global/VersionCheck.vue";
import RequestQueueProgress from "@/components/global/RequestQueueProgress.vue";
import {useRoute} from "vue-router";
import LayoutV2 from "@/components/page/wrappers/LayoutV2.vue";
import { useI18n } from "vue-i18n";
@@ -23,7 +24,8 @@ const routeTitleI18nKeys = {
"adminlogin": ["header.operator_login"],
"guestHome": ["guest.home.title"],
"PwaDownload": ["guest.pwa.title"],
"connectivity-issue": ["connectivity.title"]
"connectivity-issue": ["connectivity.title"],
"selfservestudio": ["admin.self_serve.title"]
};
const toReadableTitle = (value) => {
@@ -90,6 +92,7 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
</DefaultPageWrapper>
</main>
</template>
<RequestQueueProgress />
</template>
<style scoped>
+28 -1
View File
@@ -98,4 +98,31 @@ textarea.has-sharp-edges {
/*}*/
/*button.is-link {*/
/* background-color: #1584BC;*/
/*}*/
/*}*/
.mt-30 {
margin-top: 30px !important;
}
.cell .card-content .field{
width: 100%;
}
.cell .card-content .field .label{
font-weight: 800;
}
@media screen and (max-width: 768px) {
.mb-8-mobile{
margin-bottom: 8px !important;
}
}
@media screen and (min-width: 769px) {
.ml-16-tablet {
margin-left: 16px !important;
}
.mt-30-tablet {
margin-top: 30px !important;
}
}
+790
View File
@@ -0,0 +1,790 @@
:root {
--pos-shell-gap: 1.25rem;
--pos-card-padding: 1.25rem;
--pos-card-radius: 1rem;
--pos-card-border: #dce5f1;
--pos-card-shadow: 0 14px 36px rgba(6, 54, 81, 0.08);
--pos-muted: #6b7a90;
}
.pos-shell {
display: grid;
gap: var(--pos-shell-gap);
align-items: start;
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
}
.pos-main,
.pos-rail {
min-width: 0;
}
.pos-rail {
display: flex;
flex-direction: column;
gap: var(--pos-shell-gap);
}
.pos-shell-actions {
display: grid;
grid-column: 1 / -1;
gap: var(--pos-shell-gap);
align-items: start;
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
}
.pos-shell-actions__rail {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pos-duplicate-warning {
margin: 0;
border: 1px solid #f2d38a;
border-radius: 0.95rem;
box-shadow: 0 10px 24px rgba(142, 108, 24, 0.08);
}
.pos-duplicate-warning__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.pos-duplicate-warning__header-copy {
display: inline-flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
}
.pos-duplicate-warning__icon {
color: #8a6300;
}
.pos-duplicate-warning__title {
margin: 0;
color: #604700;
font-size: 0.95rem;
font-weight: 700;
}
.pos-duplicate-warning__message {
margin: 0.75rem 0 0;
color: #5e5230;
line-height: 1.45;
}
.pos-duplicate-warning__details {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.85rem;
}
.pos-duplicate-warning__detail-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.9rem 1rem;
border: 1px solid rgba(142, 108, 24, 0.16);
border-radius: 0.8rem;
background: rgba(255, 255, 255, 0.8);
}
.pos-duplicate-warning__detail-copy {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.pos-duplicate-warning__detail-title {
margin: 0;
color: #2b3442;
font-weight: 700;
}
.pos-duplicate-warning__detail-content {
margin: 0;
color: #5c6a7c;
line-height: 1.45;
}
.pos-duplicate-warning__detail-actions {
margin: 0;
}
.pos-duplicate-warning__detail-actions .button {
margin: 0;
}
@media screen and (min-width: 1024px) {
.pos-rail--sticky {
position: sticky;
top: 1rem;
}
}
@media screen and (max-width: 1023px) {
.pos-shell {
grid-template-columns: minmax(0, 1fr);
}
.pos-shell-actions {
grid-template-columns: minmax(0, 1fr);
}
.pos-duplicate-warning__header {
flex-direction: column;
align-items: stretch;
}
}
.box.has-sharp-edges.pos-card {
padding: var(--pos-card-padding);
border-radius: var(--pos-card-radius);
border: 1px solid var(--pos-card-border);
box-shadow: var(--pos-card-shadow);
background: #ffffff;
}
.box.has-sharp-edges.pos-card.pos-card--flush {
padding: 0;
overflow: hidden;
}
.box.has-sharp-edges.pos-card.pos-card--compact {
padding: 1rem;
}
.pos-card-tabs .tabs {
margin: 0;
padding: 0.85rem 1rem 0.35rem;
border-bottom: 1px solid #e6edf7;
}
.pos-card-tabs .tabs ul {
gap: 0.35rem;
flex-wrap: wrap;
border-bottom: none;
}
.pos-card-tabs .tabs li a {
min-height: 2.4rem;
padding: 0.45rem 0.9rem;
border: none;
border-radius: 999px;
color: #56657a;
font-weight: 600;
}
.pos-card-tabs .tabs li.is-active a {
background: #eef4ff;
color: #21446d;
}
.tabs.pos-sidebar-panel-tabs {
margin: 0 0 0.35rem;
}
.tabs.pos-sidebar-panel-tabs ul {
justify-content: flex-start;
margin: 0;
gap: 0.35rem;
flex-wrap: wrap;
border-bottom: none;
}
.tabs.pos-sidebar-panel-tabs li a {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: none;
border-radius: 999px;
min-height: 2.1rem;
padding: 0.45rem 0.85rem;
color: #5a6980;
font-weight: 600;
}
.tabs.pos-sidebar-panel-tabs li.is-active a {
background: #eef4ff;
color: #21446d;
}
.buttons.pos-actions {
margin: 0;
gap: 0.75rem;
}
.buttons.pos-actions > * {
margin: 0;
min-width: 0;
}
.buttons.pos-actions .button {
margin: 0;
}
.buttons.pos-actions.pos-actions--desktop {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.buttons.pos-actions.pos-actions--stacked,
.buttons.pos-actions.pos-actions--rail {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.auto-excel-export-inline,
[data-auto-excel-export-button="1"] {
display: flex;
align-items: center;
}
.auto-excel-export-inline .button,
[data-auto-excel-export-button="1"] .button {
margin: 0;
}
.pos-inline-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.85rem;
}
.pos-inline-tag {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
padding: 0;
border-radius: 0;
background: transparent;
color: #5c6d83;
font-size: var(--pos-order-subheading-font-size, 0.75rem);
font-weight: 500;
line-height: var(--pos-order-subheading-line-height, 1.5);
white-space: nowrap;
}
.pos-inline-tag__label {
color: #7f8ea3;
font-weight: 600;
}
.pos-inline-tag__value {
color: #243347;
font-weight: 600;
}
.pos-desktop-step {
display: flex;
flex-direction: column;
gap: 1rem;
}
.pos-order-metadata-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pos-order-metadata-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
min-height: 100%;
padding: 1rem;
border: 1px solid #dce5f1;
border-radius: 0.95rem;
background: linear-gradient(180deg, #fbfdff 0%, #f6faff 100%);
}
.pos-order-metadata-card--full {
grid-column: 1 / -1;
}
.pos-order-metadata-label {
margin: 0;
color: #607086;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.pos-order-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.pos-order-field__control {
width: 100%;
min-height: 3.25rem;
padding: 0.8rem 0.95rem;
border: 1px solid #d7e2ef;
border-radius: 0.85rem;
background: #ffffff;
color: #223347;
font-size: 0.95rem;
line-height: 1.4;
text-align: left;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.pos-order-field__control--interactive {
cursor: text;
display: flex;
align-items: center;
justify-content: flex-start;
}
.pos-order-field__control--interactive:hover,
.pos-order-field__control--interactive:focus-visible {
border-color: #93b3d8;
box-shadow: 0 0 0 3px rgba(93, 142, 191, 0.12);
outline: none;
}
.pos-order-field__control--empty {
color: #8ea0b6;
}
.pos-order-field__control--editing {
padding: 0;
}
.pos-order-field__preview {
flex: 1 1 auto;
min-width: 0;
display: -webkit-box;
overflow: hidden;
white-space: pre-wrap;
word-break: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.pos-order-field__empty-state {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
}
.pos-order-field__empty-pill {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 0.32rem 0.7rem;
border-radius: 999px;
background: #f3f6fa;
color: #4c5f76;
font-size: 0.79rem;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pos-order-note-editor .pos-order-field__control {
min-height: 7.25rem;
}
.pos-order-note-editor .pos-order-field__control--interactive {
align-items: flex-start;
}
.pos-order-note-editor .pos-order-field__preview {
-webkit-line-clamp: 5;
}
.pos-order-note-editor__empty {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0.55rem;
width: 100%;
min-width: 0;
min-height: 100%;
}
.pos-order-note-editor__empty-copy {
color: #7a8ba1;
font-size: 0.92rem;
line-height: 1.35;
}
.pos-order-field__input {
width: 100%;
min-height: 3.25rem;
resize: vertical;
border: 0;
box-shadow: none;
background: transparent;
color: inherit;
padding: 0.8rem 0.95rem;
}
.pos-order-note-editor .pos-order-field__input {
min-height: 7.25rem;
}
.pos-order-field__input:focus {
outline: none;
box-shadow: none;
}
.pos-registration-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.pos-registration-grid--customer-wishes {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pos-registration-slot {
min-width: 0;
}
.pos-registration-control {
width: 100%;
min-width: 0;
margin-bottom: 0;
}
.pos-registration-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
width: 100%;
min-height: 4.25rem;
padding: 0.75rem 0.9rem;
border: 1px solid #d7e2ef;
border-radius: 0.85rem;
background: #ffffff;
color: #223347;
text-align: left;
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
}
.pos-registration-field--editing {
justify-content: center;
cursor: text;
}
.pos-registration-field:hover,
.pos-registration-field:focus-visible {
border-color: #93b3d8;
box-shadow: 0 0 0 3px rgba(93, 142, 191, 0.12);
outline: none;
transform: translateY(-1px);
}
.pos-registration-field--empty {
color: #8ea0b6;
}
.pos-registration-field__label {
color: #607086;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.pos-registration-field__label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.45rem;
min-width: 0;
}
.pos-registration-field__status-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
font-size: 0.8rem;
line-height: 1;
}
.pos-registration-field__status-icon--warning {
color: #a46d00;
}
.pos-registration-field__status-icon--danger {
color: #b53e3e;
}
.pos-registration-field__value {
min-width: 0;
font-weight: 600;
line-height: 1.35;
word-break: break-word;
}
.pos-registration-field--add {
border-style: dashed;
background: #fbfcfe;
}
.pos-registration-field--warning {
border-style: solid;
border-color: #ddc15b;
background: #fff4c4;
}
.pos-registration-field--danger {
border-style: solid;
border-color: #e0a7a7;
background: #fff1f1;
}
.pos-registration-field--add:hover,
.pos-registration-field--add:focus-visible {
border-color: #93b3d8;
box-shadow: 0 0 0 3px rgba(93, 142, 191, 0.12);
background: #fbfdff;
outline: none;
transform: none;
}
.pos-registration-field--warning:hover,
.pos-registration-field--warning:focus-visible {
border-color: #cfaf3f;
background: #ffefb2;
box-shadow: 0 0 0 3px rgba(207, 175, 63, 0.16);
}
.pos-registration-field--danger:hover,
.pos-registration-field--danger:focus-visible {
border-color: #d38c8c;
background: #ffe7e7;
box-shadow: 0 0 0 3px rgba(211, 140, 140, 0.16);
}
.pos-registration-field__value--muted {
color: #63768f;
}
.pos-registration-field__input {
width: 100%;
min-width: 0;
padding: 0;
border: 0;
box-shadow: none;
background: transparent;
color: inherit;
font-size: 0.95rem;
font-weight: 600;
line-height: 1.35;
text-transform: uppercase;
}
.pos-registration-field__input--customer-wishes {
text-transform: none;
}
.pos-registration-field__input:focus {
outline: none;
box-shadow: none;
}
.pos-order-items--order-detail .pos-order-metadata-grid {
gap: 0.9rem;
grid-template-columns: minmax(20rem, 1.22fr) minmax(16rem, 1fr);
grid-template-areas:
"license-plates customer-wishes"
"note note";
align-items: stretch;
}
.pos-order-items--order-detail .pos-order-metadata-card {
gap: 0.7rem;
padding: 0.95rem 1rem;
border-color: #e2e8f0;
border-radius: 1rem;
background: linear-gradient(180deg, #ffffff 0%, #fafbfd 100%);
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
}
.pos-order-items--order-detail .pos-order-metadata-card--license-plates {
grid-area: license-plates;
}
.pos-order-items--order-detail .pos-order-metadata-card--customer-wishes {
grid-area: customer-wishes;
}
.pos-order-items--order-detail .pos-order-metadata-card--note {
grid-area: note;
}
.pos-order-items--order-detail .pos-order-metadata-label {
color: #738397;
font-size: 0.68rem;
letter-spacing: 0.08em;
}
.pos-order-items--order-detail .pos-order-field__control {
min-height: 3rem;
padding: 0.7rem 0.85rem;
border-color: #dbe3eb;
border-radius: 0.8rem;
box-shadow: none;
}
.pos-order-items--order-detail .pos-order-field__empty-pill {
background: #f4f6f9;
color: #445466;
}
.pos-order-items--order-detail .pos-order-field__input {
min-height: 3rem;
padding: 0.7rem 0.85rem;
}
.pos-order-items--order-detail .pos-order-customer-wishes .pos-registration-field__value {
font-size: 0.95rem;
}
.pos-order-items--order-detail .pos-order-note-editor .pos-order-field__control {
min-height: 4.6rem;
}
.pos-order-items--order-detail .pos-order-note-editor .pos-order-field__preview {
-webkit-line-clamp: 2;
}
.pos-order-items--order-detail .pos-order-note-editor .pos-order-field__input {
min-height: 4.6rem;
}
.pos-order-items--order-detail .pos-order-note-editor__empty {
justify-content: center;
gap: 0.45rem;
}
.pos-order-items--order-detail .pos-order-note-editor__empty-copy {
font-size: 0.88rem;
}
.pos-order-items--order-detail .pos-registration-grid {
gap: 0.55rem;
grid-auto-rows: 1fr;
}
.pos-order-items--order-detail .pos-registration-slot {
display: flex;
min-width: 0;
}
.pos-order-items--order-detail .pos-registration-control {
display: flex;
flex: 1 1 0;
width: 100%;
min-width: 0;
}
.pos-order-items--order-detail .pos-registration-field {
flex: 1 1 0;
height: 100%;
justify-content: space-between;
}
.pos-order-items--order-detail .pos-registration-field {
min-height: 3.35rem;
padding: 0.62rem 0.78rem;
border-color: #dbe3eb;
border-radius: 0.8rem;
background: #ffffff;
box-shadow: none;
}
.pos-order-items--order-detail .pos-registration-field--editing {
width: 100%;
}
.pos-order-items--order-detail .pos-registration-field:hover,
.pos-order-items--order-detail .pos-registration-field:focus-visible {
transform: none;
background: #fcfdff;
}
.pos-order-items--order-detail .pos-registration-field--warning:hover,
.pos-order-items--order-detail .pos-registration-field--warning:focus-visible {
background: #ffefb2;
}
.pos-order-items--order-detail .pos-registration-field--danger:hover,
.pos-order-items--order-detail .pos-registration-field--danger:focus-visible {
background: #ffe7e7;
}
.pos-order-items--order-detail .pos-registration-field__label {
color: #738397;
font-size: 0.68rem;
letter-spacing: 0.08em;
}
.pos-order-items--order-detail .pos-registration-field__value {
font-size: 0.95rem;
}
.pos-order-items--order-detail .pos-registration-field--add {
border-color: #d2dbe5;
}
.pos-order-items--order-detail .pos-registration-field--warning {
border-color: #d8b748;
background: #fff3bf;
}
.pos-order-items--order-detail .pos-registration-field--danger {
border-color: #dc9f9f;
background: #fff0f0;
}
@media screen and (max-width: 1080px) {
.pos-order-items--order-detail .pos-order-metadata-grid {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"license-plates"
"customer-wishes"
"note";
}
.pos-order-items--order-detail .pos-order-note-editor .pos-order-field__control,
.pos-order-items--order-detail .pos-order-note-editor .pos-order-field__input {
min-height: 5rem;
}
}
@media screen and (max-width: 768px) {
.box.has-sharp-edges.pos-card {
padding: 1rem;
border-radius: 0.9rem;
}
.pos-card-tabs .tabs {
padding: 0.75rem 0.75rem 0.35rem;
}
.pos-order-metadata-grid,
.pos-registration-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -0,0 +1,20 @@
<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" />
</div>
</template>
<style scoped>
</style>
+1 -2
View File
@@ -8,8 +8,7 @@ const { t } = useI18n();
<template>
<div>
<PageTitle :title="t('objects.subuser_grants.title')" :subtitle="t('objects.subuser_grants.subtitle')">
</PageTitle>
<PageTitle :title="t('objects.subuser_grants.title')" :subtitle="t('objects.subuser_grants.subtitle')"></PageTitle>
<SubuserGrantsPagination auto-load="true" />
</div>
</template>
+46 -4
View File
@@ -1,19 +1,61 @@
<script setup>
import SubusersPagination from "@/components/displays/pagination/models/SuperUserDashboard/SubusersPagination.vue";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import PageTitle from "@/components/global/PageTitle.vue";
import { useI18n } from 'vue-i18n';
import SubusersPagination from "@/components/displays/pagination/models/SuperUserDashboard/SubusersPagination.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue";
const { t } = useI18n();
const canInvite = computed(() => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_ADD"));
const requiresGrantSelection = computed(
() => SessionUser.isSubuser.value && !SessionUser.subuser.selectedGrantCustomerNumber.value
);
const paginationVersion = ref(0);
const paginationKey = computed(() =>
SessionUser.isSubuser.value
? `subusers-${SessionUser.subuser.selectedGrantCustomerNumber.value || "none"}-${paginationVersion.value}`
: `subusers-user-${paginationVersion.value}`
);
const onInviteClick = async () => {
await SessionUser.objects.subusers.functions.showInviteForm(() => {
paginationVersion.value += 1;
});
};
</script>
<template>
<div>
<PageTitle :title="t('superuser.pages.subusers.title')" :subtitle="t('superuser.pages.subusers.subtitle')">
<template #buttons>
<button v-if="canInvite" class="button is-dark" type="button" @click="onInviteClick">
<span class="icon">
<i class="fas fa-user-plus"></i>
</span>
<span>Invitér chauffør</span>
</button>
</template>
</PageTitle>
<SubusersPagination auto-load="true" />
<div v-if="SessionUser.isSubuser.value" 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.
</p>
</div>
<div v-if="requiresGrantSelection" class="notification is-info is-light">
Vælg først en kunde for at se og administrere chauffører.
</div>
<SubusersPagination v-else :key="paginationKey" auto-load="true" />
</div>
</template>
<style scoped>
.help {
font-size: 0.95rem;
}
</style>
@@ -51,33 +51,22 @@ const visibleTabs = ref([]);
* The is by default the prop defaultActiveTab, but can be changed by the user and won't be emitted, as it is not a prop.
*/
const activeTab = ref(props.defaultActiveTab);
const isHidden = (tab) => {
if (typeof tab.hidden === 'function') {
return tab.hidden();
}
return !!tab.hidden;
};
/**
* Update the visible tabs
* This checks if the tab is hidden or not, and returns the visible tabs
*/
const updateVisibleTabs = () => {
// Loop through the tabs and set the visible tabs
console.log('Updating visible tabs');
const tmp_visibleTabs = [];
props.tabs.forEach((tab) => {
if (typeof tab.hidden === 'function') {
if (!tab.hidden()) {
tmp_visibleTabs.push(tab);
}
} else if (typeof tab.hidden === 'boolean') {
if (!tab.hidden) {
tmp_visibleTabs.push(tab);
}
} else {
if (!tab.hidden) {
tmp_visibleTabs.push(tab);
}
}
});
visibleTabs.value = tmp_visibleTabs;
visibleTabs.value = props.tabs.filter((tab) => !isHidden(tab));
// If the active tab is not visible, set the active tab to the first visible tab
if (!visibleTabs.value.find((tab) => tab.slot === activeTab.value)) {
activeTab.value = visibleTabs.value[0].slot;
activeTab.value = visibleTabs.value[0]?.slot ?? null;
}
};
// Update the visible tabs every time the slot changes (If the tabs is a reactive object)
@@ -98,26 +87,10 @@ watch(activeTab, () => {
});
const isTabVisible = (tab) => {
let tmp_visible = null
console.log('Checking if tab is hidden', tab);
switch (typeof tab.hidden) {
case 'function':
console.log('Tab is hidden function');
tmp_visible = !tab.hidden();
break;
case 'boolean':
console.log('Tab is hidden boolean');
tmp_visible = !tab.hidden;
break;
default:
console.log('Tab is hidden default');
tmp_visible = !tab.hidden;
break;
}
console.log('Tab is visible', tmp_visible);
const tmp_visible = !isHidden(tab);
// If the tab is the current active tab, and the tab is not visible, set the active tab to the first visible tab
if (tab.slot === activeTab.value && !tmp_visible) {
activeTab.value = visibleTabs.value[0].slot;
activeTab.value = visibleTabs.value[0]?.slot ?? null;
}
return tmp_visible;
};
@@ -191,4 +164,4 @@ if (props.forceActiveTab !== null && props.forceActiveTab !== undefined) {
<style scoped>
</style>
</style>
@@ -15,6 +15,7 @@ const toggleIsSelected = () => {
</script>
<template>
<div class="expandable-content-box">
<div v-if="props.expanded">
<slot name="expandedContent"></slot>
</div>
@@ -36,8 +37,9 @@ const toggleIsSelected = () => {
</span>
</p>
</div>
</div>
</template>
<style scoped>
</style>
</style>
+36 -21
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, defineProps, defineEmits } from 'vue';
import { ref, computed, defineProps, defineEmits } from 'vue';
import { Colors } from "@/ThemeConfig.vue";
import { showPopper, popperBoxProductAddonHtml, removePopperIfOpen } from "@/components/displays/PopperDefault.vue";
import Swal from "sweetalert2";
@@ -37,21 +37,29 @@ const props = defineProps({
}
});
const addonsSafe = computed(() => (Array.isArray(props.addons) ? props.addons : []));
const selectedAddonsSafe = computed(() => (Array.isArray(props.selectedAddons) ? props.selectedAddons : []));
const customerAttributesSafe = computed(() => (Array.isArray(props.customerAttributes) ? props.customerAttributes : []));
const getAddonProduct = (addon) => addon?.product || {};
const getAddonName = (addon) => addon?.name || '';
const isAddonSelected = (productId, addonId) => {
return props.selectedAddons.some((addon) => addon.product_id === productId && addon.addon_id === addonId && addon.status === true);
return selectedAddonsSafe.value.some((addon) => addon.product_id === productId && addon.addon_id === addonId && addon.status === true);
};
const getAddonPrice = (addonId) => {
const addon = props.addons.find((addon) => addon.id === addonId);
return addon.product.price;
const addon = addonsSafe.value.find((entry) => entry.id === addonId);
return Number(getAddonProduct(addon).price ?? 0);
};
const showAddonPopper = (addon, element) => {
const addonProduct = getAddonProduct(addon);
showPopper(popperBoxProductAddonHtml(
addon.id,
addon.name,
addon.product.price,
addon.product.description,
addonProduct.price ?? 0,
addonProduct.description ?? '',
'pos-addon-popper'
), element);
};
@@ -73,23 +81,23 @@ const toggleIsSelected = () => {
};
const hasRestrictAdditionalServices = () => {
return props.customerAttributes.some((attribute) => attribute.attribute === 'restrictAdditionalServices');
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictAdditionalServices');
};
const hasRestrictSpotFree = () => {
return props.customerAttributes.some((attribute) => attribute.attribute === 'restrictSpotFree');
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictSpotFree');
};
const hasRestrictInteriorCleaning = () => {
return props.customerAttributes.some((attribute) => attribute.attribute === 'restrictInteriorCleaning');
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictInteriorCleaning');
};
const hasRestrictTankCleaning = () => {
return props.customerAttributes.some((attribute) => attribute.attribute === 'restrictTankCleaning');
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictTankCleaning');
};
const hasOnlyTankCleaning = () => {
return props.customerAttributes.some((attribute) => attribute.attribute === 'onlyTankCleaning');
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'onlyTankCleaning');
};
@@ -151,31 +159,33 @@ const onBeforeToggleProductAddon = (addon, isAddonCurrentlySelected, onAfterPreC
};
const doesAddonHaveNoteRequirement = (addon) => {
return addon.product.requires_note;
return getAddonProduct(addon).requires_note === true;
};
const isAddonRestricted = (addon) => {
const addonName = getAddonName(addon).toLowerCase();
// Check if the addon has "Spot Free" in the name
if (hasRestrictSpotFree() && addon.name.toLowerCase().includes('spot free')) {
if (hasRestrictSpotFree() && addonName.includes('spot free')) {
return true;
}
// Check if the addon has "Indvendig vask" in the name
if (hasRestrictInteriorCleaning() && addon.name.toLowerCase().includes('indvendig vask')) {
if (hasRestrictInteriorCleaning() && addonName.includes('indvendig vask')) {
return true;
}
};
const isProductRestricted = () => {
const productName = String(props.name || '').toLowerCase();
// Check if the product has "Spot Free" in the name
if (hasRestrictSpotFree() && props.name.toLowerCase().includes('spot free')) {
if (hasRestrictSpotFree() && productName.includes('spot free')) {
return true;
}
// Check if the product has "Indvendig vask" in the name
if (hasRestrictInteriorCleaning() && props.name.toLowerCase().includes('indvendig vask')) {
if (hasRestrictInteriorCleaning() && productName.includes('indvendig vask')) {
return true;
}
// Check if the product category is 5 (Tank Cleaning)
if (props.product.category === 5) {
if (props.product?.category === 5) {
if (hasRestrictTankCleaning()) {
return true;
}
@@ -187,7 +197,11 @@ const isProductRestricted = () => {
};
const orderByOrderPriority = (addons) => {
return addons.sort((a, b) => a.product.order_priority - b.product.order_priority);
return [...addons].sort((a, b) => {
const aPriority = Number(getAddonProduct(a).order_priority ?? 0);
const bPriority = Number(getAddonProduct(b).order_priority ?? 0);
return aPriority - bPriority;
});
};
</script>
@@ -247,7 +261,7 @@ const orderByOrderPriority = (addons) => {
<!-- Expandable product details -->
<div v-if="isSelected" class="column is-12-desktop">
<!-- Addons -->
<div class="is-centered mt-3 mb-0 pl-6" v-if="addons.length > 0">
<div class="is-centered mt-3 mb-0 pl-6" v-if="addonsSafe.length > 0">
<template v-if="hasRestrictAdditionalServices()">
<div class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap">
<button
@@ -261,7 +275,7 @@ const orderByOrderPriority = (addons) => {
</button>
</div>
</template>
<template v-for="addon in orderByOrderPriority(addons)" :key="addon.id" v-else>
<template v-for="addon in orderByOrderPriority(addonsSafe)" :key="addon.id" v-else>
<template v-if="!isAddonRestricted(addon)">
<div
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"
@@ -329,6 +343,7 @@ const orderByOrderPriority = (addons) => {
<div class="buttons is-centered mt-2 mb-3 pl-6">
<button
v-bind:disabled="isProductRestricted()"
:data-testid="`pos-add-to-cart-${id}`"
class="button is-link is-small is-fullwidth"
@click="emitAddToCart(id);"
>
@@ -369,4 +384,4 @@ const orderByOrderPriority = (addons) => {
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
</style>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
<script setup>
import { defineProps } from 'vue';
import { defineEmits, defineProps, ref } from 'vue';
const props = defineProps({
clickAction: Function,
@@ -8,6 +8,7 @@ const props = defineProps({
disabled: Boolean,
template: String // The style of the button (default, danger, success, warning, info, light)
});
const emit = defineEmits(['selected']);
const styles = {
default: {
@@ -72,9 +73,22 @@ const styles = {
},
};
const click = () => {
if (props.clickAction && !props.disabled)
props.clickAction();
const isProcessing = ref(false);
const click = async (event) => {
if (isDisabled() || isProcessing.value) {
return;
}
isProcessing.value = true;
try {
if (props.clickAction) {
await props.clickAction();
}
} finally {
isProcessing.value = false;
emit('selected');
event?.currentTarget?.dispatchEvent(new CustomEvent('dropdown-action-selected', { bubbles: true }));
}
}
const isDisabled = () => {
@@ -111,19 +125,31 @@ const getLabelColor = () => {
</script>
<template>
<a class="dropdown-item" @click="click" :class="{'is-disabled': isDisabled()}">
<button
type="button"
class="dropdown-item dropdown-item-action"
@click.stop.prevent="click"
:class="{'is-disabled': isDisabled()}"
:disabled="isDisabled()"
>
<span class="icon">
<i :class="getIcon() + ' ' + getIconColor()"></i>
</span>
<span class="ml-1"
:class="getLabelColor()"
>{{ getLabel() }}</span>
</a>
</button>
</template>
<style scoped>
.dropdown-item-action {
width: 100%;
text-align: left;
background: transparent;
border: 0;
}
.is-disabled {
pointer-events: none;
opacity: 0.5;
}
@@ -134,4 +160,4 @@ const getLabelColor = () => {
.is-disabled .icon {
opacity: 0.5;
}
</style>
</style>
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,9 @@
<script setup>
import {defineProps, ref} from 'vue';
import {computed, defineProps, ref, useSlots} from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {BTooltip} from "buefy";
const slots = useSlots();
const props = defineProps({
object: Object,
loadList: Function,
@@ -44,10 +46,28 @@ const props = defineProps({
default: () => {
return false;
}
},
cellTestId: {
type: String,
default: () => {
return null;
}
},
editButtonTestId: {
type: String,
default: () => {
return null;
}
}
});
const canEdit = computed(() => Boolean(props.editFunction) && props.permissionCheckFunction());
const clickEdit = () => {
if (!canEdit.value) {
return;
}
// If the virtual column is not set, then show the edit form
if (!props.virtualColumn) {
isLoading.value = true;
@@ -71,6 +91,8 @@ const clickEdit = () => {
}
}
const isLoading = ref(false);
const hasHoverHtmlSlot = computed(() => Boolean(slots.hoverHtml));
const isTooltipMultiline = computed(() => Boolean(props.hoverHtml || hasHoverHtmlSlot.value));
//console.log(props);
</script>
@@ -79,7 +101,8 @@ const isLoading = ref(false);
<component :is="props.componentWrapper ? props.componentWrapper : 'td'"
@click="clickEdit"
class="hover-illustration"
:class="{'is-clickable': props.permissionCheckFunction() && props.editFunction}">
:class="{'is-clickable': canEdit}"
:data-testid="props.cellTestId || null">
<div>
<!-- Divided display ( value | edit button ) -->
@@ -93,8 +116,14 @@ const isLoading = ref(false);
</div>
<div class="level-right">
<div class="level-item">
<span class="ml-2 is-float-right" v-if="props.permissionCheckFunction()">
<button class="button is-small is-transparent is-text" style="text-decoration-line: none;" @click="clickEdit" v-if="props.editFunction" :class="{'is-loading': isLoading}" :disabled="isLoading">
<span class="ml-2 is-float-right" v-if="canEdit">
<button class="button is-small is-transparent is-text"
style="text-decoration-line: none;"
@click.stop="clickEdit"
v-if="props.editFunction"
:class="{'is-loading': isLoading}"
:disabled="isLoading"
:data-testid="props.editButtonTestId || null">
<span class="icon">
<i class="fas fa-edit"></i>
</span>
@@ -110,7 +139,7 @@ const isLoading = ref(false);
<b-tooltip
type="is-light"
position="is-bottom"
:multilined="$slots.hoverHtml"
:multilined="isTooltipMultiline"
square>
<!-- Value Display -->
<span class="" v-if="!props.virtualColumn">{{ parseFunction ? parseFunction(props.object[props.column]) : props.object[props.column] }}</span>
@@ -134,4 +163,4 @@ const isLoading = ref(false);
background-color: #f5f5f5;
border-radius: 4px;
}
</style>
</style>
@@ -5,14 +5,29 @@ import { authenticatedRequest } from "@/components/session/authenticatedRequest.
import {BSwitch} from "buefy";
import {useToast} from 'vue-toast-notification';
import {useI18n} from 'vue-i18n';
import {
getDepartmentSelfServeEnabled,
setDepartmentSelfServeEnabled,
} from "@/composables/departmentSelfServeEnabled.js";
const router = useRouter();
const toast = useToast();
const { t } = useI18n();
const selfServeEnabled = ref(false);
const isLoadingSelfServeEnabled = ref(false);
const isSavingSelfServeEnabled = ref(false);
const bookingsCount = ref(0);
const getDepartmentId = () => {
return parseInt(router.currentRoute.value.params.departmentId);
};
const buildDepartmentModulePath = (modulePath = "") => {
const departmentId = getDepartmentId();
return `/admin/${departmentId}${modulePath}`;
};
const modules = computed(() => [
{
id: 1,
@@ -20,13 +35,10 @@ const modules = computed(() => [
description: t('admin.department_modules.pos.description'),
icon: "fas fa-book",
onClick: () => {
// Get the departmentId from the URL
const departmentId = router.currentRoute.value.params.departmentId;
// Redirect to the POS system
router.push(`/admin/${departmentId}/modules/pos`);
router.push(buildDepartmentModulePath('/modules/pos'));
},
onClickCount: () => {
this.onClick();
router.push(buildDepartmentModulePath('/modules/pos'));
},
count: 0
},
@@ -36,16 +48,10 @@ const modules = computed(() => [
description: t('admin.department_modules.bookings.description'),
icon: "fas fa-calendar-alt",
onClick: () => {
// Get the departmentId from the URL
const departmentId = router.currentRoute.value.params.departmentId;
// Redirect to the bookings
router.push(`/admin/${departmentId}/modules/bookings`);
router.push(buildDepartmentModulePath('/modules/bookings'));
},
onClickCount: () => {
// Get the departmentId from the URL
const departmentId = router.currentRoute.value.params.departmentId;
// Redirect to the bookings
router.push(`/admin/${departmentId}/modules/bookings?search=pending`);
router.push(buildDepartmentModulePath('/modules/bookings?search=pending'));
},
count: bookingsCount.value
},
@@ -55,13 +61,10 @@ const modules = computed(() => [
description: t('admin.department_modules.daily_report.description'),
icon: "fas fa-book",
onClick: () => {
// Get the departmentId from the URL
const departmentId = router.currentRoute.value.params.departmentId;
// Redirect to the POS system
router.push(`/admin/${departmentId}/modules/daily-report`);
router.push(buildDepartmentModulePath('/modules/daily-report'));
},
onClickCount: () => {
this.onClick();
router.push(buildDepartmentModulePath('/modules/daily-report'));
},
count: 0
},
@@ -71,13 +74,10 @@ const modules = computed(() => [
description: t('admin.department_modules.goals.description'),
icon: "fas fa-bullseye",
onClick: () => {
// Get the departmentId from the URL
const departmentId = router.currentRoute.value.params.departmentId;
// Redirect to the goals
router.push(`/admin/${departmentId}/modules/goals`);
router.push(buildDepartmentModulePath('/modules/goals'));
},
onClickCount: () => {
this.onClick();
router.push(buildDepartmentModulePath('/modules/goals'));
},
count: 0
},
@@ -87,32 +87,47 @@ const modules = computed(() => [
description: t('admin.department_modules.self_wash.description'),
icon: "fas fa-tint",
onClick: () => {
// Get the departmentId from the URL
const departmentId = router.currentRoute.value.params.departmentId;
// Redirect to the wash lanes
router.push(`/admin/${departmentId}/modules/wash-lanes`);
router.push(buildDepartmentModulePath('/modules/wash-lanes'));
},
onClickCount: () => {
this.onClick();
router.push(buildDepartmentModulePath('/modules/wash-lanes'));
},
count: 0,
hasSwitch: true,
switchValue: selfServeEnabled,
switchDisabled: isLoadingSelfServeEnabled.value || isSavingSelfServeEnabled.value,
onToggle: async (newValue) => {
const departmentId = getDepartmentId();
if (!departmentId) return;
// Get the current self-serve status, and set it to the opposite of the new value
const currentStatus = selfServeEnabled.value;
selfServeEnabled.value = newValue;
isSavingSelfServeEnabled.value = true;
try {
await authenticatedRequest(`/departments/self-serve/enabled?id=${departmentId}&enabled=${newValue}`, 'PUT');
selfServeEnabled.value = await setDepartmentSelfServeEnabled(departmentId, newValue);
toast.success(newValue ? t('admin.department_modules.self_wash.enabled') : t('admin.department_modules.self_wash.disabled'));
} catch (error) {
console.error("Failed to update self-serve status", error);
// Revert switch value on error
selfServeEnabled.value = currentStatus;
toast.error(t('admin.department_modules.self_wash.update_error'));
} finally {
isSavingSelfServeEnabled.value = false;
}
}
},
{
id: 6,
name: 'Self-Serve Studio',
description: 'Configure questions, conditions, rules and tasks',
icon: "fas fa-project-diagram",
onClick: () => {
router.push(buildDepartmentModulePath('/modules/self-serve/studio'));
},
onClickCount: () => {
router.push(buildDepartmentModulePath('/modules/self-serve/studio'));
},
count: 0
}
/**
* {
@@ -152,11 +167,6 @@ const modules = computed(() => [
*/
]);
// Get the departmentId from the URL
const getDepartmentId = () => {
return parseInt(router.currentRoute.value.params.departmentId);
};
// Get today's bookings
const getTodaysBookings = async () => {
// Return, if the departmentId is not set
@@ -179,11 +189,13 @@ const getSelfServeStatus = async () => {
const departmentId = getDepartmentId();
if (!departmentId) return;
isLoadingSelfServeEnabled.value = true;
try {
const response = await authenticatedRequest(`/departments/self-serve/enabled?id=${departmentId}`, 'GET');
selfServeEnabled.value = response.data.data.enabled;
selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId);
} catch (error) {
console.error("Failed to fetch self-serve status", error);
} finally {
isLoadingSelfServeEnabled.value = false;
}
};
@@ -204,9 +216,9 @@ setInterval(() => {
</script>
<template>
<div class="columns is-multiline">
<div class="column is-3" v-for="module in modules" :key="module.id">
<div class="card is-clickable" @click="module.onClick">
<div class="columns is-multiline department-modules" data-testid="admin-mobile-modules">
<div class="column is-12-mobile is-6-tablet is-4-desktop is-3-widescreen" v-for="module in modules" :key="module.id">
<div class="card is-clickable department-module-card" @click="module.onClick">
<header class="card-header">
<div class="card-header-icon">
<span class="icon">
@@ -215,14 +227,20 @@ setInterval(() => {
</div>
<p class="card-header-title">{{ module.name }}</p>
<div class="is-pulled-right card-header-title pr-0">
<span class="button is-small is-dark" @click="module.onClickCount" v-if="module.count > 0">{{ module.count }}</span>
<span class="button is-small is-outlined" v-else-if="module?.hasSwitch" @click.stop>
<b-switch size="is-small" type="is-link" :model-value="module.switchValue" @update:model-value="module.onToggle"/>
<span class="button is-small is-dark department-module-card__count" @click.stop="module.onClickCount" v-if="module.count > 0">{{ module.count }}</span>
<span class="button is-small is-outlined department-module-card__switch" v-else-if="module?.hasSwitch" @click.stop>
<b-switch
size="is-small"
type="is-link"
:model-value="Boolean(module?.switchValue?.value ?? module?.switchValue)"
:disabled="module?.switchDisabled"
@update:model-value="module.onToggle"
/>
</span>
</div>
</header>
<div class="card-content">
<p class="title is-4">{{ module.name }}</p>
<p class="title is-4 department-module-card__title">{{ module.name }}</p>
<p class="subtitle">{{ module.description }}</p>
</div>
</div>
@@ -231,5 +249,26 @@ setInterval(() => {
</template>
<style scoped>
.department-module-card {
height: 100%;
}
</style>
.department-module-card .card-header-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.department-module-card__count,
.department-module-card__switch {
position: relative;
z-index: 1;
}
@media (max-width: 768px) {
.department-module-card__title {
display: none;
}
}
</style>
@@ -8,12 +8,14 @@ import PosDepartmentStep4 from "@/components/displays/department/pos/steps/PosDe
import ViewportResponsiveWrapper from "@/components/viewport/conditions/elements/ViewportResponsiveWrapper.vue";
import PosDepartmentStepMobile1 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue";
import PosDepartmentStepMobile2 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue";
import PosDepartmentStepMobile3 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile3.vue";
import { pos } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
import BookedCustomer from "@/components/viewport/elements/icons/BookedCustomer.vue";
import PosDepartmentStepMobilePopupRenderer
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupRenderer.vue";
import { applyPosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
const debugVehicles = () => {
pos.vehicles.select(
1,
@@ -39,34 +41,12 @@ const debugVehicles = () => {
};
setDepartment();
// Debug:
// If the &id= query parameter is set, then set the order_id to the value of the query parameter
// If the &step= query parameter is set, then set the current_step to the value of the query parameter
// If the &customer_id= query parameter is set, then set the customer_id to the value of the query parameter
if (window.location.search.includes("id=")) {
const urlParams = new URLSearchParams(window.location.search);
const orderId = urlParams.get("id");
if (orderId !== null) {
setOrderId(parseInt(orderId));
}
loadOrderItems();
}
if (window.location.search.includes("step=")) {
const urlParams = new URLSearchParams(window.location.search);
const step = urlParams.get("step");
if (step !== null) {
setStep(parseInt(step));
}
}
if (window.location.search.includes("customer_id=")) {
const urlParams = new URLSearchParams(window.location.search);
const customerId = urlParams.get("customer_id");
if (customerId !== null) {
searchAndSelectCustomer(parseInt(customerId));
}
}
applyPosRouteSearch(window.location.search, {
setOrderId,
loadOrderItems,
setStep,
searchAndSelectCustomer,
});
/** Define the createOrder function */
</script>
@@ -86,6 +66,7 @@ if (window.location.search.includes("customer_id=")) {
<PosDepartmentStepMobilePopupRenderer/>
<PosDepartmentStepMobile1 v-if="getCurrentStep() === 1" />
<PosDepartmentStepMobile2 v-else-if="getCurrentStep() === 2 && (getOrderId() !== null)" />
<PosDepartmentStepMobile3 v-else-if="getCurrentStep() === 3 && (getOrderId() !== null)" />
</template>
</ViewportResponsiveWrapper>
</div>
@@ -93,4 +74,4 @@ if (window.location.search.includes("customer_id=")) {
<style scoped>
</style>
</style>
File diff suppressed because it is too large Load Diff
@@ -18,8 +18,8 @@ showLabel.value = props.isLabelVisible || false;
<RequiresPermission permission="list_customer_notes">
<div class="field">
<label class="label" v-if="(props.notes.length > 0 || props.isAddFormVisible !== undefined ? props.isAddFormVisible : true) && showLabel"> Kundebemærkninger</label>
<div class="control mt-3" v-if="isOldNotesVisible">
<div class="message" v-for="note in notes" :key="note.id">
<div class="control mt-3" v-if="isOldNotesVisible" data-testid="pos-customer-notes">
<div class="message" v-for="note in notes" :key="note.id" :data-testid="`pos-customer-note-${note.id}`">
<div class="message-header">
<p>{{ note.created_at }}</p>
<RequiresPermission permission="delete_customer_note">
@@ -50,4 +50,4 @@ showLabel.value = props.isLabelVisible || false;
<style scoped>
</style>
</style>
File diff suppressed because it is too large Load Diff
@@ -1,267 +1,363 @@
<script setup>
import { customer_name, customer_id, customer_data, customer_attributes, loadCustomerAttributes, hideDiscountsCatalog, hidePricesCatalog, setHidePricesCatalog, setHideDiscountsCatalog, getHideDiscountsCatalog, getHidePricesCatalog, getUserDiscounts, user_discounts } from "@/components/shop/POSDepartmentProcess.vue";
import {
customer_name,
customer_id,
customer_data,
customer_attributes,
loadCustomerAttributes,
hideDiscountsCatalog,
hidePricesCatalog,
setHidePricesCatalog,
setHideDiscountsCatalog,
getHideDiscountsCatalog,
getHidePricesCatalog,
getUserDiscounts,
user_discounts,
} from "@/components/shop/POSDepartmentProcess.vue";
import { createAttribute, deleteAttribute } from "@/components/shop/CustomerAttributes.vue";
import {ref, watch} from 'vue';
import 'bulma-switch/dist/css/bulma-switch.min.css';
import 'bulma-block-list/src/block-list.scss';
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import {popperBox, popper, removePopperIfOpen, showPopperWithContent, showPopper} from "@/components/displays/PopperDefault.vue";
import { computed, ref, watch } from "vue";
import "bulma-switch/dist/css/bulma-switch.min.css";
import "bulma-block-list/src/block-list.scss";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
popperBox,
popper,
removePopperIfOpen,
showPopperWithContent,
showPopper,
} from "@/components/displays/PopperDefault.vue";
import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue";
import CustomerDiscountsDepartmentDisplay
from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
import CustomerDiscountsDepartmentDisplay from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentBox.vue";
const panel_tabs = ref([
{
name: 'Detaljer',
key: "details",
name: "Detaljer",
active: true,
permission: 'get_user'
permission: "get_user",
},
{
name: 'Regler',
key: "rules",
name: "Regler",
active: false,
permission: 'list_customer_attributes'
permission: "list_customer_attributes",
},
{
name: 'Genveje',
key: "shortcuts",
name: "Genveje",
active: false,
},
{
name: 'Rabatter',
key: "discounts",
name: "Rabatter",
active: false,
permission: 'get_custom_prices_other'
}
permission: "get_custom_prices_other",
},
]);
const details = ref([
{
name: 'Kunde nummer',
prop: 'customerNumber',
icon: 'fas fa-user'
name: "Kunde nummer",
prop: "customerNumber",
icon: "fas fa-user",
},
{
name: 'Navn',
prop: 'name',
icon: 'fas fa-user'
name: "Navn",
prop: "name",
icon: "fas fa-user",
},
{
name: 'Adresse',
prop: 'address',
icon: 'fas fa-map-marker-alt'
name: "Adresse",
prop: "address",
icon: "fas fa-map-marker-alt",
},
{
name: 'Postnummer',
prop: 'zip',
icon: 'fas fa-map-marker-alt'
name: "Postnummer",
prop: "zip",
icon: "fas fa-map-marker-alt",
},
{
name: 'Kunde by',
prop: 'city',
icon: 'fas fa-map-marker-alt'
name: "Kunde by",
prop: "city",
icon: "fas fa-map-marker-alt",
},
{
name: 'Telefon',
prop: 'mobilePhone',
icon: 'fas fa-phone'
name: "Telefon",
prop: "mobilePhone",
icon: "fas fa-phone",
},
{
name: 'Email',
prop: 'email',
icon: 'fas fa-envelope'
name: "Email",
prop: "email",
icon: "fas fa-envelope",
},
{
name: 'CVR',
prop: 'corporateIdentificationNumber',
icon: 'fas fa-id-card'
}
name: "CVR",
prop: "corporateIdentificationNumber",
icon: "fas fa-id-card",
},
]);
const attributes = ref([
{
name: 'Kræver reference nr.',
prop: 'requiresReferenceNumber',
description: 'Når denne er sat, kræves der et reference nr. på ordren før den kan oprettes',
icon: 'fas fa-cogs'
name: "Kræver reference nr.",
prop: "requiresReferenceNumber",
description: "Når denne er sat, kræves der et reference nr. på ordren før den kan oprettes",
icon: "fas fa-cogs",
},
{
name: 'Må ikke ydes tillægsydelser',
prop: 'restrictAdditionalServices',
name: "Må ikke ydes tillægsydelser",
prop: "restrictAdditionalServices",
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "addons" til ordren',
icon: 'fas fa-cogs'
icon: "fas fa-cogs",
},
{
name: 'Registreringsnumre på faktura linjer',
prop: 'requiresRegistrationNumbersInvoice',
description: 'Når denne er sat, bliver der sendt registreringsnumre med på alle faktura linjer',
icon: 'fas fa-cogs'
name: "Registreringsnumre på faktura linjer",
prop: "requiresRegistrationNumbersInvoice",
description: "Når denne er sat, bliver der sendt registreringsnumre med på alle faktura linjer",
icon: "fas fa-cogs",
},
{
name: 'Fakturer alle ordrer individuelt',
prop: 'invoiceAllOrdersIndividually',
description: 'Når denne er sat, faktureres alle ordrer individuelt og ikke samlet',
icon: 'fas fa-cogs'
name: "Fakturer alle ordrer individuelt",
prop: "invoiceAllOrdersIndividually",
description: "Når denne er sat, faktureres alle ordrer individuelt og ikke samlet",
icon: "fas fa-cogs",
},
{
name: 'Må ikke ydes tank cleaning',
prop: 'restrictTankCleaning',
name: "Må ikke ydes tank cleaning",
prop: "restrictTankCleaning",
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "tank cleaning" til ordren',
icon: 'fas fa-cogs'
icon: "fas fa-cogs",
},
{
name: 'Må ikke ydes spot free',
prop: 'restrictSpotFree',
name: "Må ikke ydes spot free",
prop: "restrictSpotFree",
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "spot free" til ordren',
icon: 'fas fa-cogs'
icon: "fas fa-cogs",
},
{
name: 'Må ikke ydes indvendig vask',
prop: 'restrictInteriorCleaning',
name: "Må ikke ydes indvendig vask",
prop: "restrictInteriorCleaning",
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "interior cleaning" til ordren',
icon: 'fas fa-cogs'
icon: "fas fa-cogs",
},
{
name: 'Faktureres med Stripe',
prop: 'invoiceWithStripe',
description: 'Når denne er sat, faktureres ordren med Stripe',
icon: 'fas fa-cogs'
name: "Faktureres med Stripe",
prop: "invoiceWithStripe",
description: "Når denne er sat, faktureres ordren med Stripe",
icon: "fas fa-cogs",
},
{
name: 'Only tank cleaning',
prop: 'onlyTankCleaning',
name: "Only tank cleaning",
prop: "onlyTankCleaning",
description: 'Når denne er sat, bliver kunden kategoriseret som "Tank cleaning" kunde.',
icon: 'fas fa-cogs'
icon: "fas fa-cogs",
},
{
name: 'Vises priser på kunde og bookingside',
prop: 'showPricesOnBookingPage',
description: 'Når denne er sat, bliver kunden vist priser på kunde og bookingsiden.',
icon: 'fas fa-cogs'
name: "Vises priser på kunde og bookingside",
prop: "showPricesOnBookingPage",
description: "Når denne er sat, bliver kunden vist priser på kunde og bookingsiden.",
icon: "fas fa-cogs",
},
{
name: 'Bruger PO nummer',
prop: 'usePONumbers',
description: 'Når denne er sat, kan kunden angive et PO nummer på ordrer.',
icon: 'fas fa-cogs'
name: "Bruger PO nummer",
prop: "usePONumbers",
description: "Når denne er sat, kan kunden angive et PO nummer på ordrer.",
icon: "fas fa-cogs",
},
{
name: 'Undtaget fra månedligt administrations- og miljøgebyr',
prop: 'exemptFromAdministrationFee',
description: 'Når denne er sat, bliver kunden undtaget fra månedligt administrations- og miljøgebyr.',
icon: 'fas fa-cogs'
name: "Undtaget fra månedligt administrations- og miljøgebyr",
prop: "exemptFromAdministrationFee",
description: "Når denne er sat, bliver kunden undtaget fra månedligt administrations- og miljøgebyr.",
icon: "fas fa-cogs",
},
]);
const shortcuts = ref([
{
name: 'Administrer kunde',
icon: 'fas fa-user-edit',
name: "Administrer kunde",
icon: "fas fa-user-edit",
// Open link in new tab
action: () => {
SessionUser.adminUser.customers.fromCustomerNumber.getUserId(customer_id.value).then(response => window.open('/superuser/users/' + response.data.data.user_id, '_blank'));
SessionUser.adminUser.customers.fromCustomerNumber
.getUserId(customer_id.value)
.then((response) => window.open("/superuser/users/" + response.data.data.user_id, "_blank"));
},
visible: SessionUser.canAccessSuperUser()
visible: SessionUser.canAccessSuperUser(),
},
{
name: 'Vaskeabonnement & Specialaftaler',
icon: 'fas fa-user-edit',
name: "Vaskeabonnement & Specialaftaler",
icon: "fas fa-user-edit",
// Open link in new tab
action: () => {
SessionUser.adminUser.customers.fromCustomerNumber.getUserId(customer_id.value).then(response => window.open('/superuser/users/' + response.data.data.user_id + '/other', '_blank'));
SessionUser.adminUser.customers.fromCustomerNumber
.getUserId(customer_id.value)
.then((response) => window.open("/superuser/users/" + response.data.data.user_id + "/other", "_blank"));
},
visible: SessionUser.canAccessSuperUser()
visible: SessionUser.canAccessSuperUser(),
},
]);
const props = defineProps({
variant: {
type: String,
default: "default",
validator: (value) => ["default", "order-detail", "sidebar"].includes(value),
},
});
const rootClasses = computed(() => ({
"pos-selected-customer": true,
[`pos-selected-customer--${props.variant}`]: true,
}));
const isSidebarVariant = computed(() => props.variant === "sidebar");
const isRulesTabActive = computed(() => panel_tabs.value[1]?.active === true);
const setActiveTab = (selectedIndex) => {
panel_tabs.value.forEach((tab, index) => {
tab.active = index === selectedIndex;
});
};
const refreshCustomerAttributesForRules = async (selectedCustomerNumber = customer_id.value) => {
if (!selectedCustomerNumber) {
return customer_attributes.value;
}
return loadCustomerAttributes(selectedCustomerNumber);
};
/** Check if an attribute is set in the customer attributes */
const hasAttribute = (prop) => {
return customer_attributes.value.some(attribute => attribute.attribute === prop);
return customer_attributes.value.some((attribute) => attribute.attribute === prop);
};
/** Fetch the customer attributes when the component is mounted */
loadCustomerAttributes();
watch(
isRulesTabActive,
(isActive) => {
if (!isActive) {
return;
}
void refreshCustomerAttributesForRules();
},
{ immediate: true }
);
watch(
() => customer_id.value,
(nextCustomerId, previousCustomerId) => {
if (!nextCustomerId || nextCustomerId === previousCustomerId) {
return;
}
void refreshCustomerAttributesForRules(nextCustomerId);
},
{ immediate: true }
);
const isCustomerDetailsExpanded = ref(false);
const customer_data_has_empty_details = () => {
return details.value.some(detail => customer_data.value[detail.prop] === null || customer_data.value[detail.prop] === '');
return details.value.some(
(detail) => customer_data.value[detail.prop] === null || customer_data.value[detail.prop] === ""
);
};
</script>
<template>
<div v-if="customer_name">
<article class="">
<div class="panel-block">
<div class="columns is-vcentered is-multiline">
<div class="column is-12">
<span class="is-size-6 title"
>{{ customer_name }}</span>
</div>
<div class="column">
<div class="tabs is-small is-centered">
<ul>
<li v-for="(tab, index) in panel_tabs" :key="index" :class="{'is-active': tab.active}" @click="panel_tabs.forEach(tab => tab.active = false); tab.active = true">
<RequiresPermission :permission="tab.permission" v-if="tab.permission">
<a>
{{ tab.name }}
</a>
</RequiresPermission>
<a v-else>
{{ tab.name }}
</a>
</li>
</ul>
</div>
</div>
<div v-if="customer_name" :class="rootClasses">
<article class="pos-selected-customer__panel">
<div class="panel-block pos-selected-customer__header">
<div class="pos-selected-customer__title-wrap">
<span class="title is-6 pos-selected-customer__title">{{ customer_name }}</span>
</div>
<div
class="tabs is-small is-centered pos-selected-customer__tabs"
:class="{ 'pos-sidebar-panel-tabs': isSidebarVariant }"
:data-testid="isSidebarVariant ? 'pos-customer-sidebar-tabs' : undefined"
>
<ul>
<li
v-for="(tab, index) in panel_tabs"
:key="tab.key"
:class="{ 'is-active': tab.active }"
:data-testid="`pos-customer-tab-${tab.key}`"
@click="setActiveTab(index)"
>
<RequiresPermission :permission="tab.permission" v-if="tab.permission">
<a>
{{ tab.name }}
</a>
</RequiresPermission>
<a v-else>
{{ tab.name }}
</a>
</li>
</ul>
</div>
</div>
<template v-if="panel_tabs[0].active" v-for="detail in details" :key="detail.name">
<!-- Details that's not empty (unless isCustomerDetailsExpanded is true) -->
<a class="panel-block" v-if="customer_data[detail.prop] || isCustomerDetailsExpanded">
<span class="panel-icon">
<a
class="panel-block pos-selected-customer__row"
v-if="customer_data[detail.prop] || isCustomerDetailsExpanded"
>
<span class="panel-icon pos-selected-customer__icon">
<i :class="detail.icon" aria-hidden="true"></i>
</span>
<span style="width: 50%;">{{ detail.name }}</span><span class="has-text-right" style="width: 50%;">{{ customer_data[detail.prop] }}</span>
<span class="pos-selected-customer__label">{{ detail.name }}</span>
<span class="pos-selected-customer__value">{{ customer_data[detail.prop] }}</span>
</a>
</template>
<!-- Details that's empty, (If there are any) -->
<ExpandableContentBox
v-if="panel_tabs[0].active && customer_data_has_empty_details"
@update:expanded="isCustomerDetailsExpanded = !isCustomerDetailsExpanded"
v-bind:expanded="isCustomerDetailsExpanded"
v-if="panel_tabs[0].active && customer_data_has_empty_details()"
@update:expanded="isCustomerDetailsExpanded = !isCustomerDetailsExpanded"
v-bind:expanded="isCustomerDetailsExpanded"
/>
<!-- Attributes -->
<RequiresPermission permission="list_customer_attributes">
<div class="panel-block" v-if="panel_tabs[1].active" v-for="attribute in attributes" :key="attribute.name">
<span class="panel-icon">
<div
class="panel-block pos-selected-customer__row"
v-if="panel_tabs[1].active"
v-for="attribute in attributes"
:key="attribute.name"
>
<span class="panel-icon pos-selected-customer__icon">
<i :class="attribute.icon" aria-hidden="true"></i>
</span>
<span style="width: 50%;"
@mouseover="showPopper(
popperBox(
attribute.name,
attribute.description
),
$event.target
)"
@mouseleave="removePopperIfOpen()"
>{{ attribute.name }}</span>
<span class="has-text-right" style="width: 50%;">
<span v-if="hasAttribute(attribute.prop)" @click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
<span
class="pos-selected-customer__label"
@mouseover="showPopper(popperBox(attribute.name, attribute.description), $event.target)"
@mouseleave="removePopperIfOpen()"
>{{ attribute.name }}</span
>
<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')"
<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')"/>
<input
:id="attribute.prop"
type="checkbox"
:name="attribute.prop"
class="switch"
:disabled="!SessionUser.hasPermission('add_customer_attribute')"
/>
<label :for="attribute.prop"></label>
</span>
</span>
@@ -271,26 +367,158 @@ const customer_data_has_empty_details = () => {
<!-- Shortcuts -->
<template v-if="panel_tabs[2].active" v-for="shortcut in shortcuts" :key="shortcut.name">
<RequiresPermission permission="access_super_user">
<a class="panel-block">
<span class="panel-icon">
<a class="panel-block pos-selected-customer__row">
<span class="panel-icon pos-selected-customer__icon">
<i :class="shortcut.icon" aria-hidden="true"></i>
</span>
<span @click="shortcut.action">{{ shortcut.name }}</span>
<span class="pos-selected-customer__shortcut" @click="shortcut.action">{{ shortcut.name }}</span>
</a>
</RequiresPermission>
</template>
<!-- Discounts -->
<RequiresPermission permission="get_custom_prices_other">
<div class="panel-block" v-if="panel_tabs[3].active">
<CustomerDiscountsDepartmentDisplay/>
<div class="panel-block pos-selected-customer__discounts" v-if="panel_tabs[3].active">
<CustomerDiscountsDepartmentDisplay />
</div>
</RequiresPermission>
</article>
<div>
</div>
<div></div>
</div>
</template>
<style scoped>
.pos-selected-customer {
height: auto;
min-height: 0;
}
</style>
.pos-selected-customer__panel {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
}
.pos-selected-customer__header {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
padding: 1rem 1rem 0.8rem;
border-bottom: 1px solid #e6edf7;
}
.pos-selected-customer__title-wrap {
min-width: 0;
}
.pos-selected-customer__title {
margin: 0;
font-size: 1.2rem;
line-height: 1.2;
color: #26384c;
}
.pos-selected-customer:not(.pos-selected-customer--sidebar) .pos-selected-customer__tabs {
margin: 0 0 0.35rem;
}
.pos-selected-customer:not(.pos-selected-customer--sidebar) .pos-selected-customer__tabs ul {
justify-content: flex-start;
margin: 0;
gap: 0.35rem;
flex-wrap: wrap;
border-bottom: none;
}
.pos-selected-customer:not(.pos-selected-customer--sidebar) .pos-selected-customer__tabs li a {
border: none;
border-radius: 999px;
min-height: 2.1rem;
padding: 0.45rem 0.85rem;
color: #5a6980;
font-weight: 600;
}
.pos-selected-customer:not(.pos-selected-customer--sidebar) .pos-selected-customer__tabs li.is-active a {
background: #eef4ff;
color: #21446d;
}
.pos-selected-customer__row {
display: grid;
grid-template-columns: 1rem minmax(0, 1fr) auto;
align-items: start;
gap: 0.75rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid #eef3f8;
text-decoration: none;
}
.pos-selected-customer__icon {
margin-right: 0;
color: #62748a;
width: 1rem;
align-self: center;
}
.pos-selected-customer__label {
min-width: 0;
color: #4b5a6d;
font-weight: 600;
align-self: center;
}
.pos-selected-customer__value {
min-width: 0;
max-width: 14rem;
justify-self: end;
text-align: right;
color: #243446;
word-break: break-word;
}
.pos-selected-customer__shortcut {
color: #21446d;
font-weight: 600;
}
.pos-selected-customer__discounts {
padding: 1rem;
}
.pos-selected-customer--sidebar .pos-selected-customer__title,
.pos-selected-customer--order-detail .pos-selected-customer__title {
font-size: 1.05rem;
}
.pos-selected-customer--sidebar .pos-selected-customer__header,
.pos-selected-customer--order-detail .pos-selected-customer__header {
padding: 0.95rem 1rem 0.75rem;
}
.pos-selected-customer :deep(.column.is-12-desktop) {
padding: 0.35rem 1rem 0.5rem;
}
.pos-selected-customer :deep(.has-text-centered) {
margin: 0;
}
.pos-selected-customer :deep(.field) {
margin-bottom: 0;
}
@media screen and (max-width: 768px) {
.pos-selected-customer__row {
grid-template-columns: 1rem minmax(0, 1fr);
}
.pos-selected-customer__value {
grid-column: 2;
justify-self: start;
text-align: left;
max-width: none;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,243 @@
<script setup>
import { computed, defineProps, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
import { isBlankPosMetadataValue } from "@/components/shop/POSDepartmentProcess.vue";
const props = defineProps({
order_id: {
type: Number,
required: true,
},
reference: {
type: String,
required: true,
},
po: {
type: String,
required: true,
},
safetySeal: {
type: String,
default: "",
},
showSafetySeal: {
type: Boolean,
default: false,
},
referenceRequired: {
type: Boolean,
default: false,
},
poRequired: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const createCustomerWishField = ({
key,
label,
source,
saveValue,
testIdBase,
isRequired,
warningStateWhenEmpty,
warningIconClass,
fillRow = false,
}) => {
const inputId = `${testIdBase}-input`;
const isEditing = ref(false);
const autosave = useOrderMetadataAutosave({
source,
saveValue: async (value) => {
await saveValue(value);
return value;
},
});
const isEmpty = computed(() => isBlankPosMetadataValue(autosave.draft.value));
const hasValue = computed(() => !isEmpty.value);
const warningState = computed(() => {
return isRequired() && isEmpty.value ? warningStateWhenEmpty : null;
});
watch(
() => autosave.draft.value,
() => {
if (isEditing.value) {
autosave.scheduleSave();
}
}
);
const focusInput = () => {
document.getElementById(inputId)?.focus();
};
const openEditor = () => {
if (isEditing.value) {
return;
}
isEditing.value = true;
nextTick(() => {
focusInput();
});
};
const closeEditor = async () => {
isEditing.value = false;
await autosave.flush();
};
return {
key,
label,
inputId,
isEditing,
autosave,
hasValue,
warningState,
openEditor,
closeEditor,
testIdBase,
warningIconClass,
fillRow,
};
};
const referenceField = createCustomerWishField({
key: "reference",
label: computed(() => t("pos.order.reference")),
source: () => props.reference,
saveValue: (value) => SessionUser.objects.orders.set.reference(props.order_id, value),
testIdBase: "pos-order-customer-wishes-reference",
isRequired: () => props.referenceRequired,
warningStateWhenEmpty: "danger",
warningIconClass: "fas fa-exclamation-circle",
});
const poField = createCustomerWishField({
key: "po",
label: computed(() => t("admin.pos.po_number")),
source: () => props.po,
saveValue: (value) => SessionUser.objects.orders.set.po(props.order_id, value),
testIdBase: "pos-order-customer-wishes-po",
isRequired: () => props.poRequired,
warningStateWhenEmpty: "warning",
warningIconClass: "fas fa-exclamation-triangle",
});
const safetySealField = createCustomerWishField({
key: "safety_seal",
label: computed(() => t("admin.pos.safety_seal")),
source: () => props.safetySeal,
saveValue: (value) => SessionUser.objects.orders.set.safety_seal(props.order_id, value),
testIdBase: "pos-order-customer-wishes-safety-seal",
isRequired: () => false,
warningStateWhenEmpty: null,
warningIconClass: "fas fa-shield-alt",
fillRow: true,
});
const fields = computed(() => {
return props.showSafetySeal ? [referenceField, poField, safetySealField] : [referenceField, poField];
});
</script>
<template>
<div class="pos-order-customer-wishes">
<div class="pos-registration-grid pos-registration-grid--customer-wishes">
<div
v-for="field in fields"
:key="field.key"
class="pos-registration-slot"
:class="{ 'pos-registration-slot--fill-row': field.fillRow }"
>
<div
class="control pos-registration-control"
:class="{ 'is-loading': field.autosave.isSaving.value }"
:data-testid="`${field.testIdBase}-control`"
:data-warning-state="field.warningState.value || undefined"
>
<div
v-if="field.isEditing.value"
class="pos-registration-field pos-registration-field--editing"
:class="{
'pos-registration-field--warning': field.warningState.value === 'warning',
'pos-registration-field--danger': field.warningState.value === 'danger',
}"
>
<div class="pos-registration-field__label-row">
<label class="pos-registration-field__label" :for="field.inputId">{{ field.label.value }}</label>
<span
v-if="field.warningState.value"
class="pos-registration-field__status-icon"
:class="{
'pos-registration-field__status-icon--warning': field.warningState.value === 'warning',
'pos-registration-field__status-icon--danger': field.warningState.value === 'danger',
}"
:data-testid="`${field.testIdBase}-warning-icon`"
>
<i :class="field.warningIconClass" aria-hidden="true"></i>
</span>
</div>
<input
:id="field.inputId"
v-model="field.autosave.draft.value"
:data-testid="`${field.testIdBase}-input`"
class="pos-registration-field__input pos-registration-field__input--customer-wishes"
type="text"
autocomplete="off"
@blur="field.closeEditor()"
@keydown.enter.prevent="field.closeEditor()"
/>
</div>
<button
v-else
class="pos-registration-field"
:class="{
'pos-registration-field--add': !field.hasValue.value,
'pos-registration-field--warning': field.warningState.value === 'warning',
'pos-registration-field--danger': field.warningState.value === 'danger',
}"
type="button"
:data-testid="field.testIdBase"
:aria-label="!field.hasValue.value ? `${t('common.add')} ${field.label.value}` : undefined"
@click="field.openEditor()"
>
<span class="pos-registration-field__label-row">
<span class="pos-registration-field__label">{{ field.label.value }}</span>
<span
v-if="field.warningState.value"
class="pos-registration-field__status-icon"
:class="{
'pos-registration-field__status-icon--warning': field.warningState.value === 'warning',
'pos-registration-field__status-icon--danger': field.warningState.value === 'danger',
}"
:data-testid="`${field.testIdBase}-warning-icon`"
>
<i :class="field.warningIconClass" aria-hidden="true"></i>
</span>
</span>
<span
class="pos-registration-field__value"
:class="{ 'pos-registration-field__value--muted': !field.hasValue.value }"
>
{{ field.hasValue.value ? field.autosave.draft.value : `+ ${t("common.add")}` }}
</span>
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.pos-registration-slot--fill-row {
grid-column: 1 / -1;
}
</style>
@@ -1,6 +1,8 @@
<script setup>
import {ref, defineProps, watch, nextTick} from 'vue';
import { computed, defineProps, nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
const props = defineProps({
order_id: {
@@ -17,150 +19,82 @@ const props = defineProps({
}
});
// Check if the required props are set
if (props.order_id === null || props.note === null || props.loadOrder === null) {
const missingProps = [];
if (props.order_id === null) {
missingProps.push("order_id");
}
if (props.note === null) {
missingProps.push("note");
}
if (props.loadOrder === null) {
missingProps.push("loadOrder");
}
throw new Error("The required props are not set: " + missingProps.join(", "));
}
// Random unique element id
const uniqueId = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
const { t } = useI18n();
// Define the input note, this is separate from the props note, because otherwise we risk loosing changes.
const input = ref(props.note);
const uniqueId = Math.random().toString(36).slice(2);
const isEditing = ref(false);
const autosave = useOrderMetadataAutosave({
source: () => props.note,
saveValue: async (value) => {
await SessionUser.objects.orders.set.notes(props.order_id, value);
return value;
},
});
// When the props note changes, then update the input note (If the input note is empty)
watch(() => props.note, (value) => {
if (input.value === "") {
input.value = value;
const previewValue = computed(() => autosave.draft.value);
const rowCount = computed(() => Math.max((autosave.draft.value || '').split('\n').length, 5));
watch(() => autosave.draft.value, () => {
if (isEditing.value) {
autosave.scheduleSave();
}
});
const isSaved = () => {
return input.value === props.note;
}
// Define the input changed function
const inputChanged = () => {}
// Define the lost focus function
const lostFocus = () => {
isEditing.value = false;
saveChanges({force: true});
}
// Save the last time the note was synced, this is used to prevent spamming the server with requests.
const lastSync = ref(new Date().getTime());
// Define the save changes function
const saveChanges = (options = {}) => {
// If the input value is the same as the note, then return
if (input.value === props.note && !options.force) {
return;
}
// Set the note to the input value
props.note = input.value;
// Save the order
SessionUser.request('/order', 'PUT', {
id: props.order_id,
field: 'notes',
value: input.value
}).then((response) => {
console.log(response);
// Load the order
props.loadOrder();
}).catch((error) => {
console.error(error);
});
}
// Sync the note with the server once every 2 seconds, if the note has changed.
setInterval(() => {
if (input.value !== props.note && new Date().getTime() - lastSync.value > 2000) {
lastSync.value = new Date().getTime();
saveChanges();
} else {
}
}, 1000);
const isEditing = ref(false);
const focusOnTextarea = () => {
document.getElementById(`${uniqueId}-textarea`)?.focus();
};
const onFieldClicked = () => {
if (isEditing.value) {
return;
}
isEditing.value = true;
// Wait for the next tick, then focus on the textarea
nextTick(() => {
focusOnTextarea();
});
}
};
const focusOnTextarea = () => {
const textarea = document.getElementById(uniqueId + '-textarea');
if (textarea) {
textarea.focus();
}
}
const getRows = () => {
return Math.max(input.value.split("\n").length, 1);
}
const shortNote = ref('');
watch(() => props.note, (value) => {
shortNote.value = generateShortNote();
});
const generateShortNote = () => {
let shownNote = '';
if (input.value.length > 20 || input.value.split("\n").length > 1) {
// Get the first line, and add ...
shownNote = input.value.split("\n")[0].substring(0, 20) + '...';
} else {
shownNote = input.value;
}
shortNote.value = shownNote;
return shownNote;
}
generateShortNote();
const lostFocus = async () => {
isEditing.value = false;
await autosave.flush();
};
</script>
<template>
<div class="field" @click="onFieldClicked()">
<div class="control" :class="{'is-loading': !isSaved()}">
<textarea
v-if="isEditing"
class="textarea"
:style="{'height': isEditing ? 'auto' : '2.25em', 'resize': isEditing ? 'vertical' : 'none', 'overflow': 'hidden', 'padding': '0.5em'}"
:readonly="!isEditing"
:rows="isEditing ? getRows() : 1"
placeholder="Ordre note, denne bliver inkluderet i fakturaen"
v-model="input"
@input="inputChanged"
@blur="lostFocus()"
:id="uniqueId + '-textarea'"
></textarea>
<textarea
class="textarea"
:style="{'height': '2.25em', 'resize': 'none', 'overflow': 'hidden', 'padding': '0.5em'}"
:readonly="true"
:rows="1"
v-if="!isEditing"
v-model="shortNote"
placeholder="Ordre note, denne bliver inkluderet i fakturaen"
></textarea>
<div class="pos-order-field pos-order-note-editor">
<div class="control" :class="{ 'is-loading': autosave.isSaving.value }">
<div v-if="isEditing" class="pos-order-field__control pos-order-field__control--editing">
<textarea
:id="`${uniqueId}-textarea`"
v-model="autosave.draft.value"
data-testid="pos-order-note-textarea"
class="textarea pos-order-field__input"
:rows="rowCount"
:placeholder="t('pos.order.order_note')"
@blur="lostFocus()"
></textarea>
</div>
<button
v-else
class="pos-order-field__control pos-order-field__control--interactive"
:class="{ 'pos-order-field__control--empty': !autosave.draft.value }"
type="button"
@click="onFieldClicked()"
>
<span v-if="previewValue" class="pos-order-field__preview">{{ previewValue }}</span>
<span
v-else
class="pos-order-field__empty-state pos-order-note-editor__empty"
data-testid="pos-order-note-empty-state"
>
<span class="pos-order-field__empty-pill">+ {{ t('pos.add_note') }}</span>
</span>
</button>
</div>
</div>
</template>
<style scoped>
</style>
</style>
@@ -1,164 +0,0 @@
<script setup>
import {ref, defineProps, watch, nextTick} from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const props = defineProps({
order_id: {
type: Number,
required: true
},
reference: {
type: String,
required: true
},
loadOrder: {
type: Function,
required: true
}
});
// Check if the required props are set
if (props.order_id === null || props.reference === null || props.loadOrder === null) {
const missingProps = [];
if (props.order_id === null) {
missingProps.push("order_id");
}
if (props.reference === null) {
missingProps.push("reference");
}
if (props.loadOrder === null) {
missingProps.push("loadOrder");
}
throw new Error("The required props are not set: " + missingProps.join(", "));
}
// Random unique element id
const uniqueId = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
// Define the input reference, this is separate from the props reference, because otherwise we risk loosing changes.
const input = ref(props.reference);
// When the props reference changes, then update the input reference (If the input reference is empty)
watch(() => props.reference, (value) => {
if (input.value === "") {
input.value = value;
}
});
const isSaved = () => {
return input.value === props.reference;
}
// Define the input changed function
const inputChanged = () => {}
// Define the lost focus function
const lostFocus = () => {
isEditing.value = false;
saveChanges({force: true});
}
// Save the last time the reference was synced, this is used to prevent spamming the server with requests.
const lastSync = ref(new Date().getTime());
// Define the save changes function
const saveChanges = (options = {}) => {
// If the input value is the same as the reference, then return
if (input.value === props.reference && !options.force) {
return;
}
// Set the reference to the input value
props.reference = input.value;
// Save the order
SessionUser.request('/order', 'PUT', {
id: props.order_id,
field: 'reference',
value: input.value
}).then((response) => {
console.log(response);
// Load the order
props.loadOrder();
}).catch((error) => {
console.error(error);
});
}
// Sync the reference with the server once every 2 seconds, if the reference has changed.
setInterval(() => {
if (input.value !== props.reference && new Date().getTime() - lastSync.value > 2000) {
lastSync.value = new Date().getTime();
saveChanges();
} else {
}
}, 1000);
const isEditing = ref(false);
const onFieldClicked = () => {
isEditing.value = true;
// Wait for the next tick, then focus on the textarea
nextTick(() => {
focusOnTextarea();
});
}
const focusOnTextarea = () => {
const textarea = document.getElementById(uniqueId + '-textarea');
if (textarea) {
textarea.focus();
}
}
const getRows = () => {
return Math.max(input.value.split("\n").length, 1);
}
const shortReference = ref('');
watch(() => props.reference, (value) => {
generateShortReference();
});
const generateShortReference = () => {
let shownReference = '';
if (input.value.length > 20 || input.value.split("\n").length > 1) {
// Get the first line, and add ...
shownReference = input.value.split("\n")[0].substring(0, 20) + '...';
} else {
shownReference = input.value;
}
shortReference.value = shownReference;
}
generateShortReference();
</script>
<template>
<div class="field" @click="onFieldClicked()">
<div class="control" :class="{'is-loading': !isSaved()}">
<textarea
v-if="isEditing"
class="textarea"
:style="{'height': isEditing ? 'auto' : '2.25em', 'resize': isEditing ? 'vertical' : 'none', 'overflow': 'hidden', 'padding': '0.5em'}"
:readonly="!isEditing"
:rows="isEditing ? getRows() : 1"
placeholder="Ordre reference nummer, dette inkluderes i fakturaen og kan bruges til at finde ordren"
v-model="input"
@input="inputChanged"
@blur="lostFocus()"
:id="uniqueId + '-textarea'"
></textarea>
<textarea
class="textarea"
:style="{'height': '2.25em', 'resize': 'none', 'overflow': 'hidden', 'padding': '0.5em'}"
:readonly="true"
:rows="1"
v-if="!isEditing"
v-text="shortReference"
></textarea>
</div>
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,308 @@
<script setup>
import { computed, defineProps, onMounted, ref, useSlots, watch } from "vue";
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import PosOrderItemsCurrent from "@/components/displays/department/pos/PosOrderItemsCurrent.vue";
import PosSelectedCustomer from "@/components/displays/department/pos/PosSelectedCustomer.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { getDepartmentName, getDepartments } from "@/components/pagination/departmentTabs.vue";
const props = defineProps({
mode: {
type: String,
default: "summary",
validator: (value) => ["summary", "catalog"].includes(value),
},
rootTestId: {
type: String,
default: null,
},
orderId: {
type: [Number, String],
default: null,
},
orderItems: {
type: Array,
default: () => [],
},
loadOrderItems: {
type: Function,
default: null,
},
editPossible: {
type: Boolean,
default: false,
},
showCatalogPane: {
type: Boolean,
default: false,
},
showCustomerRail: {
type: Boolean,
default: true,
},
summaryOrder: {
type: Object,
default: null,
},
});
const slots = useSlots();
const fetchedSummaryOrder = ref(null);
const departmentTabsLoaded = ref(false);
const parsePositiveInteger = (value) => {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const normalizedOrderId = computed(() => parsePositiveInteger(props.orderId));
const resolvedSummaryOrder = computed(() => props.summaryOrder || fetchedSummaryOrder.value || null);
const shouldShowRail = computed(() => props.showCustomerRail && !props.showCatalogPane);
const shouldShowInlineSummary = computed(() => !props.showCatalogPane);
const resolvedDepartmentLabel = computed(() => {
const departmentId = parsePositiveInteger(resolvedSummaryOrder.value?.department_id);
if (!departmentId) {
return "";
}
return getDepartmentName(departmentId) || String(departmentId);
});
const resolvedCreatedAt = computed(() => {
return resolvedSummaryOrder.value?.created_at || "";
});
const hasInlineSummaryTags = computed(() => {
return shouldShowInlineSummary.value && Boolean(resolvedDepartmentLabel.value || resolvedCreatedAt.value);
});
const loadDepartmentTabs = async () => {
if (departmentTabsLoaded.value) {
return;
}
try {
await getDepartments();
} catch (error) {
console.error("Unable to load POS department labels", error);
} finally {
departmentTabsLoaded.value = true;
}
};
const loadSummaryOrder = async (candidateOrderId = normalizedOrderId.value) => {
if (props.summaryOrder) {
fetchedSummaryOrder.value = null;
return;
}
if (!candidateOrderId) {
fetchedSummaryOrder.value = null;
return;
}
try {
fetchedSummaryOrder.value = await SessionUser.objects.orders.get.single(candidateOrderId);
} catch (error) {
console.error("Unable to load POS order summary", error);
fetchedSummaryOrder.value = null;
}
};
onMounted(async () => {
await loadDepartmentTabs();
await loadSummaryOrder();
});
watch(() => props.summaryOrder, async () => {
await loadSummaryOrder();
}, { deep: true });
watch(normalizedOrderId, async (nextOrderId, previousOrderId) => {
if (nextOrderId === previousOrderId) {
return;
}
await loadSummaryOrder(nextOrderId);
});
</script>
<template>
<div
class="pos-shell pos-order-detail"
:class="{ 'pos-order-detail--editing': showCatalogPane }"
:data-testid="rootTestId || undefined"
>
<div class="pos-main pos-order-detail__main" data-testid="pos-order-main">
<div v-if="$slots.leading" class="pos-order-detail__leading">
<slot name="leading"></slot>
</div>
<div
class="pos-order-detail__workspace"
data-testid="pos-order-workspace"
:class="{ 'pos-order-detail__workspace--editing': showCatalogPane }"
>
<div
v-if="showCatalogPane"
class="pos-order-detail__products"
data-testid="pos-order-add-items-panel"
>
<slot name="catalog"></slot>
</div>
<div
class="pos-order-detail__order-column"
:class="{ 'pos-order-detail__order-column--editing': showCatalogPane }"
>
<WhiteBox class="pos-card pos-card--flush pos-order-detail__order" data-testid="pos-order-card">
<PosOrderItemsCurrent
:orderItems="orderItems"
:orderId="orderId"
:loadOrderItems="loadOrderItems"
:editPossible="editPossible"
:stackMetadata="showCatalogPane"
variant="order-detail"
>
<template #default>
<div v-if="$slots.status" class="is-hidden-touch pos-order-detail__status-group">
<slot name="status"></slot>
</div>
</template>
<template #headingcenter>
<div class="pos-inline-tags is-hidden-touch" v-if="hasInlineSummaryTags">
<span
v-if="resolvedDepartmentLabel"
class="pos-inline-tag"
data-testid="pos-order-inline-department"
>
<span class="pos-inline-tag__label">{{ $t('pos.order.department') }}:</span>
<span class="pos-inline-tag__value">{{ resolvedDepartmentLabel }}</span>
</span>
<span
v-if="resolvedCreatedAt"
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__value">{{ resolvedCreatedAt }}</span>
</span>
</div>
<slot name="headingcenter"></slot>
</template>
<template v-if="$slots.headerActions" #headerActions>
<slot name="headerActions"></slot>
</template>
<template v-if="$slots.beforeItems" #beforeItems>
<slot name="beforeItems"></slot>
</template>
<template v-if="$slots.attachments" #attachments>
<slot name="attachments"></slot>
</template>
<template v-if="$slots.other" #other>
<slot name="other"></slot>
</template>
<template v-if="$slots.details" #details>
<slot name="details"></slot>
</template>
<template #bottom-order-items>
<slot name="bottom-order-items"></slot>
</template>
</PosOrderItemsCurrent>
</WhiteBox>
<div
v-if="$slots.orderFooter"
class="pos-order-detail__order-footer"
>
<slot name="orderFooter"></slot>
</div>
</div>
</div>
</div>
<div v-if="shouldShowRail" class="pos-rail pos-rail--sticky" data-testid="pos-order-rail">
<WhiteBox class="pos-card pos-card--flush is-hidden-touch" data-testid="pos-customer-card">
<PosSelectedCustomer variant="order-detail" />
</WhiteBox>
<div v-if="$slots['rail-actions']" class="pos-order-detail__rail-actions">
<slot name="rail-actions"></slot>
</div>
</div>
</div>
</template>
<style scoped>
.pos-order-detail__main {
display: flex;
flex-direction: column;
gap: 1rem;
}
.pos-order-detail__leading,
.pos-order-detail__rail-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pos-order-detail__workspace {
display: grid;
gap: 1rem;
align-items: start;
}
.pos-order-detail__order {
min-width: 0;
width: 100%;
margin-bottom: 0;
}
.pos-order-detail__order-column {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-self: start;
}
.pos-order-detail__products {
min-width: 0;
width: 100%;
}
.pos-order-detail__order-footer {
min-width: 0;
}
.pos-order-detail--editing {
grid-template-columns: minmax(0, 1fr);
}
.pos-order-detail__workspace--editing {
grid-template-columns: minmax(0, 1fr) minmax(20rem, 23.75rem);
}
.pos-order-detail__order-column--editing {
grid-column: 2;
}
@media screen and (max-width: 1023px) {
.pos-order-detail__workspace--editing {
grid-template-columns: 1fr;
}
.pos-order-detail__order-column--editing {
grid-column: auto;
}
}
</style>
@@ -0,0 +1,392 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
const props = defineProps({
rows: {
type: Array,
default: () => [],
},
totalPrice: {
type: Number,
default: 0,
},
showActions: {
type: Boolean,
default: false,
},
canEditOrderItems: {
type: Boolean,
default: false,
},
});
const emits = defineEmits(['edit', 'delete']);
const { t } = useI18n();
const getOrderItemProductName = (orderItem) => {
return orderItem?.product?.name || t('global.no_data');
};
const getOrderItemBasePrice = (orderItem) => {
return Number(orderItem?.product?.price ?? orderItem?.price ?? 0);
};
const getRowTotal = (orderItem) => {
return Number(orderItem?.price ?? 0) * Number(orderItem?.quantity ?? 0);
};
const getRowClass = (row) => {
return {
'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,
};
};
const showValuePopper = (title, value, target) => {
showPopper(popperBox(title, value), target);
};
const showPricePopper = (orderItem, target) => {
const basePrice = getOrderItemBasePrice(orderItem);
const total = getRowTotal(orderItem);
const body = Number(orderItem?.price ?? 0) !== basePrice
? `Pris changed from ${basePrice} DKK to ${orderItem.price} DKK / unit (${total} DKK total)`
: `${orderItem.price} DKK / unit (${total} DKK total)`;
showValuePopper('Pris', body, target);
};
const getPriceColumnWidth = () => (props.showActions ? '10.5rem' : '11.5rem');
const getActionsColumnWidth = () => '7.25rem';
const getPriceHeaderAttrs = () => ({
style: {
width: getPriceColumnWidth(),
},
'data-testid': 'pos-order-price-header',
});
const getPriceCellAttrs = () => ({
class: 'pos-order-items__price-column',
style: {
width: getPriceColumnWidth(),
},
});
const getActionsHeaderAttrs = () => ({
style: {
width: getActionsColumnWidth(),
},
'data-testid': 'pos-order-actions-header',
});
const getActionsCellAttrs = () => ({
class: 'pos-order-items__actions-column',
style: {
width: getActionsColumnWidth(),
},
});
</script>
<template>
<div class="pos-order-items__detail-table" data-testid="pos-order-detail-items-table">
<b-table
:data="rows"
:mobile-cards="false"
:hoverable="true"
:row-class="getRowClass"
custom-row-key="rowKey"
>
<b-table-column
field="product"
:label="$t('admin.pos.product')"
v-slot="tableProps"
>
<div class="pos-order-items__product-cell" :data-testid="`pos-order-item-name-${tableProps.row.orderItem.id}`">
<span
class="pos-order-items__product-name"
:class="{ 'related-item-text': tableProps.row.isRelated }"
>
<template v-if="tableProps.row.isRelated">
+ <template v-if="tableProps.row.orderItem.quantity > 1">{{ tableProps.row.orderItem.quantity }} x </template>{{ getOrderItemProductName(tableProps.row.orderItem) }}
</template>
<template v-else>
{{ getOrderItemProductName(tableProps.row.orderItem) }}
</template>
</span>
<span
v-if="tableProps.row.orderItem.notes"
:data-testid="`pos-order-item-note-trigger-${tableProps.row.orderItem.id}`"
class="ml-2 is-size-7 has-text-grey is-clickable"
@mouseover="showValuePopper('Note', tableProps.row.orderItem.notes, $event.target)"
@mouseleave="removePopperIfOpen()"
@click="emits('edit', tableProps.row.orderItem)"
>
<i class="fas fa-comment"></i>
{{ $t('admin.pos.see_note') }}
</span>
<span
v-if="tableProps.row.orderItem.reference"
:data-testid="`pos-order-item-reference-trigger-${tableProps.row.orderItem.id}`"
class="ml-2 is-size-7 has-text-grey is-clickable"
@mouseover="showValuePopper('Reference', tableProps.row.orderItem.reference, $event.target)"
@mouseleave="removePopperIfOpen()"
@click="emits('edit', tableProps.row.orderItem)"
>
<i class="fas fa-link"></i>
{{ $t('admin.pos.see_reference') }}
</span>
<span
v-if="!tableProps.row.isRelated && tableProps.row.orderItem.quantity > 1"
:data-testid="`pos-order-item-quantity-${tableProps.row.orderItem.id}`"
class="ml-2 is-size-7 has-text-grey is-clickable"
@mouseover="showValuePopper('Quantity', tableProps.row.orderItem.quantity, $event.target)"
@mouseleave="removePopperIfOpen()"
@click="emits('edit', tableProps.row.orderItem)"
>
<i class="fas fa-box"></i>
{{ $t('admin.pos.quantity') }}: {{ tableProps.row.orderItem.quantity }}
</span>
</div>
</b-table-column>
<b-table-column
field="price"
:label="$t('admin.pos.price_dkk')"
custom-key="price"
header-class="pos-order-items__price-header"
:th-attrs="getPriceHeaderAttrs"
:td-attrs="getPriceCellAttrs"
v-slot="tableProps"
>
<div
class="pos-order-items__price-cell"
:data-testid="`pos-order-item-price-${tableProps.row.orderItem.id}`"
>
<span
@mouseover="showPricePopper(tableProps.row.orderItem, $event.target)"
@mouseleave="removePopperIfOpen()"
>
<template v-if="Number(tableProps.row.orderItem.price ?? 0) !== getOrderItemBasePrice(tableProps.row.orderItem)">
<span class="pos-order-items__base-price">{{ getOrderItemBasePrice(tableProps.row.orderItem) * tableProps.row.orderItem.quantity }}</span>
<span>{{ getRowTotal(tableProps.row.orderItem) }}</span>
</template>
<template v-else>
{{ getRowTotal(tableProps.row.orderItem) }}
</template>
</span>
</div>
</b-table-column>
<b-table-column
v-if="showActions"
field="actions"
:label="$t('global.actions')"
custom-key="actions"
header-class="pos-order-items__actions-header"
:th-attrs="getActionsHeaderAttrs"
:td-attrs="getActionsCellAttrs"
v-slot="tableProps"
>
<div class="pos-order-items__action-cell">
<div class="pos-order-items__item-actions">
<button
class="button is-small pos-order-items__item-action pos-order-items__item-action--edit"
type="button"
:data-testid="`pos-order-item-edit-${tableProps.row.orderItem.id}`"
:disabled="!canEditOrderItems"
:aria-label="$t('admin.pos.edit')"
:title="$t('admin.pos.edit')"
@click="emits('edit', tableProps.row.orderItem)"
>
<span class="icon is-small"><i class="fas fa-pen"></i></span>
</button>
<button
class="button is-small pos-order-items__item-action pos-order-items__item-action--delete"
type="button"
:data-testid="`pos-order-item-delete-${tableProps.row.orderItem.id}`"
:aria-label="$t('admin.pos.delete')"
:title="$t('admin.pos.delete')"
@click="emits('delete', tableProps.row.orderItem.id)"
>
<span class="icon is-small"><i class="fas fa-trash"></i></span>
</button>
</div>
</div>
</b-table-column>
<template #empty>
<div class="pos-order-items__empty" data-testid="pos-order-empty-state">
{{ $t('global.no_data') }}
</div>
</template>
<template #footer>
<td class="pos-order-items__footer-label">{{ $t('admin.pos.total') }}</td>
<td class="pos-order-items__footer-value">
<span
data-testid="pos-order-total"
@mouseover="showValuePopper('Total', `Order total: ${totalPrice} DKK`, $event.target)"
@mouseleave="removePopperIfOpen()"
>
{{ totalPrice }} DKK
</span>
</td>
<td v-if="showActions"></td>
</template>
</b-table>
</div>
</template>
<style scoped>
.pos-order-items__detail-table :deep(.b-table) {
width: 100%;
}
.pos-order-items__detail-table :deep(.table) {
table-layout: fixed;
margin-bottom: 0;
}
.pos-order-items__detail-table :deep(.table th) {
color: #20344f;
font-size: 0.88rem;
font-weight: 700;
}
.pos-order-items__detail-table :deep(.table th.pos-order-items__price-header),
.pos-order-items__detail-table :deep(.table th.pos-order-items__actions-header) {
text-align: right;
}
.pos-order-items__detail-table :deep(.table th.pos-order-items__price-header .th-wrap),
.pos-order-items__detail-table :deep(.table th.pos-order-items__actions-header .th-wrap) {
justify-content: flex-end;
text-align: right;
}
.pos-order-items__detail-table :deep(.table td) {
vertical-align: middle;
}
.pos-order-items__detail-table :deep(.table td.pos-order-items__price-column),
.pos-order-items__detail-table :deep(.table td.pos-order-items__actions-column) {
white-space: nowrap;
}
.pos-order-items__detail-table :deep(.table tbody tr:first-child td) {
border-top: 1px solid #f5f5f5;
}
.pos-order-items__detail-table :deep(.table tbody tr.pos-order-items__row--group-parent td) {
border-bottom-color: transparent;
}
.pos-order-items__detail-table :deep(.table tbody tr.pos-order-items__row--group-middle td) {
border-bottom-color: transparent;
}
.pos-order-items__detail-table :deep(.table tfoot td) {
font-weight: 600;
border-top: 1px solid #dfe7f1;
background: #fbfdff;
}
.pos-order-items__product-cell {
min-width: 0;
}
.pos-order-items__product-name {
color: #243347;
}
.pos-order-items__price-cell,
.pos-order-items__footer-value {
text-align: right;
}
.pos-order-items__footer-label {
color: #243347;
}
.pos-order-items__base-price {
margin-right: 0.45rem;
color: #303030;
text-decoration: line-through;
}
.pos-order-items__action-cell {
white-space: nowrap;
text-align: right;
}
.pos-order-items__item-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0.45rem;
}
.pos-order-items__item-action {
min-width: 2.2rem;
min-height: 2.2rem;
border-radius: 0.75rem;
}
.pos-order-items__item-action--edit {
border-color: #d8e2ee;
background: #f7f9fc;
color: #22446d;
}
.pos-order-items__item-action--edit:hover,
.pos-order-items__item-action--edit:focus-visible {
border-color: #9bb3cf;
background: #eef4fb;
color: #173558;
}
.pos-order-items__item-action--delete {
border-color: #f0d2d2;
background: #fff6f6;
color: #b24141;
}
.pos-order-items__item-action--delete:hover,
.pos-order-items__item-action--delete:focus-visible {
border-color: #e4b5b5;
background: #ffecec;
color: #9a2d2d;
}
.related-item-text {
color: #4a4a4a;
font-size: 0.9rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.pos-order-items__empty {
padding: 1rem 0;
color: #65758b;
text-align: center;
}
@media screen and (max-width: 768px) {
.pos-order-items__action-cell {
text-align: left;
}
.pos-order-items__item-actions {
justify-content: flex-start;
}
}
</style>
@@ -0,0 +1,244 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { editOrderItem } from "@/components/shop/OrdersItems.vue";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
orderItem: {
type: Object,
default: null,
},
canEdit: {
type: Boolean,
default: false,
},
});
const emits = defineEmits(['update:modelValue', 'saved']);
const { t } = useI18n();
const form = reactive({
name: '',
price: '',
notes: '',
reference: '',
quantity: '1',
});
const isSubmitting = ref(false);
const errorMessage = ref('');
const isActive = computed({
get: () => props.modelValue,
set: (value) => emits('update:modelValue', value),
});
const syncForm = () => {
form.name = props.orderItem?.product?.name || t('global.no_data');
form.price = String(props.orderItem?.price ?? '');
form.notes = String(props.orderItem?.notes ?? '');
form.reference = String(props.orderItem?.reference ?? '');
form.quantity = String(props.orderItem?.quantity ?? 1);
errorMessage.value = '';
};
watch(() => [props.modelValue, props.orderItem?.id], syncForm, { immediate: true });
const isPriceValid = computed(() => {
const value = Number(form.price);
return Number.isFinite(value) && value >= 0;
});
const isQuantityValid = computed(() => {
const value = Number(form.quantity);
return Number.isInteger(value) && value > 0;
});
const canSubmit = computed(() => {
return Boolean(props.orderItem) && props.canEdit && !isSubmitting.value && isPriceValid.value && isQuantityValid.value;
});
const closeModal = () => {
if (isSubmitting.value) {
return;
}
isActive.value = false;
};
const saveChanges = async () => {
if (!props.orderItem || !canSubmit.value) {
return;
}
isSubmitting.value = true;
errorMessage.value = '';
try {
await editOrderItem(
props.orderItem.id,
Number(form.price),
form.notes,
form.reference,
Number(form.quantity)
);
emits('saved');
} catch (error) {
errorMessage.value = error?.response?.data?.error
|| error?.response?.data?.data?.message
|| error?.message
|| 'Unable to save order item';
} finally {
isSubmitting.value = false;
}
};
</script>
<template>
<b-modal
v-model="isActive"
:width="720"
:can-cancel="['escape', 'x', 'outside']"
:destroy-on-hide="false"
trap-focus
aria-role="dialog"
aria-modal
>
<div
v-if="orderItem"
class="pos-order-item-edit-modal"
data-testid="pos-order-item-edit-modal"
>
<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>
</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>
<input
id="pos-order-item-edit-name"
class="input"
type="text"
:value="form.name"
data-testid="pos-order-item-edit-name"
readonly
>
</div>
<div class="column is-12-mobile is-6-tablet">
<label class="label" for="pos-order-item-edit-price">{{ t('global.price') }}</label>
<input
id="pos-order-item-edit-price"
v-model="form.price"
class="input"
type="number"
min="0"
step="1"
:disabled="!canEdit"
data-testid="pos-order-item-edit-price"
>
</div>
<div class="column is-12-mobile is-6-tablet">
<label class="label" for="pos-order-item-edit-quantity">{{ t('global.quantity') }}</label>
<input
id="pos-order-item-edit-quantity"
v-model="form.quantity"
class="input"
type="number"
min="1"
step="1"
:disabled="!canEdit"
data-testid="pos-order-item-edit-quantity"
>
</div>
<div class="column is-12">
<label class="label" for="pos-order-item-edit-notes">{{ t('global.note') }}</label>
<textarea
id="pos-order-item-edit-notes"
v-model="form.notes"
class="textarea"
rows="4"
:disabled="!canEdit"
data-testid="pos-order-item-edit-notes"
></textarea>
</div>
<div class="column is-12">
<label class="label" for="pos-order-item-edit-reference">{{ t('objects.columns.reference') }}</label>
<input
id="pos-order-item-edit-reference"
v-model="form.reference"
class="input"
type="text"
:disabled="!canEdit"
data-testid="pos-order-item-edit-reference"
>
</div>
</div>
<p
v-if="errorMessage"
class="help is-danger mb-3"
data-testid="pos-order-item-edit-error"
>
{{ errorMessage }}
</p>
<div class="buttons is-right pos-order-item-edit-modal__actions">
<button
class="button is-light"
type="button"
data-testid="pos-order-item-edit-cancel"
:disabled="isSubmitting"
@click="closeModal"
>
{{ SessionUser.objects.global.language.cancel || 'Cancel' }}
</button>
<button
v-if="canEdit"
class="button is-link"
type="button"
data-testid="pos-order-item-edit-save"
:class="{ 'is-loading': isSubmitting }"
:disabled="!canSubmit"
@click="saveChanges"
>
{{ SessionUser.objects.global.language.save || 'Save' }}
</button>
</div>
</div>
</b-modal>
</template>
<style scoped>
.pos-order-item-edit-modal {
background: #ffffff;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 1.2rem 2.8rem rgba(18, 32, 52, 0.18);
}
.pos-order-item-edit-modal__header {
margin-bottom: 1rem;
}
.pos-order-item-edit-modal__actions {
margin-bottom: 0;
}
@media screen and (max-width: 768px) {
.pos-order-item-edit-modal {
padding: 1rem;
border-radius: 0.9rem;
}
}
</style>
@@ -0,0 +1,266 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
const props = defineProps({
rows: {
type: Array,
default: () => [],
},
totalPrice: {
type: Number,
default: 0,
},
showActions: {
type: Boolean,
default: false,
},
canEditOrderItems: {
type: Boolean,
default: false,
},
});
const emits = defineEmits(['edit', 'delete']);
const { t } = useI18n();
const getOrderItemProductName = (orderItem) => {
return orderItem?.product?.name || t('global.no_data');
};
const getOrderItemBasePrice = (orderItem) => {
return Number(orderItem?.product?.price ?? orderItem?.price ?? 0);
};
const getRowTotal = (orderItem) => {
return Number(orderItem?.price ?? 0) * Number(orderItem?.quantity ?? 0);
};
const getRowClass = (row) => {
return {
'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,
};
};
</script>
<template>
<div class="pos-order-items__default-table" data-testid="pos-order-items-table">
<b-table
:data="rows"
:mobile-cards="false"
:hoverable="true"
:row-class="getRowClass"
custom-row-key="rowKey"
>
<b-table-column
field="product"
:label="$t('admin.pos.product')"
v-slot="tableProps"
>
<div class="pos-order-items__product-cell" :data-testid="`pos-order-item-name-${tableProps.row.orderItem.id}`">
<span
class="pos-order-items__product-name"
:class="{ 'related-item-text': tableProps.row.isRelated }"
>
<template v-if="tableProps.row.isRelated">
+ <template v-if="tableProps.row.orderItem.quantity > 1">{{ tableProps.row.orderItem.quantity }} x </template>{{ getOrderItemProductName(tableProps.row.orderItem) }}
</template>
<template v-else>
{{ getOrderItemProductName(tableProps.row.orderItem) }}
</template>
</span>
<span
v-if="tableProps.row.orderItem.notes"
:data-testid="`pos-order-item-note-trigger-${tableProps.row.orderItem.id}`"
class="ml-2 is-size-7 has-text-grey is-clickable"
@mouseover="showPopper(popperBox('Note', tableProps.row.orderItem.notes), $event.target)"
@mouseleave="removePopperIfOpen()"
@click="emits('edit', tableProps.row.orderItem)"
>
<i class="fas fa-comment"></i>
{{ $t('admin.pos.see_note') }}
</span>
<span
v-if="tableProps.row.orderItem.reference"
:data-testid="`pos-order-item-reference-trigger-${tableProps.row.orderItem.id}`"
class="ml-2 is-size-7 has-text-grey is-clickable"
@mouseover="showPopper(popperBox('Reference', tableProps.row.orderItem.reference), $event.target)"
@mouseleave="removePopperIfOpen()"
@click="emits('edit', tableProps.row.orderItem)"
>
<i class="fas fa-link"></i>
{{ $t('admin.pos.see_reference') }}
</span>
<span
v-if="!tableProps.row.isRelated && tableProps.row.orderItem.quantity > 1"
:data-testid="`pos-order-item-quantity-${tableProps.row.orderItem.id}`"
class="ml-2 is-size-7 has-text-grey is-clickable"
@mouseover="showPopper(popperBox('Antal', tableProps.row.orderItem.quantity), $event.target)"
@mouseleave="removePopperIfOpen()"
@click="emits('edit', tableProps.row.orderItem)"
>
<i class="fas fa-box"></i>
{{ $t('admin.pos.quantity') }}: {{ tableProps.row.orderItem.quantity }}
</span>
</div>
</b-table-column>
<b-table-column
field="price"
:label="$t('admin.pos.price_dkk')"
custom-key="price"
v-slot="tableProps"
>
<div
class="pos-order-items__price-cell"
:data-testid="`pos-order-item-price-${tableProps.row.orderItem.id}`"
>
<span
:class="{ 'related-item-text': tableProps.row.isRelated }"
@mouseover="showPopper(
popperBox(
'Pris',
Number(tableProps.row.orderItem.price ?? 0) !== getOrderItemBasePrice(tableProps.row.orderItem)
? 'Pris ændret fra ' + getOrderItemBasePrice(tableProps.row.orderItem) + ' DKK til ' + tableProps.row.orderItem.price + ' DKK / Enhed (' + getRowTotal(tableProps.row.orderItem) + ' DKK i alt)'
: tableProps.row.orderItem.price + ' DKK / Enhed (' + getRowTotal(tableProps.row.orderItem) + ' DKK i alt)'
),
$event.target
)"
@mouseleave="removePopperIfOpen()"
>
<template v-if="Number(tableProps.row.orderItem.price ?? 0) !== getOrderItemBasePrice(tableProps.row.orderItem)">
<span class="pos-order-items__base-price" :class="{ 'related-item-text': tableProps.row.isRelated }">{{ getOrderItemBasePrice(tableProps.row.orderItem) * tableProps.row.orderItem.quantity }}</span>
<span>{{ getRowTotal(tableProps.row.orderItem) }}</span>
</template>
<template v-else>
{{ getRowTotal(tableProps.row.orderItem) }}
</template>
</span>
</div>
</b-table-column>
<b-table-column
v-if="showActions"
field="actions"
label=""
custom-key="actions"
v-slot="tableProps"
>
<div class="pos-order-items__action-cell">
<div class="buttons is-narrow">
<ActionSettingsWheelButton>
<template #actions>
<ActionSettingsWheelItem
:label="$t('admin.pos.delete')"
icon="fas fa-trash"
template="danger"
@click="emits('delete', tableProps.row.orderItem.id)"
/>
<ActionSettingsWheelItem
:label="$t('admin.pos.edit')"
icon="fas fa-edit"
template="warning"
:disabled="!canEditOrderItems"
@click="emits('edit', tableProps.row.orderItem)"
/>
</template>
</ActionSettingsWheelButton>
</div>
</div>
</b-table-column>
<template #empty>
<div class="pos-order-items__empty" data-testid="pos-order-empty-state">
{{ $t('global.no_data') }}
</div>
</template>
<template #footer>
<td class="pos-order-items__footer-label">{{ $t('admin.pos.total') }}</td>
<td class="pos-order-items__footer-value">
<span
data-testid="pos-order-total"
@mouseover="showPopper(popperBox('Total', 'Total pris for ordren: ' + totalPrice + ' DKK'), $event.target)"
@mouseleave="removePopperIfOpen()"
>
{{ totalPrice }} DKK
</span>
</td>
<td v-if="showActions"></td>
</template>
</b-table>
</div>
</template>
<style scoped>
.pos-order-items__default-table :deep(.b-table) {
width: 100%;
}
.pos-order-items__default-table :deep(.table) {
table-layout: fixed;
margin-bottom: 0;
}
.pos-order-items__default-table :deep(.table th) {
color: #20344f;
font-size: 0.88rem;
font-weight: 700;
}
.pos-order-items__default-table :deep(.table td) {
vertical-align: middle;
}
.pos-order-items__default-table :deep(.table tbody tr:first-child td) {
border-top: 1px solid #f5f5f5;
}
.pos-order-items__default-table :deep(.table tbody tr.pos-order-items__row--group-parent td) {
border-bottom-color: transparent;
}
.pos-order-items__default-table :deep(.table tbody tr.pos-order-items__row--group-middle td) {
border-bottom-color: transparent;
}
.pos-order-items__default-table :deep(.table tfoot td) {
font-weight: 600;
}
.pos-order-items__product-cell {
min-width: 0;
}
.pos-order-items__product-name {
color: #243347;
}
.pos-order-items__price-cell,
.pos-order-items__footer-value,
.pos-order-items__action-cell {
text-align: right;
}
.pos-order-items__base-price {
text-decoration: line-through;
color: #1f2f3f;
margin-right: 0.35rem;
}
.related-item-text {
color: #4a4a4a;
font-size: 0.9rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
</style>
@@ -1,15 +1,38 @@
<script setup>
import SpanSkeleton from "@/components/displays/skeletons/SpanSkeleton.vue";
import { ref, defineProps } from "vue";
const props = defineProps(["registration_numbers", "order_id", "loadOrder"]);
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { Colors } from "@/ThemeConfig.vue";
import { computed, defineProps } from "vue";
import PosOrderRegistrationField from "@/components/displays/department/pos/order/PosOrderRegistrationField.vue";
const props = defineProps({
registration_numbers: {
type: Object,
required: true,
},
order_id: {
type: [Number, String],
required: true,
},
loadOrder: {
type: Function,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
});
const registrationEntries = computed(() => {
return [1, 2, 3].map((index) => ({
index,
value: props.registration_numbers?.[index] ?? '',
}));
});
// Check if the required props are passed
if (!props.registration_numbers) {
throw new Error("Missing required prop: registration_numbers");
}
if (!props.order_id) {
if (props.order_id === null || props.order_id === undefined) {
throw new Error("Missing required prop: order_id");
}
if (!props.loadOrder) {
@@ -18,44 +41,23 @@ if (!props.loadOrder) {
</script>
<template>
<!-- Registration numbers shown with space between them -->
<div class="columns is-multiline is-mobile mb-0">
<template v-for="(item, index ) in props.registration_numbers" :key="index">
<div class="column is-4">
<div class="pos-registration-grid">
<template v-for="entry in registrationEntries" :key="entry.index">
<div class="pos-registration-slot">
<SpanSkeleton
:isSkeleton="props.registration_numbers[index] === null"
v-if="props.isLoading"
:isSkeleton="true"
width="100%"
height="1.5rem"
skeleton-class="is-size-4"
>
<!-- Field -->
<div class="field">
<div class="control">
<div
class="tags has-addons is-fullwidth is-flex-wrap-nowrap is-clickable"
@click="SessionUser.editField.showEditFieldForm(
'/order',
props.order_id,
'reg_' + index,
item,
'Reg ' + index,
props.loadOrder
).then(() => props.loadOrder())">
<span class="tag is-dark">Reg. {{ index }}</span>
<span
class="tag is-info"
style="width: 100%;"
:style="{ 'background-color': (props.registration_numbers[index])
? Colors.global.activeColor
: Colors.global.disabledTextColor,
}"
>
{{ item }}
</span>
</div>
</div>
</div>
</SpanSkeleton>
height="3rem"
skeleton-class="is-size-6"
/>
<PosOrderRegistrationField
v-else
:index="entry.index"
:order_id="props.order_id"
:modelValue="entry.value"
:loadOrder="props.loadOrder"
/>
</div>
</template>
</div>
@@ -63,4 +65,4 @@ if (!props.loadOrder) {
<style scoped>
</style>
</style>
@@ -0,0 +1,116 @@
<script setup>
import { computed, defineProps, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
const props = defineProps({
index: {
type: Number,
required: true,
},
order_id: {
type: [Number, String],
required: true,
},
modelValue: {
type: String,
default: "",
},
loadOrder: {
type: Function,
required: true,
},
});
const { t } = useI18n();
const normalizeRegistrationDraft = (value) => String(value ?? "").toUpperCase();
const inputId = `pos-order-registration-input-${props.index}`;
const isEditing = ref(false);
const fieldKey = computed(() => `reg_${props.index}`);
let autosave;
autosave = useOrderMetadataAutosave({
source: () => props.modelValue,
normalizeValue: normalizeRegistrationDraft,
saveValue: async (value) => {
await SessionUser.objects.orders.set[fieldKey.value](props.order_id, value);
return value;
},
onSaved: async () => {
await props.loadOrder();
await nextTick();
autosave.syncFromSource(props.modelValue);
},
});
const hasValue = computed(() => autosave.draft.value.length > 0);
const buttonTestId = computed(() => (hasValue.value ? `pos-order-registration-${props.index}` : `pos-order-registration-add-${props.index}`));
watch(() => autosave.draft.value, () => {
if (isEditing.value) {
autosave.scheduleSave();
}
});
const focusInput = () => {
document.getElementById(inputId)?.focus();
};
const openEditor = () => {
if (isEditing.value) {
return;
}
isEditing.value = true;
nextTick(() => {
focusInput();
});
};
const closeEditor = async () => {
isEditing.value = false;
await autosave.flush();
};
</script>
<template>
<div class="control pos-registration-control" :class="{ 'is-loading': autosave.isSaving.value }">
<div v-if="isEditing" class="pos-registration-field pos-registration-field--editing">
<label class="pos-registration-field__label" :for="inputId">{{ t('pos.order.reg') }} {{ props.index }}</label>
<input
:id="inputId"
v-model="autosave.draft.value"
:data-testid="`pos-order-registration-input-${props.index}`"
class="pos-registration-field__input"
type="text"
autocomplete="off"
@blur="closeEditor()"
@keydown.enter.prevent="closeEditor()"
/>
</div>
<button
v-else
class="pos-registration-field"
:class="{ 'pos-registration-field--add': !hasValue }"
type="button"
:data-testid="buttonTestId"
:aria-label="!hasValue ? `${t('common.add')} ${t('pos.order.reg')} ${props.index}` : undefined"
@click="openEditor()"
>
<span class="pos-registration-field__label">{{ t('pos.order.reg') }} {{ props.index }}</span>
<span
class="pos-registration-field__value"
:class="{ 'pos-registration-field__value--muted': !hasValue }"
>
{{ hasValue ? autosave.draft.value : `+ ${t('common.add')}` }}
</span>
</button>
</div>
</template>
<style scoped>
</style>
@@ -60,6 +60,14 @@ const calculateTotalPrice = (price, quantity) => {
const tax = calculateTax(totalPrice, props.taxPercentage);
return totalPrice + tax;
};
const getOrderItemProductName = (orderItem) => {
return orderItem?.product?.name || t('global.no_data');
};
const getOrderItemBasePrice = (orderItem) => {
return Number(orderItem?.product?.price ?? orderItem?.price ?? 0);
};
</script>
<template>
@@ -81,7 +89,7 @@ const calculateTotalPrice = (price, quantity) => {
</td>
</tr>
<tr v-for="orderItem in props.orderItems" :key="orderItem.id" v-else>
<td>{{ orderItem.product.name }}</td>
<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" />
<span v-else>{{ orderItem.notes }}</span>
@@ -94,8 +102,8 @@ const calculateTotalPrice = (price, quantity) => {
<input type="number" v-if="props.allowEdit" v-model.number="orderItem.quantity" @input="props.setItemValue(orderItem, 'quantity', orderItem.quantity)" class="input is-small" min="1" />
<span v-else>{{ orderItem.quantity }}</span>
</td>
<td style="text-align: right;" v-if="orderItem.price !== orderItem.product.price">
<span style="text-decoration: line-through;" class="has-text-dark">{{ orderItem.product.price * orderItem.quantity}}</span> <span>{{ SessionUser.functions.currency.toLocal(orderItem.price * orderItem.quantity) }}</span>
<td style="text-align: right;" v-if="orderItem.price !== getOrderItemBasePrice(orderItem)">
<span style="text-decoration: line-through;" class="has-text-dark">{{ getOrderItemBasePrice(orderItem) * orderItem.quantity}}</span> <span>{{ SessionUser.functions.currency.toLocal(orderItem.price * orderItem.quantity) }}</span>
</td>
<td style="text-align: right;" v-else>{{ SessionUser.functions.currency.toLocal(orderItem.price * orderItem.quantity) }}</td>
</tr>
@@ -140,4 +148,4 @@ const calculateTotalPrice = (price, quantity) => {
<style scoped>
</style>
</style>
@@ -0,0 +1,680 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import Swal from "sweetalert2";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
const props = defineProps({
order: {
type: Object,
required: true,
},
refreshFunction: {
type: Function,
default: () => {},
},
dataTestId: {
type: String,
default: "",
},
});
const { t } = useI18n();
const dropdownRoot = ref(null);
const isDropdownOpen = ref(false);
const attachments = ref([]);
const attachmentsLoaded = ref(false);
const isLoadingAttachments = ref(false);
const activePreviewAttachmentId = ref(null);
const previewLoadingAttachmentId = ref(null);
const previewSourcesById = ref({});
const previewRequestsInFlight = new Set();
const generatedObjectUrls = new Set();
const normalizeAttachments = (order) => (Array.isArray(order?.attachments) ? order.attachments : []);
const isObjectUrl = (value) => typeof value === "string" && value.startsWith("blob:");
const releaseObjectUrl = (value) => {
if (!isObjectUrl(value)) {
return;
}
URL.revokeObjectURL(value);
generatedObjectUrls.delete(value);
};
const clearPreviewState = () => {
Object.values(previewSourcesById.value).forEach((value) => {
releaseObjectUrl(value);
});
previewSourcesById.value = {};
activePreviewAttachmentId.value = null;
previewLoadingAttachmentId.value = null;
};
const resetAttachmentsFromOrder = () => {
const normalizedAttachments = normalizeAttachments(props.order);
attachments.value = normalizedAttachments;
attachmentsLoaded.value = normalizedAttachments.length > 0;
};
resetAttachmentsFromOrder();
watch(
() => [props.order?.id, props.order?.attachments],
() => {
resetAttachmentsFromOrder();
clearPreviewState();
isDropdownOpen.value = false;
}
);
const canAddOrderAttachments = computed(() => {
return !!props.order?.id && (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser());
});
const canAccessOrderAttachments = computed(() => {
return (
SessionUser.canAccessAdmin() ||
SessionUser.canAccessSuperUser() ||
SessionUser.hasPermission("download_order_attachments_own") ||
SessionUser.hasPermission("download_order_attachments")
);
});
const hasAttachmentIndicator = computed(() => {
if (attachments.value.length > 0 || props.order?.has_attachments === true) {
return true;
}
return [props.order?.attachments_count, props.order?.attachment_count, props.order?.attachmentsCount].some(
(value) => {
const normalizedValue = Number.parseInt(value, 10);
return Number.isInteger(normalizedValue) && normalizedValue > 0;
}
);
});
const shouldShowButton = computed(() => {
if (hasAttachmentIndicator.value) {
return canAccessOrderAttachments.value || canAddOrderAttachments.value;
}
return canAddOrderAttachments.value;
});
const triggerIcon = computed(() => (hasAttachmentIndicator.value ? "fas fa-paperclip" : "fas fa-plus"));
const isTextTriggerButton = computed(() => !hasAttachmentIndicator.value);
const triggerButtonClass = computed(() => ({
button: true,
"is-small": true,
"is-dark": hasAttachmentIndicator.value,
"action-settings-wheel-trigger": true,
"action-settings-wheel-trigger--text": isTextTriggerButton.value,
"action-settings-wheel-trigger--icon-only": true,
}));
const activePreviewAttachment = computed(() => {
return attachments.value.find((attachment) => attachment.id === activePreviewAttachmentId.value) || null;
});
const getAttachmentLabel = (attachment) => {
return (
attachment?.content?.document ||
attachment?.content?.image ||
attachment?.content?.other ||
`Attachment ${attachment?.id ?? ""}`.trim()
);
};
const getAttachmentExtension = (attachment) => {
const match = getAttachmentLabel(attachment)
.toLowerCase()
.match(/(\.[a-z0-9]+)$/);
return match?.[1] || "";
};
const getAttachmentPreviewKind = (attachment) => {
const extension = getAttachmentExtension(attachment);
if (attachment?.content?.image || imageExtensions.includes(extension)) {
return "image";
}
if (attachment?.content?.document || extension === ".pdf") {
return officeExtensions.includes(extension) ? "office" : "document";
}
if (officeExtensions.includes(extension)) {
return "office";
}
if (String(attachment?.content?.other || "").startsWith("http")) {
return "link";
}
if (attachment?.content?.other) {
return "text";
}
return "none";
};
const activePreviewKind = computed(() => {
if (!activePreviewAttachment.value) {
return "none";
}
return getAttachmentPreviewKind(activePreviewAttachment.value);
});
const activePreviewSource = computed(() => {
if (!activePreviewAttachment.value) {
return null;
}
return previewSourcesById.value[activePreviewAttachment.value.id] ?? null;
});
const closeDropdown = () => {
isDropdownOpen.value = false;
activePreviewAttachmentId.value = null;
previewLoadingAttachmentId.value = null;
};
const onActionSelected = () => {
closeDropdown();
};
const onDocumentClick = (event) => {
if (!isDropdownOpen.value) {
return;
}
if (dropdownRoot.value && !dropdownRoot.value.contains(event.target)) {
closeDropdown();
}
};
const onDocumentKeydown = (event) => {
if (event.key === "Escape") {
closeDropdown();
}
};
onMounted(() => {
document.addEventListener("click", onDocumentClick);
document.addEventListener("keydown", onDocumentKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener("click", onDocumentClick);
document.removeEventListener("keydown", onDocumentKeydown);
clearPreviewState();
});
const ensureAttachmentsLoaded = async (force = false) => {
const shouldRefetchAttachmentList = attachments.value.length === 0;
if (
!props.order?.id ||
(attachmentsLoaded.value && !force && !shouldRefetchAttachmentList) ||
isLoadingAttachments.value
) {
return attachments.value;
}
isLoadingAttachments.value = true;
try {
const response = await SessionUser.objects.orders.functions.fetchAttachments(props.order.id);
attachments.value = Array.isArray(response) ? response : [];
attachmentsLoaded.value = true;
} catch (error) {
console.error("Error loading order attachments:", error);
} finally {
isLoadingAttachments.value = false;
}
return attachments.value;
};
watch(
() => [props.order?.id, props.order?.attachments, canAccessOrderAttachments.value, canAddOrderAttachments.value],
() => {
if (!props.order?.id || (!canAccessOrderAttachments.value && !canAddOrderAttachments.value)) {
return;
}
if (attachments.value.length > 0) {
return;
}
void ensureAttachmentsLoaded();
},
{ immediate: true }
);
const readAttachmentFileAsBase64 = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
resolve(event.target?.result ?? null);
};
reader.onerror = () => {
reject(new Error("Failed to read attachment file"));
};
reader.readAsDataURL(file);
});
const refreshAfterMutation = async () => {
await ensureAttachmentsLoaded(true);
await props.refreshFunction();
};
const uploadOrderAttachmentFile = async (file) => {
try {
const base64File = await readAttachmentFileAsBase64(file);
const response = await SessionUser.objects.orders.functions.uploadAttachment(props.order.id, file.name, base64File);
if (!response) {
throw new Error("Attachment upload failed");
}
await refreshAfterMutation();
} catch (error) {
console.error("Error uploading order attachment:", error);
Swal.fire({
title: t("admin.pos.settings_wheel.error"),
text: t("admin.pos.attachments_upload_error"),
icon: "error",
});
}
};
const openOrderAttachmentFilePicker = () => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = acceptedOrderAttachmentFileTypes;
fileInput.onchange = async (event) => {
const target = event.target;
if (target instanceof HTMLInputElement && target.files && target.files[0]) {
await uploadOrderAttachmentFile(target.files[0]);
}
};
fileInput.click();
};
const showAddOrderAttachmentsForm = () => {
return SessionUser.objects.orders.functions.showAttachWashCertificateForm(props.order.id, async () => {
await refreshAfterMutation();
});
};
const hasCachedPreviewSource = (attachmentId) => {
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
};
const createEmbeddablePreviewUrl = async (downloadLink) => {
if (!downloadLink) {
return null;
}
try {
const response = await fetch(downloadLink, { method: "GET" });
if (!response.ok) {
return null;
}
const fileBlob = await response.blob();
if (!fileBlob || fileBlob.size === 0) {
return null;
}
const objectUrl = URL.createObjectURL(fileBlob);
generatedObjectUrls.add(objectUrl);
return objectUrl;
} catch (error) {
console.warn("Unable to create embeddable preview blob", error);
return null;
}
};
const ensurePreviewSource = async (attachment) => {
if (!props.order?.id || !attachment) {
return null;
}
const previewKind = getAttachmentPreviewKind(attachment);
if (!["image", "document"].includes(previewKind)) {
return null;
}
if (hasCachedPreviewSource(attachment.id) || previewRequestsInFlight.has(attachment.id)) {
return previewSourcesById.value[attachment.id] ?? null;
}
previewRequestsInFlight.add(attachment.id);
previewLoadingAttachmentId.value = attachment.id;
try {
const downloadLink = await SessionUser.objects.orders.functions.downloadAttachment(
props.order.id,
attachment.id,
false
);
const previewSource = await createEmbeddablePreviewUrl(downloadLink);
previewSourcesById.value = {
...previewSourcesById.value,
[attachment.id]: previewSource,
};
return previewSource;
} catch (error) {
console.warn("Unable to load attachment preview", attachment.id, error);
previewSourcesById.value = {
...previewSourcesById.value,
[attachment.id]: null,
};
return null;
} finally {
previewRequestsInFlight.delete(attachment.id);
if (previewLoadingAttachmentId.value === attachment.id) {
previewLoadingAttachmentId.value = null;
}
}
};
const onAttachmentHover = async (attachment) => {
activePreviewAttachmentId.value = attachment.id;
await ensurePreviewSource(attachment);
};
const downloadOrderAttachment = async (attachment) => {
const downloadLink = await SessionUser.objects.orders.functions.downloadAttachment(
props.order.id,
attachment.id,
false
);
if (!downloadLink) {
return;
}
const link = document.createElement("a");
link.href = downloadLink;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.download = getAttachmentLabel(attachment);
document.body.appendChild(link);
link.click();
link.remove();
};
const toggleDropdown = async () => {
if (isDropdownOpen.value) {
closeDropdown();
return;
}
isDropdownOpen.value = true;
if (hasAttachmentIndicator.value) {
await ensureAttachmentsLoaded();
}
};
</script>
<template>
<div
v-if="shouldShowButton"
ref="dropdownRoot"
class="dropdown is-right"
:class="{ 'is-active': isDropdownOpen }"
:data-testid="props.dataTestId"
>
<div class="dropdown-trigger">
<button
type="button"
:class="triggerButtonClass"
aria-haspopup="true"
aria-controls="dropdown-menu"
:aria-expanded="isDropdownOpen ? 'true' : 'false'"
@click.stop="toggleDropdown"
>
<span class="icon">
<i :class="triggerIcon"></i>
</span>
</button>
</div>
<div class="dropdown-menu order-attachments-dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content" @dropdown-action-selected="onActionSelected">
<ActionSettingsWheelItem
v-if="canAddOrderAttachments"
:label="t('admin.pos.settings_wheel.attach_wash_certificate')"
icon="fas fa-plus"
:click-action="showAddOrderAttachmentsForm"
:disabled="false"
/>
<ActionSettingsWheelItem
v-if="canAddOrderAttachments"
:label="t('admin.pos.attachments_upload_action')"
icon="fas fa-upload"
:click-action="openOrderAttachmentFilePicker"
:disabled="false"
/>
<template v-if="attachments.length > 0">
<ActionSettingsWheelItemLabel :label="t('admin.pos.settings_wheel.attached_files')" />
<div
v-for="attachment in attachments"
:key="attachment.id"
class="order-attachments-previewable-item"
:data-testid="`pos-order-list-attachment-item-${props.order.id}-${attachment.id}`"
@mouseenter="onAttachmentHover(attachment)"
@focusin="onAttachmentHover(attachment)"
>
<ActionSettingsWheelItem
:label="getAttachmentLabel(attachment)"
icon="fas fa-paperclip"
:click-action="() => downloadOrderAttachment(attachment)"
:disabled="false"
/>
</div>
</template>
</div>
<div
v-if="activePreviewAttachment"
class="order-attachments-preview-panel"
:data-testid="`pos-order-list-attachment-preview-${props.order.id}`"
>
<div class="order-attachments-preview-panel__header">
{{ getAttachmentLabel(activePreviewAttachment) }}
</div>
<div class="order-attachments-preview-panel__content">
<span
v-if="previewLoadingAttachmentId === activePreviewAttachment.id"
class="order-attachments-preview-panel__text"
>
{{ t("global.loading") }}
</span>
<img
v-else-if="activePreviewKind === 'image' && activePreviewSource"
:src="activePreviewSource"
alt=""
class="order-attachments-preview-panel__image"
/>
<iframe
v-else-if="activePreviewKind === 'document' && activePreviewSource"
:src="activePreviewSource"
class="order-attachments-preview-panel__document"
title="Attachment preview"
></iframe>
<a
v-else-if="activePreviewKind === 'link'"
:href="activePreviewAttachment.content?.other"
target="_blank"
rel="noopener noreferrer"
class="order-attachments-preview-panel__link"
>
{{ activePreviewAttachment.content?.other }}
</a>
<span v-else-if="activePreviewKind === 'office'" class="order-attachments-preview-panel__text">
{{ t("admin.pos.attachments_office_preview_unavailable") }}
</span>
<span v-else-if="activePreviewKind === 'text'" class="order-attachments-preview-panel__text">
{{ activePreviewAttachment.content?.other }}
</span>
<span v-else class="order-attachments-preview-panel__text">
{{ t("admin.pos.attachments_no_preview") }}
</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.action-settings-wheel-trigger {
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.action-settings-wheel-trigger--text {
background: transparent;
border: 0;
box-shadow: none;
color: #25344d;
padding-inline: 0.35rem;
text-decoration: none;
}
.action-settings-wheel-trigger--text:hover,
.action-settings-wheel-trigger--text:focus,
.action-settings-wheel-trigger--text[aria-expanded="true"] {
background: rgba(37, 52, 77, 0.1);
color: #132339;
text-decoration: none;
}
.action-settings-wheel-trigger--text .icon,
.action-settings-wheel-trigger--text .icon:hover,
.action-settings-wheel-trigger--text .icon:focus {
text-decoration: none;
}
.action-settings-wheel-trigger--icon-only {
width: 2rem;
min-width: 2rem;
height: 2rem;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
vertical-align: middle;
}
.action-settings-wheel-trigger--icon-only .icon {
width: 1rem;
height: 1rem;
margin: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.action-settings-wheel-trigger--icon-only .icon i {
line-height: 1;
}
.order-attachments-dropdown-menu {
min-width: 15rem;
overflow: visible;
}
.dropdown-content {
background: #ffffff;
border: 1px solid #cfd8e3;
border-radius: 0.8rem;
box-shadow: 0 18px 36px rgba(19, 35, 57, 0.14);
padding: 0.35rem;
}
.dropdown-content :deep(.dropdown-item-action),
.dropdown-content :deep(.dropdown-item-label) {
border-radius: 0.55rem;
}
.order-attachments-previewable-item {
width: 100%;
}
.order-attachments-preview-panel {
position: absolute;
top: 0;
right: calc(100% + 0.75rem);
width: min(22rem, 55vw);
min-height: 20rem;
background: #ffffff;
border: 1px solid #cfd8e3;
border-radius: 0.9rem;
box-shadow: 0 18px 36px rgba(19, 35, 57, 0.14);
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 2;
}
.order-attachments-preview-panel__header {
font-size: 0.95rem;
font-weight: 600;
color: #132339;
overflow-wrap: anywhere;
}
.order-attachments-preview-panel__content {
min-height: 17rem;
border: 1px solid #e7edf5;
border-radius: 0.75rem;
background: linear-gradient(180deg, #fbfcfe 0%, #f3f6fa 100%);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.order-attachments-preview-panel__image,
.order-attachments-preview-panel__document {
width: 100%;
height: 100%;
border: 0;
background: #ffffff;
}
.order-attachments-preview-panel__image {
object-fit: contain;
}
.order-attachments-preview-panel__text,
.order-attachments-preview-panel__link {
padding: 1rem;
text-align: center;
color: #4a5568;
overflow-wrap: anywhere;
}
@media (hover: none) {
.order-attachments-preview-panel {
display: none;
}
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,77 +1,56 @@
<script setup>
import SelectCustomerFormPOS from "@/components/forms/department/pos/SelectCustomerFormPOS.vue";
import PosLastScannedLicensePlates from "@/components/displays/department/pos/PosLastScannedLicensePlates.vue";
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
import SelectProductsFormPOS from "@/components/forms/department/pos/SelectProductsFormPOS.vue";
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
import NextStepError from "@/components/forms/department/pos/error/NextStepError.vue";
import PosSelectedCustomer from "@/components/displays/department/pos/PosSelectedCustomer.vue";
import PosOrderItemsCurrent from "@/components/displays/department/pos/PosOrderItemsCurrent.vue";
import { order_items, order_id, loadOrderItems, setProductsCategory, notes, user_discounts, getUserDiscounts, customer_id } from "@/components/shop/POSDepartmentProcess.vue";
import {
order_items,
order_id,
loadOrderItems,
setProductsCategory,
notes,
} from "@/components/shop/POSDepartmentProcess.vue";
import Cancel from "@/components/forms/department/pos/buttons/Cancel.vue";
import PosNotes from "@/components/displays/department/pos/PosNotes.vue";
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue";
import { ref, watch } from "vue";
// Set the category to products
import ButtonsBox from "@/components/displays/boxes/ButtonsBox.vue";
import PosDesktopOrderWorkspace from "@/components/displays/department/pos/order/PosDesktopOrderWorkspace.vue";
setProductsCategory(null);
// Define the tabs
const right_tabs = [
{
name: "Kurv",
slot: "basket",
icon: "fas fa-shopping-basket",
hidden: false,
},
{
name: "Kunde",
slot: "customer",
icon: "fas fa-user",
hidden: false,
}
];
</script>
<template>
<div>
<div class="columns">
<!-- Choose products -->
<div class="column is-8-desktop pl-0 pr-3">
<SelectProductsFormPOS class="mt-3" category="null" :isCustomerBooking="false"/>
<PosNotes :notes="notes" class="mt-3" :isAddFormVisible="false" :isOldNotesVisible="true"/>
</div>
<!-- Current order items -->
<div class="column is-4-desktop keep-in-viewport pl-3 pr-0">
<WhiteBox style="min-height: 40%;">
<ElementTabsBox
v-bind:tabs="right_tabs"
default-active-tab="basket"
class="is-boxed"
:allowCompactWhenOneTab="true"
>
<template #customer>
<PosSelectedCustomer />
</template>
<template #basket>
<PosOrderItemsCurrent :orderItems="order_items" :orderId="order_id" :loadOrderItems="loadOrderItems" :editPossible="true" />
</template>
</ElementTabsBox>
</WhiteBox>
<!--<PosSelectedCustomer class="mt-3"/> -->
<NextStepError class="is-fullwidth" />
<div class="buttons">
<NextStep
class="is-fullwidth"
label="Næste"
/>
<Cancel class="is-fullwidth" />
<div data-testid="pos-step-2">
<PosDesktopOrderWorkspace
v-if="order_id"
mode="catalog"
:orderId="order_id"
:orderItems="order_items"
:loadOrderItems="loadOrderItems"
:editPossible="true"
:showCatalogPane="true"
:showCustomerRail="false"
>
<template #orderFooter>
<div class="pos-step-2__actions" data-testid="pos-step-2-actions">
<NextStepError class="is-fullwidth" />
<ButtonsBox class="pos-actions pos-actions--desktop">
<NextStep class="is-fullwidth" />
<Cancel class="is-fullwidth" />
</ButtonsBox>
</div>
</div>
</div>
</template>
<template #catalog>
<SelectProductsFormPOS class="mt-3" :isCustomerBooking="false" />
<PosNotes :notes="notes" class="mt-3" :isAddFormVisible="false" :isOldNotesVisible="true" />
</template>
</PosDesktopOrderWorkspace>
</div>
</template>
<style scoped>
</style>
.pos-step-2__actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>
@@ -1,83 +1,72 @@
<script setup>
import SelectCustomerFormPOS from "@/components/forms/department/pos/SelectCustomerFormPOS.vue";
import PosLastScannedLicensePlates from "@/components/displays/department/pos/PosLastScannedLicensePlates.vue";
import { computed } from "vue";
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
import SelectProductsFormPOS from "@/components/forms/department/pos/SelectProductsFormPOS.vue";
import NextStepError from "@/components/forms/department/pos/error/NextStepError.vue";
import { order_items, order_id, loadOrderItems, isCustomerSelected, doesOrderContainMaterial, customer_id, department_id } from "@/components/shop/POSDepartmentProcess.vue";
import PosOrderItemsCurrent from "@/components/displays/department/pos/PosOrderItemsCurrent.vue";
import PosSelectedCustomer from "@/components/displays/department/pos/PosSelectedCustomer.vue";
import {
order_items,
order_id,
loadOrderItems,
customer_id,
department_id,
} from "@/components/shop/POSDepartmentProcess.vue";
import Cancel from "@/components/forms/department/pos/buttons/Cancel.vue";
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import ButtonsBox from "@/components/displays/boxes/ButtonsBox.vue";
import PreviousStep from "@/components/forms/department/pos/buttons/PreviousStep.vue";
import PosOrderLicensePlates from "@/components/displays/department/pos/order/PosOrderLicensePlates.vue";
import PayWithStripeButton from "@/components/displays/department/pos/displays/PayWithStripeButton.vue";
import { useI18n } from 'vue-i18n';
import { useI18n } from "vue-i18n";
import PosDesktopOrderWorkspace from "@/components/displays/department/pos/order/PosDesktopOrderWorkspace.vue";
const { t } = useI18n();
const isGuestStripeCustomer = computed(() => Number(customer_id.value) === 999);
</script>
<template>
<div>
<div class="columns">
<!-- Choose add-ons -->
<div class="column is-8 pl-0 pr-3">
<WhiteBox>
<template #default>
<div>
<PosOrderItemsCurrent :orderItems="order_items" :orderId="order_id" :loadOrderItems="loadOrderItems" :editPossible="true" :is-large="true">
<template #bottom>
<!-- Back button -->
<ButtonsBox>
<PreviousStep :is-text="true" :label="t('pos.add_more_items')" />
</ButtonsBox>
</template>
</PosOrderItemsCurrent>
</div>
</template>
</WhiteBox>
<NextStepError class="is-fullwidth" />
<div data-testid="pos-step-3">
<PosDesktopOrderWorkspace
v-if="order_id"
mode="summary"
:orderId="order_id"
:orderItems="order_items"
:loadOrderItems="loadOrderItems"
:editPossible="true"
:showCatalogPane="false"
:showCustomerRail="true"
>
<template #bottom-order-items>
<ButtonsBox>
<Cancel
style="width: 25%;"
tabindex="2"
class="is-fullwidth"
:label="t('common.cancel')"
/>
<template v-if="customer_id !== 999">
<NextStep
style="width: 25%;"
class="is-fullwidth"
:label="t('pos.complete')"
/>
</template>
<!-- Stripe customer -->
<template v-else>
<PayWithStripeButton
style="width: 25%;"
class="is-fullwidth"
:label="t('pos.pay_with_stripe')"
:departmentId="department_id"
:order_id="order_id"
v-bind:order_items="order_items"
/>
</template>
<PreviousStep :is-text="true" :label="t('admin.pos.add_more_items')" />
</ButtonsBox>
</div>
<!-- Last provided orders -->
<div class="column is-4 pl-3 pr-0">
<WhiteBox>
<template #default v-if="isCustomerSelected()">
<PosSelectedCustomer />
</template>
<template #rail-actions>
<NextStepError class="is-fullwidth" />
<ButtonsBox class="pos-actions pos-actions--rail">
<template v-if="!isGuestStripeCustomer">
<NextStep
class="is-fullwidth"
:label="t('pos.complete')"
/>
</template>
</WhiteBox>
</div>
</div>
<template v-else>
<PayWithStripeButton
class="is-fullwidth"
:label="t('pos.pay_with_stripe')"
:departmentId="department_id"
:order_id="order_id"
v-bind:order_items="order_items"
/>
</template>
<Cancel
tabindex="2"
class="is-fullwidth"
:label="t('common.cancel')"
/>
</ButtonsBox>
</template>
</PosDesktopOrderWorkspace>
</div>
</template>
<style scoped>
</style>
</style>
@@ -1,71 +1,52 @@
<script setup>
import SelectCustomerFormPOS from "@/components/forms/department/pos/SelectCustomerFormPOS.vue";
import PosLastScannedLicensePlates from "@/components/displays/department/pos/PosLastScannedLicensePlates.vue";
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
import SelectProductsFormPOS from "@/components/forms/department/pos/SelectProductsFormPOS.vue";
import NextStepError from "@/components/forms/department/pos/error/NextStepError.vue";
import { order_items, order_id, loadOrderItems, isCustomerSelected, doesOrderContainMaterial } from "@/components/shop/POSDepartmentProcess.vue";
import PosOrderItemsCurrent from "@/components/displays/department/pos/PosOrderItemsCurrent.vue";
import PosSelectedCustomer from "@/components/displays/department/pos/PosSelectedCustomer.vue";
import { order_items, order_id, loadOrderItems } from "@/components/shop/POSDepartmentProcess.vue";
import Cancel from "@/components/forms/department/pos/buttons/Cancel.vue";
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import ButtonsBox from "@/components/displays/boxes/ButtonsBox.vue";
import PreviousStep from "@/components/forms/department/pos/buttons/PreviousStep.vue";
import PosOrderLicensePlates from "@/components/displays/department/pos/order/PosOrderLicensePlates.vue";
import { useI18n } from 'vue-i18n';
import { useI18n } from "vue-i18n";
import PosDesktopOrderWorkspace from "@/components/displays/department/pos/order/PosDesktopOrderWorkspace.vue";
const { t } = useI18n();
</script>
<template>
<div>
<div class="columns">
<!-- Choose add-ons -->
<div class="column is-8 pl-0 pr-3">
<WhiteBox>
<template #default>
<div>
<PosOrderItemsCurrent :orderItems="order_items" :orderId="order_id" :loadOrderItems="loadOrderItems" :editPossible="true" :is-large="true">
<template #bottom>
<!-- Back button -->
<ButtonsBox>
<PreviousStep :is-text="true" :label="t('pos.add_more_items')" />
</ButtonsBox>
</template>
</PosOrderItemsCurrent>
</div>
</template>
</WhiteBox>
<NextStepError class="is-fullwidth" />
<div data-testid="pos-step-4">
<PosDesktopOrderWorkspace
v-if="order_id"
mode="summary"
:orderId="order_id"
:orderItems="order_items"
:loadOrderItems="loadOrderItems"
:editPossible="true"
:showCatalogPane="false"
:showCustomerRail="true"
>
<template #bottom-order-items>
<ButtonsBox>
<div class="buttons">
<Cancel
style="width: 25%;"
tabindex="2"
class="is-fullwidth"
:label="t('common.cancel')"
/>
<NextStep
style="width: 25%;"
class="is-fullwidth"
:label="t('pos.complete')"
/>
</div>
<PreviousStep :is-text="true" :label="t('admin.pos.add_more_items')" />
</ButtonsBox>
</div>
<!-- Last provided orders -->
<div class="column is-4 pl-3 pr-0">
<WhiteBox>
<template #default v-if="isCustomerSelected()">
<PosSelectedCustomer />
</template>
</WhiteBox>
</div>
</div>
</template>
<template #rail-actions>
<NextStepError class="is-fullwidth" />
<ButtonsBox class="pos-actions pos-actions--rail">
<NextStep
class="is-fullwidth"
:label="t('pos.complete')"
/>
<Cancel
tabindex="2"
class="is-fullwidth"
:label="t('common.cancel')"
/>
</ButtonsBox>
</template>
</PosDesktopOrderWorkspace>
</div>
</template>
<style scoped>
</style>
</style>
@@ -0,0 +1,297 @@
<script setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const props = defineProps({
isActive: {
type: Boolean,
default: false,
},
allowClose: {
type: Boolean,
default: true,
},
title: {
type: String,
default: "",
},
message: {
type: String,
default: "",
},
conflict: {
type: Object,
default: null,
},
});
const emit = defineEmits(["update:isActive", "keep-selected-customer", "use-vehicle-customer", "cancel"]);
const { t } = useI18n();
const conflictRows = computed(() => {
if (!props.conflict) {
return [];
}
return [
{
label: t("admin.pos.customer_conflict.current_customer_label"),
value: [props.conflict.currentCustomerName, props.conflict.currentCustomerNumber].filter(Boolean).join(" - "),
},
{
label: t("admin.pos.customer_conflict.matched_customer_label"),
value: [props.conflict.matchedCustomerName, props.conflict.matchedCustomerNumber].filter(Boolean).join(" - "),
},
{
label: SessionUser.objects.orders.columns.reg_1.label,
value: props.conflict.vehicleReg || props.conflict.plate || t("admin.pos.not_found"),
},
];
});
const summaryChips = computed(() => {
const chips = [];
if (props.conflict?.plate) {
chips.push(`${SessionUser.objects.orders.columns.reg_1.label}: ${props.conflict.plate}`);
}
if (props.conflict?.matchedCustomerNumber) {
chips.push(`${t("admin.pos.customer")}: ${props.conflict.matchedCustomerNumber}`);
}
return chips;
});
const closeModal = () => {
if (!props.allowClose) {
return;
}
emit("update:isActive", false);
};
</script>
<template>
<Teleport to="body">
<div
class="modal pos-desktop-customer-conflict-modal"
:class="{ 'is-active': isActive }"
data-testid="pos-desktop-customer-conflict-modal"
>
<div class="modal-background" @click="closeModal" />
<div class="modal-card pos-desktop-customer-conflict-modal__card">
<header class="modal-card-head pos-desktop-customer-conflict-modal__head">
<div class="pos-desktop-customer-conflict-modal__head-content">
<div>
<p class="modal-card-title">{{ title || t("admin.pos.customer_conflict.title") }}</p>
<p v-if="message" class="pos-desktop-customer-conflict-modal__message">{{ message }}</p>
</div>
<div class="pos-desktop-customer-conflict-modal__summary">
<span
v-for="chip in summaryChips"
:key="chip"
class="pos-desktop-customer-conflict-modal__summary-chip"
>
{{ chip }}
</span>
</div>
</div>
<button v-if="allowClose" class="delete" aria-label="close" @click="closeModal" />
</header>
<section class="modal-card-body pos-desktop-customer-conflict-modal__body">
<div class="pos-desktop-customer-conflict-modal__callout">
<span class="icon" aria-hidden="true">
<i class="fas fa-triangle-exclamation" />
</span>
<p>{{ t("admin.pos.customer_conflict.warning_text") }}</p>
</div>
<dl class="pos-desktop-customer-conflict-modal__grid">
<div
v-for="row in conflictRows"
:key="row.label"
class="pos-desktop-customer-conflict-modal__row"
>
<dt>{{ row.label }}</dt>
<dd>{{ row.value || t("admin.pos.not_found") }}</dd>
</div>
</dl>
</section>
<footer class="modal-card-foot pos-desktop-customer-conflict-modal__foot">
<button
class="button is-light"
type="button"
data-testid="pos-desktop-customer-conflict-cancel"
@click="emit('cancel')"
>
{{ t("admin.pos.customer_conflict.cancel") }}
</button>
<button
class="button is-primary is-light"
type="button"
data-testid="pos-desktop-customer-conflict-keep"
@click="emit('keep-selected-customer')"
>
{{ t("admin.pos.customer_conflict.keep_selected_customer") }}
</button>
<button
class="button is-primary"
type="button"
data-testid="pos-desktop-customer-conflict-use-vehicle"
@click="emit('use-vehicle-customer')"
>
{{ t("admin.pos.customer_conflict.use_vehicle_customer") }}
</button>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.pos-desktop-customer-conflict-modal.modal {
z-index: 125;
}
.pos-desktop-customer-conflict-modal__card {
width: min(760px, calc(100vw - 2rem));
max-width: 760px;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 28px 80px rgba(10, 24, 61, 0.35);
}
.pos-desktop-customer-conflict-modal__head {
align-items: flex-start;
padding: 1.4rem 1.6rem 1.1rem;
background: linear-gradient(135deg, rgba(78, 36, 12, 0.98), rgba(120, 60, 14, 0.95));
border-bottom: none;
}
.pos-desktop-customer-conflict-modal__head-content {
display: flex;
flex: 1;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.pos-desktop-customer-conflict-modal__head :deep(.modal-card-title) {
color: #fff;
font-size: 1.6rem;
line-height: 1.15;
}
.pos-desktop-customer-conflict-modal__message {
margin-top: 0.5rem;
max-width: 34rem;
color: rgba(255, 255, 255, 0.86);
}
.pos-desktop-customer-conflict-modal__summary {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.pos-desktop-customer-conflict-modal__summary-chip {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0.15rem 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-size: 0.84rem;
font-weight: 700;
}
.pos-desktop-customer-conflict-modal__body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem 1.5rem 1.35rem;
background: linear-gradient(180deg, #fffaf5 0%, #f8f1e8 100%);
}
.pos-desktop-customer-conflict-modal__callout {
display: flex;
align-items: flex-start;
gap: 0.65rem;
padding: 0.9rem 1rem;
border: 1px solid rgba(169, 93, 29, 0.18);
border-radius: 16px;
background: rgba(176, 105, 28, 0.08);
color: #4f321f;
}
.pos-desktop-customer-conflict-modal__callout p {
margin: 0;
font-weight: 600;
line-height: 1.45;
}
.pos-desktop-customer-conflict-modal__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.85rem;
margin: 0;
}
.pos-desktop-customer-conflict-modal__row {
min-width: 0;
padding: 0.95rem 1rem;
border: 1px solid #eadccd;
border-radius: 16px;
background: rgba(255, 255, 255, 0.84);
}
.pos-desktop-customer-conflict-modal__row dt {
margin: 0 0 0.35rem;
color: #7f654c;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pos-desktop-customer-conflict-modal__row dd {
margin: 0;
color: #2b1d10;
font-weight: 700;
line-height: 1.45;
word-break: break-word;
}
.pos-desktop-customer-conflict-modal__foot {
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem 1.25rem;
background: rgba(255, 250, 245, 0.96);
border-top: 1px solid #eadccd;
}
.pos-desktop-customer-conflict-modal__foot .button {
border-radius: 12px;
font-weight: 700;
}
@media screen and (max-width: 768px) {
.pos-desktop-customer-conflict-modal__card {
width: calc(100vw - 1rem);
}
.pos-desktop-customer-conflict-modal__grid {
grid-template-columns: minmax(0, 1fr);
}
.pos-desktop-customer-conflict-modal__foot {
flex-direction: column-reverse;
align-items: stretch;
}
}
</style>
@@ -0,0 +1,977 @@
<script setup>
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import {
getOrderBookingItemPreviewRows,
getOrderBookingItemPreviewTotal,
getOrderBookingNotesValue,
getOrderBookingPlateText,
getOrderBookingPoValue,
getOrderBookingReferenceValue,
getOrderBookingServiceLabels,
hasOrderBookingDisplayDetails,
} from "@/components/displays/department/pos/utils/orderBookingDisplay.js";
const props = defineProps({
isActive: {
type: Boolean,
default: false,
},
allowClose: {
type: Boolean,
default: true,
},
title: {
type: String,
default: "",
},
message: {
type: String,
default: "",
},
bookings: {
type: Array,
default: () => [],
},
departmentId: {
type: [Number, String],
default: null,
},
matchedPlate: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:isActive", "select", "skip", "refresh-bookings"]);
const { t, locale } = useI18n();
const detailedBookingsById = ref({});
const loadingBookingIds = ref([]);
const failedBookingIds = ref([]);
const toPositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const normalizePlateValue = (value) =>
String(value ?? "")
.trim()
.toUpperCase();
const formatBookingDateTime = (booking) => {
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
if (!rawValue) {
return t("admin.pos.not_found");
}
const parsedValue = new Date(rawValue);
if (Number.isNaN(parsedValue.getTime())) {
return rawValue;
}
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: "short",
timeStyle: "short",
}).format(parsedValue);
};
const getBookingDateValue = (booking) => {
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
if (!rawValue) {
return null;
}
const parsedValue = new Date(rawValue);
return Number.isNaN(parsedValue.getTime()) ? null : parsedValue;
};
const isSameCalendarDay = (left, right) => {
if (!(left instanceof Date) || !(right instanceof Date)) {
return false;
}
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
};
const getDetailedBooking = (booking) => {
const bookingId = toPositiveInteger(booking?.id);
return bookingId ? detailedBookingsById.value[bookingId] || null : null;
};
const getDisplayBooking = (booking) => {
const detailedBooking = getDetailedBooking(booking);
return detailedBooking ? { ...booking, ...detailedBooking } : booking;
};
const isBookingLoading = (booking) => {
const bookingId = toPositiveInteger(booking?.id);
return bookingId ? loadingBookingIds.value.includes(bookingId) : false;
};
const didBookingDetailFail = (booking) => {
const bookingId = toPositiveInteger(booking?.id);
return bookingId ? failedBookingIds.value.includes(bookingId) : false;
};
const getBookingServiceLabels = (booking) => {
return getOrderBookingServiceLabels(getDisplayBooking(booking));
};
const getBookingItemPreviewRows = (booking) => {
return getOrderBookingItemPreviewRows(getDisplayBooking(booking));
};
const getBookingItemPreviewTotal = (booking) => {
return getOrderBookingItemPreviewTotal(getDisplayBooking(booking));
};
const getBookingNote = (booking) => {
return getOrderBookingNotesValue(getDisplayBooking(booking));
};
const getBookingReference = (booking) => {
return getOrderBookingReferenceValue(getDisplayBooking(booking));
};
const getBookingPo = (booking) => {
return getOrderBookingPoValue(getDisplayBooking(booking));
};
const getBookingPlates = (booking) => {
return getOrderBookingPlateText(getDisplayBooking(booking));
};
const formatCurrencyAmount = (value) => {
const parsedValue = Number(value);
if (!Number.isFinite(parsedValue)) {
return "—";
}
return SessionUser.functions.currency.toLocal(parsedValue);
};
const totalAmountLabel = computed(() => SessionUser.objects.orders.columns.total_net_amount?.label || "Total");
const getLoadingCount = computed(() => loadingBookingIds.value.length);
const isLoadingAnyBookingDetails = computed(() => getLoadingCount.value > 0);
const resolvedBookingCount = computed(() => {
return props.bookings.filter((booking) => {
const displayBooking = getDisplayBooking(booking);
return hasOrderBookingDisplayDetails(displayBooking) || !isBookingLoading(booking) || didBookingDetailFail(booking);
}).length;
});
const summaryChips = computed(() => {
const chips = [];
if (props.bookings.length > 0) {
chips.push(`${props.bookings.length} ${SessionUser.objects.order_bookings.meta.labels.multiple}`);
}
if (props.matchedPlate) {
chips.push(`${SessionUser.objects.orders.columns.reg_1.label}: ${normalizePlateValue(props.matchedPlate)}`);
}
return chips;
});
const sortedBookings = computed(() => {
const now = new Date();
return props.bookings
.map((booking, index) => {
const displayBooking = getDisplayBooking(booking);
const bookingDate = getBookingDateValue(displayBooking);
const isToday = isSameCalendarDay(bookingDate, now);
return {
booking,
index,
bookingDate,
isToday,
};
})
.sort((left, right) => {
if (left.isToday !== right.isToday) {
return left.isToday ? -1 : 1;
}
if (left.bookingDate && right.bookingDate) {
const timeDifference = left.bookingDate.getTime() - right.bookingDate.getTime();
if (timeDifference !== 0) {
return timeDifference;
}
} else if (left.bookingDate || right.bookingDate) {
return left.bookingDate ? -1 : 1;
}
return left.index - right.index;
})
.map((entry) => entry.booking);
});
const fetchBookingDetails = async (bookings) => {
const pendingBookings = (Array.isArray(bookings) ? bookings : []).filter(Boolean);
const bookingsToLoad = pendingBookings.filter((booking) => {
const bookingId = toPositiveInteger(booking?.id);
if (!bookingId) {
return false;
}
if (detailedBookingsById.value[bookingId]) {
return false;
}
if (loadingBookingIds.value.includes(bookingId)) {
return false;
}
return true;
});
if (bookingsToLoad.length === 0) {
return;
}
const bookingIdsToLoad = bookingsToLoad
.map((booking) => toPositiveInteger(booking?.id))
.filter((bookingId) => bookingId !== null);
loadingBookingIds.value = [...new Set([...loadingBookingIds.value, ...bookingIdsToLoad])];
const bookingResults = await Promise.allSettled(
bookingsToLoad.map((booking) =>
SessionUser.objects.order_bookings.get.single(toPositiveInteger(booking?.id), {
department_id: props.departmentId,
})
)
);
const nextDetailedBookings = {
...detailedBookingsById.value,
};
const nextFailedBookingIds = new Set(failedBookingIds.value);
bookingResults.forEach((result) => {
if (result.status === "fulfilled" && result.value?.id) {
nextDetailedBookings[result.value.id] = result.value;
nextFailedBookingIds.delete(result.value.id);
return;
}
const failedBookingId = toPositiveInteger(result?.reason?.bookingId);
if (failedBookingId) {
nextFailedBookingIds.add(failedBookingId);
}
});
bookingsToLoad.forEach((booking, index) => {
const bookingId = bookingIdsToLoad[index];
const result = bookingResults[index];
if (result?.status !== "fulfilled" && bookingId) {
nextFailedBookingIds.add(bookingId);
}
});
detailedBookingsById.value = nextDetailedBookings;
failedBookingIds.value = Array.from(nextFailedBookingIds);
loadingBookingIds.value = loadingBookingIds.value.filter((bookingId) => !bookingIdsToLoad.includes(bookingId));
};
watch(
() => [props.isActive, props.bookings.map((booking) => booking?.id).join("|")],
([isActive]) => {
if (!isActive) {
return;
}
void fetchBookingDetails(props.bookings);
},
{ immediate: true }
);
const onSelect = async (booking) => {
emit("select", getDisplayBooking(booking));
};
const onSkip = async () => {
emit("skip");
};
const emitRefreshBookings = () => {
emit("refresh-bookings");
};
const getActionBooking = (booking) => getDisplayBooking(booking);
const getActionCustomerNumber = (booking) => {
const displayBooking = getActionBooking(booking);
return toPositiveInteger(displayBooking?.customer_number ?? displayBooking?.customer_id);
};
const getActionDepartmentId = (booking) => {
const displayBooking = getActionBooking(booking);
return toPositiveInteger(displayBooking?.department ?? props.departmentId);
};
const isBookingScheduledForToday = (booking) => {
return isSameCalendarDay(getBookingDateValue(getDisplayBooking(booking)), new Date());
};
const closeModal = () => {
if (!props.allowClose) {
return;
}
emit("update:isActive", false);
};
</script>
<template>
<Teleport to="body">
<div
class="modal pos-desktop-order-booking-modal"
:class="{ 'is-active': isActive }"
data-testid="pos-desktop-order-booking-modal"
>
<div class="modal-background" @click="closeModal" />
<div class="modal-card pos-desktop-order-booking-modal__card">
<header class="modal-card-head pos-desktop-order-booking-modal__head">
<div class="pos-desktop-order-booking-modal__head-content">
<div>
<p class="modal-card-title">{{ title || t("admin.pos.order_booking_selector.title") }}</p>
<p v-if="message" class="pos-desktop-order-booking-modal__message">{{ message }}</p>
</div>
<div class="pos-desktop-order-booking-modal__summary">
<span v-for="chip in summaryChips" :key="chip" class="pos-desktop-order-booking-modal__summary-chip">
{{ chip }}
</span>
</div>
</div>
<button v-if="allowClose" class="delete" aria-label="close" @click="closeModal" />
</header>
<section class="modal-card-body pos-desktop-order-booking-modal__body">
<div
v-if="isLoadingAnyBookingDetails"
class="pos-desktop-order-booking-modal__loading"
data-testid="pos-desktop-order-booking-loading"
>
<span class="icon is-small">
<i class="fas fa-spinner fa-spin" aria-hidden="true" />
</span>
<span>
{{ SessionUser.objects.global.language.loading }}
{{ resolvedBookingCount }} / {{ bookings.length }}
</span>
</div>
<div class="pos-desktop-order-booking-modal__grid">
<article
v-for="booking in sortedBookings"
:key="booking.id"
class="pos-desktop-order-booking-card"
:class="{ 'pos-desktop-order-booking-card--today': isBookingScheduledForToday(booking) }"
:data-testid="`pos-desktop-order-booking-option-${booking.id}`"
>
<div class="pos-desktop-order-booking-card__header">
<div class="pos-desktop-order-booking-card__header-main">
<p class="pos-desktop-order-booking-card__eyebrow">Booking #{{ booking.id }}</p>
<h3 class="pos-desktop-order-booking-card__title">
{{ formatBookingDateTime(getDisplayBooking(booking)) }}
</h3>
</div>
<div class="pos-desktop-order-booking-card__header-actions">
<span
v-if="isBookingScheduledForToday(booking)"
class="tag pos-desktop-order-booking-card__today-tag"
:data-testid="`pos-desktop-order-booking-today-${booking.id}`"
>
{{ t("global.time.today") }}
</span>
<span v-if="didBookingDetailFail(booking)" class="tag is-light pos-desktop-order-booking-card__tag">
{{ t("admin.pos.not_found") }}
</span>
<div
class="pos-desktop-order-booking-card__settings"
:data-testid="`pos-desktop-order-booking-settings-${booking.id}`"
>
<ActionSettingsWheelButton
:order_booking_id="toPositiveInteger(getActionBooking(booking)?.id)"
:order_id="toPositiveInteger(getActionBooking(booking)?.order_id)"
:customer_number="getActionCustomerNumber(booking)"
:department_id="getActionDepartmentId(booking)"
:reg_1="getActionBooking(booking)?.reg_1 ?? null"
:reg_2="getActionBooking(booking)?.reg_2 ?? null"
:refreshFunction="emitRefreshBookings"
:icon="'fas fa-ellipsis-v'"
trigger-button-variant="text"
>
<template #actions />
</ActionSettingsWheelButton>
</div>
</div>
</div>
<p class="pos-desktop-order-booking-card__customer">
{{ getDisplayBooking(booking)?.customer_name || t("admin.pos.not_found") }}
</p>
<dl class="pos-desktop-order-booking-card__meta-grid">
<div class="pos-desktop-order-booking-card__meta-item">
<dt>{{ t("admin.pos.order_booking_selector.plates_label") }}</dt>
<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>
<dd>{{ getBookingReference(booking) || "—" }}</dd>
</div>
<div v-if="getBookingPo(booking)" class="pos-desktop-order-booking-card__meta-item">
<dt>{{ SessionUser.objects.orders.columns.po.label }}</dt>
<dd>{{ getBookingPo(booking) }}</dd>
</div>
<div v-if="getBookingNote(booking)" class="pos-desktop-order-booking-card__meta-item">
<dt>{{ SessionUser.objects.orders.columns.notes.label }}</dt>
<dd>{{ getBookingNote(booking) }}</dd>
</div>
</dl>
<div
v-if="
getBookingItemPreviewRows(booking).length > 0 ||
getBookingServiceLabels(booking).length > 0 ||
isBookingLoading(booking)
"
class="pos-desktop-order-booking-card__services"
:data-testid="`pos-desktop-order-booking-services-${booking.id}`"
>
<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") }}
</p>
<div
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
class="pos-desktop-order-booking-card__section-total"
:data-testid="`pos-desktop-order-booking-service-total-${booking.id}`"
>
<span>{{ totalAmountLabel }}</span>
<strong>{{ formatCurrencyAmount(getBookingItemPreviewTotal(booking)) }}</strong>
</div>
</div>
<div v-if="isBookingLoading(booking)" class="pos-desktop-order-booking-card__item-preview-skeleton">
<div class="pos-desktop-order-booking-card__item-preview-row is-skeleton" />
<div class="pos-desktop-order-booking-card__item-preview-row is-skeleton" />
</div>
<div
v-else-if="getBookingItemPreviewRows(booking).length > 0"
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("admin.pos.price_dkk") }}</span>
</div>
<div
v-for="(item, index) in getBookingItemPreviewRows(booking)"
:key="item.key"
class="pos-desktop-order-booking-card__item-preview-row"
:data-testid="`pos-desktop-order-booking-service-row-${booking.id}-${index}`"
>
<span class="pos-desktop-order-booking-card__item-preview-qty">{{ item.quantity }}x</span>
<div class="pos-desktop-order-booking-card__item-preview-main">
<span
class="pos-desktop-order-booking-card__item-preview-name"
:class="{ 'pos-desktop-order-booking-card__item-preview-name--addon': item.depth > 0 }"
>
{{ item.depth > 0 ? "+ " : "" }}{{ item.label }}
</span>
<span
v-if="item.unitPrice !== null && item.quantity > 1"
class="pos-desktop-order-booking-card__item-preview-meta"
>
{{ formatCurrencyAmount(item.unitPrice) }} / stk
</span>
</div>
<span
class="pos-desktop-order-booking-card__item-preview-amount"
:data-testid="`pos-desktop-order-booking-service-amount-${booking.id}-${index}`"
>
{{ item.totalPrice !== null ? formatCurrencyAmount(item.totalPrice) : "—" }}
</span>
</div>
</div>
<div v-else class="pos-desktop-order-booking-card__service-list">
<span
v-for="service in getBookingServiceLabels(booking)"
:key="`${booking.id}-${service}`"
class="pos-desktop-order-booking-card__service-pill"
>
{{ service }}
</span>
</div>
</div>
<div class="pos-desktop-order-booking-card__actions">
<button
class="button is-light"
type="button"
:class="{ 'is-loading': isBookingLoading(booking) }"
:disabled="isBookingLoading(booking)"
:data-testid="`pos-desktop-order-booking-use-${booking.id}`"
@click="onSelect(booking)"
>
{{ t("admin.pos.order_booking_selector.use_booking") }}
</button>
</div>
</article>
</div>
</section>
<footer class="modal-card-foot pos-desktop-order-booking-modal__foot">
<button class="button is-light" type="button" data-testid="pos-desktop-order-booking-skip" @click="onSkip">
{{ t("admin.pos.order_booking_selector.continue_without_booking") }}
</button>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.pos-desktop-order-booking-modal.modal {
z-index: 120;
}
.pos-desktop-order-booking-modal__card {
width: min(1180px, calc(100vw - 2.5rem));
max-width: 1180px;
max-height: calc(100vh - 2rem);
border-radius: 20px;
overflow: visible;
box-shadow: 0 28px 80px rgba(10, 24, 61, 0.35);
}
.pos-desktop-order-booking-modal__head {
align-items: flex-start;
padding: 1.5rem 1.75rem 1.1rem;
background: linear-gradient(135deg, rgba(15, 35, 77, 0.98), rgba(10, 24, 61, 0.95)),
linear-gradient(135deg, #0f234d, #102d62);
border-bottom: none;
}
.pos-desktop-order-booking-modal__head-content {
display: flex;
flex: 1;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.pos-desktop-order-booking-modal__head :deep(.modal-card-title) {
color: #fff;
font-size: 1.85rem;
line-height: 1.15;
}
.pos-desktop-order-booking-modal__message {
margin-top: 0.55rem;
max-width: 48rem;
color: rgba(255, 255, 255, 0.82);
font-size: 0.98rem;
}
.pos-desktop-order-booking-modal__summary {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.pos-desktop-order-booking-modal__summary-chip {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0.15rem 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
font-size: 0.84rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.pos-desktop-order-booking-modal__body {
padding: 1.25rem 1.5rem 1.35rem;
background: linear-gradient(180deg, #f8fbff 0%, #f2f6fb 100%);
}
.pos-desktop-order-booking-modal__loading {
display: inline-flex;
align-items: center;
gap: 0.55rem;
margin-bottom: 1rem;
padding: 0.55rem 0.85rem;
border-radius: 999px;
background: rgba(15, 35, 77, 0.08);
color: #234;
font-size: 0.92rem;
font-weight: 600;
}
.pos-desktop-order-booking-modal__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
align-items: start;
}
.pos-desktop-order-booking-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.9rem;
min-height: 100%;
padding: 1rem;
border: 1px solid #d7e2f0;
border-radius: 18px;
background: #fff;
box-shadow: 0 10px 24px rgba(21, 42, 76, 0.08);
overflow: visible;
}
.pos-desktop-order-booking-card--today {
border-color: #3d7ef0;
background: linear-gradient(180deg, rgba(61, 126, 240, 0.08), rgba(61, 126, 240, 0.02)), #fff;
box-shadow: 0 14px 32px rgba(34, 88, 184, 0.16);
}
.pos-desktop-order-booking-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
overflow: visible;
}
.pos-desktop-order-booking-card__header-main {
min-width: 0;
}
.pos-desktop-order-booking-card__header-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.45rem;
flex-shrink: 0;
position: relative;
z-index: 2;
}
.pos-desktop-order-booking-card__eyebrow {
margin: 0;
color: #5c6d84;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.pos-desktop-order-booking-card__title {
margin: 0.2rem 0 0;
color: #0f234d;
font-size: 1.15rem;
font-weight: 800;
line-height: 1.2;
}
.pos-desktop-order-booking-card__customer {
margin: 0;
color: #1b314f;
font-size: 1rem;
font-weight: 700;
}
.pos-desktop-order-booking-card__tag {
color: #49576b;
}
.pos-desktop-order-booking-card__today-tag {
border-radius: 999px;
border: 1px solid rgba(61, 126, 240, 0.2);
background: rgba(61, 126, 240, 0.12);
color: #18408b;
font-weight: 700;
}
.pos-desktop-order-booking-card__settings {
position: relative;
}
.pos-desktop-order-booking-card__settings :deep(.dropdown) {
position: relative;
}
.pos-desktop-order-booking-card__settings :deep(.dropdown-menu) {
z-index: 20;
}
.pos-desktop-order-booking-card__settings :deep(.action-settings-wheel-trigger--text) {
border-radius: 999px;
padding: 0.2rem;
}
.pos-desktop-order-booking-card__meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem 0.9rem;
margin: 0;
}
.pos-desktop-order-booking-card__meta-item {
min-width: 0;
}
.pos-desktop-order-booking-card__meta-item dt,
.pos-desktop-order-booking-card__section-title {
margin: 0 0 0.25rem;
color: #6b7c92;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pos-desktop-order-booking-card__meta-item dd {
margin: 0;
color: #14243b;
font-size: 0.94rem;
line-height: 1.45;
word-break: break-word;
}
.pos-desktop-order-booking-card__services {
padding-top: 0.1rem;
}
.pos-desktop-order-booking-card__section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.45rem;
}
.pos-desktop-order-booking-card__section-total {
display: inline-flex;
align-items: baseline;
gap: 0.45rem;
color: #173559;
font-size: 0.76rem;
font-weight: 700;
}
.pos-desktop-order-booking-card__section-total strong {
color: #0f234d;
font-size: 0.92rem;
}
.pos-desktop-order-booking-card__item-preview {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.pos-desktop-order-booking-card__item-preview-head,
.pos-desktop-order-booking-card__item-preview-row {
display: grid;
grid-template-columns: 3.2rem minmax(0, 1fr) auto;
gap: 0.65rem;
align-items: center;
}
.pos-desktop-order-booking-card__item-preview-head {
padding: 0 0.1rem;
color: #6b7c92;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pos-desktop-order-booking-card__item-preview-head span:last-child {
text-align: right;
}
.pos-desktop-order-booking-card__item-preview-row {
padding: 0.55rem 0.65rem;
border: 1px solid #e5edf7;
border-radius: 12px;
background: linear-gradient(180deg, #fdfefe 0%, #f7fbff 100%);
}
.pos-desktop-order-booking-card__item-preview-qty {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 1.7rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: #e9f1fb;
color: #18408b;
font-size: 0.8rem;
font-weight: 800;
}
.pos-desktop-order-booking-card__item-preview-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.pos-desktop-order-booking-card__item-preview-name {
color: #14243b;
font-size: 0.92rem;
font-weight: 700;
line-height: 1.25;
word-break: break-word;
}
.pos-desktop-order-booking-card__item-preview-name--addon {
color: #24456e;
font-weight: 600;
}
.pos-desktop-order-booking-card__item-preview-meta {
color: #6a7890;
font-size: 0.76rem;
font-weight: 600;
}
.pos-desktop-order-booking-card__item-preview-amount {
color: #0f234d;
font-size: 0.9rem;
font-weight: 800;
text-align: right;
white-space: nowrap;
}
.pos-desktop-order-booking-card__item-preview-skeleton {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.pos-desktop-order-booking-card__item-preview-row.is-skeleton {
min-height: 3rem;
border-color: transparent;
color: transparent;
background: linear-gradient(90deg, #ebf0f7 0%, #f7f9fc 50%, #ebf0f7 100%);
background-size: 200% 100%;
animation: pos-booking-selector-pulse 1.2s linear infinite;
}
.pos-desktop-order-booking-card__service-list,
.pos-desktop-order-booking-card__service-skeleton {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.pos-desktop-order-booking-card__service-pill {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0.3rem 0.7rem;
border-radius: 999px;
background: #eef4fb;
color: #173559;
font-size: 0.88rem;
font-weight: 600;
line-height: 1.2;
}
.pos-desktop-order-booking-card__service-pill.is-skeleton {
min-width: 6rem;
color: transparent;
background: linear-gradient(90deg, #ebf0f7 0%, #f7f9fc 50%, #ebf0f7 100%);
background-size: 200% 100%;
animation: pos-booking-selector-pulse 1.2s linear infinite;
}
.pos-desktop-order-booking-card__actions {
margin-top: auto;
padding-top: 0.2rem;
}
.pos-desktop-order-booking-card__actions .button {
width: 100%;
border-radius: 12px;
font-weight: 700;
}
.pos-desktop-order-booking-modal__foot {
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.5rem 1.25rem;
background: rgba(248, 251, 255, 0.96);
border-top: 1px solid #e1e8f2;
}
.pos-desktop-order-booking-modal__foot .button {
border-radius: 12px;
font-weight: 700;
}
@keyframes pos-booking-selector-pulse {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media screen and (max-width: 768px) {
.pos-desktop-order-booking-modal__card {
width: calc(100vw - 1rem);
max-height: calc(100vh - 1rem);
}
.pos-desktop-order-booking-modal__head,
.pos-desktop-order-booking-modal__body,
.pos-desktop-order-booking-modal__foot {
padding-left: 1rem;
padding-right: 1rem;
}
.pos-desktop-order-booking-card__meta-grid {
grid-template-columns: minmax(0, 1fr);
}
.pos-desktop-order-booking-card__section-header {
align-items: flex-start;
flex-direction: column;
}
.pos-desktop-order-booking-card__item-preview-head,
.pos-desktop-order-booking-card__item-preview-row {
grid-template-columns: 2.8rem minmax(0, 1fr);
}
.pos-desktop-order-booking-card__item-preview-head span:last-child,
.pos-desktop-order-booking-card__item-preview-amount {
grid-column: 2;
text-align: left;
}
}
</style>
@@ -121,12 +121,10 @@ const handleLPRResult = () => {
const parseImage = async (image: string) => {
// Check if the time since the last successful parse is enough
if (!camera.hasDelayAfterSuccessPassed()) {
console.log("Skipping parsing due to delay after success.");
return;
}
if (lastParsedImage.value === image) {
// If the image is the same as the last parsed one, skip parsing
console.log("Skipping parsing for the same image.");
return;
}
lastParsedImage.value = image; // Update the last parsed image
@@ -143,13 +141,11 @@ const parseImage = async (image: string) => {
if (debug_mode.value) {
debug_request_results.value.push(response);
}
// If the response is not successful, log an error and return
// If the response is not successful, stop here.
if (!response.data.success) {
console.error("LPR request was not successful:", response);
return;
}
latestLPRResponse.value = response.data.data as LPRResponse;
console.log("LPR Response:", latestLPRResponse.value);
// Set the last successful capture time
camera.setLastSuccess();
// Handle parsed result.
@@ -195,10 +191,8 @@ function getSearchResultIndex(object: PosSearchResult) {
const onAutomaticSelection = (object: PosSearchResult | null) => {
if (!object) {
console.warn("No object selected for automatic selection.");
return;
}
console.warn("Automatic selection for object:", object);
// Check if the registration number already exists in the vehicles
let targetIndex = getSearchResultIndex(object);
@@ -212,6 +206,7 @@ const onAutomaticSelection = (object: PosSearchResult | null) => {
last_order_id: object?.lastOrderId || null,
wash_subscription: object?.washSubscription,
booking_id: object?.bookingId || null,
booking_matches: object?.bookingMatches || [],
} as PosVehicle;
switch (vehicles.activeVehicleIndex.value) {
case 1:
@@ -226,7 +221,7 @@ const onAutomaticSelection = (object: PosSearchResult | null) => {
}
break;
default:
console.warn("Invalid active vehicle index:", targetIndex);
return;
}
}
@@ -252,7 +247,6 @@ onMounted(() => {
setOverflow(false); // Prevent scrolling
});
onUnmounted(() => {
console.warn("Unmounting PosDepartmentStepMobile1 component.");
// Reset the header settings when the component is unmounted
setTransparency(false); // Reset transparency
setBackgroundColor(backgroundColors.default); // Reset to default background color
@@ -272,11 +266,11 @@ onUnmounted(() => {
<!-- Default: Scanner view -->
<template v-else>
<div class="background-fixed">
<ScannerCamera
@update:frame="parseImage"
<ScannerCamera
@update:frame="parseImage"
/>
</div>
<div class="custom-content">
<div class="custom-content" data-testid="pos-mobile-step-1">
<!-- Meta objects, registration number auto-lookup -->
<RegistrationNumberSearchResult :searchQuery="getQuery" @select="onAutomaticSelection" :isHidden="true" :automaticallySelect="true" :modifyCustomerOnChange="shouldCustomerBeModified"/>
@@ -286,6 +280,10 @@ onUnmounted(() => {
title="Scan nummerplader"
subtitle="Hold kameraet op mod nummerpladerne."
buttonText="Skriv registreringsnummer manuelt"
rootTestId="pos-mobile-step-1-shell"
titleTestId="pos-mobile-step-1-title"
subtitleTestId="pos-mobile-step-1-subtitle"
buttonTestId="pos-mobile-manual-input-toggle"
@button-click="manualInput = true"
/>
<!-- Scanner outline object -->
@@ -293,7 +291,7 @@ onUnmounted(() => {
<ScannerOutline :loading="false" v-if="!views.attachmentView.value"/>
</div>
<!-- Reg. 1, Reg. 2, Reg. 3 -->
<div class="is-flex is-align-content-center is-justify-content-center is-flex-direction-column">
<div class="is-flex is-align-content-center is-justify-content-center is-flex-direction-column" data-testid="pos-mobile-step-1-registration-list">
<PosDepartmentStepMobile1RegistrationNumbers v-if="!views.attachmentView.value"/>
</div>
<!-- Debug -->
@@ -301,7 +299,7 @@ onUnmounted(() => {
<!-- Location -->
<PosDepartmentStepMobile1Location/>
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl>
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
<!-- Attachments -->
<div class="is-flex is-justify-content-center">
<PosDepartmentStepMobileAttachments :showDefaultControls="false" v-show="views.attachmentView.value"/>
@@ -313,11 +311,12 @@ onUnmounted(() => {
<!-- Take picture button (When attachment view is active) -->
<div class="is-flex is-justify-content-center">
<PosDepartmentStepMobileButtonNextStep :isWhite="false" v-if="views.attachmentView.value" style="width: 100%;" class="mb-2" :customDisabled="false" :customAction="() => { attachments.takePicture(); }" :buttonClasses="['has-background-primary-dark', 'has-text-black']">
<span class="is-float-left has-text-white">{{ SessionUser.objects.global.language.take_picture }}</span>
<span class="is-float-right has-text-white">
<!-- Picture icon -->
<span class="icon is-small">
<i class="fas fa-camera"></i>
<span class="pos-mobile-action-content" data-testid="pos-mobile-attachment-view-take-picture-action">
<span class="pos-mobile-action-label has-text-white" data-testid="pos-mobile-attachment-view-take-picture-label">{{ SessionUser.objects.global.language.take_picture }}</span>
<span class="pos-mobile-action-icon has-text-white" data-testid="pos-mobile-attachment-view-take-picture-icon">
<span class="icon is-small">
<i class="fas fa-camera"></i>
</span>
</span>
</span>
</PosDepartmentStepMobileButtonNextStep>
@@ -327,11 +326,12 @@ onUnmounted(() => {
<PosDepartmentStepMobileButtonNextStep style="width: 100%;" v-if="!views.attachmentView.value" :buttonClasses="['has-background-primary', 'has-text-black']" :isWhite="false"/>
<!-- Next step button (If in attachment view) -->
<PosDepartmentStepMobileButtonNextStep style="width: 100%;" v-if="views.attachmentView.value" :customDisabled="false" :customAction="() => { views.attachmentView.value = false; }" :buttonClasses="['has-background-primary', 'has-text-black']" :isWhite="false">
<span class="is-float-left has-text-dark">{{ SessionUser.objects.global.language.close }}</span>
<span class="is-float-right has-text-dark">
<!-- x icon -->
<span class="icon is-small">
<i class="fas fa-times"></i>
<span class="pos-mobile-action-content" data-testid="pos-mobile-attachment-view-close-action">
<span class="pos-mobile-action-label has-text-dark" data-testid="pos-mobile-attachment-view-close-label">{{ SessionUser.objects.global.language.close }}</span>
<span class="pos-mobile-action-icon has-text-dark" data-testid="pos-mobile-attachment-view-close-icon">
<span class="icon is-small">
<i class="fas fa-times"></i>
</span>
</span>
</span>
</PosDepartmentStepMobileButtonNextStep>
@@ -356,24 +356,24 @@ onUnmounted(() => {
/** Spread the custom content over the entire available area **/
.custom-content {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: -webkit-fill-available;
min-height: 100vh;
min-height: 100dvh;
z-index: 1; /* Ensure it is above the background */
display: flex;
flex: 1 1 auto; /* Allow to grow and shrink */
flex-direction: column;
justify-content: space-between; /* Space out elements vertically */
max-height: 70vh; /* Limit the maximum height to 80% of the viewport height */
justify-content: flex-start;
align-items: center; /* Center elements horizontally */
color: white; /* Default text color */
padding: 1rem; /* Optional padding for content */
gap: 1rem;
padding: clamp(5rem, 12vh, 6.5rem) 1rem 1rem;
overflow-y: auto;
}
/** On smaller screens, set the top position lower for custom-content **/
@media (max-height: 768px) {
.custom-content {
top: 80px; /* Adjust the top position for smaller screens */
}
.custom-content > * {
width: min(100%, 48rem);
}
</style>
@@ -19,8 +19,8 @@ import PosDepartmentStep2MobileVehicleSelection
import PosDepartmentStepMobileButtonNextStep
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import { primaryItem } from "./objects/PosDepartmentStepMobileFlow.vue";
import { order_id, order_notes, department_id, customer_id, getCustomerEmail, customer_name, isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
import { getOrderItems } from "@/components/shop/OrdersItems.vue";
import { order_id, order_notes, order_po, order_safety_seal, reference as persistedReference, reg_1, reg_2, reg_3, department_id, customer_id, getCustomerEmail, customer_name, isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobileButtonClearAll
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
@@ -31,22 +31,19 @@ import PosDepartmentStepMobile2AdditionalItems
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2AdditionalItems.vue";
import PosDepartmentStepMobile2Customer
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Customer.vue";
import { pendingBookings, getVehiclePlateBooking } from "@/components/shop/POSDepartmentProcess.vue";
import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
onMounted(() => {
// Set the header to be transparent
setTransparency(false);
setBackgroundColor(backgroundColors.default); // Set the default background color
// Set the reference to the vehicle 1 reference if it's not already set
if (vehicles?.vehicle_1?.value?.reference && !reference.value) {
reference.value = vehicles.vehicle_1.value.reference;
console.warn('Setting reference to vehicle 1 reference:', reference.value);
}
hydrateReferenceFromSources();
hydrateSafetySealFromSources();
// Set the notes to the order notes if it's not already set
if (order_notes.value && !notes.value) {
notes.value = order_notes.value;
console.warn('Setting notes to order notes:', notes.value);
}
// Check for potential customer number changes
const initialCustomerId = Number.parseInt(String(customer_id.value), 10);
@@ -68,7 +65,6 @@ onMounted(() => {
const applyPotentialCustomerNumberChange = (oldId, newId) => {
const parsedCustomerId = Number.parseInt(String(newId), 10);
metadata.customerId.value = Number.isNaN(parsedCustomerId) ? null : parsedCustomerId;
console.warn('Customer ID changed from', oldId, 'to', metadata.customerId.value)
}
// Watch the customer id, and if it changes, update it in the metadata
@@ -103,14 +99,7 @@ const getSelectedPendingBooking = () => {
if (booking) return booking;
}
const reg = vehicles?.vehicle_1?.value?.reg || null;
if (!reg) return null;
try {
return getVehiclePlateBooking(reg) || null;
} catch (e) {
return null;
}
return null;
}
const getSelectedBooking = async () => {
@@ -152,7 +141,7 @@ const fetchProductById = async (id: number): Promise<PosProduct> => {
const getFirstWashProduct = async (): Promise<PosProduct | null> => {
try {
const washProds = await SessionUser.objects.products.get.list({
const washProds = await SessionUser.objects.products.get.all({
department_id: department_id.value,
is_wash: true,
limit: 1
@@ -165,6 +154,12 @@ const getFirstWashProduct = async (): Promise<PosProduct | null> => {
}
};
const normalizeRegistrationValue = (value: string | null | undefined) =>
String(value ?? '')
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]/g, '');
const applyPendingBookingFromSelection = async () => {
const booking: any = await getSelectedBooking();
if (!booking) return false;
@@ -180,7 +175,7 @@ const applyPendingBookingFromSelection = async () => {
if (booking.po && typeof booking.po === 'string' && booking.po.trim() !== '') {
try {
await SessionUser.objects.orders.set.po(order_id.value, booking.po);
console.warn('Applied PO from booking:', booking.po);
order_po.value = booking.po.trim();
} catch (e) {
console.error('Failed to set PO on order from booking', e);
}
@@ -197,29 +192,91 @@ const applyPendingBookingFromSelection = async () => {
}
items = Array.isArray(fullBookingData.items) ? fullBookingData.items : [];
// Apply reference and notes from full booking
if (fullBookingData.reference && fullBookingData.reference.trim() !== '') {
const bookingReference = String(fullBookingData.reference ?? fullBookingData.reference_number ?? '').trim();
if (bookingReference !== '') {
try {
await SessionUser.objects.orders.set.reference(order_id.value, fullBookingData.reference);
console.log('Applied reference from booking:', fullBookingData.reference);
await SessionUser.objects.orders.set.reference(order_id.value, bookingReference);
persistedReference.value = bookingReference;
metadata.setReference(bookingReference);
hydrateReferenceFromSources();
} catch (setRefError) {
console.error('Failed to set reference on order from booking:', setRefError);
}
}
if (fullBookingData.notes !== undefined && fullBookingData.notes !== null && fullBookingData.notes.trim() !== '') {
const bookingNotes = String(fullBookingData.notes ?? fullBookingData.note ?? '').trim();
if (bookingNotes !== '') {
try {
await SessionUser.objects.orders.set.notes(order_id.value, fullBookingData.notes);
console.log('Applied notes from booking:', fullBookingData.notes);
await SessionUser.objects.orders.set.notes(order_id.value, bookingNotes);
order_notes.value = bookingNotes;
notes.value = bookingNotes;
metadata.setNotes(bookingNotes);
} catch (setNotesError) {
console.error('Failed to set notes on order from booking:', setNotesError);
}
}
const bookingReg1 = normalizeRegistrationValue(fullBookingData.reg_1 ?? booking.reg_1 ?? reg_1.value);
const bookingReg2 = normalizeRegistrationValue(fullBookingData.reg_2 ?? booking.reg_2 ?? '');
const currentReg1 = normalizeRegistrationValue(reg_1.value);
const currentReg2 = normalizeRegistrationValue(reg_2.value);
if (bookingReg1 && bookingReg1 !== currentReg1) {
reg_1.value = bookingReg1;
await SessionUser.objects.orders.set.reg_1(order_id.value, bookingReg1);
}
if (bookingReg2 !== currentReg2) {
reg_2.value = bookingReg2;
await SessionUser.objects.orders.set.reg_2(order_id.value, bookingReg2);
}
const bookingMatches = Array.isArray(vehicles.vehicle_1.value?.booking_matches)
? vehicles.vehicle_1.value.booking_matches
: [booking];
const bookingCustomerNumber =
Number.parseInt(
String(
fullBookingData?.customer_number ??
fullBookingData?.customer_id ??
booking?.customer_number ??
booking?.customer_id ??
vehicles.vehicle_1.value?.customer_id ??
customer_id.value ??
0
),
10
) || 0;
vehicles.select(1, {
...(vehicles.vehicle_1.value || {}),
reg: bookingReg1 || vehicles.vehicle_1.value?.reg || '',
customer_id: bookingCustomerNumber,
status: 'booked',
barred: false,
booking_id: booking.id,
booking_matches: bookingMatches,
reference: bookingReference || vehicles.vehicle_1.value?.reference || null,
});
if (bookingReg2) {
vehicles.select(2, {
...(vehicles.vehicle_2.value || {}),
reg: bookingReg2,
customer_id: bookingCustomerNumber,
status: 'booked',
barred: false,
booking_id: booking.id,
booking_matches: bookingMatches,
});
} else {
vehicles.select(2, null);
}
} catch (fetchError) {
console.error('Failed to fetch full booking for items:', fetchError);
lastAppliedBookingId.value = booking.id;
return false;
}
if (!items.length) {
console.warn('Full booking fetched but no items found.');
lastAppliedBookingId.value = booking.id;
return false;
}
@@ -236,8 +293,6 @@ const applyPendingBookingFromSelection = async () => {
if (typeof primaryRaw.price === 'number') primaryProduct.price = primaryRaw.price;
primaryProduct.name = primaryRaw?.name || primaryProduct.name;
primaryProduct.quantity = 1; // Primary item quantity is handled as 1 in order creation
// Apply as primary item
transactionItems.setPrimaryItem(primaryProduct as any);
// Remaining items become addons
const addonRawList = items.slice(1);
@@ -253,35 +308,32 @@ const applyPendingBookingFromSelection = async () => {
const preparedAddons = addonProducts
.filter(p => !!p)
.map(p => transactionItems.convertProductToAddon(p as any, { quantity: (p as any)?.quantity || 1 }));
const bookingIncludesWashCertificate = preparedAddons.some((addon: any) =>
addon?.id === 41 || addon?.product?.id === 41
);
attachments.setWashCertificate(bookingIncludesWashCertificate);
if (!transactionItems.primaryItem.value) {
// Safety: if primary was cleared/changed while awaiting fetches, set again
transactionItems.setPrimaryItem(primaryProduct as any);
let effectivePrimaryProduct = primaryProduct;
if (!primaryProduct.is_wash) {
const firstWash = await getFirstWashProduct();
if (firstWash) {
effectivePrimaryProduct = firstWash;
}
}
// Attach addons to primary
effectivePrimaryProduct.addons = preparedAddons as any;
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
if (transactionItems.primaryItem.value) {
// Keep any existing addons that may have been set previously only if they are different.
transactionItems.primaryItem.value.addons = preparedAddons as any;
primaryProduct.addons = preparedAddons as any;
}
lastAppliedBookingId.value = booking.id;
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
lastFetchedPrimaryItemProduct.value = primaryProduct; // Update last fetched primary item
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
// Set vehicle type for consistency
vehicles.vehicle_1.value.type = primaryProduct.id;
// Override with first wash if primary not wash
if (!primaryProduct.is_wash) {
const firstWash = await getFirstWashProduct();
if (firstWash) {
firstWash.addons = primaryProduct.addons;
transactionItems.setPrimaryItem(firstWash as any);
lastFetchedPrimaryItemProduct.value = firstWash;
vehicles.vehicle_1.value.type = firstWash.id;
}
if (vehicles.vehicle_1.value) {
vehicles.vehicle_1.value.type = effectivePrimaryProduct.id;
}
// Hide the vehicle selection as booking has been applied
@@ -294,7 +346,7 @@ const applyPendingBookingFromSelection = async () => {
}
const initializeStepTwo = async () => {
const hasBookingSelection = !!getSelectedBookingId() || !!getSelectedPendingBooking();
const hasBookingSelection = !!getSelectedBookingId();
if (!hasBookingSelection) {
fetchPrimaryItemProduct();
return;
@@ -309,50 +361,50 @@ const initializeStepTwo = async () => {
const fetchPrimaryItemProduct = () => {
if (!vehicles.vehicle_1.value?.type && !transactionItems.primaryItem.value) {
vehicleSelection.value = true; // Force the user to select a product.
} else {
// Load the product from the type id.
if (!vehicles.vehicle_1.value?.type) {
return; // If no vehicle type is set, do not proceed.
}
// If the primary item is already set and matches the vehicle type, do not fetch again.
//if (transactionItems.primaryItem.value && transactionItems.primaryItem.value.id === vehicles.vehicle_1.value.type) {
// console.warn('Primary item already set and matches vehicle type, skipping fetch.');
// return;
//}
SessionUser.objects.products.get.single(vehicles.vehicle_1.value.type, {department_id: department_id.value, customer_id: metadata.getCustomerId(), category_id: null, final_price: true})
.then(product => {
// If the product is found, set it as the primary item
lastFetchedPrimaryItemProduct.value = product; // Store the last fetched primary item product
console.warn('Setting primary item to vehicle type product:', product);
// If the primary item is already set, simply update the product
if (transactionItems.primaryItem.value) {
transactionItems.primaryItem.value = {
...transactionItems.primaryItem.value,
...product,
addons: transactionItems.primaryItem.value.addons.map(addon => ({
...addon,
product: {
...addon.product,
price: product.price // Update the addon price to match the new primary item price
}
}))
};
return;
}
transactionItems.setPrimaryItem(product);
})
.catch(error => vehicleSelection.value = true); // If there's an error, force the user to select a product.
return;
}
// Load the product from the type id.
if (!vehicles.vehicle_1.value?.type) {
return; // If no vehicle type is set, do not proceed.
}
vehicleSelection.value = false;
// If the primary item is already set and matches the vehicle type, do not fetch again.
//if (transactionItems.primaryItem.value && transactionItems.primaryItem.value.id === vehicles.vehicle_1.value.type) {
// console.warn('Primary item already set and matches vehicle type, skipping fetch.');
// return;
//}
SessionUser.objects.products.get.single(vehicles.vehicle_1.value.type, {department_id: department_id.value, customer_id: metadata.getCustomerId(), category_id: null, final_price: true})
.then(product => {
// If the product is found, set it as the primary item
lastFetchedPrimaryItemProduct.value = product; // Store the last fetched primary item product
// If the primary item is already set, simply update the product
if (transactionItems.primaryItem.value) {
transactionItems.primaryItem.value = {
...transactionItems.primaryItem.value,
...product,
addons: transactionItems.primaryItem.value.addons.map(addon => ({
...addon,
product: {
...addon.product,
price: product.price // Update the addon price to match the new primary item price
}
}))
};
return;
}
transactionItems.setPrimaryItem(product);
})
.catch(error => vehicleSelection.value = true); // If there's an error, force the user to select a product.
}
const fetchLastOrder = (vehicleIndex: number) => {
// This function fetches the last order for a given vehicle ID (Provided it has a last_order_id
const tmp_order_id = vehicles[`vehicle_${vehicleIndex}`]?.value?.last_order_id || 0;
if (!tmp_order_id) {
console.warn(`No last order ID found for vehicle ${vehicleIndex}`);
return; // If no last order ID is found, do not proceed.
}
console.log('Fetching last order details for vehicle', vehicleIndex, tmp_order_id);
SessionUser.objects.orders.get.single(tmp_order_id)
.then(order => lastOrders.set(vehicleIndex, {...order}))
.catch(error => console.error('Error fetching last order:', error));
@@ -371,7 +423,6 @@ const updateLastOrderItemPrices = (items: PosOrderItem[]) => {
// Update the prices of the last order items to reflect any changes in the product prices
return items.map(item => {
SessionUser.objects.products.get.single(item.product_id, {category_id: null, department_id: department_id.value, customer_id: customer_id.value, final_price: true}).then(product => {
console.warn('Fetched product for last order item:', product);
transactionItems.updateTransactionPrices([product]); // Update the transaction prices with the fetched product
}).catch(error => {
console.error('Error fetching product for last order item:', error);
@@ -437,77 +488,382 @@ onUnmounted(() => {
window.removeEventListener('scroll', onScroll, true);
});
const onBeforeComplete = () => {
// This function is called before the step is completed
console.log('On before complete called');
return new Promise((resolve, reject) => {
// Check if the primary item is set
if (!transactionItems.primaryItem.value) {
reject(new Error('No primary item selected'));
return;
}
// If the transaction items contain a wash certificate, create a booking for it
const hasWashCertificate = transactionItems.containsWashCertificate();
// Add the order items to the transaction
SessionUser.objects.global.add.object(
'/order/items',
{
order_id: parseInt(order_id.value), // The order ID to which the item will be added
product_id: primaryItem.value.id, // The product ID of the primary item
quantity: 1,
price: primaryItem.value.price,
notes: primaryItem.value?.notes || '', // Notes can be added here if needed
related_item_id: null,
}, // The order item to create
{authenticated: true}
).then((response) => {
const relatedItemId = response.data.data.id; // Get the ID of the created order item
// Create the addon items (With a quantity of > 0)
const addonItems = transactionItems.primaryItem.value.addons.filter(addon => addon.quantity > 0);
const addonPromises = addonItems.map(addon => {
// For each addon, create an order item
return SessionUser.objects.global.add.object(
'/order/items',
{
order_id: parseInt(order_id.value), // The order ID to which the addon will be added
product_id: addon.product.id, // The product ID of the addon
quantity: addon.quantity,
price: addon.product.price,
notes: addon.product?.notes || '', // Notes can be added here if needed
related_item_id: relatedItemId, // Link the addon to the primary item
},
{authenticated: true}
);
});
// Create the additional items (With a quantity of > 0)
const additionalItems = transactionItems.additionalItems.value.filter(item => item.quantity > 0);
const additionalPromises = additionalItems.map(item => {
// For each additional item, create an order item
return SessionUser.objects.global.add.object(
'/order/items',
{
order_id: parseInt(order_id.value), // The order ID to which the additional item will be added
product_id: item.id, // The product ID of the additional item
quantity: item.quantity,
price: item.price,
notes: item?.notes || '', // Notes can be added here if needed
related_item_id: null, // Additional items are not linked to the primary item
},
{authenticated: true}
);
});
// Combine addon and additional item promises
addonPromises.push(...additionalPromises);
// IF the booking is being created, wait for it to finish first
return Promise.all(addonPromises);
}).then(() => {
// After successfully adding the order item, resolve the promise
resolve(true);
}).catch(error => {
// If there's an error, reject the promise
reject(error);
});
const getNormalizedOrderId = () => {
const parsedOrderId = Number.parseInt(String(order_id.value), 10);
return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null;
}
const normalizeReferenceValue = (value: unknown) => String(value ?? "");
const hasReferenceValue = (value: unknown) => normalizeReferenceValue(value).trim() !== "";
const syncVehicleRegistrationFromOrder = (vehicleIndex: number, value: string | null | undefined) => {
const normalizedValue = normalizeRegistrationValue(value);
if (!normalizedValue) {
return;
}
const currentVehicle = vehicles.get(vehicleIndex);
if (currentVehicle?.reg === normalizedValue) {
return;
}
vehicles.select(vehicleIndex, {
...(currentVehicle ?? {}),
reg: normalizedValue,
});
};
const notesAutosave = useOrderMetadataAutosave({
source: order_notes,
saveValue: async (value) => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
return value;
}
await SessionUser.objects.orders.set.notes(normalizedOrderId, value);
return value;
},
onSaved: async (value) => {
order_notes.value = value;
metadata.setNotes(value);
},
});
const referenceAutosave = useOrderMetadataAutosave({
source: persistedReference,
saveValue: async (value) => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
return value;
}
await SessionUser.objects.orders.set.reference(normalizedOrderId, value);
return value;
},
onSaved: async (value) => {
persistedReference.value = value;
metadata.setReference(value);
},
});
const safetySealAutosave = useOrderMetadataAutosave({
source: order_safety_seal,
saveValue: async (value) => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
return value;
}
await SessionUser.objects.orders.set.safety_seal(normalizedOrderId, value);
return value;
},
onSaved: async (value) => {
order_safety_seal.value = value;
metadata.setSafetySeal(value);
},
});
const stepTwoNotesInput = notesAutosave.draft;
const stepTwoReferenceInput = referenceAutosave.draft;
const stepTwoSafetySealInput = safetySealAutosave.draft;
const showSafetySealField = computed(() => transactionItems.containsWashCertificate());
const resolveReferenceFromSources = () => {
const orderReference = normalizeReferenceValue(persistedReference.value);
if (hasReferenceValue(orderReference)) {
return orderReference;
}
const metadataReference = normalizeReferenceValue(metadata.getReference?.() ?? reference.value);
if (hasReferenceValue(metadataReference)) {
return metadataReference;
}
const vehicleReference = normalizeReferenceValue(vehicles?.vehicle_1?.value?.reference);
if (hasReferenceValue(vehicleReference)) {
return vehicleReference;
}
return "";
};
function syncReferenceState(value: unknown) {
const normalizedValue = normalizeReferenceValue(value);
reference.value = normalizedValue;
metadata.setReference(normalizedValue);
}
const resolveSafetySealFromSources = () => {
const orderSafetySeal = normalizeReferenceValue(order_safety_seal.value);
if (hasReferenceValue(orderSafetySeal)) {
return orderSafetySeal;
}
const metadataSafetySeal = normalizeReferenceValue(metadata.getSafetySeal?.());
if (hasReferenceValue(metadataSafetySeal)) {
return metadataSafetySeal;
}
return "";
};
function syncSafetySealState(value: unknown) {
const normalizedValue = normalizeReferenceValue(value);
order_safety_seal.value = normalizedValue;
metadata.setSafetySeal(normalizedValue);
}
function hydrateReferenceFromSources() {
const currentDraft = normalizeReferenceValue(stepTwoReferenceInput.value);
if (referenceAutosave.isDirty.value && hasReferenceValue(currentDraft)) {
syncReferenceState(currentDraft);
return currentDraft;
}
const resolvedReference = resolveReferenceFromSources();
if (!hasReferenceValue(resolvedReference)) {
if (hasReferenceValue(currentDraft)) {
syncReferenceState(currentDraft);
}
return currentDraft;
}
const lastSavedReference = normalizeReferenceValue(referenceAutosave.lastSavedValue.value);
const canHydrateDraft = !hasReferenceValue(currentDraft) || currentDraft === lastSavedReference;
if (canHydrateDraft && currentDraft !== resolvedReference) {
referenceAutosave.syncFromSource(resolvedReference);
}
syncReferenceState(canHydrateDraft ? resolvedReference : currentDraft);
return canHydrateDraft ? resolvedReference : currentDraft;
}
function hydrateSafetySealFromSources() {
const currentDraft = normalizeReferenceValue(stepTwoSafetySealInput.value);
if (safetySealAutosave.isDirty.value && hasReferenceValue(currentDraft)) {
syncSafetySealState(currentDraft);
return currentDraft;
}
const resolvedSafetySeal = resolveSafetySealFromSources();
if (!hasReferenceValue(resolvedSafetySeal)) {
if (hasReferenceValue(currentDraft)) {
syncSafetySealState(currentDraft);
}
return currentDraft;
}
const lastSavedSafetySeal = normalizeReferenceValue(safetySealAutosave.lastSavedValue.value);
const canHydrateDraft = !hasReferenceValue(currentDraft) || currentDraft === lastSavedSafetySeal;
if (canHydrateDraft && currentDraft !== resolvedSafetySeal) {
safetySealAutosave.syncFromSource(resolvedSafetySeal);
}
syncSafetySealState(canHydrateDraft ? resolvedSafetySeal : currentDraft);
return canHydrateDraft ? resolvedSafetySeal : currentDraft;
}
watch(stepTwoNotesInput, (value) => {
metadata.setNotes(value);
if (getNormalizedOrderId()) {
notesAutosave.scheduleSave();
}
});
watch(stepTwoReferenceInput, (value) => {
syncReferenceState(value);
if (getNormalizedOrderId()) {
referenceAutosave.scheduleSave();
}
});
watch(stepTwoSafetySealInput, (value) => {
syncSafetySealState(value);
if (getNormalizedOrderId()) {
safetySealAutosave.scheduleSave();
}
});
watch(persistedReference, (newValue, oldValue) => {
if (newValue !== oldValue) {
hydrateReferenceFromSources();
}
});
watch(order_safety_seal, (newValue, oldValue) => {
if (newValue !== oldValue) {
hydrateSafetySealFromSources();
}
});
watch(() => getNormalizedOrderId(), (normalizedOrderId) => {
if (!normalizedOrderId) {
return;
}
if (notesAutosave.isDirty.value) {
notesAutosave.scheduleSave();
}
if (referenceAutosave.isDirty.value) {
referenceAutosave.scheduleSave();
}
if (safetySealAutosave.isDirty.value) {
safetySealAutosave.scheduleSave();
}
});
watch([reg_1, reg_2, reg_3], ([nextReg1, nextReg2, nextReg3]) => {
if (!getNormalizedOrderId()) {
return;
}
syncVehicleRegistrationFromOrder(1, nextReg1);
syncVehicleRegistrationFromOrder(2, nextReg2);
syncVehicleRegistrationFromOrder(3, nextReg3);
}, { immediate: true });
const normalizeOrderItemShape = (item: any) => ({
product_id: Number(item?.product_id ?? item?.product?.id ?? 0),
quantity: Number(item?.quantity ?? 0),
related_item_id: item?.related_item_id === null || item?.related_item_id === undefined
? null
: Number(item.related_item_id),
price: Number(item?.price ?? 0),
notes: String(item?.notes ?? ''),
})
const buildDesiredOrderItemShapes = () => {
if (!transactionItems.primaryItem.value) {
return [];
}
const primaryShape = {
kind: 'primary',
relatedKey: 'primary',
product_id: Number(transactionItems.primaryItem.value.id),
quantity: 1,
related_item_id: null,
price: Number(transactionItems.primaryItem.value.price ?? 0),
notes: String(transactionItems.primaryItem.value?.notes ?? ''),
};
const addonShapes = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) => ({
kind: 'addon',
relatedKey: 'primary',
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
quantity: Number(addon?.quantity ?? 0),
related_item_id: '__PRIMARY__',
price: Number(addon?.product?.price ?? addon?.price ?? 0),
notes: String(addon?.product?.notes ?? ''),
}));
const additionalShapes = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
.map((item: any) => ({
kind: 'additional',
relatedKey: null,
product_id: Number(item?.id ?? 0),
quantity: Number(item?.quantity ?? 0),
related_item_id: null,
price: Number(item?.price ?? 0),
notes: String(item?.notes ?? ''),
}));
return [primaryShape, ...addonShapes, ...additionalShapes];
}
const normalizeExistingOrderItemShapes = (items: any[]) => {
const primaryItems = items.filter((item: any) => item?.related_item_id === null || item?.related_item_id === undefined);
if (primaryItems.length === 0) {
return [];
}
const additionalItems = primaryItems.filter((item: any) => Number(item?.product?.id ?? item?.product_id ?? 0) !== Number(transactionItems.primaryItem.value?.id ?? 0));
const primaryItemShape = normalizeOrderItemShape(primaryItems[0]);
const addonShapes = items
.filter((item: any) => item?.related_item_id === primaryItems[0]?.id)
.map(normalizeOrderItemShape);
const additionalShapes = additionalItems.map(normalizeOrderItemShape);
return [primaryItemShape, ...addonShapes, ...additionalShapes];
}
const syncCurrentTransactionToOrder = async () => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
throw new Error('Order ID is required');
}
if (!transactionItems.primaryItem.value) {
throw new Error('No primary item selected');
}
const existingItemsResponse = await getOrderItems(normalizedOrderId);
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
const desiredShapes = buildDesiredOrderItemShapes();
const currentShapes = normalizeExistingOrderItemShapes(existingItems);
if (JSON.stringify(currentShapes) === JSON.stringify(desiredShapes.map(({ kind, relatedKey, ...shape }) => shape))) {
return true;
}
await Promise.all(existingItems.map((item: any) => removeOrderItem(item.id)));
const createdPrimaryItemResponse = await createOrderItem(
normalizedOrderId,
transactionItems.primaryItem.value.id,
1,
null,
transactionItems.primaryItem.value?.notes || '',
transactionItems.primaryItem.value.price
);
const createdPrimaryItemId = createdPrimaryItemResponse?.data?.data?.id;
const addonPromises = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) => createOrderItem(
normalizedOrderId,
addon.product.id,
Number(addon.quantity),
createdPrimaryItemId,
addon.product?.notes || '',
addon.product.price
));
const additionalPromises = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
.map((item: any) => createOrderItem(
normalizedOrderId,
item.id,
Number(item.quantity),
null,
item?.notes || '',
item.price
));
await Promise.all([...addonPromises, ...additionalPromises]);
return true;
}
const onBeforeComplete = async () => {
if (!transactionItems.primaryItem.value) {
throw new Error('No primary item selected');
}
await syncCurrentTransactionToOrder();
return true;
}
// Re-try applying booking whenever booking id or vehicle reg changes
@@ -522,15 +878,22 @@ watch(() => vehicles?.vehicle_1?.value?.reg, () => {
// Utility: map source addons and carry over quantities from previous addons by product.id
const mapAddonsWithQuantity = (sourceAddons = [], previousAddons = []) =>
sourceAddons.map(addon => {
const carriedQty = previousAddons.find(a => a.product.id === addon.product.id)?.quantity ?? 0;
return { ...addon, quantity: carriedQty };
const previousAddon = previousAddons.find(a => a.product.id === addon.product.id);
const nextQuantity = previousAddon?.quantity ?? addon?.quantity ?? addon?.product?.quantity ?? 0;
return {
...addon,
quantity: nextQuantity,
product: addon?.product ? {
...addon.product,
quantity: nextQuantity,
} : addon?.product,
};
});
watch(
() => transactionItems.primaryItem.value,
(nextPrimary, prevPrimary) => {
if (!nextPrimary) {
console.warn('Primary item is not set, cannot proceed with the transaction.');
fetchPrimaryItemProduct();
return;
}
@@ -541,7 +904,6 @@ watch(
if (isNewPrimary) {
lastFetchedPrimaryItemProduct.value = nextPrimary;
console.log('Primary item changed from:', prevPrimary ?? null, 'to:', nextPrimary);
fetchPrimaryItemProduct();
if (Array.isArray(nextPrimary.addons) && nextPrimary.addons.length > 0) {
@@ -556,12 +918,6 @@ watch(
// Same product as last fetched: merge/normalize addons from last fetched into the current primary item
const sourceAddons = lastFetched?.addons ?? [];
if (sourceAddons.length > 0) {
console.log(
'Merging addons from last fetched primary item product:',
sourceAddons,
'into primary item:',
transactionItems.primaryItem.value
);
transactionItems.primaryItem.value.addons = mapAddonsWithQuantity(
sourceAddons,
transactionItems.primaryItem.value.addons ?? []
@@ -574,39 +930,39 @@ watch(
// Watch for changes in the last orders and fetch the order items when they change
watch(() => lastOrders.get(1), (newValue, oldValue) => {
if (newValue && newValue.id !== oldValue?.id) {
console.log('Last order 1 changed:', newValue);
fetchLastOrderItems(1); // Fetch the order items for the last order 1
}
}, { immediate: true });
watch(() => lastOrders.get(2), (newValue, oldValue) => {
if (newValue && newValue.id !== oldValue?.id) {
console.log('Last order 2 changed:', newValue);
fetchLastOrderItems(2); // Fetch the order items for the last order 2
}
}, { immediate: true });
watch(() => lastOrders.get(3), (newValue, oldValue) => {
if (newValue && newValue.id !== oldValue?.id) {
console.log('Last order 3 changed:', newValue);
fetchLastOrderItems(3); // Fetch the order items for the last order 3
}
}, { immediate: true });
// Watch for changes in the vehicle 1 last_order_id and fetch the last order when it changes
watch(() => vehicles.vehicle_1.value.last_order_id, (newValue, oldValue) => {
watch(() => vehicles.vehicle_1.value?.last_order_id, (newValue, oldValue) => {
if (newValue && newValue !== oldValue) {
console.log('Vehicle 1 last_order_id changed:', newValue);
fetchLastOrder(1); // Fetch the last order for vehicle 1
} else {
lastOrders.set(1, null); // Clear the last order if the last_order_id is removed
}
});
watch(() => vehicles.vehicle_1.value?.type, (newValue, oldValue) => {
if (newValue && newValue !== oldValue && !transactionItems.primaryItem.value) {
fetchPrimaryItemProduct();
}
});
// Watch for changes in the vehicle 1 reference and update the reference field when it changes
watch(() => vehicles.vehicle_1.value.reference, (newValue, oldValue) => {
watch(() => vehicles.vehicle_1.value?.reference, (newValue, oldValue) => {
if (newValue && newValue !== oldValue) {
console.log('Vehicle 1 reference changed:', newValue);
reference.value = newValue; // Update the reference field
hydrateReferenceFromSources();
}
});
@@ -627,16 +983,16 @@ const filteredAddons = computed(() => {
<template v-if="vehicleSelection">
<PosDepartmentStep2MobileVehicleSelection @close="vehicleSelection = false"/>
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl>
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
<!-- Buttons -->
<!-- Next button -->
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => { vehicleSelection = false; fetchPrimaryItemProduct(); }" :buttonClasses="['has-background-primary', 'has-text-black']" v-show="vehicles.vehicle_1?.value?.type">
<span class="is-float-left " v-if="false">{{ SessionUser.objects.global.language.next }}</span>
<span class="is-float-left">{{ primaryItem ? primaryItem.name : SessionUser.objects.global.language.next }}</span>
<span class="is-float-right ">
<!-- Arrow right icon -->
<span class="pos-mobile-cta-content">
<span class="pos-mobile-cta-label">{{ primaryItem ? primaryItem.name : SessionUser.objects.global.language.next }}</span>
<span class="pos-mobile-cta-value">
<i class="fas fa-arrow-right"></i>
</span>
</span>
</PosDepartmentStepMobileButtonNextStep>
</PosDepartmentStepMobileFixedBottomControl>
</template>
@@ -644,9 +1000,9 @@ const filteredAddons = computed(() => {
<PosDepartmentStepMobile2AdditionalItems :label="SessionUser.objects.global.language.additional_items" :subtitle="''" @close="additionalItemSelection = false"/>
</template>
<template v-else>
<div class="is-flex is-flex-direction-column is-justify-content-space-between is-gap-2">
<div class="pos-mobile-step-layout" data-testid="pos-mobile-step-2">
<!-- Customer selection -->
<h3 class="is-size-5 has-text-weight-bold">{{ customer_name }}</h3>
<h3 class="is-size-5 has-text-weight-bold" data-testid="pos-mobile-customer-name">{{ customer_name }}</h3>
<PosDepartmentStepMobile2Customer subtitle="" :label="customer_name" v-show="false"/>
<!-- Registration numbers -->
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
@@ -670,31 +1026,67 @@ const filteredAddons = computed(() => {
<!-- Notes -->
<ControlField>
<ControlFieldInputLabel label="Notes" :classes="layout.classes" :optional="true"/>
<ControlFieldInput type="text" placeholder="" :classes="layout.classes" :vmodel="order_notes" @change="console.warn('Setting notes to', $event); metadata.setNotes($event); SessionUser.objects.orders.set.notes(order_id, $event);"/>
<ControlFieldInput
testId="pos-mobile-notes-input"
type="text"
placeholder=""
:classes="layout.classes"
:vmodel="stepTwoNotesInput"
@input="stepTwoNotesInput = $event"
@change="notesAutosave.flush()"
/>
</ControlField>
<!-- Reference -->
<ControlField>
<ControlFieldInputLabel label="Reference" :classes="layout.classes" :optional="true"/> <!-- TODO: Make this required, if the customer requires it -->
<ControlFieldInput type="text" placeholder="" :classes="layout.classes" :vmodel="reference" @change="console.warn('Setting ref. to', $event); metadata.setReference($event); SessionUser.objects.orders.set.reference(order_id, $event);"/>
<ControlFieldInput
testId="pos-mobile-reference-step-2-input"
type="text"
placeholder=""
:classes="layout.classes"
:vmodel="stepTwoReferenceInput"
@input="stepTwoReferenceInput = $event"
@change="referenceAutosave.flush()"
/>
</ControlField>
<!-- Spacer to push the buttons to the bottom -->
<ControlField>
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl>
<ControlField v-if="showSafetySealField">
<ControlFieldInputLabel label="Safety seal" :classes="layout.classes" :optional="true"/>
<ControlFieldInput
testId="pos-mobile-safety-seal-step-2-input"
type="text"
placeholder=""
:classes="layout.classes"
:vmodel="stepTwoSafetySealInput"
@input="stepTwoSafetySealInput = $event"
@change="safetySealAutosave.flush()"
/>
</ControlField>
<PosDepartmentStepMobileFixedBottomControl variant="pos-step" :reserveSpace="false">
<!-- Complete button -->
<PosDepartmentStepMobileButtonNextStep :isDark="false" :onBeforeStep="() => onBeforeComplete()" :buttonClasses="['has-background-primary', 'has-text-white']">
<span class="is-float-left">Afslut</span>
<span class="is-float-right">{{ SessionUser.functions.currency.toLocal(transactionItems.getTransactionTotal())}}</span>
<span class="pos-mobile-cta-content">
<span class="pos-mobile-cta-label">Afslut</span>
<span class="pos-mobile-cta-value">{{ SessionUser.functions.currency.toLocal(transactionItems.getTransactionTotal())}}</span>
</span>
</PosDepartmentStepMobileButtonNextStep>
<div>
<PosDepartmentStepMobileButtonClearAll/>
</div>
</PosDepartmentStepMobileFixedBottomControl>
</ControlField>
</div>
</template>
</template>
<style scoped>
.pos-mobile-step-layout {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem 1rem calc(var(--pos-mobile-fixed-bottom-height, 0px) + 1rem);
scroll-padding-bottom: calc(var(--pos-mobile-fixed-bottom-height, 0px) + 1rem);
}
.pos-mobile-step-layout :deep(.control-field-input) {
scroll-margin-bottom: calc(var(--pos-mobile-fixed-bottom-height, 0px) + 1rem);
}
</style>
@@ -0,0 +1,296 @@
<script setup>
import { computed, ref } from 'vue';
import Swal from "sweetalert2";
import PayWithStripeButton from "@/components/displays/department/pos/displays/PayWithStripeButton.vue";
import {
customer_id,
department_id,
order_id,
order_items,
reg_1,
reset_all_values,
setStep,
} from "@/components/shop/POSDepartmentProcess.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { StripeModule } from "@/components/stripe/StripeModule.vue";
import {
metadata,
popups,
resetPos,
transactionItems,
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import { finalizeCurrentMobileOrder } from "@/components/displays/department/pos/steps/mobile/objects/mobileOrderCompletion.js";
const isCompleting = ref(false);
const completionError = ref(null);
const completedOrderId = ref(null);
const normalizedOrderId = computed(() => {
const parsed = Number.parseInt(String(order_id.value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
});
const normalizedDepartmentId = computed(() => {
const parsed = Number.parseInt(String(department_id.value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
});
const currentPaymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
const paymentIntentState = computed(() => StripeModule.paymentIntents.getPaymentIntentState(currentPaymentIntent.value));
const hasActivePaymentIntent = computed(() => StripeModule.paymentIntents.isPaymentIntentActive(currentPaymentIntent.value));
const orderItemsTotal = computed(() => {
return (Array.isArray(order_items.value) ? order_items.value : []).reduce((sum, item) => {
const quantity = Number(item?.quantity ?? 1);
const price = Number(item?.price ?? item?.product?.price ?? 0);
return sum + (Number.isFinite(quantity) ? quantity : 0) * (Number.isFinite(price) ? price : 0);
}, 0);
});
const mobileOrderTotal = computed(() => {
const transactionTotal = Number(transactionItems.getTransactionTotal?.() ?? 0);
if (Number.isFinite(transactionTotal) && transactionTotal > 0) {
return transactionTotal;
}
return orderItemsTotal.value;
});
const orderReference = computed(() => metadata.getReference?.() || '');
const orderNotes = computed(() => metadata.getNotes?.() || '');
const orderRegistration = computed(() => reg_1.value || 'Unknown vehicle');
const isBackDisabled = computed(() => isCompleting.value || paymentIntentState.value === 'succeeded');
const formatCurrency = (amount) => {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
}).format(Number(amount || 0));
};
const navigateToItems = () => {
setStep(2);
if (normalizedOrderId.value) {
window.history.pushState({}, '', `?id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
return;
}
window.history.pushState({}, '', `?step=2`);
};
const clearSuccessfulMobileCardFlow = () => {
window.setTimeout(() => {
reset_all_values();
order_id.value = 0;
popups.clear();
resetPos();
}, 3000);
};
const completeMobileCardPayment = async () => {
if (!normalizedOrderId.value) {
return;
}
if (isCompleting.value || completedOrderId.value === normalizedOrderId.value) {
return;
}
isCompleting.value = true;
completionError.value = null;
completedOrderId.value = normalizedOrderId.value;
try {
await finalizeCurrentMobileOrder();
popups.select('completed_transaction', {
message: `Order #${normalizedOrderId.value} successfully created.`,
});
clearSuccessfulMobileCardFlow();
} catch (error) {
completedOrderId.value = null;
completionError.value = SessionUser.functions.parseErrorMessage(error) || 'Unable to complete card payment.';
console.error('Failed to complete mobile card payment:', error);
} finally {
isCompleting.value = false;
}
};
const onBackClick = async () => {
if (paymentIntentState.value === 'succeeded') {
return;
}
if (!hasActivePaymentIntent.value) {
navigateToItems();
return;
}
const result = await Swal.fire({
title: 'Payment in progress',
text: 'Resume the current payment or cancel it before returning to the item step.',
icon: 'warning',
showDenyButton: true,
showCancelButton: true,
confirmButtonText: 'Resume payment',
denyButtonText: 'Cancel payment',
cancelButtonText: 'Stay here',
reverseButtons: true,
});
if (result.isConfirmed || result.dismiss) {
return;
}
if (result.isDenied && normalizedOrderId.value) {
try {
await StripeModule.paymentIntents.deletePaymentIntent(normalizedOrderId.value);
navigateToItems();
} catch (error) {
completionError.value = SessionUser.functions.parseErrorMessage(error) || 'Unable to cancel the current payment.';
}
}
};
</script>
<template>
<div class="mobile-card-payment-view" data-testid="pos-mobile-step-3">
<div class="mobile-card-payment-view__header">
<div>
<p class="mobile-card-payment-view__eyebrow">Mobile POS</p>
<h3 class="mobile-card-payment-view__title">Card payment</h3>
</div>
<button
class="button is-small is-light"
type="button"
data-testid="pos-mobile-step-3-back"
:disabled="isBackDisabled"
@click="onBackClick"
>
Back
</button>
</div>
<div class="mobile-card-payment-view__summary box">
<div class="mobile-card-payment-view__summary-row">
<span>Order</span>
<strong data-testid="pos-mobile-step-3-order-id">
#{{ normalizedOrderId || 'Pending' }}
</strong>
</div>
<div class="mobile-card-payment-view__summary-row">
<span>Total</span>
<strong data-testid="pos-mobile-step-3-total">
{{ formatCurrency(mobileOrderTotal) }}
</strong>
</div>
<div class="mobile-card-payment-view__summary-row">
<span>Vehicle</span>
<strong data-testid="pos-mobile-step-3-registration">{{ orderRegistration }}</strong>
</div>
<div v-if="orderReference" class="mobile-card-payment-view__summary-row">
<span>Reference</span>
<strong data-testid="pos-mobile-step-3-reference">{{ orderReference }}</strong>
</div>
<div v-if="orderNotes" class="mobile-card-payment-view__notes" data-testid="pos-mobile-step-3-notes">
{{ orderNotes }}
</div>
</div>
<div
v-if="paymentIntentState === 'succeeded' && isCompleting"
class="notification is-info is-light"
data-testid="pos-mobile-step-3-completing"
>
Finalizing the paid order.
</div>
<div v-if="completionError" class="notification is-danger is-light" data-testid="pos-mobile-step-3-error">
<div>{{ completionError }}</div>
<button
v-if="paymentIntentState === 'succeeded'"
class="button is-small is-danger is-light mobile-card-payment-view__retry"
type="button"
data-testid="pos-mobile-step-3-retry-complete"
:disabled="isCompleting"
@click="completeMobileCardPayment"
>
Retry completion
</button>
</div>
<p class="mobile-card-payment-view__description">
Use the selected Stripe Terminal reader to collect and capture the card payment for this order.
</p>
<PayWithStripeButton
:department-id="normalizedDepartmentId"
:order_id="normalizedOrderId || 0"
:order_items="order_items"
:mobile-mode="true"
:on-payment-success="completeMobileCardPayment"
label="Start card payment"
/>
</div>
</template>
<style scoped>
.mobile-card-payment-view {
display: flex;
flex-direction: column;
gap: 1rem;
}
.mobile-card-payment-view__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.mobile-card-payment-view__eyebrow {
margin: 0;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6b7280;
}
.mobile-card-payment-view__title {
margin: 0.15rem 0 0;
font-size: 1.2rem;
font-weight: 700;
}
.mobile-card-payment-view__summary {
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08);
}
.mobile-card-payment-view__summary-row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
padding: 0.1rem 0;
}
.mobile-card-payment-view__summary-row span {
color: #6b7280;
}
.mobile-card-payment-view__description {
margin: 0;
color: #6b7280;
font-size: 0.9rem;
}
.mobile-card-payment-view__notes {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(15, 23, 42, 0.08);
color: #374151;
font-size: 0.9rem;
}
.mobile-card-payment-view__retry {
margin-top: 0.75rem;
}
</style>
@@ -11,7 +11,6 @@ const { coords, locatedAt, error, resume, pause } = useGeolocation({
timeout: 27000,
});
const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => {
console.log("Geolocation updated:", newCoords);
if (newCoords.latitude && newCoords.longitude) {
locations.set({
coords: {
@@ -48,4 +47,4 @@ watch(coords, (newCoords) => {
<style scoped>
</style>
</style>
@@ -10,6 +10,10 @@ const props = defineProps({
label: {
type: String,
default: ''
},
inputTestId: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:modelValue', 'update:hasFocus', 'onInput']);
@@ -58,6 +62,7 @@ const onInput = (value: string) => {
<div class="control">
<input
class="custom-input"
:data-testid="props.inputTestId || undefined"
:class="{ 'is-searched': hasFocus }"
type="text"
v-model="modelValue"
@@ -214,4 +219,4 @@ const onInput = (value: string) => {
flex-grow: 0;
}
</style>
</style>
@@ -1,19 +1,21 @@
<script setup lang="ts">
import PosDepartmentStepMobile1RegistrationNumber1
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber1.vue";
import PosDepartmentStepMobile1RegistrationNumber2
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber2.vue";
import PosDepartmentStepMobile1RegistrationNumber3
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber3.vue";
import { activeVehicleIndex, setActiveVehicleIndex, vehicles, popups, metadata, reference } from "../objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStepMobileButtonClearAll
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
import PosDepartmentStepMobileReference
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileReference.vue";
import PosDepartmentStepMobile1RegistrationNumber1 from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber1.vue";
import PosDepartmentStepMobile1RegistrationNumber2 from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber2.vue";
import PosDepartmentStepMobile1RegistrationNumber3 from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber3.vue";
import {
activeVehicleIndex,
setActiveVehicleIndex,
vehicles,
popups,
metadata,
reference,
} from "../objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
import LongPressListener from "@/components/viewport/elements/wrappers/LongPressListener.vue";
import {ref, watch} from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { getNotes } from "@/components/shop/CustomerNotes.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import { customerRequiresReferenceNumber, isBlankPosMetadataValue } from "@/components/shop/POSDepartmentProcess.vue";
const isActive = (number: number): boolean => {
return activeVehicleIndex.value === number;
};
@@ -26,15 +28,16 @@ const setActive = (number: number): void => {
setActiveVehicleIndex(number);
};
const onLongPress = (number: number) => {
console.warn('Long press detected in parent for number', number);
console.warn("Long press detected in parent for number", number);
// Show the select vehicle popup
popups.select('select_vehicle', { props: { vehicleNumber: number } });
}
popups.select("select_vehicle", { props: { vehicleNumber: number } });
};
const showNewDemo = ref(true);
const registrationRowElement = ref<HTMLElement | null>(null);
const referenceWidth = ref(174);
let registrationRowResizeObserver: ResizeObserver | null = null;
// Get the customers notes (if any)
const customerNotes = ref([]); // TODO: define type
@@ -44,73 +47,174 @@ const getCustomerNotes = async () => {
customerNotes.value = response.data.data;
});
} catch (error) {
console.error('Error fetching customer notes:', error);
console.error("Error fetching customer notes:", error);
}
}
};
// Wait for changes to the customer number, and fetch the notes
watch(() => metadata.getCustomerId(), (newCustomerId) => {
if (newCustomerId) {
getCustomerNotes();
} else {
customerNotes.value = [];
}
}, { immediate: true });
watch(
() => metadata.getCustomerId(),
(newCustomerId) => {
if (newCustomerId) {
getCustomerNotes();
} else {
customerNotes.value = [];
}
},
{ immediate: true }
);
const updateReferenceWidth = () => {
const rowWidth = registrationRowElement.value?.getBoundingClientRect().width ?? 0;
referenceWidth.value = Math.max(174, Math.round(rowWidth));
};
const showReferenceWarning = computed(() => {
return customerRequiresReferenceNumber() && isBlankPosMetadataValue(reference.value);
});
const observeRegistrationRow = () => {
registrationRowResizeObserver?.disconnect();
registrationRowResizeObserver = null;
if (!registrationRowElement.value || typeof ResizeObserver === "undefined") {
updateReferenceWidth();
return;
}
registrationRowResizeObserver = new ResizeObserver(() => {
updateReferenceWidth();
});
registrationRowResizeObserver.observe(registrationRowElement.value);
updateReferenceWidth();
};
onMounted(async () => {
await nextTick();
observeRegistrationRow();
});
onBeforeUnmount(() => {
registrationRowResizeObserver?.disconnect();
});
watch(registrationRowElement, async () => {
await nextTick();
observeRegistrationRow();
});
watch(
() => [vehicles.get(1)?.reg?.length ?? 0, vehicles.get(2)?.reg?.length ?? 0, vehicles.get(3)?.reg?.length ?? 0],
async () => {
await nextTick();
updateReferenceWidth();
},
{ flush: "post" }
);
</script>
<template>
<div style="max-height: 25vh; overflow-y: auto; padding-bottom: 10px;">
<div class="registration-number-list">
<template v-if="!showNewDemo">
<LongPressListener @long-press="onLongPress(1)">
<PosDepartmentStepMobile1RegistrationNumber1 :active="isActive(1)" @click="setActive(1)"/>
<PosDepartmentStepMobile1RegistrationNumber1 :active="isActive(1)" @click="setActive(1)" />
</LongPressListener>
<LongPressListener @long-press="onLongPress(2)">
<PosDepartmentStepMobile1RegistrationNumber2 :active="isActive(2)" @click="setActive(2)"/>
<PosDepartmentStepMobile1RegistrationNumber2 :active="isActive(2)" @click="setActive(2)" />
</LongPressListener>
<LongPressListener @long-press="onLongPress(3)">
<PosDepartmentStepMobile1RegistrationNumber3 :active="isActive(3)" @click="setActive(3)"/>
<PosDepartmentStepMobile1RegistrationNumber3 :active="isActive(3)" @click="setActive(3)" />
</LongPressListener>
<PosDepartmentStepMobileButtonClearAll/>
<PosDepartmentStepMobileButtonClearAll />
</template>
<template v-else>
<div>
<div class="columns is-mobile is-vcentered is-centered is-gapless">
<div class="column is-narrow mr-1">
<div class="registration-summary">
<div ref="registrationRowElement" class="registration-row" data-testid="pos-mobile-step-1-registration-row">
<div class="registration-slot">
<LongPressListener @long-press="onLongPress(1)">
<PosDepartmentStepMobile1RegistrationNumber1 :active="isActive(1)" @click="setActive(1)" :showLabel="false" :isWider="!vehicles.get(1)?.reg?.length && !vehicles.get(2)?.reg?.length && !vehicles.get(3)?.reg?.length"/>
<PosDepartmentStepMobile1RegistrationNumber1
:active="isActive(1)"
@click="setActive(1)"
:showLabel="false"
:isWider="
!vehicles.get(1)?.reg?.length && !vehicles.get(2)?.reg?.length && !vehicles.get(3)?.reg?.length
"
/>
</LongPressListener>
</div>
<div class="column is-narrow ml-1" v-if="vehicles.get(1)?.reg?.length || vehicles.get(2)?.reg?.length">
<div class="registration-slot" v-if="vehicles.get(1)?.reg?.length || vehicles.get(2)?.reg?.length">
<LongPressListener @long-press="onLongPress(2)">
<PosDepartmentStepMobile1RegistrationNumber2 :active="isActive(2)" @click="setActive(2)" :showLabel="false" :class="{'opacity-invisible': !vehicles.get(1)?.reg?.length}"/>
<PosDepartmentStepMobile1RegistrationNumber2
:active="isActive(2)"
@click="setActive(2)"
:showLabel="false"
:class="{ 'opacity-invisible': !vehicles.get(1)?.reg?.length }"
/>
</LongPressListener>
</div>
<div class="column is-narrow ml-1" v-if="vehicles.get(3)?.reg?.length">
<div class="registration-slot" v-if="vehicles.get(3)?.reg?.length">
<LongPressListener @long-press="onLongPress(3)">
<PosDepartmentStepMobile1RegistrationNumber3 :active="isActive(3)" @click="setActive(3)" :showLabel="false" :class="{'opacity-invisible': !vehicles.get(2)?.reg?.length}"/>
<PosDepartmentStepMobile1RegistrationNumber3
:active="isActive(3)"
@click="setActive(3)"
:showLabel="false"
:class="{ 'opacity-invisible': !vehicles.get(2)?.reg?.length }"
/>
</LongPressListener>
</div>
</div>
<!-- Reference -->
<div
class="mt-2 mb-2 has-text-centered custom-button-secondary reference-trigger"
:class="{
'opacity-invisible': !vehicles.get(1)?.reg?.length,
'reference-trigger--danger': showReferenceWarning,
}"
:style="{ width: `${referenceWidth}px` }"
data-testid="pos-mobile-step-1-reference-trigger"
:data-warning-state="showReferenceWarning ? 'danger' : undefined"
@click="popups.select('change_reference')"
>
<span class="reference-trigger__content">
<span
v-if="showReferenceWarning"
class="reference-trigger__icon"
data-testid="pos-mobile-step-1-reference-warning-icon"
>
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
</span>
<span
v-if="reference.length > 0"
class="reference-trigger__text has-overflow-ellipsis has-text-weight-bold mx-3"
:class="{ 'has-text-white': !showReferenceWarning }"
>{{ reference }}</span
>
<span v-else class="reference-trigger__text is-italic">Indtast reference...</span>
</span>
</div>
</div>
<!-- Reference -->
<div class="mt-2 mb-2 has-text-centered custom-button-secondary" style="min-width: 174px;" :class="{'opacity-invisible': !vehicles.get(1)?.reg?.length}" @click="popups.select('change_reference')">
<p v-if="reference.length > 0"
class="has-overflow-ellipsis has-text-weight-bold has-text-white mx-3"
style="max-height: 20px; max-width: 90vw;"
>{{ reference }}</p>
<p v-else>
<span class="is-italic">Indtast reference...</span>
</p>
</div>
<!-- Reference -->
<div class="mt-2 mb-2 has-text-centered custom-button-secondary px-2" style="min-width: 174px;" :class="{'opacity-invisible': customerNotes.length === 0, 'has-background-warning': customerNotes.length > 0}" @click="popups.select('customer_notes')" v-if="customerNotes.length > 0">
<div
class="mt-2 mb-2 has-text-centered custom-button-secondary px-2 notes-trigger"
:class="{ 'opacity-invisible': customerNotes.length === 0, 'has-background-warning': customerNotes.length > 0 }"
:style="{ width: `${referenceWidth}px` }"
@click="popups.select('customer_notes')"
v-if="customerNotes.length > 0"
data-testid="pos-mobile-customer-notes-trigger"
>
<p>
<!-- If there are notes, show a warning icon -->
<span v-if="customerNotes.length > 0" class="icon is-small has-text-dark pr-1">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span class="has-overflow-ellipsis" :class="{'has-text-dark': customerNotes.length > 0, 'has-text-weight-bold': customerNotes.length > 0, 'is-italic': customerNotes.length === 0}">{{ SessionUser.objects.global.language.customer_notes}}</span>
<span
class="has-overflow-ellipsis"
:class="{
'has-text-dark': customerNotes.length > 0,
'has-text-weight-bold': customerNotes.length > 0,
'is-italic': customerNotes.length === 0,
}"
>{{ SessionUser.objects.global.language.customer_notes }}</span
>
</p>
</div>
</template>
@@ -118,8 +222,33 @@ watch(() => metadata.getCustomerId(), (newCustomerId) => {
</template>
<style scoped>
.registration-number-list {
max-height: 25vh;
overflow-y: auto;
padding-bottom: 10px;
}
.registration-summary {
display: flex;
flex-direction: column;
align-items: center;
}
.registration-row {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: fit-content;
max-width: 100%;
}
.registration-slot {
flex: 0 0 auto;
}
.is-wider {
width: 200px!important;
width: 200px !important;
}
.opacity-invisible {
opacity: 0.3;
@@ -153,4 +282,44 @@ watch(() => metadata.getCustomerId(), (newCustomerId) => {
flex-grow: 0;
}
</style>
.reference-trigger,
.notes-trigger {
display: flex;
align-items: center;
justify-content: center;
min-width: 174px;
margin-inline: auto;
}
.reference-trigger--danger {
background: #fff0f0;
border-color: #dc9f9f;
color: #8f2c2c;
}
.reference-trigger__content {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
width: 100%;
min-width: 0;
padding-inline: 0.45rem;
}
.reference-trigger__text {
display: inline-block;
min-width: 0;
max-width: 100%;
line-height: 1.2;
}
.reference-trigger__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
font-size: 0.75rem;
line-height: 1;
}
</style>
@@ -169,7 +169,7 @@ const onClickAddProduct = async (product: PosProduct) => {
</script>
<template>
<div>
<div data-testid="pos-mobile-additional-items">
<!-- Minimal view, when not set as fullscreen view -->
<WhiteBoxCard :toggleable="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length == 0"
:defaultOpen="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0"
@@ -178,13 +178,15 @@ const onClickAddProduct = async (product: PosProduct) => {
v-if="!pos.views.additionalItemSelection.value">
<!-- Header -->
<template #header>
<div class="card-header-title">{{ displayLabel }}</div>
<div class="card-header-icon">
<!-- Right arrow, if there's no items, down arrow if there are items -->
<span class="icon">
<i v-if="!pos.transactionItems.additionalItems.value || pos.transactionItems.additionalItems.value.length === 0" class="fas fa-angle-right"></i>
<i v-else class="fas fa-angle-down"></i>
</span>
<div class="pos-mobile-additional-items-header" data-testid="pos-mobile-additional-items-open">
<div class="card-header-title pos-mobile-additional-items-header__title" data-testid="pos-mobile-additional-items-header-title">{{ displayLabel }}</div>
<div class="card-header-icon pos-mobile-additional-items-header__icon" data-testid="pos-mobile-additional-items-header-icon">
<!-- Right arrow, if there's no items, down arrow if there are items -->
<span class="icon">
<i v-if="!pos.transactionItems.additionalItems.value || pos.transactionItems.additionalItems.value.length === 0" class="fas fa-angle-right"></i>
<i v-else class="fas fa-angle-down"></i>
</span>
</div>
</div>
</template>
<!-- Content -->
@@ -201,7 +203,7 @@ const onClickAddProduct = async (product: PosProduct) => {
<!-- Footer -->
<template #footer>
<!-- Select other product button -->
<a class="card-footer-item" @click="onClickAddOtherProduct">
<a class="card-footer-item" data-testid="pos-mobile-additional-items-open" @click="onClickAddOtherProduct">
<span class="icon">
<i class="fas fa-plus"></i>
</span>
@@ -211,23 +213,45 @@ const onClickAddProduct = async (product: PosProduct) => {
</WhiteBoxCard>
<!-- Fullscreen view, when selecting other products -->
<template v-else>
<div data-testid="pos-mobile-additional-items-selection">
<!-- Categories of products -->
<PosDepartmentStep2MobileVehicleSelection :onAddProduct="onClickAddProduct" :onSearchClick="() => console.warn('AdditionalItem Search Clicked')"/><!-- :asAddons="true" :addons="availableAdditionalItems" @update:addons="availableAdditionalItems = $event"/>-->
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl>
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
<!-- Floating Card -->
<PosDepartmentStepMobile2FloatingCart/>
<!-- Next button -->
<PosDepartmentStepMobileButtonNextStep :isDark="true" :customAction="onClickAddOtherProduct">
<span class="is-float-left has-text-white">{{ SessionUser.objects.global.language.next }}</span>
<span class="is-float-right has-text-white">
<!-- Arrow right icon -->
<i class="fas fa-arrow-right"></i>
<span class="pos-mobile-cta-content">
<span class="pos-mobile-cta-label has-text-white">{{ SessionUser.objects.global.language.next }}</span>
<span class="pos-mobile-cta-value has-text-white">
<i class="fas fa-arrow-right"></i>
</span>
</span>
</PosDepartmentStepMobileButtonNextStep>
</PosDepartmentStepMobileFixedBottomControl>
</div>
</template>
</div>
</template>
<style scoped>
</style>
.pos-mobile-additional-items-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
min-width: 0;
}
.pos-mobile-additional-items-header__title {
flex: 1 1 auto;
min-width: 0;
margin-bottom: 0;
}
.pos-mobile-additional-items-header__icon {
flex: 0 0 auto;
margin-left: auto;
}
</style>
@@ -116,11 +116,13 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
:price="addon.price"
:label="getLabel(addon)"
:piktogram="addon.product.piktogram"
:testId="`pos-mobile-addon-${addon.product.id}`"
:showPrices="props.showPrices"
@addProduct="onAddProduct(addon)"
:customButton="addon.quantity > 0">
<span class="custom-select-quantity-container">
<ControlSelectAmount
:testIdPrefix="`pos-mobile-addon-${addon.product.id}`"
@update:quantity="addon.quantity = $event; emit('update:addons', [...props.addons])"
v-model:quantity="addon.quantity"
:max="addon.max"
@@ -262,4 +264,4 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
flex-grow: 0;
}
</style>
</style>
@@ -53,7 +53,7 @@ onMounted(() => {
<template>
<div class="columns is-mobile is-multiline is-gapless">
<div class="column is-narrow" v-for="category in pos.categories.get()" :key="category.id">
<div class="column is-narrow" v-for="category in pos.categories.get()" :key="category.id" :data-testid="`pos-mobile-category-${category.id}`">
<GenericTag
:active="pos.categories.isCategorySelected(category)"
@click="pos.categories.select(category)"
@@ -68,4 +68,4 @@ onMounted(() => {
.custom-gap {
margin: 6px 5px;
}
</style>
</style>
@@ -33,6 +33,10 @@ const props = defineProps({
showPrices: {
type: Boolean,
default: true,
},
testId: {
type: String,
default: '',
}
});
@@ -72,7 +76,7 @@ const imageStyleCompact = {
:style="props.compact ? imageStyleCompact : imageSize"
style="border-radius: 8px; object-fit: contain;" />
</div>
<div class="column" @click="onClick" style="cursor: pointer;">
<div class="column" @click="onClick" style="cursor: pointer;" :data-testid="props.testId || undefined">
<template v-if="!props.compact">
<h1 class="title is-6">{{ props.label }}</h1>
<h2 class="subtitle is-6" v-if="props.showPrices">{{ SessionUser.functions.currency.toLocal(props.price) }}</h2>
@@ -152,4 +156,4 @@ const imageStyleCompact = {
.has-quantity .column .subtitle {
color: rgba(255, 255, 255, 0.6);
}
</style>
</style>
@@ -53,7 +53,7 @@ const displaySubtitle = computed(() => {
<template>
<!-- Box -->
<div class="box" style="width: inherit; height: inherit;">
<div class="box" style="width: inherit; height: inherit;" data-testid="pos-mobile-last-order">
<!-- Last order date & radio button -->
<div class="columns is-mobile is-vcentered is-multiline is-gapless">
<!-- Last order date -->
@@ -176,4 +176,4 @@ const displaySubtitle = computed(() => {
color: #000000;
}
</style>
</style>
@@ -15,7 +15,7 @@ const props = defineProps({
<template>
<!-- Box -->
<div class="box" style="border: 3px solid rgb(0,45,255);">
<div class="box" data-testid="pos-mobile-primary-product-card" style="border: 3px solid rgb(0,45,255);">
<!-- Product image -->
<div class="has-text-centered">
<img :src="getPicture(props?.product?.piktogram || props?.product?.id || '')" :alt="props?.product?.name || 'N/A'" class="product-image">
@@ -145,4 +145,4 @@ const props = defineProps({
order: 1;
flex-grow: 0;
}
</style>
</style>
@@ -34,7 +34,6 @@ const slots = defineSlots();
const fetchProducts = async (category: PosCategory, departmentId: number) => {
await SessionUser.objects.products.get.category(category.id, departmentId, (customer_id.value ? parseInt(customer_id.value) : null), true)
.then((response) => {
console.log("Products fetched for category:", category.name, response);
pos.productList.clear();
orderProducts(response).forEach((result: PosProduct) => {
pos.productList.add(result);
@@ -57,14 +56,12 @@ const fetchSuggestedProducts = async () => {
reg_3: pos.vehicles.vehicle_3?.value?.reg || null,
}
).then((response) => {
console.log("Suggested products fetched:", response);
});
};
// Watch for changes in the selected category
watch(() => pos.categories.selection(), (newCategory) => {
if (newCategory) {
console.log("Selected category changed:", newCategory);
// If the category id is -1, get the suggested products
if (newCategory.id === -1) {
fetchSuggestedProducts();
@@ -121,6 +118,7 @@ const filteredProducts = computed(() => {
:price="product.price"
:label="product.name"
:piktogram="product.piktogram"
:testId="`pos-mobile-product-${product.id}`"
>
<template #right>
<slot name="right" :product="product"/>
@@ -136,4 +134,4 @@ const filteredProducts = computed(() => {
</template>
<style scoped>
</style>
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
import { defineProps, defineEmits, onBeforeUnmount, watch } from 'vue';
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import { useI18n } from 'vue-i18n';
@@ -21,6 +21,30 @@ type attachment = {
updated_at: string | null;
deleted_at: string | null;
}
const getAttachmentContent = (attachmentEntry: attachment) => {
if (!attachmentEntry.content) {
return {
image: null,
document: null,
relation: null,
other: null,
src: null,
};
}
return {
image: attachmentEntry.content.image ?? null,
document: attachmentEntry.content.document ?? null,
relation: attachmentEntry.content.relation ?? null,
other: attachmentEntry.content.other ?? null,
src: attachmentEntry.content.src ?? null,
};
};
const getAttachmentOtherText = (attachmentEntry: attachment) => {
return getAttachmentContent(attachmentEntry).other || '';
};
const props = defineProps({
attachments: {
type: Array as () => attachment[],
@@ -29,12 +53,17 @@ const props = defineProps({
getPreviewLink: {
type: Function as () => (id: number) => Promise<string>,
required: true
},
showAttachWashCertificateAction: {
type: Boolean,
default: false
}
})
const emits = defineEmits<{
(e: 'delete', id: number): void;
(e: 'upload', payload: { filename: string; base64String: string | ArrayBuffer | null }): void;
(e: 'download', id: number): void;
(e: 'attach-wash-certificate'): void;
}>();
const onClickDelete = (id: number) => {
@@ -42,10 +71,11 @@ const onClickDelete = (id: number) => {
}
const determineAttachmentType = (attachment: attachment): 'image' | 'document' | 'relation' | 'other' | 'unknown' => {
if (attachment.content.image) return 'image';
if (attachment.content.document) return 'document';
if (attachment.content.relation) return 'relation';
if (attachment.content.other) return 'other';
const content = getAttachmentContent(attachment);
if (content.image) return 'image';
if (content.document) return 'document';
if (content.relation) return 'relation';
if (content.other) return 'other';
return 'unknown';
};
@@ -68,6 +98,99 @@ const getAttachmentTypeIcon = (attachment: attachment): string => {
const isAttachmentTypeIn = (types: ('image' | 'document' | 'relation' | 'other' | 'unknown')[]): boolean => {
return props.attachments.some(att => types.includes(determineAttachmentType(att)));
};
const isOfficeDocument = (attachmentEntry: attachment): boolean => {
const otherText = getAttachmentOtherText(attachmentEntry).toLowerCase();
return ['.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx'].some(extension => otherText.endsWith(extension));
};
const previewRequestsInFlight = new Set<number>();
const generatedObjectUrls = new Set<string>();
const isObjectUrl = (url: string | null): boolean => Boolean(url && url.startsWith('blob:'));
const releaseObjectUrl = (url: string | null): void => {
if (!isObjectUrl(url)) {
return;
}
URL.revokeObjectURL(url as string);
generatedObjectUrls.delete(url as string);
};
const shouldLoadDocumentPreview = (attachmentEntry: attachment): boolean => {
const content = getAttachmentContent(attachmentEntry);
return determineAttachmentType(attachmentEntry) === 'document'
&& Boolean(content.document)
&& !isOfficeDocument(attachmentEntry)
&& !content.src;
};
const createEmbeddablePreviewUrl = async (downloadLink: string | null): Promise<string | null> => {
if (!downloadLink) {
return null;
}
try {
const response = await fetch(downloadLink, { method: 'GET' });
if (!response.ok) {
return null;
}
const fileBlob = await response.blob();
if (!fileBlob || fileBlob.size === 0) {
return null;
}
const objectUrl = URL.createObjectURL(fileBlob);
generatedObjectUrls.add(objectUrl);
return objectUrl;
} catch (error) {
console.warn('Unable to create embeddable preview blob', error);
return null;
}
};
const loadPreviewLink = async (id: number): Promise<void> => {
if (previewRequestsInFlight.has(id)) {
return;
}
previewRequestsInFlight.add(id);
try {
const downloadLink = await props.getPreviewLink(id);
const src = await createEmbeddablePreviewUrl(downloadLink);
const attachment = props.attachments.find(att => att.id === id);
if (!attachment) {
return;
}
const content = getAttachmentContent(attachment);
if (content.src && content.src !== src) {
releaseObjectUrl(content.src);
}
content.src = src || null;
attachment.content = content;
} catch (error) {
console.warn('Error loading preview link for attachment', id, error);
} finally {
previewRequestsInFlight.delete(id);
}
};
watch(
() => props.attachments.map((attachment) => `${attachment.id}:${getAttachmentContent(attachment).src ?? ''}:${getAttachmentOtherText(attachment)}`),
() => {
props.attachments.forEach((attachment) => {
if (shouldLoadDocumentPreview(attachment)) {
void loadPreviewLink(attachment.id);
}
});
},
{ immediate: true }
);
onBeforeUnmount(() => {
generatedObjectUrls.forEach((url) => URL.revokeObjectURL(url));
generatedObjectUrls.clear();
});
const uploadAttachment = (file: File) => {
// Implement the upload logic here
@@ -96,21 +219,10 @@ const onClickUpload = () => {
fileInput.click();
};
const getPreviewLink = async (id: number): Promise<boolean> => {
const result = props.getPreviewLink(id);
console.warn('Preview link for attachment', id, ':', result);
// Store the preview link in the attachment object for later use
const attachment = props.attachments.find(att => att.id === id);
if (attachment) {
attachment.content.src = await result;
}
return false; // Default to false if not found or no preview available (yet)
const onClickAttachWashCertificate = () => {
emits('attach-wash-certificate');
};
const filterOutQuery = (url: string | null): string => {
if (!url) return '';
return url.split('?')[0];
};
</script>
<template>
@@ -118,7 +230,7 @@ const filterOutQuery = (url: string | null): string => {
<div class="columns is-mobile is-multiline is-variable is-1">
<!-- Add Attachment Button -->
<div class="column is-12">
<WhiteBoxCard :toggleable="true">
<WhiteBoxCard :toggleable="true" data-testid="pos-order-attachments-add-card">
<template #header>
<div class="card-header-icon">
<span class="icon">
@@ -139,9 +251,21 @@ const filterOutQuery = (url: string | null): string => {
<p>{{ t('admin.pos.attachments_upload_description') }}</p>
</template>
<template #footer>
<a class="card-footer-item" @click="onClickUpload">
<a
class="card-footer-item"
data-testid="pos-order-attachments-upload-action"
@click="onClickUpload"
>
<!-- Footer actions can go here -->
{{ t('upload') }}
{{ t('admin.pos.attachments_upload_action') }}
</a>
<a
v-if="props.showAttachWashCertificateAction"
class="card-footer-item"
data-testid="pos-order-attachments-attach-wash-certificate"
@click="onClickAttachWashCertificate"
>
{{ t('admin.pos.settings_wheel.attach_wash_certificate') }}
</a>
</template>
</WhiteBoxCard>
@@ -149,7 +273,7 @@ const filterOutQuery = (url: string | null): string => {
<!-- Existing Attachments -->
<template v-for="attachment in props.attachments" :key="attachment.id">
<div class="column is-12">
<WhiteBoxCard :toggleable="true">
<WhiteBoxCard :toggleable="true" :data-testid="`pos-order-attachment-card-${attachment.id}`">
<template #header>
<div class="card-header-icon">
<span class="icon">
@@ -171,32 +295,28 @@ const filterOutQuery = (url: string | null): string => {
<template #content>
<!--{{ attachment }}-->
<!-- DOCUMENT PREVIEW -->
<template v-if="determineAttachmentType(attachment) === 'document' && attachment.content.document">
<template v-if="determineAttachmentType(attachment) === 'document' && getAttachmentContent(attachment).document">
<!-- If the file extension is office document, you can use an online viewer -->
<template v-if="attachment.content.other.endsWith('.doc') || attachment.content.other.endsWith('.docx') || attachment.content.other.endsWith('.xls') || attachment.content.other.endsWith('.xlsx') || attachment.content.other.endsWith('.ppt') || attachment.content.other.endsWith('.pptx')">
<!--<iframe
v-if="getPreviewLink(attachment.id)"
:src="`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(filterOutQuery(attachment.content.src))}`"
style="width: 100%; height: 400px; border: none;"
></iframe>-->
<span>{{ t('admin.pos.attachments_office_preview_unavailable') }} <a @click="$emit('download', attachment.id)">{{ t('admin.pos.download') }}</a></span>
<template v-if="isOfficeDocument(attachment)">
<span>{{ t('admin.pos.attachments_office_preview_unavailable') }} <a @click="$emit('download', attachment.id)">{{ t('admin.pos.attachments_download_action') }}</a></span>
</template>
<iframe
v-else-if="getPreviewLink(attachment.id)"
:src="attachment.content.src"
v-else-if="getAttachmentContent(attachment).src"
:src="getAttachmentContent(attachment).src || ''"
style="width: 100%; height: 400px; border: none;"
></iframe>
<span v-else>{{ t('admin.pos.attachments_no_preview') }}</span>
</template>
<!-- OTHER PREVIEW -->
<template v-else-if="determineAttachmentType(attachment) === 'other' && attachment.content.other">
<template v-else-if="determineAttachmentType(attachment) === 'other' && getAttachmentContent(attachment).other">
<!-- If the other type is a URL, you can create a link -->
<template v-if="attachment.content.other.startsWith('http')">
<a :href="attachment.content.other" target="_blank" rel="noopener noreferrer">
{{ attachment.content.other }}
<template v-if="getAttachmentContent(attachment).other.startsWith('http')">
<a :href="getAttachmentContent(attachment).other" target="_blank" rel="noopener noreferrer">
{{ getAttachmentContent(attachment).other }}
</a>
</template>
<template v-else>
<span>{{ attachment.content.other }}</span>
<span>{{ getAttachmentContent(attachment).other }}</span>
</template>
</template>
<!-- NO PREVIEW -->
@@ -213,8 +333,13 @@ const filterOutQuery = (url: string | null): string => {
{{ t('admin.pos.delete') }}
</a>
<!-- Download, if applicable -->
<a class="card-footer-item" v-if="isAttachmentTypeIn(['image', 'document']) && (attachment.content.image || attachment.content.document)" @click="$emit('download', attachment.id)">
{{ t('admin.pos.download') }}
<a
class="card-footer-item"
:data-testid="`pos-order-attachment-download-${attachment.id}`"
v-if="isAttachmentTypeIn(['image', 'document']) && (getAttachmentContent(attachment).image || getAttachmentContent(attachment).document)"
@click="$emit('download', attachment.id)"
>
{{ t('admin.pos.attachments_download_action') }}
</a>
</template>
</WhiteBoxCard>
@@ -226,4 +351,5 @@ const filterOutQuery = (url: string | null): string => {
<style scoped>
</style>
</style>
@@ -199,7 +199,7 @@ const isAttachmentsVisible = () => {
<div>
<div class="columns is-mobile is-vcentered">
<!-- Clear All Button -->
<div class="column is-narrow" v-if="isDefaultControlsVisible() && props.showClearAllButton" @click="onClickClearAllButton" style="cursor: pointer;">
<div class="column is-narrow" data-testid="pos-mobile-clear-all" v-if="isDefaultControlsVisible() && props.showClearAllButton" @click="onClickClearAllButton" style="cursor: pointer;">
<div class="columns is-mobile is-vcentered">
<div class="column is-narrow pr-1">
<span class="icon is-small">
@@ -214,7 +214,7 @@ const isAttachmentsVisible = () => {
</div>
</div>
<!-- Attachment Icon and Label -->
<div class="column is-narrow" v-if="isDefaultControlsVisible() && showAttachButton" @click="onClickAttachmentButton" style="cursor: pointer;">
<div class="column is-narrow" data-testid="pos-mobile-attachments-toggle" v-if="isDefaultControlsVisible() && showAttachButton" @click="onClickAttachmentButton" style="cursor: pointer;">
<div>
<!-- Count Badge -->
<div class="is-pulled-right" v-if="attachments.count() > 0">
@@ -239,7 +239,7 @@ const isAttachmentsVisible = () => {
</div>
</div>
<!-- Reference Display -->
<div class="column is-narrow" v-if="isDefaultControlsVisible() && props.showReferenceButton" @click="onClickReferenceButton" style="cursor: pointer;">
<div class="column is-narrow" data-testid="pos-mobile-change-reference" v-if="isDefaultControlsVisible() && props.showReferenceButton" @click="onClickReferenceButton" style="cursor: pointer;">
<div>
<!-- Invisible badge to keep alignment -->
<div class="is-pulled-right" v-if="true" style="visibility: hidden;">
@@ -265,7 +265,7 @@ const isAttachmentsVisible = () => {
</div>
</div>
<!-- History Icon and Label -->
<div class="column is-narrow" v-if="isDefaultControlsVisible() && props.showHistoryButton" @click="onClickTransactionHistoryButton" style="cursor: pointer;">
<div class="column is-narrow" data-testid="pos-mobile-transaction-history" v-if="isDefaultControlsVisible() && props.showHistoryButton" @click="onClickTransactionHistoryButton" style="cursor: pointer;">
<div>
<!-- Count Badge -->
<div class="is-pulled-right" v-if="transactionHistory.getPending().length > 0">
@@ -290,7 +290,7 @@ const isAttachmentsVisible = () => {
</div>
</div>
<!-- Zoom -->
<div class="column is-narrow" v-if="isDefaultControlsVisible() && props.showZoomButton" @click="camera.setZoom(camera.getZoom() === 1 ? 2 : 1)" style="cursor: pointer;">
<div class="column is-narrow" data-testid="pos-mobile-camera-zoom" v-if="isDefaultControlsVisible() && props.showZoomButton" @click="camera.setZoom(camera.getZoom() === 1 ? 2 : 1)" style="cursor: pointer;">
<div class="columns is-mobile is-vcentered">
<div class="column is-narrow pr-1">
<span class="icon is-small">
@@ -307,7 +307,7 @@ const isAttachmentsVisible = () => {
</div>
<!-- Attachment Selector Options -->
<!-- Back button -->
<div class="column is-narrow" v-if="isAttachmentSelectorVisible && props.showBackButton" @click="onClickAttachmentButton" style="cursor: pointer;">
<div class="column is-narrow" data-testid="pos-mobile-attachments-back" v-if="isAttachmentSelectorVisible && props.showBackButton" @click="onClickAttachmentButton" style="cursor: pointer;">
<div class="columns is-mobile is-vcentered">
<div class="column is-narrow pr-1">
<span class="icon is-small">
@@ -322,7 +322,7 @@ const isAttachmentsVisible = () => {
</div>
</div>
<!-- Take picture -->
<div class="column is-narrow" v-if="isAttachmentSelectorVisible && props.showTakePictureButton" @click="onClickAttachPicture" style="cursor: pointer;">
<div class="column is-narrow" data-testid="pos-mobile-attachments-take-picture" v-if="isAttachmentSelectorVisible && props.showTakePictureButton" @click="onClickAttachPicture" style="cursor: pointer;">
<div class="columns is-mobile is-vcentered">
<div class="column is-narrow pr-1">
<span class="icon is-small">
@@ -337,7 +337,7 @@ const isAttachmentsVisible = () => {
</div>
</div>
<!-- Upload from device -->
<div class="column is-narrow" v-if="isAttachmentSelectorVisible && props.showUploadFileButton" @click="onClickAttachFile" style="cursor: pointer;">
<div class="column is-narrow" data-testid="pos-mobile-attachments-upload-file" v-if="isAttachmentSelectorVisible && props.showUploadFileButton" @click="onClickAttachFile" style="cursor: pointer;">
<div class="columns is-mobile is-vcentered">
<div class="column is-narrow pr-1">
<span class="icon is-small">
@@ -352,7 +352,7 @@ const isAttachmentsVisible = () => {
</div>
</div>
<!-- Add wash certificate -->
<div class="column is-narrow" v-if="isAttachmentSelectorVisible" @click="onClickAttachmentWashCertificate" style="cursor: pointer;">
<div class="column is-narrow" data-testid="pos-mobile-attachments-wash-certificate" v-if="isAttachmentSelectorVisible" @click="onClickAttachmentWashCertificate" style="cursor: pointer;">
<div class="columns is-mobile is-vcentered">
<div class="column is-narrow pr-1">
<span class="icon is-small">
@@ -387,4 +387,4 @@ const isAttachmentsVisible = () => {
border-bottom: 1px solid #ddd;
white-space: nowrap;
}
</style>
</style>
@@ -28,6 +28,7 @@ const onClickClearAll = () => {
<template>
<div class="button is-fullwidth is-white mt-2"
data-testid="pos-mobile-clear-all-button"
@click="onClickClearAll()">
<div class="columns is-mobile is-vcentered has-text-centered">
<!-- Clear all -->
@@ -51,4 +52,4 @@ const onClickClearAll = () => {
<style scoped>
</style>
</style>
@@ -1,18 +1,21 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import {defineProps, onMounted, computed, ref} from "vue";
import { reset_all_values, customer_name, nextStep, searchAndSelectCustomer, isCustomerSelected, order_id, customer_id, step, reg_1, reg_2, reg_3, reference, order_notes, loadCustomerAttributes, hasAttribute, uploadAttachment } from "@/components/shop/POSDepartmentProcess.vue";
import { reset_all_values, customer_name, nextStep, searchAndSelectCustomer, isCustomerSelected, order_id, customer_id, step, reg_1, reg_2, reg_3, reference, order_notes, order_safety_seal, loadCustomerAttributes, hasAttribute, uploadAttachment, restoreStoredPosOrderId } from "@/components/shop/POSDepartmentProcess.vue";
import * as POSDepartmentProcess from "@/components/shop/POSDepartmentProcess.vue";
import { errors } from "@/components/request/HandleGlobalError.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import ContinueArrow from "@/components/viewport/elements/icons/ContinueArrow.vue";
import { vehicles, metadata, getCustomerId, popups, resetPos, attachments, transactionHistory } from "../objects/PosDepartmentStepMobileFlow.vue";
import { vehicles, metadata, getCustomerId, popups, resetPos, attachments, transactionHistory, transactionItems } from "../objects/PosDepartmentStepMobileFlow.vue";
import { finalizeCurrentMobileOrder, getResolvedMobileSafetySeal, syncMobileSafetySealState } from "../objects/mobileOrderCompletion.js";
import Swal from "sweetalert2";
import LongPressListener from "@/components/viewport/elements/wrappers/LongPressListener.vue";
import {views} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
const { t } = useI18n();
const selectCustomerString = `${SessionUser.objects.global.language.select} ${SessionUser.objects.global.language.customer.toLowerCase()}`;
const isCreatingOrder = computed(() => POSDepartmentProcess.isCreatingOrder?.value ?? false);
const props = defineProps({
// If true, the button will have a white background. Default is true.
@@ -61,6 +64,11 @@ const updateNotes = () => {
SessionUser.objects.orders.set.notes(order_id.value, order_notes.value);
}
const updateSafetySeal = () => {
if (!!order_id.value && order_id.value > 0)
SessionUser.objects.orders.set.safety_seal(order_id.value, order_safety_seal.value);
}
// Helpers: extracted for clarity and reuse
const isNonEmptyString = (val) => typeof val === 'string' && val.trim().length > 0;
@@ -152,6 +160,7 @@ const step1 = () => {
// Merge references and notes
mergeReferences();
mergeNotes();
mergeSafetySeal();
// We only need to check if the customer requires reference, if the field is empty.
if (!isNonEmptyString(reference.value)) {
validateReferenceRequirements().then((isValid) => {
@@ -192,9 +201,16 @@ const mergeNotes = () => {
updateNotes();
}
}
const mergeSafetySeal = () => {
const resolvedSafetySeal = getResolvedMobileSafetySeal();
order_safety_seal.value = resolvedSafetySeal;
syncMobileSafetySealState(resolvedSafetySeal);
updateSafetySeal();
}
const finalizeStep1 = () => {
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()) {
@@ -224,114 +240,73 @@ const addTransactionToHistory = async (orderId: number) => {
});
};
const step2 = () => {
/** This function can be used to perform any specific actions for step 2 */
// Add all additional items to the order
const toPositiveInteger = (value: any) => {
const parsedValue = Number.parseInt(String(value ?? ''), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
}
const getSelectedBookingId = () => {
return toPositiveInteger(metadata.getBookingId?.() ?? metadata.bookingId?.value ?? null);
}
const completeStep2Order = async ({
bookingSafetySeal = null,
markOrderCompleted = false,
}: {
bookingSafetySeal?: string | null;
markOrderCompleted?: boolean;
} = {}) => {
await finalizeCurrentMobileOrder({
bookingSafetySeal,
markOrderCompleted,
});
popups.select('completed_transaction', {
message: `Order #${order_id.value} successfully created.`,
});
completeOrder();
}
const step2 = async () => {
/** This function can be used to perform any specific actions for step 2 */
// If the customer is paying with a card, go to the payment step
if (isCustomerSelected() && getCustomerId() === 999) {
nextStep({isMobile: true});
return;
}
if (metadata.getBookingId() && metadata.getBookingId() > 0) {
// If there is a booking ID, complete the booking
SessionUser.objects.orders.set.booking_id(order_id.value, metadata.getBookingId());
// Check if the booking has been completed
SessionUser.objects.order_bookings.get.single(metadata.getBookingId()).then((booking: any) => {
const isBookingCompleted = booking?.order_id && booking.order_id > 0;
if (!isBookingCompleted) {
// Set the order bookings id to the metadata
if (metadata.getBookingId() && metadata.getBookingId() > 0) {
SessionUser.objects.order_bookings.set.order_id(metadata.getBookingId(), order_id.value).then(() => {
// Check if order requires safety seal popup (product id 41 present as item or addon)
try {
const posStr = localStorage.getItem('pos');
let hasSeal = false;
if (posStr) {
try {
const posData = JSON.parse(posStr);
const primaryItem = posData?.transactionItems?.primaryItem;
const additionalItems = posData?.transactionItems?.additionalItems || [];
const allItems = [primaryItem, ...additionalItems].filter(Boolean);
hasSeal = allItems.some((item: any) =>
item?.product?.id === 41 ||
(item?.addons && item.addons.some((addon: any) => addon?.product?.id === 41))
);
} catch (parseErr) {
console.error('Failed to parse pos data:', parseErr);
}
}
if (!hasSeal) {
metadata.setLoadingState(true);
metadata.setLoadingMessage(t('admin.pos.waiting_for_booking'));
SessionUser.objects.order_bookings.functions.complete(metadata.getBookingId(), null, (response: any) => {
console.log('Auto-completed booking without safety seal prompt (mobile POS - localStorage)');
completeOrder();
});
} else {
popups.select('complete_booking', {});
awaitBookingCompletion(() => {
completeOrder();
});
}
} catch (err) {
console.error('Error checking safety seal via localStorage:', err);
popups.select('complete_booking', {});
awaitBookingCompletion(() => {
completeOrder();
});
}
}).catch((err: any) => {
console.error('Error setting order_id on booking:', err);
errors.value.push(err);
completeOrder();
});
}
} else {
// The booking has been completed, complete the order
completeOrder();
}
}).catch((error: any) => {
errors.value.push(error);
// An error occurred, log it and complete the order anyway
console.warn('An error occurred while checking if the booking has been completed:', error);
completeOrder();
});
} else {
completeOrder();
}
};
const awaitBookingCompletion = (callback: Function) => {
// Lock the next step button to a loading state
metadata.setLoadingState(true);
metadata.setLoadingMessage(t('admin.pos.waiting_for_booking'));
const interval = setInterval(() => {
// If the popup is still open, we know the booking is not completed yet
if (popups.isSet.value) {
// The popup is still open, so the booking is not completed yet
const selectedBookingId = getSelectedBookingId();
const hasWashCertificateInBasket = Boolean(transactionItems.containsWashCertificate?.());
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
try {
if (requiresBookingCompletionPopup) {
const onCompleteBookingWithSafetySeal = async ({ safetySeal = '' } = {}) => {
syncMobileSafetySealState(safetySeal);
await completeStep2Order({
bookingSafetySeal: String(safetySeal ?? ''),
markOrderCompleted: false,
});
};
popups.select('complete_booking', {
props: {
onCompleteWithCertificate: onCompleteBookingWithSafetySeal,
onCompleteWithoutCertificate: onCompleteBookingWithSafetySeal,
},
});
return;
}
// Check if the booking has been completed
SessionUser.objects.bookings.functions.isBookingCompleted(metadata.getBookingId()).then((isBookingCompleted: boolean) => {
if (isBookingCompleted) {
clearInterval(interval);
callback();
} // Else, console.warn and wait for the next interval
else {
console.warn('Booking is not completed, but the popup is closed. Expecting cancelation or error.');
clearInterval(interval);
callback(); // Proceed to complete the order anyway
}
}).catch((error: any) => {
errors.value.push(error);
console.warn('An error occurred while checking if the booking has been completed:', error);
completeOrder();
await completeStep2Order({
markOrderCompleted: shouldMarkOrderAsCompleted,
});
}, 250) // Check every 250ms (Should be quick enough to catch the popup close event);
}
} catch (error: any) {
errors.value.push(error);
console.warn('An error occurred while completing the mobile order:', error);
popups.select('error');
}
};
const completeOrder = () => {
addTransactionToHistory(order_id.value);
@@ -351,46 +326,73 @@ const step3 = () => {
console.warn('Step 3: Card payment step is not defined.');
};
const onClick = () => {
const isProcessingClick = ref(false);
const isBusy = computed(() => metadata.getLoadingState() || isCreatingOrder.value || isProcessingClick.value);
const onClick = async () => {
// Check if the click is cancelled
if (isClickCancelled.value) {
if (isClickCancelled.value || isProcessingClick.value) {
return;
}
// Check if a custom action is provided
if (props.customAction) {
props.customAction();
isProcessingClick.value = true;
try {
await Promise.resolve(props.customAction());
} finally {
isProcessingClick.value = false;
}
return;
}
if (isCustomerSelected() && getCustomerId() > 0) {
props.onBeforeStep().then(() => {
// Proceed to the next step
switch (step.value) {
case 1:
step1();
// Set the order ID (If it's stored in pos_order_id in local storage)
if (localStorage.getItem('pos_order_id')) {
order_id.value = parseInt(localStorage.getItem('pos_order_id') || '0');
console.warn('Order ID retrieved from local storage:', order_id.value);
nextStep({isMobile: true, orderCreation: false});
} else {
nextStep({isMobile: true, orderCreation: true});
}
break;
case 2:
step1();
step2();
break;
case 3:
// This step is not defined, but you can add your logic here if needed
step3();
break;
default:
break;
}
});
} else {
if (!(isCustomerSelected() && getCustomerId() > 0)) {
// Show the popup to select a customer
popups.select('select_customer');
return;
}
isProcessingClick.value = true;
try {
await props.onBeforeStep();
// Proceed to the next step
switch (step.value) {
case 1:
step1();
const restoredOrderId = await restoreStoredPosOrderId({
validateOrder: true,
customerId: getCustomerId(),
departmentId: POSDepartmentProcess.getDepartment(),
allowCompleted: false,
});
if (restoredOrderId) {
console.warn('Order ID retrieved from local storage:', restoredOrderId);
nextStep({
isMobile: true,
orderCreation: false,
bookingId: metadata.getBookingId?.() ?? null,
});
return;
}
nextStep({
isMobile: true,
orderCreation: true,
bookingId: metadata.getBookingId?.() ?? null,
});
break;
case 2:
step1();
await step2();
break;
case 3:
// This step is not defined, but you can add your logic here if needed
step3();
break;
default:
break;
}
} catch (error) {
console.warn('Next-step action was interrupted:', error);
} finally {
isProcessingClick.value = false;
}
};
@@ -436,8 +438,8 @@ const isRequirementsForClickMet = () => {
if (popups.isSet.value) {
return false;
}
// If the metadata state is loading, the button cannot be clicked.
if (metadata.getLoadingState()) {
// Prevent duplicate click handling while one action is in-flight.
if (isBusy.value) {
return false;
}
if (!isCustomerSelected()) {
@@ -494,17 +496,16 @@ const defaultLongPressBehavior = () => {
<template>
<LongPressListener @long-press="onLongPress">
<GenericButton @click="onClick" :class="{'white': props.isWhite, 'has-background-black': props.isDark, 'is-loading': metadata.getLoadingState(), ...(props.buttonClasses.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}))}"
<GenericButton @click="onClick" :class="{'white': props.isWhite, 'has-background-black': props.isDark, 'is-loading': isBusy, ...(props.buttonClasses.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}))}"
data-testid="pos-mobile-next-step"
class="is-size-6-mobile is-size-5-tablet is-size-4-desktop"
:disabled="!isRequirementsForClickMet()" v-show="isVisible">
<!-- Default content if no slot is provided -->
<template v-if="!$slots.default">
<template v-if="isCustomerSelected()">
<span class="is-float-left" style="width: fit-content;">
{{ customer_name }}
</span>
<span class="is-float-right" style="width: fit-content; height: fit-content;">
<span class="icon is-medium is-clickable" style="width: fit-content; height: fit-content;">
<span class="pos-mobile-cta-content">
<span class="pos-mobile-cta-label">{{ customer_name }}</span>
<span class="pos-mobile-cta-value">
<ContinueArrow style="width: 100%; height: 100%; padding: 6px 12px; gap: 5px;" />
</span>
</span>
@@ -523,9 +524,104 @@ const defaultLongPressBehavior = () => {
</template>
<style scoped>
.pos-mobile-cta-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
min-width: 0;
}
.pos-mobile-cta-label {
min-width: 0;
flex: 1;
display: -webkit-box;
text-align: left;
line-height: 1.2;
overflow: hidden;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.pos-mobile-cta-value {
display: inline-flex;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
}
.has-text-black >:not(.icon) {
color: black !important;
}
:deep(.pos-mobile-cta-content) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
min-width: 0;
}
:deep(.pos-mobile-cta-label) {
min-width: 0;
flex: 1;
display: -webkit-box;
text-align: left;
line-height: 1.2;
overflow: hidden;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
:deep(.pos-mobile-cta-value) {
display: inline-flex;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
}
:deep(.pos-mobile-action-content) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
width: 100%;
min-width: 0;
min-height: 100%;
box-sizing: border-box;
padding-inline: 0.75rem;
}
:deep(.pos-mobile-action-label) {
flex: 1 1 auto;
min-width: 0;
text-align: left;
line-height: 1;
}
:deep(.pos-mobile-action-icon) {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
margin-left: auto;
min-width: 1.25rem;
line-height: 1;
}
:deep(.pos-mobile-action-icon i) {
display: block;
line-height: 1;
}
:deep(.generic-button__content) {
width: 100%;
min-width: 0;
}
/* Loading state */
.is-loading {
pointer-events: none;
@@ -561,4 +657,4 @@ const defaultLongPressBehavior = () => {
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import {defineProps, computed, ref} from "vue";
import {computed, ref, nextTick, onMounted, onUnmounted} from "vue";
const props = defineProps({
// Blur amount
blurAmount: {
@@ -11,8 +11,19 @@ const props = defineProps({
type: Boolean,
default: true,
},
variant: {
type: String,
default: 'default',
validator: (value: string) => ['default', 'pos-step'].includes(value),
},
reserveSpace: {
type: Boolean,
default: true,
},
})
const FIXED_BOTTOM_HEIGHT_CSS_VAR = '--pos-mobile-fixed-bottom-height';
const style = computed(() => {
// Smooth blur transition
if (props.smoothBlur) {
@@ -32,20 +43,55 @@ const style = computed(() => {
}
})
// Get height of control box dynamically
const controlBox = ref<HTMLDivElement | null>(null);
const heightOfControlBox = computed(() => {
if (controlBox.value) {
return controlBox.value.offsetHeight
const reservedHeight = ref(0);
let resizeObserver: ResizeObserver | null = null;
const syncReservedHeight = () => {
reservedHeight.value = controlBox.value?.offsetHeight ?? 0;
if (typeof document !== "undefined") {
document.documentElement.style.setProperty(FIXED_BOTTOM_HEIGHT_CSS_VAR, `${reservedHeight.value}px`);
}
return 0;
};
onMounted(() => {
nextTick(syncReservedHeight);
if (typeof ResizeObserver !== "undefined" && controlBox.value) {
resizeObserver = new ResizeObserver(() => syncReservedHeight());
resizeObserver.observe(controlBox.value);
}
window.addEventListener("resize", syncReservedHeight);
});
onUnmounted(() => {
resizeObserver?.disconnect();
window.removeEventListener("resize", syncReservedHeight);
if (typeof document !== "undefined") {
document.documentElement.style.removeProperty(FIXED_BOTTOM_HEIGHT_CSS_VAR);
}
});
const anchorStyle = computed(() => {
if (!props.reserveSpace) {
return {};
}
return {
minHeight: `${reservedHeight.value}px`,
};
})
</script>
<template>
<div :style="{ minHeight: heightOfControlBox + 'px' }">
<div class="custom-mobile-fixed-bottom-control" :style="style" ref="controlBox">
<div class="p-4">
<div :style="anchorStyle" :class="['custom-mobile-fixed-bottom-anchor', `custom-mobile-fixed-bottom-anchor--${props.variant}`]">
<div
class="custom-mobile-fixed-bottom-control"
:class="[`custom-mobile-fixed-bottom-control--${props.variant}`]"
:style="style"
ref="controlBox"
:data-testid="props.variant === 'pos-step' ? 'pos-mobile-fixed-actions' : undefined"
>
<div class="custom-mobile-fixed-bottom-control__inner">
<slot></slot>
</div>
</div>
@@ -58,7 +104,21 @@ const heightOfControlBox = computed(() => {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
z-index: 1000;
}
</style>
.custom-mobile-fixed-bottom-control__inner {
width: min(100%, 48rem);
margin: 0 auto;
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom));
}
.custom-mobile-fixed-bottom-control--pos-step {
border-top-left-radius: 1.2rem;
border-top-right-radius: 1.2rem;
box-shadow: 0 -12px 30px rgba(8, 28, 52, 0.14);
border-top: 1px solid rgba(255, 255, 255, 0.55);
}
</style>
@@ -1,235 +1,312 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { computed, defineEmits, watch, ref } from "vue";
import { PosSearchResult } from "../objects/PosSearchResult.vue";
import CustomerSearchField from "@/components/search/economic/customerSearchField.vue";
import { searchCustomerResults, isSearching } from "@/components/search/economic/customerSearch.vue";
import ControlFieldInputSearchResults
from "@/components/viewport/elements/controls/fields/search/ControlFieldInputSearchResults.vue";
import { useI18n } from "vue-i18n";
import { computed, defineEmits, onBeforeUnmount, reactive, ref, watch } from "vue";
import { searchAndSelectCustomer } from "@/components/shop/POSDepartmentProcess.vue";
import { metadata, vehicles } from "../objects/PosDepartmentStepMobileFlow.vue";
import VehicleCustomerSuggestionsPos from "@/components/forms/department/pos/input/vehicleCustomerSuggestionsPos.vue";
import {
actionButtons,
popups
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import { actionButtons, metadata, popups } from "../objects/PosDepartmentStepMobileFlow.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import {
applyPosAddCustomerSearchResult,
createPosAddCustomerFields,
createPosAddCustomerManualOverrides,
createPosAddCustomerRequestState,
isValidPosAddCustomerSearchResult,
nextPosAddCustomerRequestState,
resolvePosAddCustomerRequestState,
setPosAddCustomerManualOverride,
} from "./posAddCustomerAutofill.js";
const SEARCH_DEBOUNCE_MS = 300;
const ADD_CUSTOMER_SUBMIT_TEST_ID = "pos-mobile-add-customer-submit";
const { t } = useI18n();
const emit = defineEmits<{
(e: 'close'): void;
(e: "close"): void;
}>();
/**
* Variables
*/
const searchQuery = ref("");
const searchResult = ref(/**{
"vat": 41004355,
"status": "Normal",
"name": "Truckwash ApS",
"address": "Letland Alle 2",
"zipcode": 2630,
"city": "Taastrup",
"protected": true,
"phone": "21754690",
"website": null,
"email": "mikkel@truckwash.dk",
"fax": null,
"startdate": "2019-12-11",
"enddate": null,
"employees": 14,
"industrycode": 953190,
"industrydesc": "Reparation og vedligeholdelse af motorkøretøjer i.a.n.",
"companytype": "APS",
"companydesc": "Anpartsselskab",
"owners": [
"DELOITTE STATSAUTORISERET REVISIONSPARTNERSELSKAB",
"MBL Revision I/S",
"WASH GROUP A/S"
]
}*/null);
const lastSearchResult = ref(null); // This is used to change the autofilled fields, when the search result changes
const searchResult = ref<any | null>(null);
const searching = ref(false);
const error = ref(popups.get().props.error); // Used to display an error message if the customer creation fails
const search = (query: string) => {
return SessionUser.request("/cvr/search", "GET", {
query: query,
}).then((response) => {
console.warn(response, 'response');
searchResult.value = response.data.data;
}).catch((error) => {
console.error(error);
searchResult.value = null;
}).finally(() => {
searching.value = false;
changeAutofilledFields();
});
};
/**
* Form fields
*/
const cvr = ref("");
const companyPhone = ref("");
const invoiceEmail = ref("");
const contactEmail = ref("");
const contactPhone = ref("");
/**
* Autofill fields, when search result changes
* If the operator has changed the values in the fields, we want to keep them, so we can autofill them again when the search result changes
*/
const changeAutofilledFields = () => {
if (lastSearchResult.value !== null) {
cvr.value = "";
if (lastSearchResult.value.phone === companyPhone.value) {
companyPhone.value = "";
}
if (lastSearchResult.value.phone === contactPhone.value) {
contactPhone.value = "";
}
if (lastSearchResult.value.email === contactEmail.value) {
contactEmail.value = "";
}
if (lastSearchResult.value.email === invoiceEmail.value) {
invoiceEmail.value = "";
}
}
// Fill the fields with the new search result
if (searchResult.value !== null) {
if (cvr.value === "") {
cvr.value = searchResult.value.vat.toString();
}
if (companyPhone.value === "") {
companyPhone.value = searchResult.value.phone;
}
if (contactPhone.value === "") {
contactPhone.value = searchResult.value.phone;
}
if (contactEmail.value === "") {
contactEmail.value = searchResult.value.email;
}
if (invoiceEmail.value === "") {
invoiceEmail.value = searchResult.value.email;
}
}
lastSearchResult.value = searchResult.value;
}
const requestState = ref(createPosAddCustomerRequestState());
const manualOverrides = ref(createPosAddCustomerManualOverrides());
const form = reactive(createPosAddCustomerFields());
/**
* Watch the validity of the fields and emit an event to the parent component
* If the fields are valid, we can enable the "Add Customer" button
*/
watch([cvr, companyPhone, invoiceEmail, contactEmail, contactPhone], (newValues) => {
// Remove the "Add Customer" button if it exists
const index = popups.get().actionButtons.findIndex((button) => button.label === t('admin.pos.add_customer'));
if (index !== -1) {
popups.get().actionButtons.splice(index, 1);
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const isValidSearchResult = computed(() =>
isValidPosAddCustomerSearchResult(searchResult.value, t("admin.pos.not_found"))
);
const canCreateCustomer = computed(() => {
const requiredFields = [form.cvr, form.companyPhone, form.contactPhone];
return isValidSearchResult.value && requiredFields.every((field) => String(field).trim() !== "");
});
const clearQueuedSearch = () => {
if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = null;
}
// Add the "Add Customer" button if all fields are filled
const requiredFields = [cvr, companyPhone, contactPhone];
if (searchResult.value !== null &&
searchResult.value.name !== t('admin.pos.not_found') &&
requiredFields.every((field) => field.value !== "")) {
popups.get().actionButtons.push({
label: t('admin.pos.add_customer'),
onClick: () => {
popups.get().actionButtons = []; // Remove all action buttons to prevent multiple clicks
try {
SessionUser.request("/auth/register/cvr", "POST", {
cvr: cvr.value,
companyPhone: parseInt(companyPhone.value),
invoiceEmail: invoiceEmail.value,
contactEmail: contactEmail.value,
contactPhone: parseInt(contactPhone.value),
searchResult: searchResult.value,
}).then((response) => {
console.warn(response, 'response register cvr');
searchAndSelectCustomer(companyPhone.value);
metadata.setCustomerId(parseInt(companyPhone.value));
emit('close');
}).catch((error) => {
console.error(error);
const errorResponse = SessionUser.functions.parseErrorMessage(error);
if (errorResponse !== null) {
popups.get().props.error = t('admin.pos.customer_creation_error', { error: errorResponse });
// Reset the buttons
popups.get().actionButtons = popups.get().actionButtons = [{...actionButtons.default.value.cancel}];
return;
}
popups.get().props.error = t('admin.pos.customer_creation_error_generic');
});
} catch (error) {
console.error(error);
popups.get().props.error = t('admin.pos.customer_creation_error_generic');
}
},
};
const syncPopupActionButtons = () => {
const popup = popups.get();
if (!popup) {
return;
}
const nextButtons = [{ ...actionButtons.default.value.cancel }];
if (canCreateCustomer.value) {
nextButtons.push({
label: t("admin.pos.add_customer"),
onClick: submitCustomer,
color: "primary",
testId: ADD_CUSTOMER_SUBMIT_TEST_ID,
});
}
})
popup.actionButtons = nextButtons;
};
const applySearchResult = (nextSearchResult: any | null) => {
searchResult.value = nextSearchResult;
if (!isValidPosAddCustomerSearchResult(nextSearchResult, t("admin.pos.not_found"))) {
return;
}
Object.assign(
form,
applyPosAddCustomerSearchResult({
fields: form,
manualOverrides: manualOverrides.value,
searchResult: nextSearchResult,
notFoundLabel: t("admin.pos.not_found"),
})
);
};
const runSearch = async (query: string) => {
const normalizedQuery = String(query ?? "").trim();
if (normalizedQuery === "") {
searchResult.value = null;
searching.value = false;
return;
}
const nextRequest = nextPosAddCustomerRequestState(requestState.value);
requestState.value = nextRequest.state;
try {
const response = await SessionUser.request("/cvr/search", "GET", {
query: normalizedQuery,
});
const resolution = resolvePosAddCustomerRequestState(requestState.value, nextRequest.requestId);
if (!resolution.shouldApply) {
return;
}
requestState.value = resolution.state;
applySearchResult(response?.data?.data ?? null);
} catch (error) {
console.error(error);
const resolution = resolvePosAddCustomerRequestState(requestState.value, nextRequest.requestId);
if (!resolution.shouldApply) {
return;
}
requestState.value = resolution.state;
searchResult.value = null;
} finally {
if (nextRequest.requestId === requestState.value.latestRequestedId) {
searching.value = false;
}
}
};
const queueSearch = (query: string) => {
clearQueuedSearch();
const normalizedQuery = String(query ?? "").trim();
popups.get().props.error = null;
if (normalizedQuery === "") {
searchResult.value = null;
searching.value = false;
return;
}
searching.value = true;
searchDebounceTimer = setTimeout(() => {
searchDebounceTimer = null;
runSearch(normalizedQuery);
}, SEARCH_DEBOUNCE_MS);
};
const handleAutofillFieldInput = (field: keyof typeof manualOverrides.value) => {
manualOverrides.value = setPosAddCustomerManualOverride(manualOverrides.value, field, form[field]);
popups.get().props.error = null;
};
const submitCustomer = async () => {
const popup = popups.get();
if (!popup) {
return;
}
popup.actionButtons = [];
try {
await SessionUser.request("/auth/register/cvr", "POST", {
cvr: form.cvr,
companyPhone: Number.parseInt(form.companyPhone, 10),
invoiceEmail: form.invoiceEmail,
contactEmail: form.contactEmail,
contactPhone: Number.parseInt(form.contactPhone, 10),
searchResult: searchResult.value,
});
await searchAndSelectCustomer(form.companyPhone);
metadata.setCustomerId(Number.parseInt(form.companyPhone, 10));
emit("close");
} catch (error) {
console.error(error);
const errorResponse = SessionUser.functions.parseErrorMessage(error);
if (errorResponse !== null) {
popups.get().props.error = t("admin.pos.customer_creation_error", { error: errorResponse });
} else {
popups.get().props.error = t("admin.pos.customer_creation_error_generic");
}
} finally {
syncPopupActionButtons();
}
};
watch(searchQuery, queueSearch);
watch(
() => [
form.cvr,
form.companyPhone,
form.invoiceEmail,
form.contactEmail,
form.contactPhone,
canCreateCustomer.value,
searchResult.value,
],
syncPopupActionButtons,
{ immediate: true }
);
onBeforeUnmount(() => {
clearQueuedSearch();
});
</script>
<template>
<div style="overflow-y: auto; overflow-x: hidden; height: 100%; padding-bottom: 10px;">
<div
style="overflow-y: auto; overflow-x: hidden; height: 100%; padding-bottom: 10px;"
data-testid="pos-mobile-add-customer-popup"
>
<div class="mx-3">
<div class="field">
<label class="label"><small>CVR</small></label>
<div class="control has-icons-right" :class="{'is-loading': searching}">
<div class="control has-icons-right" :class="{ 'is-loading': searching }">
<input
v-model="searchQuery"
@input="searching = true; search(searchQuery);"
class="input is-searched"
:class="{'is-danger': searchResult === null, 'is-success': searchResult !== null && searchResult.name !== $t('admin.pos.not_found')}"
type="text"
:placeholder="$t('global.search_cvr')"
v-model="searchQuery"
class="input is-searched"
:class="{
'is-danger': searchResult === null || !isValidSearchResult,
'is-success': isValidSearchResult,
}"
type="text"
:placeholder="$t('global.search_cvr')"
data-testid="pos-mobile-add-customer-search-input"
/>
<span class="icon is-right" :class="{'has-text-danger': (searchResult === null || searchResult.name === $t('admin.pos.not_found')) && !searching, 'has-text-success': (searchResult !== null && searchResult.name !== $t('admin.pos.not_found')) && !searching}">
<i class="fas fa-check" v-if="(searchResult !== null && searchResult.name !== $t('admin.pos.not_found')) && !searching"></i>
<i class="fas fa-exclamation-triangle" v-else-if="(searchResult === null || searchResult.name === $t('admin.pos.not_found')) && !searching"></i>
<span
class="icon is-right"
:class="{
'has-text-danger': !isValidSearchResult && !searching,
'has-text-success': isValidSearchResult && !searching,
}"
>
<i class="fas fa-check" v-if="isValidSearchResult && !searching"></i>
<i class="fas fa-exclamation-triangle" v-else-if="!isValidSearchResult && !searching"></i>
<i class="fas fa-search" v-else-if="!searching"></i>
</span>
</div>
<p class="help" v-if="searchResult !== null && searchResult.name !== $t('admin.pos.not_found')">{{ searchResult.name }}</p>
<p class="help" v-if="isValidSearchResult">{{ searchResult.name }}</p>
</div>
<!-- Company phone -->
<div class="field">
<label class="label"><small>{{ $t('admin.pos.company_phone') }}</small></label>
<label class="label"><small>{{ $t("admin.pos.company_phone") }}</small></label>
<div class="control">
<input v-model="companyPhone" class="input" type="text" :placeholder="$t('admin.pos.company_phone')" :disabled="searchResult === null" :class="{'is-danger': searchResult !== null && companyPhone === ''}"/>
<input
v-model="form.companyPhone"
class="input"
type="text"
:placeholder="$t('admin.pos.company_phone')"
:disabled="searchResult === null"
:class="{ 'is-danger': searchResult !== null && form.companyPhone === '' }"
data-testid="pos-mobile-add-customer-company-phone"
@input="handleAutofillFieldInput('companyPhone')"
/>
</div>
</div>
<!-- Invoice email -->
<div class="field">
<label class="label"><small>{{ $t('admin.pos.invoice_email') }}</small></label>
<label class="label"><small>{{ $t("admin.pos.invoice_email") }}</small></label>
<div class="control">
<input v-model="invoiceEmail" class="input" type="text" :placeholder="$t('admin.pos.invoice_email')" :disabled="searchResult === null"/>
<input
v-model="form.invoiceEmail"
class="input"
type="text"
:placeholder="$t('admin.pos.invoice_email')"
:disabled="searchResult === null"
data-testid="pos-mobile-add-customer-invoice-email"
@input="handleAutofillFieldInput('invoiceEmail')"
/>
</div>
</div>
<!-- Contact email -->
<div class="field">
<label class="label"><small>{{ $t('admin.pos.contact_email') }}</small></label>
<label class="label"><small>{{ $t("admin.pos.contact_email") }}</small></label>
<div class="control">
<input v-model="contactEmail" class="input" type="text" :placeholder="$t('admin.pos.contact_email')" :disabled="searchResult === null"/>
<input
v-model="form.contactEmail"
class="input"
type="text"
:placeholder="$t('admin.pos.contact_email')"
:disabled="searchResult === null"
data-testid="pos-mobile-add-customer-contact-email"
@input="handleAutofillFieldInput('contactEmail')"
/>
</div>
</div>
<!-- Contact phone -->
<div class="field">
<label class="label"><small>{{ $t('admin.pos.contact_phone') }}</small></label>
<label class="label"><small>{{ $t("admin.pos.contact_phone") }}</small></label>
<div class="control">
<input v-model="contactPhone" class="input" type="text" :placeholder="$t('admin.pos.contact_phone')" :disabled="searchResult === null" :class="{'is-danger': searchResult !== null && contactPhone === ''}"/>
<input
v-model="form.contactPhone"
class="input"
type="text"
:placeholder="$t('admin.pos.contact_phone')"
:disabled="searchResult === null"
:class="{ 'is-danger': searchResult !== null && form.contactPhone === '' }"
data-testid="pos-mobile-add-customer-contact-phone"
@input="handleAutofillFieldInput('contactPhone')"
/>
</div>
</div>
<!-- Error message -->
<div class="notification is-danger" v-if="popups.get().props.error !== null">
{{ popups.get().props.error}}
{{ popups.get().props.error }}
</div>
</div>
<!-- Search results -->
<!--{{ searchResult }}-->
</div>
</template>
<style scoped>
</style>
</style>
@@ -1,19 +1,77 @@
<script setup lang="ts">
import { computed, defineEmits, watch, ref } from "vue";
import { metadata } from "../objects/PosDepartmentStepMobileFlow.vue";
import FormDisplay from "@/components/displays/FormDisplay.vue";
import { computed, defineEmits, ref } from "vue";
import { metadata, popups } from "../objects/PosDepartmentStepMobileFlow.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
const emit = defineEmits<{
(e: 'close'): void;
}>();
const onSuccessfulCertificateSubmission = (response: any) => {
console.log("Certificate generated successfully:", response);
emit('close');
}
const popupProps = computed(() => popups.get()?.props || {});
const isSubmitting = ref(false);
const safetySealNumber = ref("");
const closePopup = () => {
emit('close');
};
const runPopupCompletionCallback = async (callback: any, safetySeal: string) => {
isSubmitting.value = true;
try {
await Promise.resolve(
callback({
bookingId: metadata.getBookingId?.() ?? null,
safetySeal,
})
);
closePopup();
} catch (error) {
console.warn("Failed to complete booking from popup callback:", error);
} finally {
isSubmitting.value = false;
}
};
const submitBookingCompletionFallback = async (safetySeal: string) => {
isSubmitting.value = true;
try {
await SessionUser.objects.order_bookings.functions.complete(
metadata.getBookingId(),
safetySeal === "" ? null : safetySeal
);
closePopup();
} catch (error) {
console.warn("Failed to complete booking from popup fallback action:", error);
} finally {
isSubmitting.value = false;
}
};
const completeWithoutCertificate = async () => {
if (isSubmitting.value) {
return;
}
const callback = popupProps.value?.onCompleteWithoutCertificate || popupProps.value?.onComplete;
if (typeof callback === "function") {
await runPopupCompletionCallback(callback, "");
return;
}
await submitBookingCompletionFallback("");
};
const completeWithCertificate = async () => {
if (isSubmitting.value) {
return;
}
const normalizedSafetySeal = String(safetySealNumber.value ?? "").trim();
const callback = popupProps.value?.onCompleteWithCertificate || popupProps.value?.onComplete;
if (typeof callback === "function") {
await runPopupCompletionCallback(callback, normalizedSafetySeal);
return;
}
await submitBookingCompletionFallback(normalizedSafetySeal);
};
</script>
<template>
@@ -26,6 +84,7 @@ const safetySealNumber = ref("");
<input
type="text"
class="input is-searched"
data-testid="pos-mobile-booking-safety-seal-input"
placeholder="Indtast safety seal nummer"
v-model="safetySealNumber"
/>
@@ -33,12 +92,22 @@ const safetySealNumber = ref("");
</div>
<!-- Generate certificate button -->
<div class="buttons is-centered mb-4">
<button class="button is-light" @click="SessionUser.objects.order_bookings.functions.complete(metadata.getBookingId(), '', onSuccessfulCertificateSubmission)">
<button
class="button is-light"
data-testid="pos-mobile-booking-complete-without-certificate"
:class="{ 'is-loading': isSubmitting }"
:disabled="isSubmitting"
@click="completeWithoutCertificate"
>
Intet vaskecertifikat
</button>
<button
class="button is-primary"
@click="SessionUser.objects.order_bookings.functions.complete(metadata.getBookingId(), safetySealNumber, onSuccessfulCertificateSubmission)">
data-testid="pos-mobile-booking-complete-with-certificate"
:class="{ 'is-loading': isSubmitting }"
:disabled="isSubmitting"
@click="completeWithCertificate"
>
Generer vaskecertifikat
</button>
</div>
@@ -145,4 +214,4 @@ input.is-searched {
flex-grow: 0;
}
</style>
</style>
@@ -3,20 +3,42 @@ import { popups } from '../objects/PosDepartmentStepMobileFlow.vue';
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
import { popupComponentKeyToComponent } from "@/components/displays/department/pos/steps/mobile/objects/PosPopup.vue";
const handleHeaderClose = () => {
const activePopup = popups.get();
if (activePopup?.onHeaderClose) {
activePopup.onHeaderClose();
return;
}
popups.clear();
};
</script>
<template>
<div class="popup-container" v-if="popups.isSet.value">
<div class="popup-content">
<div class="popup-container" data-testid="pos-mobile-popup" :data-popup-id="popups.get()?.id || undefined" v-if="popups.isSet.value">
<div class="popup-content" :style="{ ...(popups.get()?.style || {}) }">
<!-- Icon? and title? -->
<WhiteBoxCard :defaultOpen="true" :hideHeader="popups.get()?.hideHeader || false">
<template #header>
<div class="card-header-icon">
<UnknownCustomer/>
</div>
<div class="card-header-title">
<div class="card-header-title popup-header-title">
{{ popups.get()?.title || 'Default Title' }}
</div>
<button
v-if="popups.get()?.showHeaderClose"
class="card-header-icon popup-header-close"
type="button"
:data-testid="popups.get()?.headerCloseTestId"
aria-label="Close popup"
@click.stop="handleHeaderClose"
>
<span class="icon is-small">
<i class="fas fa-times" aria-hidden="true"></i>
</span>
</button>
</template>
<template #content>
<component :is="popupComponentKeyToComponent(popups.get()?.component)" @close="popups.clear()" :style="{... popups.get()?.style || {} }"/>
@@ -28,6 +50,7 @@ import { popupComponentKeyToComponent } from "@/components/displays/department/p
>
<a class="card-footer-item"
:class="'is-' + (actionButton?.color || 'primary')"
:data-testid="actionButton.testId"
@click="actionButton.onClick ? actionButton.onClick() : null">
{{ actionButton.label }}
</a>
@@ -47,18 +70,78 @@ import { popupComponentKeyToComponent } from "@/components/displays/department/p
height: 100%;
display: flex;
justify-content: center;
align-items: center;
align-items: flex-start;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
padding: max(4.5rem, env(safe-area-inset-top, 0px) + 1rem) 1rem max(1rem, env(safe-area-inset-bottom, 0px) + 0.75rem);
overflow-y: auto;
overscroll-behavior: contain;
}
.popup-content {
background-color: white;
border: 0;
border-radius: 8px;
border-radius: 12px;
padding: 0;
overflow: hidden;
width: 80%;
width: min(92vw, 26rem);
max-width: 26rem;
max-height: min(80dvh, calc(100vh - 6rem));
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(7, 18, 46, 0.32);
}
.popup-content h2 {
}
</style>
.popup-content :deep(.white-box) {
height: 100%;
margin-bottom: 0;
}
.popup-content :deep(.card) {
display: flex;
flex-direction: column;
height: 100%;
}
.popup-content :deep(.card-header) {
flex-shrink: 0;
}
.popup-header-title {
flex: 1 1 auto;
min-width: 0;
}
.popup-header-close {
border: 0;
background: transparent;
color: #213047;
min-width: 44px;
min-height: 44px;
padding: 0.5rem;
margin-left: auto;
cursor: pointer;
}
.popup-header-close:hover {
color: #121b29;
}
.popup-content :deep(.card-content) {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.popup-content :deep(.card-footer) {
flex-shrink: 0;
background: #fff;
border-top: 1px solid #e5ebf3;
}
.popup-content :deep(.card-footer-item) {
font-weight: 700;
}
</style>
@@ -49,14 +49,21 @@ const onClickSuggestion = (customerId: number) => {
metadata.setCustomerId(customerId);
emit('close');
};
const onClickDirectCardPayment = () => {
const cardCustomerId = 999;
searchAndSelectCustomer(cardCustomerId);
metadata.setCustomerId(cardCustomerId);
emit('close');
};
</script>
<template>
<div style="overflow-y: auto; overflow-x: hidden; height: 100%; padding-bottom: 10px;">
<div style="overflow-y: auto; overflow-x: hidden; height: 100%; padding-bottom: 10px;" data-testid="pos-mobile-customer-popup">
<!-- Search input field, with it's icon -->
<div class="field underlined-input-field">
<div class="control has-icons-right">
<CustomerSearchField/>
<CustomerSearchField testId="pos-mobile-customer-search-input"/>
<span class="icon is-right">
<i class="fas fa-search"></i>
</span>
@@ -64,8 +71,18 @@ const onClickSuggestion = (customerId: number) => {
</div>
<template v-if="formattedSearchResults.length > 0">
<!-- Search results (If any) -->
<ControlFieldInputSearchResults v-bind:results="formattedSearchResults" @result-clicked="onClick"/>
<ControlFieldInputSearchResults v-bind:results="formattedSearchResults" testIdPrefix="pos-mobile-customer-search-result" @result-clicked="onClick"/>
</template>
<div class="mt-2">
<button
class="button is-text is-fullwidth"
type="button"
data-testid="pos-mobile-direct-card-payment"
@click="onClickDirectCardPayment"
>
Vælg direkte betaling med betalingskort
</button>
</div>
<!-- Customer suggestions (If any) -->
<VehicleCustomerSuggestionsPos v-if="vehicles?.vehicle_1?.value?.reg" :reg_1="vehicles.vehicle_1.value.reg" @customerSelected="onClickSuggestion"/>
</div>
@@ -164,4 +181,4 @@ input.is-searched {
flex-grow: 0;
}
</style>
</style>
@@ -0,0 +1,782 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { popups } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import {
getOrderBookingItemPreviewRows,
getOrderBookingItemPreviewTotal,
getOrderBookingNotesValue,
getOrderBookingPlateText,
getOrderBookingPoValue,
getOrderBookingReferenceValue,
getOrderBookingServiceLabels,
hasOrderBookingDisplayDetails,
} from "@/components/displays/department/pos/utils/orderBookingDisplay.js";
type BookingRefreshResult = any[] | { bookings?: any[] } | void;
type OrderBookingPopupProps = {
bookings?: any[];
onSelect?: (booking: any) => Promise<void> | void;
onSkip?: () => Promise<void> | void;
onRefreshBookings?: () => Promise<BookingRefreshResult> | BookingRefreshResult;
departmentId?: number | string | null;
matchedPlate?: string;
};
const { t, locale } = useI18n();
const popupProps = computed<OrderBookingPopupProps>(() => {
return (popups.get()?.props || {}) as OrderBookingPopupProps;
});
const bookingsFromProps = computed(() => (Array.isArray(popupProps.value.bookings) ? popupProps.value.bookings : []));
const bookings = ref<any[]>([]);
const detailedBookingsById = ref<Record<number, any>>({});
const loadingBookingIds = ref<number[]>([]);
const failedBookingIds = ref<number[]>([]);
const isRefreshingBookings = ref(false);
const toPositiveInteger = (value: any) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const normalizePlateValue = (value: any) =>
String(value ?? "")
.trim()
.toUpperCase();
const formatBookingDateTime = (booking: any) => {
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
if (!rawValue) {
return t("admin.pos.not_found");
}
const parsedValue = new Date(rawValue);
if (Number.isNaN(parsedValue.getTime())) {
return rawValue;
}
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: "short",
timeStyle: "short",
}).format(parsedValue);
};
const getBookingDateValue = (booking: any) => {
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
if (!rawValue) {
return null;
}
const parsedValue = new Date(rawValue);
return Number.isNaN(parsedValue.getTime()) ? null : parsedValue;
};
const isSameCalendarDay = (left: Date | null, right: Date | null) => {
if (!(left instanceof Date) || !(right instanceof Date)) {
return false;
}
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
};
const getDetailedBooking = (booking: any) => {
const bookingId = toPositiveInteger(booking?.id);
return bookingId ? detailedBookingsById.value[bookingId] || null : null;
};
const getDisplayBooking = (booking: any) => {
const detailedBooking = getDetailedBooking(booking);
return detailedBooking ? { ...booking, ...detailedBooking } : booking;
};
const isBookingLoading = (booking: any) => {
const bookingId = toPositiveInteger(booking?.id);
return bookingId ? loadingBookingIds.value.includes(bookingId) : false;
};
const didBookingDetailFail = (booking: any) => {
const bookingId = toPositiveInteger(booking?.id);
return bookingId ? failedBookingIds.value.includes(bookingId) : false;
};
const getBookingServiceLabels = (booking: any) => getOrderBookingServiceLabels(getDisplayBooking(booking));
const getBookingItemPreviewRows = (booking: any) => getOrderBookingItemPreviewRows(getDisplayBooking(booking));
const getBookingItemPreviewTotal = (booking: any) => getOrderBookingItemPreviewTotal(getDisplayBooking(booking));
const getBookingNote = (booking: any) => getOrderBookingNotesValue(getDisplayBooking(booking));
const getBookingReference = (booking: any) => getOrderBookingReferenceValue(getDisplayBooking(booking));
const getBookingPo = (booking: any) => getOrderBookingPoValue(getDisplayBooking(booking));
const getBookingPlates = (booking: any) => getOrderBookingPlateText(getDisplayBooking(booking));
const formatCurrencyAmount = (value: any) => {
const parsedValue = Number(value);
if (!Number.isFinite(parsedValue)) {
return "-";
}
return SessionUser.functions.currency.toLocal(parsedValue);
};
const totalAmountLabel = computed(() => SessionUser.objects.orders.columns.total_net_amount?.label || "Total");
const resolvedBookingCount = computed(() => {
return bookings.value.filter((booking) => {
const displayBooking = getDisplayBooking(booking);
return hasOrderBookingDisplayDetails(displayBooking) || !isBookingLoading(booking) || didBookingDetailFail(booking);
}).length;
});
const isLoadingAnyBookingDetails = computed(() => loadingBookingIds.value.length > 0);
const isAnyBookingFetchInProgress = computed(() => isLoadingAnyBookingDetails.value || isRefreshingBookings.value);
const sortedBookings = computed(() => {
const now = new Date();
return bookings.value
.map((booking, index) => {
const displayBooking = getDisplayBooking(booking);
const bookingDate = getBookingDateValue(displayBooking);
const isToday = isSameCalendarDay(bookingDate, now);
return {
booking,
index,
bookingDate,
isToday,
};
})
.sort((left, right) => {
if (left.isToday !== right.isToday) {
return left.isToday ? -1 : 1;
}
if (left.bookingDate && right.bookingDate) {
const timeDifference = left.bookingDate.getTime() - right.bookingDate.getTime();
if (timeDifference !== 0) {
return timeDifference;
}
} else if (left.bookingDate || right.bookingDate) {
return left.bookingDate ? -1 : 1;
}
return left.index - right.index;
})
.map((entry) => entry.booking);
});
const fetchBookingDetails = async (bookingsToInspect: any[]) => {
const pendingBookings = (Array.isArray(bookingsToInspect) ? bookingsToInspect : []).filter(Boolean);
const bookingsToLoad = pendingBookings.filter((booking) => {
const bookingId = toPositiveInteger(booking?.id);
if (!bookingId) {
return false;
}
if (detailedBookingsById.value[bookingId]) {
return false;
}
if (loadingBookingIds.value.includes(bookingId)) {
return false;
}
return true;
});
if (bookingsToLoad.length === 0) {
return;
}
const bookingIdsToLoad = bookingsToLoad
.map((booking) => toPositiveInteger(booking?.id))
.filter((bookingId): bookingId is number => bookingId !== null);
loadingBookingIds.value = [...new Set([...loadingBookingIds.value, ...bookingIdsToLoad])];
const bookingResults = await Promise.allSettled(
bookingsToLoad.map((booking) =>
SessionUser.objects.order_bookings.get.single(toPositiveInteger(booking?.id), {
department_id: popupProps.value.departmentId ?? null,
})
)
);
const nextDetailedBookings = {
...detailedBookingsById.value,
};
const nextFailedBookingIds = new Set(failedBookingIds.value);
bookingsToLoad.forEach((booking, index) => {
const bookingId = bookingIdsToLoad[index];
const result = bookingResults[index];
if (result.status === "fulfilled" && result.value?.id) {
nextDetailedBookings[result.value.id] = result.value;
nextFailedBookingIds.delete(result.value.id);
return;
}
if (bookingId) {
nextFailedBookingIds.add(bookingId);
}
});
detailedBookingsById.value = nextDetailedBookings;
failedBookingIds.value = Array.from(nextFailedBookingIds);
loadingBookingIds.value = loadingBookingIds.value.filter((bookingId) => !bookingIdsToLoad.includes(bookingId));
};
watch(
bookingsFromProps,
(nextBookings) => {
bookings.value = Array.isArray(nextBookings) ? [...nextBookings] : [];
},
{ immediate: true }
);
watch(
() => bookings.value.map((booking) => booking?.id).join("|"),
() => {
void fetchBookingDetails(bookings.value);
},
{ immediate: true }
);
const onSelect = async (booking: any) => {
if (typeof popupProps.value.onSelect === "function") {
await popupProps.value.onSelect(getDisplayBooking(booking));
}
};
const emitRefreshBookings = async () => {
if (typeof popupProps.value.onRefreshBookings !== "function") {
return;
}
isRefreshingBookings.value = true;
try {
const refreshResult = await popupProps.value.onRefreshBookings();
if (Array.isArray(refreshResult)) {
bookings.value = refreshResult;
return;
}
if (Array.isArray(refreshResult?.bookings)) {
bookings.value = refreshResult.bookings;
}
} finally {
isRefreshingBookings.value = false;
}
};
const getActionBooking = (booking: any) => getDisplayBooking(booking);
const getActionCustomerNumber = (booking: any) => {
const displayBooking = getActionBooking(booking);
return toPositiveInteger(displayBooking?.customer_number ?? displayBooking?.customer_id);
};
const getActionDepartmentId = (booking: any) => {
const displayBooking = getActionBooking(booking);
return toPositiveInteger(displayBooking?.department ?? popupProps.value.departmentId);
};
const isBookingScheduledForToday = (booking: any) => {
return isSameCalendarDay(getBookingDateValue(getDisplayBooking(booking)), new Date());
};
</script>
<template>
<div class="booking-popup" data-testid="pos-mobile-order-booking-popup">
<p class="booking-popup__intro">
{{ popups.get()?.message || t("admin.pos.order_booking_selector.help_text") }}
</p>
<div
v-if="isAnyBookingFetchInProgress"
class="booking-popup__loading"
data-testid="pos-mobile-order-booking-loading"
>
<span class="icon is-small">
<i class="fas fa-spinner fa-spin" aria-hidden="true" />
</span>
<span>
{{ SessionUser.objects.global.language.loading }}
{{ resolvedBookingCount }} / {{ bookings.length }}
</span>
</div>
<div class="booking-popup__list" data-testid="pos-mobile-order-booking-list">
<article
v-for="booking in sortedBookings"
:key="booking.id"
class="booking-option"
:class="{ 'booking-option--today': isBookingScheduledForToday(booking) }"
:data-testid="`pos-mobile-order-booking-option-${booking.id}`"
>
<div class="booking-option__header">
<div class="booking-option__header-main">
<p class="booking-option__eyebrow">Booking #{{ booking.id }}</p>
<h3 class="booking-option__title">{{ formatBookingDateTime(getDisplayBooking(booking)) }}</h3>
</div>
<div class="booking-option__header-actions">
<span
v-if="isBookingScheduledForToday(booking)"
class="tag booking-option__today-tag"
:data-testid="`pos-mobile-order-booking-today-${booking.id}`"
>
{{ t("global.time.today") }}
</span>
<span v-if="didBookingDetailFail(booking)" class="tag is-light booking-option__tag">
{{ t("admin.pos.not_found") }}
</span>
<div class="booking-option__settings" :data-testid="`pos-mobile-order-booking-settings-${booking.id}`">
<ActionSettingsWheelButton
:order_booking_id="toPositiveInteger(getActionBooking(booking)?.id)"
:order_id="toPositiveInteger(getActionBooking(booking)?.order_id)"
:customer_number="getActionCustomerNumber(booking)"
:department_id="getActionDepartmentId(booking)"
:reg_1="getActionBooking(booking)?.reg_1 ?? null"
:reg_2="getActionBooking(booking)?.reg_2 ?? null"
:refreshFunction="emitRefreshBookings"
:icon="'fas fa-ellipsis-v'"
trigger-button-variant="text"
>
<template #actions />
</ActionSettingsWheelButton>
</div>
</div>
</div>
<p class="booking-option__customer">
{{ getDisplayBooking(booking)?.customer_name || t("admin.pos.not_found") }}
</p>
<dl class="booking-option__meta-grid">
<div class="booking-option__meta-item">
<dt>{{ t("admin.pos.order_booking_selector.plates_label") }}</dt>
<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>
<dd>{{ getBookingReference(booking) || "-" }}</dd>
</div>
<div v-if="getBookingPo(booking)" class="booking-option__meta-item">
<dt>{{ SessionUser.objects.orders.columns.po.label }}</dt>
<dd>{{ getBookingPo(booking) }}</dd>
</div>
<div v-if="getBookingNote(booking)" class="booking-option__meta-item">
<dt>{{ SessionUser.objects.orders.columns.notes.label }}</dt>
<dd>{{ getBookingNote(booking) }}</dd>
</div>
</dl>
<div
v-if="getBookingItemPreviewRows(booking).length > 0 || getBookingServiceLabels(booking).length > 0 || isBookingLoading(booking)"
class="booking-option__services"
: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>
<div
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
class="booking-option__section-total"
:data-testid="`pos-mobile-order-booking-service-total-${booking.id}`"
>
<span>{{ totalAmountLabel }}</span>
<strong>{{ formatCurrencyAmount(getBookingItemPreviewTotal(booking)) }}</strong>
</div>
</div>
<div v-if="isBookingLoading(booking)" class="booking-option__item-preview-skeleton">
<div class="booking-option__item-preview-row is-skeleton" />
<div class="booking-option__item-preview-row is-skeleton" />
</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("admin.pos.price_dkk") }}</span>
</div>
<div
v-for="(item, index) in getBookingItemPreviewRows(booking)"
:key="item.key"
class="booking-option__item-preview-row"
:data-testid="`pos-mobile-order-booking-service-row-${booking.id}-${index}`"
>
<span class="booking-option__item-preview-qty">{{ item.quantity }}x</span>
<div class="booking-option__item-preview-main">
<span
class="booking-option__item-preview-name"
:class="{ 'booking-option__item-preview-name--addon': item.depth > 0 }"
>
{{ item.depth > 0 ? "+ " : "" }}{{ item.label }}
</span>
<span v-if="item.unitPrice !== null && item.quantity > 1" class="booking-option__item-preview-meta">
{{ formatCurrencyAmount(item.unitPrice) }} / stk
</span>
</div>
<span class="booking-option__item-preview-amount">
{{ item.totalPrice !== null ? formatCurrencyAmount(item.totalPrice) : "-" }}
</span>
</div>
</div>
<div v-else class="booking-option__service-list">
<span
v-for="service in getBookingServiceLabels(booking)"
:key="`${booking.id}-${service}`"
class="booking-option__service-pill"
>
{{ service }}
</span>
</div>
</div>
<div class="booking-option__actions">
<button
class="button is-primary is-fullwidth"
type="button"
:class="{ 'is-loading': isBookingLoading(booking) }"
:disabled="isBookingLoading(booking)"
:data-testid="`pos-mobile-order-booking-use-${booking.id}`"
@click="onSelect(booking)"
>
{{ t("admin.pos.order_booking_selector.use_booking") }}
</button>
</div>
</article>
</div>
</div>
</template>
<style scoped>
.booking-popup {
display: flex;
flex-direction: column;
min-height: 0;
}
.booking-popup__intro {
margin-bottom: 0.75rem;
color: #4c5a6e;
font-size: 0.98rem;
line-height: 1.45;
}
.booking-popup__loading {
display: inline-flex;
align-items: center;
gap: 0.55rem;
margin-bottom: 0.8rem;
padding: 0.45rem 0.75rem;
border-radius: 999px;
background: rgba(15, 35, 77, 0.08);
color: #234;
font-size: 0.88rem;
font-weight: 600;
}
.booking-popup__list {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.booking-option {
display: flex;
flex-direction: column;
gap: 0.7rem;
border: 1px solid #d7e2f0;
border-radius: 14px;
padding: 0.85rem;
background: #fff;
box-shadow: 0 8px 22px rgba(21, 42, 76, 0.08);
overflow: visible;
}
.booking-option--today {
border-color: #3d7ef0;
background: linear-gradient(180deg, rgba(61, 126, 240, 0.08), rgba(61, 126, 240, 0.02)), #fff;
}
.booking-option__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.65rem;
}
.booking-option__header-main {
min-width: 0;
}
.booking-option__header-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.4rem;
flex-shrink: 0;
}
.booking-option__eyebrow {
margin: 0;
color: #5c6d84;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.booking-option__title {
margin: 0.15rem 0 0;
color: #0f234d;
font-size: 1rem;
font-weight: 800;
line-height: 1.25;
}
.booking-option__today-tag {
border-radius: 999px;
border: 1px solid rgba(61, 126, 240, 0.2);
background: rgba(61, 126, 240, 0.12);
color: #18408b;
font-weight: 700;
}
.booking-option__tag {
color: #49576b;
}
.booking-option__settings :deep(.dropdown-menu) {
z-index: 30;
}
.booking-option__settings :deep(.action-settings-wheel-trigger--text) {
border-radius: 999px;
padding: 0.2rem;
}
.booking-option__customer {
margin: 0;
color: #1b314f;
font-size: 0.95rem;
font-weight: 700;
}
.booking-option__meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
margin: 0;
}
.booking-option__meta-item dt,
.booking-option__section-title {
margin: 0 0 0.25rem;
color: #6b7c92;
font-size: 0.71rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.booking-option__meta-item dd {
margin: 0;
color: #14243b;
font-size: 0.9rem;
line-height: 1.4;
word-break: break-word;
}
.booking-option__section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
margin-bottom: 0.4rem;
}
.booking-option__section-total {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
color: #173559;
font-size: 0.71rem;
font-weight: 700;
}
.booking-option__section-total strong {
color: #0f234d;
font-size: 0.85rem;
}
.booking-option__item-preview {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.booking-option__item-preview-head,
.booking-option__item-preview-row {
display: grid;
grid-template-columns: 2.5rem minmax(0, 1fr) auto;
gap: 0.45rem;
align-items: center;
}
.booking-option__item-preview-head {
padding: 0 0.1rem;
color: #6b7c92;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.booking-option__item-preview-head span:last-child {
text-align: right;
}
.booking-option__item-preview-row {
padding: 0.45rem 0.55rem;
border: 1px solid #e5edf7;
border-radius: 10px;
background: linear-gradient(180deg, #fdfefe 0%, #f7fbff 100%);
}
.booking-option__item-preview-qty {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 1.55rem;
padding: 0.1rem 0.38rem;
border-radius: 999px;
background: #e9f1fb;
color: #18408b;
font-size: 0.74rem;
font-weight: 800;
}
.booking-option__item-preview-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.booking-option__item-preview-name {
color: #14243b;
font-size: 0.84rem;
font-weight: 700;
line-height: 1.2;
word-break: break-word;
}
.booking-option__item-preview-name--addon {
color: #24456e;
font-weight: 600;
}
.booking-option__item-preview-meta {
color: #6a7890;
font-size: 0.72rem;
font-weight: 600;
}
.booking-option__item-preview-amount {
color: #0f234d;
font-size: 0.84rem;
font-weight: 800;
text-align: right;
white-space: nowrap;
}
.booking-option__item-preview-skeleton {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.booking-option__item-preview-row.is-skeleton {
min-height: 2.7rem;
border-color: transparent;
color: transparent;
background: linear-gradient(90deg, #ebf0f7 0%, #f7f9fc 50%, #ebf0f7 100%);
background-size: 200% 100%;
animation: mobile-booking-selector-pulse 1.2s linear infinite;
}
.booking-option__service-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.booking-option__service-pill {
display: inline-flex;
align-items: center;
min-height: 1.85rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
background: #eef4fb;
color: #173559;
font-size: 0.82rem;
font-weight: 600;
line-height: 1.2;
}
.booking-option__actions {
margin-top: 0.1rem;
}
.booking-option__actions .button {
border-radius: 10px;
font-weight: 700;
}
@keyframes mobile-booking-selector-pulse {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media screen and (max-width: 420px) {
.booking-option__meta-grid {
grid-template-columns: minmax(0, 1fr);
}
.booking-option__section-header {
align-items: flex-start;
flex-direction: column;
}
.booking-option__item-preview-head,
.booking-option__item-preview-row {
grid-template-columns: 2.3rem minmax(0, 1fr);
}
.booking-option__item-preview-head span:last-child,
.booking-option__item-preview-amount {
grid-column: 2;
text-align: left;
}
}
</style>
@@ -0,0 +1,135 @@
export const POS_ADD_CUSTOMER_AUTOFILL_FIELDS = [
"companyPhone",
"invoiceEmail",
"contactEmail",
"contactPhone",
];
export function normalizePosAddCustomerValue(value) {
if (value === null || value === undefined) {
return "";
}
return String(value);
}
export function createPosAddCustomerFields(overrides = {}) {
return {
cvr: "",
companyPhone: "",
invoiceEmail: "",
contactEmail: "",
contactPhone: "",
...overrides,
};
}
export function createPosAddCustomerManualOverrides(overrides = {}) {
return {
companyPhone: false,
invoiceEmail: false,
contactEmail: false,
contactPhone: false,
...overrides,
};
}
export function createPosAddCustomerRequestState(overrides = {}) {
return {
latestRequestedId: 0,
latestAppliedId: 0,
...overrides,
};
}
export function nextPosAddCustomerRequestState(currentState = createPosAddCustomerRequestState()) {
const requestId = Number(currentState.latestRequestedId || 0) + 1;
return {
requestId,
state: {
...currentState,
latestRequestedId: requestId,
},
};
}
export function resolvePosAddCustomerRequestState(currentState, requestId) {
const normalizedRequestId = Number(requestId || 0);
if (!currentState || normalizedRequestId !== Number(currentState.latestRequestedId || 0)) {
return {
shouldApply: false,
state: currentState,
};
}
return {
shouldApply: true,
state: {
...currentState,
latestAppliedId: normalizedRequestId,
},
};
}
export function isValidPosAddCustomerSearchResult(searchResult, notFoundLabel = "") {
if (!searchResult || typeof searchResult !== "object") {
return false;
}
const normalizedName = normalizePosAddCustomerValue(searchResult.name).trim();
if (normalizedName === "") {
return false;
}
return normalizedName !== normalizePosAddCustomerValue(notFoundLabel).trim();
}
export function buildPosAddCustomerAutofillValues(searchResult) {
return {
cvr: normalizePosAddCustomerValue(searchResult?.vat),
companyPhone: normalizePosAddCustomerValue(searchResult?.phone),
invoiceEmail: normalizePosAddCustomerValue(searchResult?.email),
contactEmail: normalizePosAddCustomerValue(searchResult?.email),
contactPhone: normalizePosAddCustomerValue(searchResult?.phone),
};
}
export function applyPosAddCustomerSearchResult({
fields = createPosAddCustomerFields(),
manualOverrides = createPosAddCustomerManualOverrides(),
searchResult = null,
notFoundLabel = "",
} = {}) {
const nextFields = {
...createPosAddCustomerFields(),
...fields,
};
if (!isValidPosAddCustomerSearchResult(searchResult, notFoundLabel)) {
return nextFields;
}
const autofillValues = buildPosAddCustomerAutofillValues(searchResult);
nextFields.cvr = autofillValues.cvr;
for (const field of POS_ADD_CUSTOMER_AUTOFILL_FIELDS) {
if (!manualOverrides[field] || normalizePosAddCustomerValue(nextFields[field]) === "") {
nextFields[field] = autofillValues[field];
}
}
return nextFields;
}
export function setPosAddCustomerManualOverride(manualOverrides, field, nextValue) {
if (!manualOverrides || !Object.prototype.hasOwnProperty.call(manualOverrides, field)) {
return manualOverrides;
}
return {
...manualOverrides,
[field]: normalizePosAddCustomerValue(nextValue).trim() !== "",
};
}
@@ -6,6 +6,7 @@ export type PosActionButton = {
description?: string; // Optional description
onClick?: () => void;
icon?: string; // Optional icon name
testId?: string; // Optional test id
disabled?: boolean; // Is the button disabled?
disabled_description?: string; // Optional description when disabled
color?: string | 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark'; // Button color
@@ -27,4 +28,4 @@ export default defineComponent({
},
},
});
</script>
</script>
@@ -9,6 +9,10 @@ import type {Addon} from "@/components/displays/department/pos/steps/mobile/obje
import type { PosLocation } from './PosLocation.vue';
import type { PosSoundEffect } from './PosSoundEffect.vue';
import type { PosActionButton } from './PosActionButton.vue';
import {
doesTransactionContainWashCertificateProduct,
WASH_CERTIFICATE_PRODUCT_ID,
} from "@/components/displays/department/pos/utils/washCertificate.js";
import i18n from '@/i18n';
/** Views */
@@ -154,7 +158,6 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
label: t('admin.pos.action_buttons.select_customer'),
description: t('admin.pos.action_buttons.select_customer_desc'),
onClick: () => {
console.warn('Select Customer button clicked');
// Open the select customer popup
popups.select('select_customer');
},
@@ -164,7 +167,6 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
label: t('admin.pos.action_buttons.add_customer'),
description: t('admin.pos.action_buttons.add_customer_desc'),
onClick: () => {
console.warn('Add Customer button clicked');
// Open the add customer popup
popups.select('add_customer', { props: { error: null, canCreate: true } });
},
@@ -174,10 +176,8 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
label: t('admin.pos.action_buttons.edit_reference'),
description: t('admin.pos.action_buttons.edit_reference_desc'),
onClick: () => {
console.warn('Edit Reference button clicked');
// Open the edit reference popup
//popups.select('edit_reference');
console.warn('Edit Reference button not yet implemented.');
},
color: 'primary'
},
@@ -185,7 +185,6 @@ const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() =>
label: t('admin.pos.action_buttons.add_note'),
description: t('admin.pos.action_buttons.add_note_desc'),
onClick: () => {
console.warn('Add Note button clicked');
// Open the add note popup
popups.select('customer_notes', { props: { showInput: true, showNotes: false } });
},
@@ -205,20 +204,16 @@ const isPopupSet = computed(() => {
// Function to get the current popup
const getPopup = () => {
if (popup.value) {
console.log('Current popup:', popup.value);
return popup.value;
}
console.warn('No popup is currently set.');
return null;
}
// Function to set the popup
const setPopup = (newPopup: PosPopup | null) => {
console.log('Setting popup:', newPopup);
popup.value = newPopup;
}
// Function to clear the popup
const clearPopup = () => {
console.log('Clearing popup');
popup.value = null;
}
// Default list of popups
@@ -231,7 +226,6 @@ const addPopup = (popup: PosPopup) => {
return;
}
popupsList.value[popup.id] = {value: popup};
console.log(`Added popup with id: ${popup.id}`);
}
// Function to add default popups to the list
const addDefaultPopups = () => {
@@ -241,9 +235,21 @@ const addDefaultPopups = () => {
title: 'Vælg Kunde',
message: 'Bekræft venligst valget af kunde til denne transaktion',
component: 'select_customer',
showHeaderClose: true,
headerCloseTestId: 'pos-mobile-select-customer-header-close',
style: {height: '50vh'},
actionButtons: [{...defaultActionButtons.value.cancel}, {...defaultActionButtons.value.addCustomer, label: 'Ny kunde'}],
});
addPopup({
id: 'select_order_booking',
title: i18n.global.t('admin.pos.order_booking_selector.title'),
message: i18n.global.t('admin.pos.order_booking_selector.help_text'),
component: 'select_order_booking',
showHeaderClose: true,
headerCloseTestId: 'pos-mobile-order-booking-header-close',
style: { maxHeight: '72dvh' },
actionButtons: [],
});
// Completed
addPopup({
id: 'completed_transaction',
@@ -285,6 +291,8 @@ const addDefaultPopups = () => {
title: 'Vælg køretøj',
message: 'Vælg venligst et køretøj til denne transaktion',
component: 'select_vehicle', // This component should handle vehicle selection
showHeaderClose: true,
headerCloseTestId: 'pos-mobile-select-vehicle-header-close',
style: {height: '50vh'},
actionButtons: [
{...defaultActionButtons.value.confirm},
@@ -297,6 +305,8 @@ const addDefaultPopups = () => {
title: 'Indtast reference',
message: 'Indtast venligst en reference for denne transaktion',
component: 'change_reference',
showHeaderClose: true,
headerCloseTestId: 'pos-mobile-change-reference-header-close',
style: {maxHeight: '40vh'},
actionButtons: [
{...defaultActionButtons.value.confirm},
@@ -309,6 +319,8 @@ const addDefaultPopups = () => {
title: 'Kundebemærkninger',
message: 'Læs venligst kundens bemærkninger',
component: 'customer_notes',
showHeaderClose: true,
headerCloseTestId: 'pos-mobile-customer-notes-header-close',
style: {maxHeight: '50vh'},
actionButtons: [
{...defaultActionButtons.value.addNote},
@@ -322,6 +334,8 @@ const addDefaultPopups = () => {
title: 'Billede',
message: '',
component: 'image_viewer',
showHeaderClose: true,
headerCloseTestId: 'pos-mobile-image-viewer-header-close',
style: {maxHeight: '50vh'},
actionButtons: [
{...defaultActionButtons.value.close}
@@ -334,6 +348,8 @@ const addDefaultPopups = () => {
title: 'Tilføj kunde',
message: 'Indtast venligst oplysninger for den nye kunde',
component: 'add_customer',
showHeaderClose: true,
headerCloseTestId: 'pos-mobile-add-customer-header-close',
style: {maxHeight: '60vh'},
actionButtons: [
{...defaultActionButtons.value.cancel}
@@ -358,7 +374,6 @@ const getPopupByKey = (key: string): PosPopup | null => {
const removePopupByKey = (key: string): boolean => {
if (popupsList.value[key]) {
delete popupsList.value[key];
console.log(`Removed popup with key: ${key}`);
return true;
}
console.warn(`Popup with key "${key}" not found.`);
@@ -367,13 +382,11 @@ const removePopupByKey = (key: string): boolean => {
// Function to clear the popups list
const clearPopupsList = () => {
popupsList.value = {};
console.log('Cleared popups list');
}
// Function to select a popup by its ID
const selectPopupById = (id: string, modifications?: Partial<PosPopup>): PosPopup | null => {
const popupItem = popupsList.value[id];
if (popupItem) {
console.log(`Selected popup with id: ${id}`);
setPopup({
...popupItem.value,
...modifications, // Apply any modifications if provided
@@ -422,7 +435,6 @@ const setActiveVehicleIndex = (index: number): void => {
// Function to select a vehicle
const selectVehicle = (index: number, vehicle: PosVehicle | null) => {
console.log(`Selecting vehicle at index ${index}:`, vehicle);
switch (index) {
case 1:
vehicle_1.value = vehicle;
@@ -541,19 +553,24 @@ const primaryItem = ref<PosProduct | null>(null);
// This could include things like additional products, services, or fees.
// It is a separate list to allow for flexibility in the transaction.
const additionalItems = ref<PosProduct[]>([]);
const WASH_CERTIFICATE_PRODUCT_ID = 41;
const normalizeAdditionalItem = (item: PosProduct): PosProduct => ({
...item,
quantity: Number(item?.quantity ?? 0) > 0 ? Number(item.quantity) : 1,
});
// Function to add another item to the transaction
const addAdditionalItem = (item: PosProduct) => {
// If the additional item requires a note, prompt for it before adding
if (item.requires_note) {
promptForNotesIfRequired(item, (notes: string) => {
item.notes = notes;
additionalItems.value.push(item);
additionalItems.value.push(normalizeAdditionalItem({
...item,
notes,
}));
});
return;
}
additionalItems.value.push(item);
additionalItems.value.push(normalizeAdditionalItem(item));
};
// Function to remove an additional item from the transaction
const removeAdditionalItem = (item: PosProduct) => {
@@ -635,7 +652,6 @@ const removeWashCertificateIfPresent = (item: PosProduct | null): PosProduct | n
}
// Function to set the primary item for the transaction
const setPrimaryItem = (item: PosProduct | null) => {
console.warn('Setting primary item:', item);
// Check if the attachments include a wash certificate and if the item requires it
if (hasWashCertificate()) {
item = applyWashCertificateIfPresent(item)
@@ -647,10 +663,6 @@ const setPrimaryItem = (item: PosProduct | null) => {
if (item && vehicles.vehicle_1.value) {
vehicles.vehicle_1.value.type = item.id;
}
// Wait 0.5 second and then log the primary item value to ensure reactivity has updated it
setTimeout(() => {
primaryItem.value = item;
}, 500);
};
// Function to clear the primary item from the transaction
const clearPrimaryItem = () => {
@@ -756,30 +768,11 @@ const promptForNotesIfRequired = (product: PosProduct, callback: (notes: string)
}
const containsWashCertificate = (): boolean => {
// Check if the transaction contains a wash certificate, either as an additional item or as an addon to any item
const washCertificateId = WASH_CERTIFICATE_PRODUCT_ID;
// Check primary item addons
if (primaryItem.value && primaryItem.value.addons) {
const hasAddon = primaryItem.value.addons.some(addon => addon.product.id === washCertificateId && (addon.quantity || 0) > 0);
if (hasAddon) return true;
}
// Check additional items
for (const item of additionalItems.value) {
if (item.id === washCertificateId) {
return true;
}
if (item.addons) {
const hasAddon = item.addons.some(addon => addon.product.id === washCertificateId && (addon.quantity || 0) > 0);
if (hasAddon) return true;
}
}
// Mobile POS also allows wash certificate selection through attachments UI.
if (attachments.hasWashCertificate()) {
return true;
}
return false;
return doesTransactionContainWashCertificateProduct({
primaryItem: primaryItem.value,
additionalItems: additionalItems.value,
attachmentsHasWashCertificate: attachments.hasWashCertificate(),
});
}
@@ -1128,8 +1121,10 @@ const camera = {
const customerId = ref<number | null>(null);
const notes = ref<string>('');
const reference = ref<string>('');
const safetySeal = ref<string>('');
const washId = ref<string | null>(null); // The Wash ID is used to track the related wash event
const bookingId = ref<number | null>(null); // The Booking ID is used to track the related booking event
const bookingSelectionSkippedPlate = ref<string>('');
const laneId = ref<number | null>(null); // The Lane ID is used to track the related lane event
const loadingState = ref<boolean>(false); // Loading state for the transaction
const loadingMessage = ref<string>('Indlæser...'); // Loading message for the transaction
@@ -1163,21 +1158,29 @@ const getCustomerId = () => {
return customerId.value;
}
// Function to set notes
const setNotes = (newNotes: string) => {
notes.value = newNotes;
const setNotes = (newNotes: string | null | undefined) => {
notes.value = newNotes ?? '';
}
// Function to get notes
const getNotes = () => {
return notes.value;
}
// Function to set reference
const setReference = (newReference: string) => {
reference.value = newReference;
const setReference = (newReference: string | null | undefined) => {
reference.value = newReference ?? '';
}
// Function to get reference
const getReference = () => {
return reference.value;
}
// Function to set safety seal
const setSafetySeal = (newSafetySeal: string | null | undefined) => {
safetySeal.value = newSafetySeal ?? '';
}
// Function to get safety seal
const getSafetySeal = () => {
return safetySeal.value;
}
// Function to set the wash ID
const setWashId = (id: string | null) => {
washId.value = id;
@@ -1194,6 +1197,15 @@ const setBookingId = (id: number | null) => {
const getBookingId = () => {
return bookingId.value;
}
const setBookingSelectionSkippedPlate = (plate: string | null | undefined) => {
bookingSelectionSkippedPlate.value = String(plate ?? '').replace(/\s/g, '').toUpperCase();
}
const getBookingSelectionSkippedPlate = () => {
return bookingSelectionSkippedPlate.value;
}
const clearBookingSelectionSkippedPlate = () => {
bookingSelectionSkippedPlate.value = '';
}
// Function to set the lane ID
const setLaneId = (id: number | null) => {
laneId.value = id;
@@ -1221,6 +1233,10 @@ const metadata = {
reference,
setReference,
getReference,
// Safety seal
safetySeal,
setSafetySeal,
getSafetySeal,
// Wash ID
washId,
setWashId,
@@ -1229,6 +1245,10 @@ const metadata = {
bookingId,
setBookingId,
getBookingId,
bookingSelectionSkippedPlate,
setBookingSelectionSkippedPlate,
getBookingSelectionSkippedPlate,
clearBookingSelectionSkippedPlate,
// Lane ID
laneId,
setLaneId,
@@ -1307,17 +1327,14 @@ const selectLastVehicleOrder = (vehicleIndex: number) => {
product: item.product,
} as Addon;
});
console.log(`addons for last order:`, addons);
const primaryItem = <PosProduct>{
...lastOrder.items[0].product,
addons: addons,
}; // Assuming the first item is the primary item for the transaction.
console.log(`primary item for last order:`, primaryItem);
// If the last order exists, set it as the primary item for the transaction.
vehicles.select(vehicleIndex, {...vehicles.get(vehicleIndex), type: primaryItem.id});
transactionItems.setPrimaryItem({...primaryItem}); // Clear addons for the primary item
transactionItems.setPrimaryItem({...primaryItem}); // Set the primary item for the transaction
console.log(`Selected last vehicle order for vehicle ${vehicleIndex}:`, lastOrder);
} else {
console.warn(`No last vehicle order found for vehicle ${vehicleIndex}.`);
}
@@ -1379,8 +1396,10 @@ const resetMetadata = () => {
metadata.customerId.value = null;
metadata.notes.value = '';
metadata.reference.value = '';
metadata.safetySeal.value = '';
metadata.washId.value = null;
metadata.bookingId.value = null;
metadata.bookingSelectionSkippedPlate.value = '';
metadata.laneId.value = null;
metadata.clearLoadingState();
}
@@ -1543,8 +1562,10 @@ const buildSnapshot = () => ({
customerId: metadata.customerId.value,
notes: metadata.notes.value,
reference: metadata.reference.value,
safetySeal: metadata.safetySeal.value,
washId: metadata.washId.value,
bookingId: metadata.bookingId.value,
bookingSelectionSkippedPlate: metadata.bookingSelectionSkippedPlate.value,
laneId: metadata.laneId.value,
},
attachments: {
@@ -1627,8 +1648,11 @@ const retrievePos = () => {
metadata.customerId.value = parsedData.metadata.customerId ?? null;
metadata.notes.value = parsedData.metadata.notes ?? '';
metadata.reference.value = parsedData.metadata.reference ?? '';
metadata.safetySeal.value = parsedData.metadata.safetySeal ?? '';
metadata.washId.value = parsedData.metadata.washId ?? null;
metadata.bookingId.value = parsedData.metadata.bookingId ?? null;
metadata.bookingSelectionSkippedPlate.value = parsedData.metadata.bookingSelectionSkippedPlate ?? '';
metadata.laneId.value = parsedData.metadata.laneId ?? null;
}
// Restore last vehicle orders
@@ -1695,8 +1719,10 @@ watch(() => [
metadata.customerId.value,
metadata.notes.value,
metadata.reference.value,
metadata.safetySeal.value,
metadata.washId.value,
metadata.bookingId.value,
metadata.bookingSelectionSkippedPlate.value,
metadata.laneId.value
], savePos);
@@ -1851,12 +1877,19 @@ export const Customer = {
reference,
setReference,
getReference,
safetySeal,
setSafetySeal,
getSafetySeal,
washId,
setWashId,
getWashId,
bookingId,
setBookingId,
getBookingId,
bookingSelectionSkippedPlate,
setBookingSelectionSkippedPlate,
getBookingSelectionSkippedPlate,
clearBookingSelectionSkippedPlate,
};
export const Meta = {
@@ -2026,6 +2059,9 @@ export {
reference,
setReference,
getReference,
safetySeal,
setSafetySeal,
getSafetySeal,
washId,
setWashId,
getWashId,
@@ -2068,4 +2104,4 @@ export {
getCameraImageCaptureDelay as getCameraCaptureDelay,
};
</script>
</script>
@@ -22,11 +22,23 @@ import PosDepartmentStepMobilePopupImage
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupImage.vue";
import PosDepartmentStepMobilePopupAddCustomer
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupAddCustomer.vue";
import PosDepartmentStepMobilePopupSelectOrderBooking
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupSelectOrderBooking.vue";
export type PopupComponentKey = 'select_customer' | 'completed_transaction' | 'complete_booking' | 'error' | 'add_product_note' | 'select_vehicle' | 'change_reference' | 'customer_notes' | 'image_viewer' | 'add_customer';
export type PopupComponentKey = 'select_customer' | 'select_order_booking' | 'completed_transaction' | 'complete_booking' | 'error' | 'add_product_note' | 'select_vehicle' | 'change_reference' | 'customer_notes' | 'image_viewer' | 'add_customer';
export type SelectOrderBookingPopupProps = {
bookings?: any[];
onSelect?: (booking: any) => Promise<void> | void;
onSkip?: () => Promise<void> | void;
onRefreshBookings?: () => Promise<any[] | { bookings?: any[] } | void> | any[] | { bookings?: any[] } | void;
departmentId?: number | null;
matchedPlate?: string;
};
export const PopupComponents = {
select_customer: PosDepartmentStepMobilePopupSelectCustomer,
select_order_booking: PosDepartmentStepMobilePopupSelectOrderBooking,
completed_transaction: PosDepartmentStepMobilePopupCompletedTransaction,
complete_booking: PosDepartmentStepMobilePopupCompleteBooking,
error: PosDepartmentStepMobilePopupError,
@@ -50,6 +62,9 @@ export type PosPopup = {
status?: VehicleStatusKey;
hideHeader?: boolean;
hideFooter?: boolean;
showHeaderClose?: boolean;
onHeaderClose?: () => void;
headerCloseTestId?: string;
style?: Record<string, any>; // CSS styles
/** Action buttons */
actionButtons?: PosActionButton[];
@@ -59,6 +74,7 @@ export default defineComponent({
name: 'PosPopup',
components: {
PosDepartmentStepMobilePopupSelectCustomer,
PosDepartmentStepMobilePopupSelectOrderBooking,
PosDepartmentStepMobilePopupCompletedTransaction,
PosDepartmentStepMobilePopupCompleteBooking,
PosDepartmentStepMobilePopupError,
@@ -81,4 +97,4 @@ export default defineComponent({
},
},
});
</script>
</script>
@@ -1,6 +1,28 @@
<script lang="ts">
import type { VehicleStatusKey } from './PosVehicleStatus.vue';
import type { PosCustomerSuggestion } from './PosCustomerSuggestion.vue';
export type OrderBookingSummary = {
id: number;
customer_name?: string;
customer_number?: number;
customer_id?: number;
reg_1?: string;
reg_2?: string;
reference?: string;
reference_number?: string;
note?: string;
notes?: string;
po?: string;
datetime?: string;
created_at?: string;
wash_type?: string;
parsed_services?: {
string?: string;
array?: string[];
};
[key: string]: any;
};
export type PosSearchResult = {
registrationNumber?: string;
customerName: string;
@@ -13,6 +35,7 @@ export type PosSearchResult = {
lastOrderId?: number;
washSubscription?: boolean;
customerSuggestions?: PosCustomerSuggestion[]; // This is an array of previously billed customers.
bookingId?: number; // If the search result is linked to a booking.
bookingId?: number; // The explicitly selected booking id.
bookingMatches?: OrderBookingSummary[];
};
</script>
</script>
@@ -1,5 +1,6 @@
<script lang="ts">
import type { VehicleStatusKey } from './PosVehicleStatus.vue';
import type { OrderBookingSummary } from './PosSearchResult.vue';
export type PosVehicle = {
id?: number;
customer_id?: number;
@@ -19,7 +20,8 @@ export type PosVehicle = {
last_order_id?: number;
// Booking ID (if applicable)
booking_id?: number;
booking_matches?: OrderBookingSummary[];
// additional data
[key: string]: any;
}
</script>
</script>
@@ -0,0 +1,119 @@
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
order_id,
order_notes,
order_safety_seal,
reference,
} from "@/components/shop/POSDepartmentProcess.vue";
import { metadata } from "./PosDepartmentStepMobileFlow.vue";
const normalizeTextValue = (value) => {
return value === null || value === undefined ? "" : String(value).trim();
};
const getNormalizedOrderId = () => {
const parsed = Number.parseInt(String(order_id.value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const getNormalizedBookingId = () => {
const parsed = Number.parseInt(String(metadata.getBookingId?.() ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
export const getResolvedMobileSafetySeal = () => {
const metadataSafetySeal = normalizeTextValue(metadata.getSafetySeal?.());
if (metadataSafetySeal !== "") {
return metadataSafetySeal;
}
return normalizeTextValue(order_safety_seal.value);
};
export const syncMobileSafetySealState = (value) => {
const normalizedValue = normalizeTextValue(value);
order_safety_seal.value = normalizedValue;
metadata.setSafetySeal?.(normalizedValue);
return normalizedValue;
};
export const persistCurrentMobileOrderMetadata = async () => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
return null;
}
const normalizedReference = normalizeTextValue(metadata.getReference?.() ?? reference.value);
const normalizedNotes = normalizeTextValue(metadata.getNotes?.() ?? order_notes.value);
const normalizedSafetySeal = syncMobileSafetySealState(getResolvedMobileSafetySeal());
reference.value = normalizedReference;
order_notes.value = normalizedNotes;
await Promise.all([
SessionUser.objects.orders.set.reference(normalizedOrderId, normalizedReference),
SessionUser.objects.orders.set.notes(normalizedOrderId, normalizedNotes),
SessionUser.objects.orders.set.safety_seal(normalizedOrderId, normalizedSafetySeal),
]);
return normalizedOrderId;
};
export const linkCurrentMobileOrderToBooking = async () => {
const normalizedOrderId = getNormalizedOrderId();
const normalizedBookingId = getNormalizedBookingId();
if (!normalizedOrderId || !normalizedBookingId) {
return null;
}
await SessionUser.objects.orders.set.booking_id(normalizedOrderId, normalizedBookingId);
await SessionUser.objects.order_bookings.set.order_id(normalizedBookingId, normalizedOrderId);
return normalizedBookingId;
};
export const completeLinkedBookingIfNeeded = async (bookingId = getNormalizedBookingId(), options = {}) => {
const normalizedOptions = {
safetySeal: null,
...options,
};
const normalizedBookingId = Number.parseInt(String(bookingId ?? ""), 10);
if (!Number.isInteger(normalizedBookingId) || normalizedBookingId <= 0) {
return null;
}
const booking = await SessionUser.objects.order_bookings.get.single(normalizedBookingId).catch(() => null);
if (booking?.status === "completed") {
return booking;
}
const normalizedSafetySeal = normalizeTextValue(normalizedOptions.safetySeal);
return SessionUser.objects.order_bookings.functions.complete(
normalizedBookingId,
normalizedSafetySeal === "" ? null : normalizedSafetySeal
);
};
export const finalizeCurrentMobileOrder = async (options = {}) => {
const normalizedOptions = {
completeLinkedBooking: true,
bookingSafetySeal: null,
markOrderCompleted: true,
...options,
};
const normalizedOrderId = await persistCurrentMobileOrderMetadata();
if (!normalizedOrderId) {
return null;
}
const normalizedBookingId = await linkCurrentMobileOrderToBooking();
if (normalizedBookingId && normalizedOptions.completeLinkedBooking) {
await completeLinkedBookingIfNeeded(normalizedBookingId, {
safetySeal: normalizedOptions.bookingSafetySeal,
});
}
if (normalizedOptions.markOrderCompleted) {
await SessionUser.objects.orders.functions.mark_as_completed(normalizedOrderId);
}
return normalizedOrderId;
};
@@ -38,6 +38,7 @@ const save = () => {
<div class="control">
<input
class="custom-input"
data-testid="pos-mobile-reference-input"
id="reference_input"
:class="{ 'is-searched': hasFocus }"
type="text"
@@ -107,4 +108,4 @@ const save = () => {
}
</style>
</style>
@@ -147,7 +147,10 @@ watch(() => props.showInput, (newVal) => {
<br />
<small>{{ SessionUser.functions.ucFirst(SessionUser.functions.date.toWordsWithTime(note?.created_at || new Date())) }}</small>
<br />
<small v-if="note?.cashier_id">{{ t('pos.created_by') }} <strong>{{ cashierNamesMap.find(cashier => cashier.id === parseInt(note.cashier_id))?.display_name || t('pos.unknown') }}</strong></small>
<small
v-if="note?.cashier_id"
:data-testid="`pos-mobile-customer-note-created-by-${note.id}`"
>{{ t('admin.pos.created_by') }} <strong>{{ cashierNamesMap.find(cashier => cashier.id === parseInt(note.cashier_id))?.display_name || t('admin.pos.unknown') }}</strong></small>
</span>
</div>
</div>
@@ -158,7 +161,7 @@ watch(() => props.showInput, (newVal) => {
<input
class="input has-sharp-edges"
type="text"
:placeholder="t('pos.add_note_placeholder')"
:placeholder="t('admin.pos.add_note_placeholder')"
v-model="input"
id="input_field"
@keyup.enter="createNewNote(input); input=''"
@@ -226,4 +229,4 @@ watch(() => props.showInput, (newVal) => {
overflow-y: scroll;
}
</style>
</style>
@@ -1,19 +1,19 @@
<script setup lang="ts">
import {ref, defineEmits, onMounted, watch, defineProps} from "vue";
import { vehicles } from "../objects/PosDepartmentStepMobileFlow.vue";
import { reg_1, reg_2, reg_3, vehicles_matching } from "@/components/shop/POSDepartmentProcess.vue";
import {ref, defineEmits, onBeforeUnmount, onMounted, watch, defineProps} from "vue";
import { order_id, reg_1, reg_2, reg_3, getOrderDetails } from "@/components/shop/POSDepartmentProcess.vue";
import PosDepartmentStepMobile1RegistrationNumberInputField from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumberInputField.vue";
import BarcodeScanner from "@/components/viewport/elements/icons/BarcodeScanner.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue";
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import { PosVehicle } from "@/components/displays/department/pos/steps/mobile/objects/PosVehicle.vue";
import type { PosSearchResult } from "../objects/PosSearchResult.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import PosDepartmentStepMobileFixedBottomControl
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import PosDepartmentStepMobileButtonNextStep
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
// Define the close event to emit when the component is closed
const emit = defineEmits(["close"]);
// Define the props
@@ -42,61 +42,189 @@ const props = defineProps({
}
});
const inputTestIds = {
reg1: 'pos-mobile-reg-input-1',
reg2: 'pos-mobile-reg-input-2',
reg3: 'pos-mobile-reg-input-3',
};
const normalizeRegistrationValue = (value: string | null | undefined) => String(value ?? '').toUpperCase();
const getNormalizedOrderId = () => {
const parsedOrderId = Number.parseInt(String(order_id.value), 10);
return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null;
};
const getRegistrationSourceRef = (vehicleIndex: number) => {
switch (vehicleIndex) {
case 1:
return reg_1;
case 2:
return reg_2;
case 3:
return reg_3;
default:
return reg_1;
}
};
const setPersistedRegistrationValue = (vehicleIndex: number, value: string) => {
const normalizedValue = normalizeRegistrationValue(value);
getRegistrationSourceRef(vehicleIndex).value = normalizedValue;
};
const syncVehicleRegistration = (vehicleIndex: number, value: string, overrides: Record<string, any> = {}) => {
const normalizedValue = normalizeRegistrationValue(value);
if (normalizedValue === "") {
pos.vehicles.select(vehicleIndex, null);
return;
}
const currentVehicle = pos.vehicles.get(vehicleIndex);
pos.vehicles.select(vehicleIndex, {
...(currentVehicle ?? {}),
...overrides,
reg: normalizedValue,
});
};
// Define a reactive variable to control the expanded state of the component
const expanded = ref(props.forceShowApplicable);
// Define reactive variables for registration numbers and labels
const registrationNumber1 = ref("");
const label1 = ref("Reg 1*");
const registrationNumber2 = ref("");
const label2 = ref("Reg 2*");
const registrationNumber3 = ref("");
const label3 = ref("Reg 3");
const createRegistrationAutosave = (vehicleIndex: number) => useOrderMetadataAutosave({
source: getRegistrationSourceRef(vehicleIndex),
normalizeValue: normalizeRegistrationValue,
saveValue: async (value) => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
return value;
}
await SessionUser.objects.orders.set[`reg_${vehicleIndex}`](normalizedOrderId, value);
const refreshedOrder = await getOrderDetails(normalizedOrderId);
return refreshedOrder?.[`reg_${vehicleIndex}`] ?? value;
},
onSaved: async (value) => {
setPersistedRegistrationValue(vehicleIndex, value);
syncVehicleRegistration(vehicleIndex, value);
},
});
const registrationAutosaves = {
1: createRegistrationAutosave(1),
2: createRegistrationAutosave(2),
3: createRegistrationAutosave(3),
};
const reg1Draft = registrationAutosaves[1].draft;
const reg2Draft = registrationAutosaves[2].draft;
const reg3Draft = registrationAutosaves[3].draft;
const setRegistrationDraft = (vehicleIndex: number, value: string) => {
const normalizedValue = normalizeRegistrationValue(value);
switch (vehicleIndex) {
case 1:
reg1Draft.value = normalizedValue;
break;
case 2:
reg2Draft.value = normalizedValue;
break;
case 3:
reg3Draft.value = normalizedValue;
break;
default:
break;
}
};
watch(reg1Draft, (value) => {
syncVehicleRegistration(1, value);
if (getNormalizedOrderId()) {
registrationAutosaves[1].scheduleSave();
return;
}
setPersistedRegistrationValue(1, value);
});
watch(reg2Draft, (value) => {
syncVehicleRegistration(2, value);
if (getNormalizedOrderId()) {
registrationAutosaves[2].scheduleSave();
return;
}
setPersistedRegistrationValue(2, value);
});
watch(reg3Draft, (value) => {
syncVehicleRegistration(3, value);
if (getNormalizedOrderId()) {
registrationAutosaves[3].scheduleSave();
return;
}
setPersistedRegistrationValue(3, value);
});
watch(() => getNormalizedOrderId(), (normalizedOrderId) => {
if (!normalizedOrderId) {
return;
}
[1, 2, 3].forEach((vehicleIndex) => {
if (registrationAutosaves[vehicleIndex].isDirty.value) {
registrationAutosaves[vehicleIndex].scheduleSave();
}
});
});
const flushRegistration = async (vehicleIndex: number) => {
await registrationAutosaves[vehicleIndex].flush();
};
const flushAllRegistrations = async () => {
await Promise.all([1, 2, 3].map((vehicleIndex) => registrationAutosaves[vehicleIndex].flush()));
};
// Function to set a value if it is not null
function setIfNotNull(variable: string, value: { registrationNumber: string, customerStatus: any, customerId?: number, lastOrderId?: number } | null) {
const createVehicleSelectionFromSearchResult = (value: PosSearchResult) => ({
...value,
reg: value.registrationNumber,
status: value.customerStatus,
customer_id: value.customerId,
last_order_id: value.lastOrderId,
booking_id: value.bookingId,
booking_matches: value.bookingMatches || [],
wash_subscription: value.washSubscription,
});
function setIfNotNull(variable: string, value: PosSearchResult | null) {
//console.warn("setIfNotNull", variable, value);
if (value !== null) {
const nextVehicleSelection = createVehicleSelectionFromSearchResult(value);
switch (variable) {
case 'reg_1':
//console.log("Setting reg_1 with value:", value);
pos.vehicles.select(
1,
{
reg: value.registrationNumber,
status: value.customerStatus,
customer_id: value.customerId,
last_order_id: value.lastOrderId,
}
)
registrationNumber1.value = value.registrationNumber;
reg_1.value = value.registrationNumber;
pos.vehicles.select(1, nextVehicleSelection);
setRegistrationDraft(1, value.registrationNumber);
//reg_1_status.value = value.customerStatus;
break;
case 'reg_2':
pos.vehicles.select(
2,
{
reg: value.registrationNumber,
status: value.customerStatus,
customer_id: value.customerId,
}
)
registrationNumber2.value = value.registrationNumber;
reg_2.value = value.registrationNumber;
pos.vehicles.select(2, nextVehicleSelection);
setRegistrationDraft(2, value.registrationNumber);
//reg_2_status.value = value.customerStatus;
break;
case 'reg_3':
pos.vehicles.select(
3,
{
reg: value.registrationNumber,
status: value.customerStatus,
customer_id: value.customerId,
}
)
registrationNumber3.value = value.registrationNumber;
reg_3.value = value.registrationNumber;
pos.vehicles.select(3, nextVehicleSelection);
setRegistrationDraft(3, value.registrationNumber);
//reg_3_status.value = value.customerStatus;
break;
}
@@ -112,95 +240,106 @@ function setIfNotNull(variable: string, value: { registrationNumber: string, cus
// - Automatically select the first vehicle in the list, if the user input matches one of the vehicles exactly
function setValueUserInput(vehicleIndex: number, value: string) {
//console.log("setValueUserInput", vehicleIndex, value);
let vehicle: PosVehicle | null = null; // Initialize vehicle as null
const normalizedValue = normalizeRegistrationValue(value);
/** Case: Empty input */
// If the value is empty, clear the registration number
if (value === "") {
pos.vehicles.select(vehicleIndex, vehicle);
if (normalizedValue === "") {
pos.vehicles.select(vehicleIndex, null);
setRegistrationDraft(vehicleIndex, '');
void flushRegistration(vehicleIndex);
return;
}
/** Case: Input matches a vehicle exactly */
// If the value matches a result exactly, select that vehicle
let matchingVehicle = vehicles_matching.value.filter(vehicle => vehicle.reg === value); // Filter the vehicles_matching array to find a vehicle with the same registration number
if (matchingVehicle.length > 0) {
//console.log("setValueUserInput: matchingVehicle", matchingVehicle);
}
setRegistrationDraft(vehicleIndex, normalizedValue);
void flushRegistration(vehicleIndex);
}
onMounted(() => {
// Initialize the registration numbers from the pos.vehicles object
reg_1.value = pos.vehicles.get(1)?.reg ?? "";
reg_2.value = pos.vehicles.get(2)?.reg ?? "";
reg_3.value = pos.vehicles.get(3)?.reg ?? "";
setRegistrationDraft(1, pos.vehicles.get(1)?.reg ?? reg_1.value ?? "");
setRegistrationDraft(2, pos.vehicles.get(2)?.reg ?? reg_2.value ?? "");
setRegistrationDraft(3, pos.vehicles.get(3)?.reg ?? reg_3.value ?? "");
// If the reg_3 is not empty, set the expanded state to true
expanded.value = reg_3.value !== "" || props.forceShowApplicable;
});
onBeforeUnmount(() => {
void flushAllRegistrations();
});
const closeManualInput = async () => {
await flushAllRegistrations();
emit('close');
};
</script>
<template>
<div>
<div data-testid="pos-mobile-manual-input">
<template v-if="props.showReg1">
<!-- First input field with a required label -->
<PosDepartmentStepMobile1RegistrationNumberInputField
v-model:modelValue="reg_1"
v-model:modelValue="reg1Draft"
v-model:label="label1"
@focusout="setValueUserInput(1, reg_1)"
@onInput="reg_1 = $event"
:inputTestId="inputTestIds.reg1"
@focusout="setValueUserInput(1, reg1Draft)"
>
<template #searchResults>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_1', $event)" :searchQuery="reg_1" :automaticallySelect="true"/>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_1', $event)" :searchQuery="reg1Draft" :automaticallySelect="true"/>
</template>
</PosDepartmentStepMobile1RegistrationNumberInputField>
</template>
<template v-if="props.showReg2">
<!-- Second input field with a custom expander button -->
<PosDepartmentStepMobile1RegistrationNumberInputField
v-model:modelValue="reg_2"
v-model:modelValue="reg2Draft"
v-model:label="label2"
@focusout="setValueUserInput(2, reg_2)"
:inputTestId="inputTestIds.reg2"
@focusout="setValueUserInput(2, reg2Draft)"
>
<template #default>
<div class="custom-expander" @click="expanded = !expanded" v-if="!reg_3">
<div class="custom-expander" @click="expanded = !expanded" v-if="!reg3Draft">
<span class="custom-text">+</span>
</div>
</template>
<template #searchResults>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_2', $event)" :searchQuery="reg_2" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_2', $event)" :searchQuery="reg2Draft" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
</template>
</PosDepartmentStepMobile1RegistrationNumberInputField>
</template>
<template v-if="props.showReg3">
<!-- Third input field that is conditionally displayed based on the expanded state -->
<PosDepartmentStepMobile1RegistrationNumberInputField
v-show="expanded || reg_3 !== ''"
v-model:modelValue="reg_3"
v-show="expanded || reg3Draft !== ''"
v-model:modelValue="reg3Draft"
v-model:label="label3"
@focusout="setValueUserInput(3, reg_3)"
:inputTestId="inputTestIds.reg3"
@focusout="setValueUserInput(3, reg3Draft)"
>
<template #searchResults>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_3', $event)" :searchQuery="reg_3" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_3', $event)" :searchQuery="reg3Draft" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
</template>
</PosDepartmentStepMobile1RegistrationNumberInputField>
</template>
<template v-if="props.showButtons">
<!-- Back to scan button -->
<button class="button p-5 is-fullwidth mt-3 is-text" style="text-decoration: none" @click="$emit('close')">
<button class="button p-5 is-fullwidth mt-3 is-text" data-testid="pos-mobile-manual-input-close" style="text-decoration: none" @click="closeManualInput()">
<span class="m-2"><BarcodeScanner/></span>
<span class="custom-label-button">{{ SessionUser.objects.global.language.or_scan_plates}}</span>
</button>
</template>
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl v-if="props.showButtons">
<!-- Close button -->
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => emit('close')" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
<span class="is-float-left">{{ SessionUser.objects.global.language.close }}</span>
<span class="is-float-right">
<i class="fa-solid fa-xmark"></i>
<PosDepartmentStepMobileFixedBottomControl v-if="props.showButtons">
<!-- Close button -->
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => closeManualInput()" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
<span class="pos-mobile-action-content">
<span class="pos-mobile-action-label">{{ SessionUser.objects.global.language.close }}</span>
<span class="pos-mobile-action-icon">
<i class="fa-solid fa-xmark"></i>
</span>
</span>
</PosDepartmentStepMobileButtonNextStep>
</PosDepartmentStepMobileFixedBottomControl>
</PosDepartmentStepMobileButtonNextStep>
</PosDepartmentStepMobileFixedBottomControl>
</div>
</template>
@@ -272,4 +411,4 @@ onMounted(() => {
order: 0;
flex-grow: 0;
}
</style>
</style>
@@ -1,14 +1,16 @@
<script setup lang="ts">
import {defineEmits, onMounted, onUnmounted, ref} from "vue";
import { setTransparency, setBackgroundColor, backgroundColors, setOverflow } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import { defineEmits, onMounted, onUnmounted, ref } from "vue";
import {
setTransparency,
setBackgroundColor,
backgroundColors,
setOverflow,
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStepMobileFixedBottomControl
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import PosDepartmentStepMobileButtonNextStep
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import PosDepartmentStepMobileButtonNextStep from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import {PosOrder} from "@/components/displays/department/pos/steps/mobile/objects/PosOrder.vue";
import { PosOrder } from "@/components/displays/department/pos/steps/mobile/objects/PosOrder.vue";
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
// Define the close event to emit when the component is closed
const emit = defineEmits(["close"]);
@@ -17,14 +19,17 @@ const showError = ref(true);
const showCompleted = ref(false);
const showPending = ref(true);
const toValidOrderId = (value: unknown): number | null => {
const parsed = Number.parseInt(String(value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
onMounted(() => {
// Set the header to be transparent
setTransparency(!pos.views.isAnyActive.value); // Set transparency based on manualInput state
setBackgroundColor(backgroundColors.default); // Set the default background color
setOverflow(true); // Prevent scrolling
// Get all customer names for the transactions
getTransactionData(pos.transactionHistory.get());
// Sync with server
// Sync with server first, then enrich transactions
syncListTransactionHistory();
});
onUnmounted(() => {
@@ -35,53 +40,69 @@ onUnmounted(() => {
});
const getTransactionCustomer = (transaction: PosOrder) => {
SessionUser.objects.global.get.object('/users/customer', {customer_number: transaction.customer_id}).then((response) => {
transaction.customer_name = response.customer_name;
});
}
const customerNumber = toValidOrderId(transaction?.customer_id);
if (!customerNumber) {
return;
}
SessionUser.objects.global.get
.object("/users/customer", { customer_number: transaction.customer_id })
.then((response) => {
if (response?.customer_name) {
transaction.customer_name = response.customer_name;
}
});
};
const getTransactionData = (transactions: PosOrder[]) => {
// get all transactions by their ids, and update the transaction (total_net_amount, created_at, customer_id)
SessionUser.objects.orders.get.multiple(transactions.map(t => t.id)).then((response) => {
response.forEach((transaction: PosOrder) => {
const index = transactions.findIndex(t => t.id === transaction.id);
if (index !== -1) {
// Validate the transaction data
if (!transaction.id) {
transactions[index].error = SessionUser.objects.global.language.error_loading_transaction;
return; // Skip if no valid id
}
transactions[index] = transaction; // Update the transaction in the array
// Get the customer name
getTransactionCustomer(transactions[index]);
}
const safeTransactions = Array.isArray(transactions) ? transactions.filter((transaction) => !!transaction) : [];
if (safeTransactions.length === 0) {
return;
}
const validIds = [
...new Set(
safeTransactions.map((transaction) => toValidOrderId(transaction?.id)).filter((id): id is number => id !== null)
),
];
if (validIds.length === 0) {
safeTransactions.forEach((transaction) => {
transaction.error = SessionUser.objects.global.language.error_loading_transaction;
});
}).catch((error) => {
console.error("Error fetching transactions:", error);
// Loop through each transaction and set the error message
transactions.forEach((transaction) => {
const index = transactions.findIndex(t => t.id === transaction.id);
if (index !== -1) {
// Try to fetch the order again individually
SessionUser.objects.orders.get.single(transaction.id).then((response) => {
if (!response.id) {
transactions[index].error = SessionUser.objects.global.language.error_loading_transaction;
return; // Skip if no valid id
}
// Get the customer name
getTransactionCustomer(transactions[index]);
transactions[index] = response; // Update the transaction in the array
}).catch((err) => {
console.error("Error fetching transaction:", err);
transactions[index].error = SessionUser.objects.global.language.error_loading_transaction;
});
return;
}
// Get all transactions by their ids and update in-place while guarding malformed payloads
SessionUser.objects.orders.get.multiple(validIds).then((response) => {
const fetchedById = new Map<number, PosOrder>();
(Array.isArray(response) ? response : []).forEach((fetchedTransaction: PosOrder | undefined) => {
const fetchedId = toValidOrderId(fetchedTransaction?.id);
if (!fetchedId || !fetchedTransaction) {
return;
}
fetchedById.set(fetchedId, fetchedTransaction);
});
safeTransactions.forEach((transaction) => {
const transactionId = toValidOrderId(transaction?.id);
if (!transactionId) {
transaction.error = SessionUser.objects.global.language.error_loading_transaction;
return;
}
const fetched = fetchedById.get(transactionId);
if (!fetched) {
transaction.error = SessionUser.objects.global.language.error_loading_transaction;
return;
}
Object.assign(transaction, fetched);
getTransactionCustomer(transaction);
});
});
}
};
const refreshInterval = setInterval(() => {
getTransactionData(pos.transactionHistory.get());
syncListTransactionHistory();
}, 60000); // Refresh every 60 seconds
onUnmounted(() => {
@@ -91,43 +112,50 @@ onUnmounted(() => {
const isPendingHandheld = (transaction: PosOrder) => {
if (!transaction?.pending_handheld) return false;
return !transaction?.completed_at;
}
};
const hasError = (transaction: PosOrder) => {
return !!transaction?.error;
}
};
const getTransactionError = (transaction: PosOrder) => {
return transaction?.error || SessionUser.objects.global.language.unknown_error;
}
};
const liveTransactions = ref(null)
const liveTransactions = ref(null);
const isLoading = ref(true);
const syncListTransactionHistory = async () => {
let dateToday = new Date().toISOString().split('T')[0]; // Get today's date in YYYY-MM-DD format
let dateToday = new Date().toISOString().split("T")[0]; // Get today's date in YYYY-MM-DD format
isLoading.value = true;
// Fetch the list of orders created today for the current department
SessionUser.objects.orders.get.list({filters: {
'created_at-date_from': dateToday,
'created_at-date_to': dateToday,
'department_id': SessionUser.functions.getDepartmentIdFromUrl(),
}, pagination: {
page: 1, limit: 1000
}}).then((response) => {
console.warn('Synced list of transactions', response);
liveTransactions.value = response;
// Clear the transactions
pos.transactionHistory.clear();
// Loop through the new transactions, and add them
for (let i = 0; i < liveTransactions.value.length; i++) {
pos.transactionHistory.add(liveTransactions.value[i]);
}
}).catch((error) => {
console.error('Error syncing list of transactions:', error);
}).finally(() => {
isLoading.value = false;
});
SessionUser.objects.orders.get
.list({
filters: {
"created_at-date_from": dateToday,
"created_at-date_to": dateToday,
department_id: SessionUser.functions.getDepartmentIdFromUrl(),
},
pagination: {
page: 1,
limit: 1000,
},
})
.then((response) => {
liveTransactions.value = Array.isArray(response) ? response : [];
// Clear the transactions
pos.transactionHistory.clear();
// Loop through the new transactions, and add them
for (let i = 0; i < liveTransactions.value.length; i++) {
if (liveTransactions.value[i]) {
pos.transactionHistory.add(liveTransactions.value[i]);
}
}
getTransactionData(pos.transactionHistory.get());
})
.finally(() => {
isLoading.value = false;
});
};
const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: boolean) => {
@@ -137,11 +165,20 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
</script>
<template>
<div>
<div data-testid="pos-mobile-transaction-history">
<!-- Visibility toggles -->
<div class="columns is-mobile is-vcentered is-multiline">
<div class="column is-half" v-for="(visibility, index) in [showPending, showCompleted]" :key="index">
<WhiteBoxCard :toggleable="false" :forceState="false" :defaultOpen="false" @click="() => { index === 0 ? showPending = !showPending : showCompleted = !showCompleted }">
<WhiteBoxCard
:toggleable="false"
:forceState="false"
:defaultOpen="false"
@click="
() => {
index === 0 ? (showPending = !showPending) : (showCompleted = !showCompleted);
}
"
>
<template #header>
<div class="card-header-title">
<span v-if="index === 0">{{ SessionUser.objects.global.language.pending }}</span>
@@ -156,24 +193,36 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
</span>
</div>
</template>
<template #content>
</template>
<template #content> </template>
</WhiteBoxCard>
</div>
</div>
<!-- Content -->
<div class="scrollable" style="overflow-x: hidden;">
<div class="scrollable" style="overflow-x: hidden">
<!-- Transaction history list -->
<div class="columns is-multiline is-mobile mb-6">
<template v-for="(transaction, index) in pos.transactionHistory.get()" :key="index">
<div class="column is-12" v-if="isVisible(transaction, showPending, showCompleted)">
<div class="box" @click="SessionUser.functions.redirectTo.department(transaction.department_id, 'modules/pos/orders/' + transaction.id)">
<div
class="box transaction-history-card"
:data-testid="`pos-mobile-transaction-history-card-${transaction.id}`"
@click="
SessionUser.functions.redirectTo.department(
transaction.department_id,
'modules/pos/orders/' + transaction.id
)
"
>
<div class="columns is-mobile is-vcentered is-multiline">
<div class="column is-12 pb-0">
<p class="is-size-7 has-text-grey">{{ SessionUser.functions.date.toWordsWithTime(new Date(transaction.created_at)) }}</p>
<p class="is-size-7 has-text-grey">
{{ SessionUser.functions.date.toWordsWithTime(new Date(transaction.created_at)) }}
</p>
</div>
<div class="column is-8 pt-0">
<p class="is-size-6 has-text-weight-semibold">{{ transaction?.customer_name || transaction?.customer_id }}</p>
<p class="is-size-6 has-text-weight-semibold">
{{ transaction?.customer_name || transaction?.customer_id }}
</p>
</div>
<div class="column is-4 has-text-right">
<p class="is-size-6 has-text-weight-semibold">
@@ -183,15 +232,39 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
<div class="column is-12">
<div class="columns is-vcentered is-mobile">
<div class="column">
<span class="icon" :class="hasError(transaction) ? 'has-text-danger' : isPendingHandheld(transaction) ? 'has-text-warning' : 'has-text-success'">
<!-- If the pending_handheld is true, show a clock icon, else show a check icon -->
<i v-if="hasError(transaction)" class="fa-solid fa-triangle-exclamation"></i>
<i v-else-if="isPendingHandheld(transaction)" class="fa-solid fa-clock"></i>
<i v-else class="fa-solid fa-check"></i>
<span
class="icon"
:class="
hasError(transaction)
? 'has-text-danger'
: isPendingHandheld(transaction)
? 'has-text-warning'
: 'has-text-success'
"
>
<!-- If the pending_handheld is true, show a clock icon, else show a check icon -->
<i v-if="hasError(transaction)" class="fa-solid fa-triangle-exclamation"></i>
<i v-else-if="isPendingHandheld(transaction)" class="fa-solid fa-clock"></i>
<i v-else class="fa-solid fa-check"></i>
</span>
<span class="is-size-7" :class="hasError(transaction) ? 'has-text-danger' : isPendingHandheld(transaction) ? 'has-text-warning' : 'has-text-success'">
<span
class="is-size-7"
:class="
hasError(transaction)
? 'has-text-danger'
: isPendingHandheld(transaction)
? 'has-text-warning'
: 'has-text-success'
"
>
<!-- If there is an error, show the error message, else if pending_handheld is true, show "Confirmation needed", else show "Completed" -->
{{ hasError(transaction) ? getTransactionError(transaction) : isPendingHandheld(transaction) ? SessionUser.objects.global.language.confirmation_needed : SessionUser.objects.global.language.completed }}
{{
hasError(transaction)
? getTransactionError(transaction)
: isPendingHandheld(transaction)
? SessionUser.objects.global.language.confirmation_needed
: SessionUser.objects.global.language.completed
}}
</span>
</div>
<div class="column is-narrow has-text-right">
@@ -215,17 +288,41 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl>
<!-- Refresh button -->
<PosDepartmentStepMobileButtonNextStep class="mb-2" :isWhite="false" :customAction="() => syncListTransactionHistory()" :customDisabled="false" :buttonClasses="['has-background-primary-dark', 'has-text-black', ...isLoading ? ['is-loading'] : []]">
<span class="is-float-left has-text-white">{{ SessionUser.objects.global.language.reload }}</span>
<span class="is-float-right has-text-white is-loading">
<i class="fa-solid fa-arrows-rotate"></i>
<PosDepartmentStepMobileButtonNextStep
class="mb-2"
:isWhite="false"
:customAction="() => syncListTransactionHistory()"
:customDisabled="false"
:buttonClasses="['has-background-primary-dark', 'has-text-black', ...(isLoading ? ['is-loading'] : [])]"
>
<span class="pos-mobile-action-content" data-testid="pos-mobile-transaction-history-reload-action">
<span
class="pos-mobile-action-label has-text-white"
data-testid="pos-mobile-transaction-history-reload-label"
>{{ SessionUser.objects.global.language.reload }}</span
>
<span
class="pos-mobile-action-icon has-text-white"
data-testid="pos-mobile-transaction-history-reload-icon"
>
<i class="fa-solid fa-arrows-rotate"></i>
</span>
</span>
</PosDepartmentStepMobileButtonNextStep>
<!-- Close button -->
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => emit('close')" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
<span class="is-float-left">{{ SessionUser.objects.global.language.close }}</span>
<span class="is-float-right">
<i class="fa-solid fa-xmark"></i>
<PosDepartmentStepMobileButtonNextStep
:isWhite="false"
:customAction="() => emit('close')"
:customDisabled="false"
:buttonClasses="['has-background-primary', 'has-text-black']"
>
<span class="pos-mobile-action-content" data-testid="pos-mobile-transaction-history-close-action">
<span class="pos-mobile-action-label" data-testid="pos-mobile-transaction-history-close-label">{{
SessionUser.objects.global.language.close
}}</span>
<span class="pos-mobile-action-icon" data-testid="pos-mobile-transaction-history-close-icon">
<i class="fa-solid fa-xmark"></i>
</span>
</span>
</PosDepartmentStepMobileButtonNextStep>
</PosDepartmentStepMobileFixedBottomControl>
@@ -238,4 +335,8 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
max-height: calc(100vh - 250px); /* Adjust based on header/footer height */
overflow-y: auto;
}
</style>
.transaction-history-card {
border-top: 1px solid #dbdbdb;
}
</style>
@@ -72,7 +72,7 @@ const canClose = () => Date.now() >= earliestCloseTime.value;
</script>
<template>
<div>
<div data-testid="pos-mobile-vehicle-selection">
<!-- Product & Search button -->
<GenericLabelSearch @click="onSearchClick">
{{ SessionUser.objects.products.meta.title }}
@@ -99,4 +99,4 @@ const canClose = () => Date.now() >= earliestCloseTime.value;
<style scoped>
</style>
</style>
@@ -228,6 +228,7 @@ const onClickAttachToOrder = (object) => {
<!-- Actions -->
<ActionSettingsWheelButton
:order_id="object.order_id"
:refreshFunction="loadList"
>
<template #actions>
<!-- Label -->
@@ -268,4 +269,4 @@ const onClickAttachToOrder = (object) => {
</template>
<style scoped>
</style>
</style>
@@ -23,7 +23,7 @@ const props = defineProps({
default: false,
}
});
import {ref, watch} from 'vue';
import {computed, ref, watch} from 'vue';
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";
@@ -33,6 +33,7 @@ import OrderContentTable from "@/components/displays/superuser/tables/OrderConte
import InvoiceMultipleCollectionsModal from "@/components/displays/modals/InvoiceMultipleCollectionsModal.vue";
import Swal from "sweetalert2";
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
import { invoiceQueue } from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue";
const redirectDepartmentOrderPage = (orderId, departmentId) => {
@@ -51,16 +52,31 @@ if (departments.value.length === 0) {
getDepartments();
}
const getEconomicInvoiceModule = (order) => order?.economic_invoice_module ?? null;
const getStripeInvoiceModule = (order) => order?.stripe_invoice_module ?? null;
const getInvoiceCollection = (order) => order?.invoice_collection ?? null;
const hasLongText = (value, maxLength = 7) => String(value ?? '').length > maxLength;
const isOrderInvoicedWithEconomic = (order) => {
return order.economic_invoice_module.invoice_id !== null || order.economic_invoice_module.invoice_draft_id !== null;
const economicInvoiceModule = getEconomicInvoiceModule(order);
if (!economicInvoiceModule) {
return false;
}
return economicInvoiceModule.invoice_id !== null || economicInvoiceModule.invoice_draft_id !== null;
};
const isOrderEconomicInvoiceBooked = (order) => {
return order.economic_invoice_module.invoice_id !== null;
const economicInvoiceModule = getEconomicInvoiceModule(order);
if (!economicInvoiceModule) {
return false;
}
return economicInvoiceModule.invoice_id !== null;
};
const isOrderInvoicedWithStripe = (order) => {
return order.stripe_invoice_module !== undefined;
return getStripeInvoiceModule(order) !== null;
};
const isOrderInvoiced = (order) => {
@@ -68,20 +84,31 @@ const isOrderInvoiced = (order) => {
};
const doesOrderHaveInvoiceCollection = (order) => {
return order.invoice_collection !== undefined && order.invoice_collection !== null;
return getInvoiceCollection(order) !== null;
};
const isOrderInvoiceCollectionClosed = (order) => {
return order.invoice_collection.closed_at !== null;
const invoiceCollection = getInvoiceCollection(order);
if (!invoiceCollection) {
return false;
}
return invoiceCollection.closed_at !== null;
};
const isOrderInvoiceCollectionBooked = (order) => {
return order.invoice_collection.booked_invoice_id !== null;
const invoiceCollection = getInvoiceCollection(order);
if (!invoiceCollection) {
return false;
}
return invoiceCollection.booked_invoice_id !== null;
};
const isOrderStripeInvoicePaid = (order) => {
if (order.stripe_invoice_module) {
return order.stripe_invoice_module.paid;
const stripeInvoiceModule = getStripeInvoiceModule(order);
if (stripeInvoiceModule) {
return stripeInvoiceModule.paid;
}
return false;
};
@@ -91,41 +118,41 @@ const doesOrderHaveErrorMessage = (order) => {
};
const getOrderInvoiceStatusBarColor = (order) => {
if (order.economic_invoice_module) {
// Check the invoice status
var color_class = 'has-text-danger';
if (isOrderInvoicedWithStripe(order)) {
isOrderStripeInvoicePaid(order)
? color_class = 'has-text-success'
: color_class = 'has-text-warning';
}
if (isOrderInvoicedWithEconomic(order)) {
isOrderEconomicInvoiceBooked(order)
? color_class = 'has-text-success'
: color_class = 'has-text-warning';
}
if (doesOrderHaveInvoiceCollection(order)) {
color_class = 'has-text-grey';
// Check if the invoice collection is closed
if (isOrderInvoiceCollectionClosed(order)) {
color_class = 'has-text-warning';
// Check if the invoice collection is booked
if (isOrderInvoiceCollectionBooked(order)) {
color_class = 'has-text-success';
}
// Check the invoice status
let color_class = 'has-text-danger';
if (isOrderInvoicedWithStripe(order)) {
isOrderStripeInvoicePaid(order)
? color_class = 'has-text-success'
: color_class = 'has-text-warning';
}
if (isOrderInvoicedWithEconomic(order)) {
isOrderEconomicInvoiceBooked(order)
? color_class = 'has-text-success'
: color_class = 'has-text-warning';
}
if (doesOrderHaveInvoiceCollection(order)) {
color_class = 'has-text-grey';
// Check if the invoice collection is closed
if (isOrderInvoiceCollectionClosed(order)) {
color_class = 'has-text-warning';
// Check if the invoice collection is booked
if (isOrderInvoiceCollectionBooked(order)) {
color_class = 'has-text-success';
}
}
if (doesOrderHaveErrorMessage(order)) {
color_class = 'has-text-danger';
}
// Return the color class
return color_class;
}
return '';
if (doesOrderHaveErrorMessage(order)) {
color_class = 'has-text-danger';
}
// Return the color class
return color_class;
};
const getOrderInvoiceStatusContent = (order) => {
const content = [];
const economicInvoiceModule = getEconomicInvoiceModule(order);
const stripeInvoiceModule = getStripeInvoiceModule(order);
const invoiceCollection = getInvoiceCollection(order);
if (isOrderInvoiced(order)) {
// If the order is invoiced with e-conomic, show the invoice id
if (isOrderInvoicedWithEconomic(order)) {
@@ -133,15 +160,15 @@ const getOrderInvoiceStatusContent = (order) => {
? content.push('Bogført i e-conomic')
: content.push('Gemt som kladde i e-conomic');
isOrderEconomicInvoiceBooked(order)
? content.push('Faktura ID: <strong>' + order.economic_invoice_module.invoice_id + '</strong>')
: content.push('Faktura Kladde ID: <strong>' + order.economic_invoice_module.invoice_draft_id + '</strong>');
? content.push('Faktura ID: <strong>' + (economicInvoiceModule?.invoice_id ?? '-') + '</strong>')
: content.push('Faktura Kladde ID: <strong>' + (economicInvoiceModule?.invoice_draft_id ?? '-') + '</strong>');
}
// If the order is invoiced with stripe, show the invoice id
if (isOrderInvoicedWithStripe(order)) {
isOrderStripeInvoicePaid(order)
? content.push('Fakturaen er <strong>betalt</strong>')
: content.push('Fakturaen er <strong>ikke betalt</strong>');
content.push('Faktura ID: <strong>' + order.stripe_invoice_module.id + '</strong>');
content.push('Faktura ID: <strong>' + (stripeInvoiceModule?.id ?? '-') + '</strong>');
}
content.push(''); // Add a line break
isOrderInvoicedWithEconomic(order)
@@ -159,7 +186,7 @@ const getOrderInvoiceStatusContent = (order) => {
}
}
content.push(''); // Add a line break
content.push('Faktura samling ID: <strong>' + order.invoice_collection.id + '</strong>');
content.push('Faktura samling ID: <strong>' + (invoiceCollection?.id ?? '-') + '</strong>');
// Add the Error message if it exists
if (doesOrderHaveErrorMessage(order)) {
content.push('Fejl: <strong class="has-text-danger">' + order.error_message + '</strong>');
@@ -344,159 +371,29 @@ const getUniqueInvoiceCollections = () => {
return [...new Set(props.objects.map(order => order.invoice_collection_id))];
};
const invoiceCollectionQueueInProgress = ref([]);
const invoiceCollectionQueueFailed = ref([]);
const invoiceCollectionQueueSuccess = ref([]);
const invoiceCollectionQueue = ref([]);
const invoiceCollectionMaxConnections = 2;
const invoiceCollectionQueueInProgress = invoiceQueue.invoiceCollectionQueueInProgress;
const invoiceCollectionQueueFailed = invoiceQueue.invoiceCollectionQueueFailed;
const invoiceCollectionQueueSuccess = invoiceQueue.invoiceCollectionQueueSuccess;
const invoiceCollectionQueue = invoiceQueue.invoiceCollectionQueue;
const collectionQueueLog = invoiceQueue.collectionQueueLog;
const isInvoiceMultipleCollectionsModalOpen = ref(false);
const isInvoiceQueueBusy = computed(() => {
return invoiceCollectionQueue.value.length > 0 || invoiceCollectionQueueInProgress.value.length > 0;
});
const invoiceSelectedCollections = () => {
// Open the invoice multiple collections modal
isInvoiceMultipleCollectionsModalOpen.value = true;
// Check if any invoice collections are selected
if (selectedInvoiceCollections.value.length > 0) {
// If they are, show a message
console.log('Selected invoice collections: ' + selectedInvoiceCollections.value);
for (const invoiceCollectionId of selectedInvoiceCollections.value) {
// Log the invoice collection id
console.log('Invoice collection id: ', invoiceCollectionId);
// Add the invoice collection to the queue
addInvoiceCollectionToQueue(invoiceCollectionId);
}
// Process the invoice collection queue
processInvoiceCollectionQueue();
} else {
// If they are not, show a message
console.log('No invoice collections selected');
}
};
const addInvoiceCollectionToQueue = (invoiceCollectionId) => {
// Check if the invoice collection is already in the queue
if (invoiceCollectionQueue.value.includes(invoiceCollectionId)) {
// If it is, remove it from the queue
invoiceCollectionQueue.value = invoiceCollectionQueue.value.filter(id => id !== invoiceCollectionId);
} else {
// If it is not, add it to the queue
invoiceCollectionQueue.value.push(invoiceCollectionId);
}
};
const processInvoiceCollectionQueue = () => {
// Check if the queue is empty
if (invoiceCollectionQueue.value.length === 0) {
// If it is, show a message
console.log('Invoice collection queue is empty');
if (selectedInvoiceCollections.value.length === 0) {
return;
}
// Check if the max connections are reached
if (invoiceCollectionQueueInProgress.value.length >= invoiceCollectionMaxConnections) {
// If they are, show a message
console.log('Max connections reached');
return;
}
while (invoiceCollectionQueueInProgress.value.length < invoiceCollectionMaxConnections && invoiceCollectionQueue.value.length > 0) {
// Process the next invoice collection
processNextInvoiceCollection();
}
invoiceQueue.addInvoiceCollectionsToQueue(selectedInvoiceCollections.value);
invoiceQueue.processInvoiceCollectionQueue();
};
const processNextInvoiceCollection = () => {
// Check if the queue is empty
if (invoiceCollectionQueue.value.length === 0) {
// If it is, show a message
console.log('Invoice collection queue is empty');
return;
}
// Get the next invoice collection from the queue
const invoiceCollectionId = invoiceCollectionQueue.value.shift();
// Process the invoice collection
processInvoiceCollection(invoiceCollectionId);
};
const getFirstInvoiceWithCollectionId = (invoiceCollectionId) => {
// Get the first invoice with the given collection id
return props.objects.find(order => order.invoice_collection_id === invoiceCollectionId);
};
const processInvoiceCollection = (invoiceCollectionId) => {
// Check if the invoice collection is already in progress
if (invoiceCollectionQueueInProgress.value.includes(invoiceCollectionId)) {
// If it is, remove it from the in progress list
invoiceCollectionQueueInProgress.value = invoiceCollectionQueueInProgress.value.filter(id => id !== invoiceCollectionId);
} else {
// If it is not, add it to the in progress list
invoiceCollectionQueueInProgress.value.push(invoiceCollectionId);
}
// Process the invoice collection
console.log('Processing invoice collection: ' + invoiceCollectionId);
// Send the invoice collection request to the server
// Send the request
SessionUser.request(
'/collected-invoices/economic',
'POST',
{
id: parseInt(invoiceCollectionId)
}
).then((response) => {
if (response.data && response.data.data && response.data.success) {
// If the request was successful, add the invoice collection to the success list
console.log('Invoice collection processed successfully: ' + invoiceCollectionId, response);
addInvoiceCollectionToSuccess(invoiceCollectionId);
addCollectionQueueLog(
true, // Success state
invoiceCollectionId, // Invoice collection id
'Invoice collection processed successfully', // Error message
);
} else {
// If the request failed, add the invoice collection to the failed list
console.log('Invoice collection processing failed: ' + invoiceCollectionId, response);
invoiceCollectionQueueFailed.value.push(invoiceCollectionId);
addCollectionQueueLog(
false, // Success state
invoiceCollectionId, // Invoice collection id
'Unknown error', // Error message
);
}
}
).catch((error) => {
// If the request failed, add the invoice collection to the failed list
console.log('Invoice collection processing failed 2: ' + invoiceCollectionId, error);
invoiceCollectionQueueFailed.value.push(invoiceCollectionId);
let errorMessage = error.response.data.data.message;
addCollectionQueueLog(
false, // Success state
invoiceCollectionId, // Invoice collection id
errorMessage, // Error message
);
}).finally(() => {
// Remove the invoice collection from the in progress list
invoiceCollectionQueueInProgress.value = invoiceCollectionQueueInProgress.value.filter(id => id !== invoiceCollectionId);
// Process the next invoice collection in the queue
processInvoiceCollectionQueue();
});
};
const collectionQueueLog = ref([]);
const addCollectionQueueLog = (success, invoiceCollectionId, errorMessage) => {
// Add the log to the collection queue log
collectionQueueLog.value.push({
success: success,
invoiceCollectionId: invoiceCollectionId,
errorMessage: errorMessage
});
};
const addInvoiceCollectionToSuccess = (invoiceCollectionId) => {
// Check if the invoice collection is already in the success list
if (invoiceCollectionQueueSuccess.value.includes(invoiceCollectionId)) {
// If it is, remove it from the success list
invoiceCollectionQueueSuccess.value = invoiceCollectionQueueSuccess.value.filter(id => id !== invoiceCollectionId);
} else {
// If it is not, add it to the success list
invoiceCollectionQueueSuccess.value.push(invoiceCollectionId);
}
const retryInvoiceCollection = (invoiceCollectionId) => {
invoiceQueue.retryInvoiceCollection(parseInt(invoiceCollectionId));
};
const confirmCloseModal = () => {
@@ -527,6 +424,7 @@ const confirmCloseModal = () => {
<button
class="button is-small"
@click="invoiceSelectedCollections()"
:disabled="selectedInvoiceCollections.length === 0 || isInvoiceQueueBusy"
>
{{SessionUser.objects.global.language.invoice}} {{SessionUser.objects.global.language.selected_multiple}} ({{selectedInvoiceCollections.length}})
</button>
@@ -729,7 +627,7 @@ const confirmCloseModal = () => {
}"
v-bind:visibility="{
icon: false,
dropdown: (order.reg_1.length > 7),
dropdown: hasLongText(order.reg_1),
}"
v-bind:dropdown_content="{
title: null,
@@ -746,7 +644,7 @@ const confirmCloseModal = () => {
</td>
<td>
<ColorIndicator
v-show="order.reg_2 !== ''"
v-show="(order.reg_2 || '') !== ''"
v-bind:label="{
text: order.reg_2,
classes: [],
@@ -754,7 +652,7 @@ const confirmCloseModal = () => {
}"
v-bind:visibility="{
icon: false,
dropdown: (order.reg_2.length > 7),
dropdown: hasLongText(order.reg_2),
}"
v-bind:dropdown_content="{
title: null,
@@ -771,7 +669,7 @@ const confirmCloseModal = () => {
</td>
<td>
<ColorIndicator
v-show="order.reg_3 !== ''"
v-show="(order.reg_3 || '') !== ''"
v-bind:label="{
text: order.reg_3,
classes: [],
@@ -779,7 +677,7 @@ const confirmCloseModal = () => {
}"
v-bind:visibility="{
icon: false,
dropdown: (order.reg_3.length > 7),
dropdown: hasLongText(order.reg_3),
}"
v-bind:dropdown_content="{
title: null,
@@ -796,7 +694,7 @@ const confirmCloseModal = () => {
</td>
<td>
<ColorIndicator
v-show="order.reference !== ''"
v-show="(order.reference || '') !== ''"
v-bind:label="{
text: order.reference,
classes: [],
@@ -804,7 +702,7 @@ const confirmCloseModal = () => {
}"
v-bind:visibility="{
icon: false,
dropdown: (order.reference.length > 7),
dropdown: hasLongText(order.reference),
}"
v-bind:dropdown_content="{
title: null,
@@ -821,7 +719,7 @@ const confirmCloseModal = () => {
</td>
<td>
<ColorIndicator
v-show="order.notes !== ''"
v-show="(order.notes || '') !== ''"
v-bind:label="{
text: order.notes,
classes: [],
@@ -829,7 +727,7 @@ const confirmCloseModal = () => {
}"
v-bind:visibility="{
icon: false,
dropdown: (order.notes.length > 7),
dropdown: hasLongText(order.notes),
}"
v-bind:dropdown_content="{
title: null,
@@ -854,6 +752,7 @@ const confirmCloseModal = () => {
v-bind:order_id="order.id"
v-bind:invoice_collection_id="order.invoice_collection_id"
v-bind:reg_1="order.reg_1"
:refreshFunction="loadList"
>
<template v-slot:actions>
<!--
@@ -902,8 +801,10 @@ const confirmCloseModal = () => {
v-bind:failed="invoiceCollectionQueueFailed"
v-bind:succeeded="invoiceCollectionQueueSuccess"
v-bind:queued="invoiceCollectionQueue"
v-bind:jobs="invoiceQueue.invoiceCollectionQueueJobs"
v-bind:log="collectionQueueLog"
@closeModal="confirmCloseModal()"
@retry="retryInvoiceCollection"
/>
</template>
@@ -929,4 +830,4 @@ const confirmCloseModal = () => {
.status-bar.has-text-success {
border-color: #48c774;
}
</style>
</style>
@@ -0,0 +1,14 @@
export const isUsageOrderAttachedToOrder = (object) => {
const duplicates = Array.isArray(object?.duplicates) ? object.duplicates : [];
return duplicates.some((duplicate) => duplicate?.wash_id === object?.wash_id);
};
export const filterUsageOrdersByAttachment = (objects, showOnlyUnattachedVehicle) => {
const sourceObjects = Array.isArray(objects) ? objects : [];
if (!showOnlyUnattachedVehicle) {
return sourceObjects;
}
return sourceObjects.filter((object) => !isUsageOrderAttachedToOrder(object));
};

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