Remove edge gateway components and templates associated with outdated workflows.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"version": 1,
|
||||
"snapshot_of": "front-end-vue",
|
||||
"projects": {
|
||||
"front-end-vue": {
|
||||
"display_name": "Truckwash Frontend",
|
||||
"relative_root": ".",
|
||||
"commands": {
|
||||
"setup": {
|
||||
"default": "npm ci\nnpx playwright install"
|
||||
},
|
||||
"run": {
|
||||
"default": "npm run dev"
|
||||
},
|
||||
"test_unit": {
|
||||
"default": "npm run test:unit"
|
||||
},
|
||||
"test_e2e": {
|
||||
"default": "npm run test:e2e:smoke"
|
||||
},
|
||||
"test_full": {
|
||||
"default": "npm run test:e2e:ci"
|
||||
},
|
||||
"debug": {
|
||||
"default": "npx playwright test --debug"
|
||||
}
|
||||
},
|
||||
"codex_environment": {
|
||||
"name": "pleno-vue",
|
||||
"actions": [
|
||||
{
|
||||
"name": "Run dev",
|
||||
"icon": "run",
|
||||
"command_id": "run"
|
||||
},
|
||||
{
|
||||
"name": "Vitest unit tests",
|
||||
"icon": "test",
|
||||
"command_id": "test_unit"
|
||||
},
|
||||
{
|
||||
"name": "Playwright smoke",
|
||||
"icon": "test",
|
||||
"command_id": "test_e2e"
|
||||
},
|
||||
{
|
||||
"name": "Playwright full suite",
|
||||
"icon": "test",
|
||||
"command_id": "test_full"
|
||||
},
|
||||
{
|
||||
"name": "Playwright debug",
|
||||
"icon": "debug",
|
||||
"command_id": "debug"
|
||||
},
|
||||
{
|
||||
"name": "AI workflow check",
|
||||
"icon": "debug",
|
||||
"literal_command": {
|
||||
"default": "node scripts/sync-ai-workflow.mjs --check"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_content": {
|
||||
"aiassistant_tests_lines": [
|
||||
"# Frontend Testing Rules",
|
||||
"",
|
||||
"These rules apply to Vue changes, browser flows, and frontend test updates.",
|
||||
"",
|
||||
"1. Add or update tests for every new feature, bug fix, API integration change, or auth, booking, POS, or i18n workflow change.",
|
||||
"2. Browser validation runs through Playwright. Legacy browser-driver tooling is outside the supported workflow.",
|
||||
"3. Use `npm run test:unit` for Vitest coverage of isolated logic and component behavior.",
|
||||
"4. Use `npm run test:e2e:smoke` for the narrow Playwright browser pass on changed user flows.",
|
||||
"5. Use `npm run test:e2e:ci` when a change affects shared navigation, authentication, release-critical flows, or multiple browser projects.",
|
||||
"6. Use `npx playwright test --debug` for interactive debugging and trace capture.",
|
||||
"7. Prefer stable selectors, explicit fixtures, and assertions about user-visible behavior.",
|
||||
"8. Avoid manual sleeps. Let Playwright waiting and expectations drive synchronization.",
|
||||
"9. If a bug fix needs a browser regression test, prove the failure first and then keep the test with the fix.",
|
||||
"",
|
||||
"Canonical workflow reference: `.ai-workflow/workflow.md`."
|
||||
],
|
||||
"junie_lines": [
|
||||
"# Truckwash Frontend Development Guidelines",
|
||||
"",
|
||||
"This file is generated from the canonical AI workflow and is the supported Junie-facing reference for the frontend repository.",
|
||||
"",
|
||||
"## Build And Run",
|
||||
"",
|
||||
"- Setup: `npm ci` and `npx playwright install`.",
|
||||
"- Start development server: `npm run dev`.",
|
||||
"- Production build sanity check: `npm run build`.",
|
||||
"",
|
||||
"## Testing",
|
||||
"",
|
||||
"- Vitest unit tests: `npm run test:unit`.",
|
||||
"- Playwright smoke: `npm run test:e2e:smoke`.",
|
||||
"- Playwright full suite: `npm run test:e2e:ci`.",
|
||||
"- Playwright debug: `npx playwright test --debug`.",
|
||||
"- Browser validation is Playwright-first. Legacy browser-driver tooling is not part of the supported workflow.",
|
||||
"",
|
||||
"## Workflow Notes",
|
||||
"",
|
||||
"- Generated assistant metadata is checked with `node scripts/sync-ai-workflow.mjs --check`.",
|
||||
"- Keep frontend browser verification aligned with the existing Playwright projects and runner scripts in `package.json`.",
|
||||
"- Use the narrowest relevant test command first, then expand to the broader suite when the change risk requires it.",
|
||||
"",
|
||||
"Canonical workflow reference: `.ai-workflow/workflow.md`."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"assistants": {
|
||||
"codex": {
|
||||
"description": "Codex environment files and actions."
|
||||
},
|
||||
"aiassistant": {
|
||||
"description": "Project-specific AI Assistant rules."
|
||||
},
|
||||
"junie": {
|
||||
"description": "Project-specific Junie guidance."
|
||||
},
|
||||
"copilot": {
|
||||
"description": "Standardized Copilot task dispatch workflow."
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"setup": {
|
||||
"description": "Install dependencies and prepare the supported local environment."
|
||||
},
|
||||
"run": {
|
||||
"description": "Start the primary local development entrypoint for the project."
|
||||
},
|
||||
"test_unit": {
|
||||
"description": "Run the project's narrow unit-style verification command."
|
||||
},
|
||||
"test_e2e": {
|
||||
"description": "Run the project's targeted browser or end-to-end verification command."
|
||||
},
|
||||
"test_full": {
|
||||
"description": "Run the broader high-confidence verification command when the project defines one."
|
||||
},
|
||||
"debug": {
|
||||
"description": "Run the supported debug entrypoint."
|
||||
}
|
||||
},
|
||||
"generated_outputs": [
|
||||
{
|
||||
"id": "frontend_codex_environment",
|
||||
"template": "codex_project_environment",
|
||||
"project": "front-end-vue",
|
||||
"path": ".codex/environments/environment.toml"
|
||||
},
|
||||
{
|
||||
"id": "frontend_aiassistant_tests",
|
||||
"template": "aiassistant_frontend_tests_rule",
|
||||
"project": "front-end-vue",
|
||||
"path": ".aiassistant/rules/Creating and maintaining tests.md"
|
||||
},
|
||||
{
|
||||
"id": "frontend_junie_guidelines",
|
||||
"template": "junie_frontend_guidelines",
|
||||
"project": "front-end-vue",
|
||||
"path": ".junie/guidelines.md"
|
||||
}
|
||||
],
|
||||
"sync_targets": {
|
||||
"front-end-vue": {
|
||||
"source_root": ".",
|
||||
"destination": "C:\\Users\\2jepp\\WebstormProjects\\pleno-vue",
|
||||
"supported_metadata_dirs": [
|
||||
".codex",
|
||||
".aiassistant",
|
||||
".junie",
|
||||
".github",
|
||||
".ai-workflow",
|
||||
"scripts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"unsupported": {
|
||||
"mcp": [
|
||||
"front-end-vue/.ai/mcp/mcp.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<!-- AUTOGENERATED SNAPSHOT for front-end-vue: refresh from the canonical workspace with `node scripts/sync-ai-workflow.mjs --write`. -->
|
||||
|
||||
# Developer AI Workflow
|
||||
|
||||
This directory is the canonical source of truth for the repository's developer-facing AI workflow.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep Codex, AI Assistant, Junie, and Copilot aligned from one maintained source.
|
||||
- Make assistant metadata deterministic so the generated files can be rewritten safely and checked in CI.
|
||||
- Preserve the current mirror-repo workflow for `backend-php` and `front-end-vue`.
|
||||
- Keep all changes in this workflow scoped to developer tooling and documentation. Runtime OpenAI features stay out of scope.
|
||||
|
||||
## Supported Assistants
|
||||
|
||||
- `Codex`: local environments and actions under `.codex/environments`.
|
||||
- `AI Assistant`: generated guidance under `.aiassistant/rules`.
|
||||
- `Junie`: generated project guidance under `.junie/guidelines.md`.
|
||||
- `Copilot`: standardized issue-dispatch workflow content under `.github/workflows/copilot.yml`.
|
||||
|
||||
## Global Rules
|
||||
|
||||
1. Change only `.ai-workflow/workflow.md` and `.ai-workflow/manifest.json` when updating the developer AI workflow.
|
||||
2. Regenerate all derived files with `node scripts/sync-ai-workflow.mjs --write`.
|
||||
3. Validate drift with `node scripts/sync-ai-workflow.mjs --check`.
|
||||
4. Generated assistant files are not hand-edited.
|
||||
5. Unsupported surfaces stay unsupported until they have a real owner and a real config.
|
||||
6. `front-end-vue/.ai/mcp/mcp.json` is intentionally unsupported and should not be recreated until there is an actual MCP integration to maintain.
|
||||
|
||||
## Command Matrix
|
||||
|
||||
### `backend-php`
|
||||
|
||||
- Setup: use the existing setup scripts in `backend-php/scripts`.
|
||||
- Run: start the API stack with `traefik`, `redis`, `mysql-debug`, `php1`, and `caddy`.
|
||||
- Debug: tail `php1` logs.
|
||||
- PHP verification: always run backend validation in the `php1` container.
|
||||
- Unit tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:unit"`.
|
||||
- Integration tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:integration"`.
|
||||
- API tests: `docker compose exec -T php1 sh -lc "cd /var/www/html && composer test:api"`.
|
||||
|
||||
### `front-end-vue`
|
||||
|
||||
- Setup: `npm ci` and `npx playwright install`.
|
||||
- Run: `npm run dev`.
|
||||
- Unit tests: `npm run test:unit` with Vitest.
|
||||
- Browser smoke: `npm run test:e2e:smoke`.
|
||||
- Browser full suite: `npm run test:e2e:ci`.
|
||||
- Debug: `npx playwright test --debug`.
|
||||
- Browser validation is Playwright-first. WebdriverIO and Appium are not part of the supported workflow.
|
||||
|
||||
### `automation`
|
||||
|
||||
- Setup: `npm ci` and `npx playwright install`.
|
||||
- Run: `npx playwright test --ui`.
|
||||
- Browser tests: `npx playwright test`.
|
||||
- Debug: `npx playwright test --debug`.
|
||||
|
||||
## Mirror Repos And Sync
|
||||
|
||||
- `backend-php` mirrors to `C:\Users\2jepp\PhpstormProjects\api`.
|
||||
- `front-end-vue` mirrors to `C:\Users\2jepp\WebstormProjects\pleno-vue`.
|
||||
- Supported metadata directories that must stay mirrored are `.codex`, `.aiassistant`, `.junie`, `.github`, generated `.ai-workflow`, and `scripts/sync-ai-workflow.mjs`.
|
||||
- Cache and build directories remain excluded from the watcher.
|
||||
- The mirror repos receive generated snapshots of `.ai-workflow` and `scripts/sync-ai-workflow.mjs` so their local CI can run `--check` without depending on the combined workspace root.
|
||||
|
||||
## Generated Outputs
|
||||
|
||||
- Root Codex environment for combined backend and frontend entrypoints.
|
||||
- Backend and frontend Codex environments.
|
||||
- Backend and frontend AI Assistant guidance.
|
||||
- Backend and frontend Junie guidance.
|
||||
- Backend Copilot dispatcher workflow.
|
||||
- Backend and frontend snapshot copies of `.ai-workflow` plus `scripts/sync-ai-workflow.mjs` for mirrored repositories.
|
||||
@@ -2,256 +2,20 @@
|
||||
apply: always
|
||||
---
|
||||
|
||||
# Guidelines for Creating and Maintaining Tests
|
||||
<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->
|
||||
|
||||
## When to Create Tests
|
||||
# Frontend Testing Rules
|
||||
|
||||
Tests **MUST** be created when any of the following criteria are met:
|
||||
These rules apply to Vue changes, browser flows, and frontend test updates.
|
||||
|
||||
1. **New Feature Development**: Any new feature, component, or functionality requires accompanying tests
|
||||
2. **Bug Fixes**: When fixing a bug, write a test that reproduces the issue first, then verify the fix
|
||||
3. **API Changes**: Changes to APIs, services, or data-fetching logic require updated or new tests
|
||||
4. **Critical User Flows**: Any modification to authentication, payments, bookings, or POS flows
|
||||
5. **Component Behavior Changes**: When modifying Vue component logic, props, or events
|
||||
6. **i18n/Localization Changes**: When adding or modifying translation keys
|
||||
1. Add or update tests for every new feature, bug fix, API integration change, or auth, booking, POS, or i18n workflow change.
|
||||
2. Browser validation runs through Playwright. Legacy browser-driver tooling is outside the supported workflow.
|
||||
3. Use `npm run test:unit` for Vitest coverage of isolated logic and component behavior.
|
||||
4. Use `npm run test:e2e:smoke` for the narrow Playwright browser pass on changed user flows.
|
||||
5. Use `npm run test:e2e:ci` when a change affects shared navigation, authentication, release-critical flows, or multiple browser projects.
|
||||
6. Use `npx playwright test --debug` for interactive debugging and trace capture.
|
||||
7. Prefer stable selectors, explicit fixtures, and assertions about user-visible behavior.
|
||||
8. Avoid manual sleeps. Let Playwright waiting and expectations drive synchronization.
|
||||
9. If a bug fix needs a browser regression test, prove the failure first and then keep the test with the fix.
|
||||
|
||||
## Running Tests When Adding Tests
|
||||
|
||||
When creating or modifying tests, you **MUST**:
|
||||
|
||||
1. **Run the specific test file** to verify it passes:
|
||||
```bash
|
||||
npx playwright test tests/your-test-file.spec.ts
|
||||
```
|
||||
|
||||
2. **Run the full test suite** before committing to ensure no regressions:
|
||||
```bash
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
3. **Verify test failures before fixes**: If writing a test for a bug, confirm the test fails before applying the fix
|
||||
|
||||
## Testing Framework
|
||||
|
||||
This project uses **Playwright** as the primary testing framework. Playwright provides reliable end-to-end testing with auto-waiting, powerful selectors, and built-in assertions.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run full test suite
|
||||
npx playwright test
|
||||
|
||||
# Run tests in headed mode (visible browser)
|
||||
npx playwright test --headed
|
||||
|
||||
# Run tests in a specific browser
|
||||
npx playwright test --project=chromium
|
||||
npx playwright test --project=firefox
|
||||
npx playwright test --project=webkit
|
||||
|
||||
# Run tests with UI mode for debugging
|
||||
npx playwright test --ui
|
||||
|
||||
# Generate HTML report
|
||||
npx playwright show-report
|
||||
```
|
||||
|
||||
## Test File Location and Naming
|
||||
|
||||
- **Location**: All test files should be placed in `tests/` or `tests/specs/`
|
||||
- **Naming Convention**: Use kebab-case with `.spec.ts` suffix
|
||||
- ✅ `i18n-settings-wheel.spec.ts`
|
||||
- ✅ `user-authentication.spec.ts`
|
||||
- ❌ `UserAuth.test.js`
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Basic Template
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Brief description of what this test suite covers
|
||||
*/
|
||||
test.describe('Feature or Component Name', () => {
|
||||
test('should describe expected behavior', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Test implementation
|
||||
await expect(page).toHaveTitle(/Expected Text/);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Key Conventions
|
||||
|
||||
1. **Use descriptive test names**: Start with `should` to describe expected behavior
|
||||
2. **Always use `async/await`**: All Playwright interactions are asynchronous
|
||||
3. **Include JSDoc comments**: Add a comment block at the top describing the test purpose
|
||||
4. **Group related tests**: Use `test.describe` blocks to organize related test cases
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Navigation
|
||||
|
||||
```typescript
|
||||
// Navigate to a page
|
||||
await page.goto('/');
|
||||
await page.goto('/dashboard');
|
||||
|
||||
// Wait for navigation
|
||||
await page.goto('/dashboard', { waitUntil: 'networkidle' });
|
||||
```
|
||||
|
||||
### Page Interactions
|
||||
|
||||
```typescript
|
||||
// Fill input fields
|
||||
await page.fill('input[name="username"]', 'testuser');
|
||||
await page.fill('input[name="password"]', 'password123');
|
||||
|
||||
// Click elements
|
||||
await page.click('button[id="login-button"]');
|
||||
|
||||
// Wait for elements
|
||||
await page.waitForSelector('input[name="2fa_code"]');
|
||||
|
||||
// Get element text
|
||||
const text = await page.locator('.message').textContent();
|
||||
|
||||
// Get page title
|
||||
const title = await page.title();
|
||||
|
||||
// Execute JavaScript in browser context
|
||||
const result = await page.evaluate(() => {
|
||||
return document.querySelector('.data')?.textContent;
|
||||
});
|
||||
```
|
||||
|
||||
### Locators (Recommended)
|
||||
|
||||
```typescript
|
||||
// Use locators for better reliability
|
||||
const submitButton = page.locator('button[type="submit"]');
|
||||
await submitButton.click();
|
||||
|
||||
// Locator with text
|
||||
const loginButton = page.locator('button:has-text("Login")');
|
||||
|
||||
// Get by role (accessibility-friendly)
|
||||
const heading = page.getByRole('heading', { name: 'Welcome' });
|
||||
|
||||
// Get by test ID
|
||||
const element = page.getByTestId('submit-button');
|
||||
```
|
||||
|
||||
### Assertions
|
||||
|
||||
```typescript
|
||||
// Use Playwright's built-in expect assertions
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
await expect(page).toHaveTitle(/Dashboard/);
|
||||
await expect(page.locator('.message')).toBeVisible();
|
||||
await expect(page.locator('.message')).toHaveText('Success');
|
||||
await expect(page.locator('input')).toHaveValue('expected value');
|
||||
await expect(page.locator('.items')).toHaveCount(5);
|
||||
```
|
||||
|
||||
## Authentication Helpers
|
||||
|
||||
Create reusable authentication helpers in `tests/fixtures/`:
|
||||
|
||||
```typescript
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Login as a customer user
|
||||
* @param page - Playwright page object
|
||||
* @param credentials - User credentials
|
||||
*/
|
||||
export async function loginAsUser(
|
||||
page: Page,
|
||||
credentials: { customerNumber: string; password: string }
|
||||
) {
|
||||
await page.goto('/login');
|
||||
await page.fill('input[name="customer_number"]', credentials.customerNumber);
|
||||
await page.fill('input[name="password"]', credentials.password);
|
||||
await page.click('button[id="login-button"]');
|
||||
await expect(page).toHaveURL('/user');
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### DO:
|
||||
- ✅ Test one behavior per `test` block
|
||||
- ✅ Use meaningful variable names
|
||||
- ✅ Use Playwright's auto-waiting (avoid manual waits when possible)
|
||||
- ✅ Use locators for element interactions
|
||||
- ✅ Use `expect` assertions with auto-retry
|
||||
- ✅ Clean up test state when necessary
|
||||
- ✅ Use test fixtures for setup/teardown
|
||||
|
||||
### DON'T:
|
||||
- ❌ Write tests that depend on execution order
|
||||
- ❌ Hardcode sensitive data (use environment variables)
|
||||
- ❌ Use `page.waitForTimeout()` for synchronization (use proper waits)
|
||||
- ❌ Leave commented-out code in test files
|
||||
|
||||
## Testing i18n/Translations
|
||||
|
||||
When testing internationalization:
|
||||
|
||||
1. Define expected translation keys as an array
|
||||
2. Fetch locale files via page context
|
||||
3. Verify all required keys exist in each locale
|
||||
4. Check parity between locales (same keys in all languages)
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const requiredKeys = ['key1', 'key2', 'key3'];
|
||||
|
||||
test('should have all translations in English', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const result = await page.evaluate(async (keys) => {
|
||||
const response = await fetch('/src/i18n/locales/en.json');
|
||||
const data = await response.json();
|
||||
const missingKeys = keys.filter(key => !(key in data));
|
||||
return { success: missingKeys.length === 0, missingKeys };
|
||||
}, requiredKeys);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
## Mobile Testing
|
||||
|
||||
Playwright supports mobile emulation out of the box:
|
||||
|
||||
```typescript
|
||||
import { test, devices } from '@playwright/test';
|
||||
|
||||
// Use device presets
|
||||
test.use({ ...devices['iPhone 13'] });
|
||||
|
||||
test('should work on mobile', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Test mobile-specific behavior
|
||||
});
|
||||
```
|
||||
|
||||
Configure mobile projects in `playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
projects: [
|
||||
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
|
||||
{ name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
|
||||
]
|
||||
```
|
||||
|
||||
## Maintaining Tests
|
||||
|
||||
1. **Keep tests updated**: When modifying features, update corresponding tests
|
||||
2. **Review failing tests**: Investigate failures before marking as skipped
|
||||
3. **Remove obsolete tests**: Delete tests for removed features
|
||||
4. **Refactor shared logic**: Extract common test utilities to helper files in `tests/fixtures/`
|
||||
Canonical workflow reference: `.ai-workflow/workflow.md`.
|
||||
|
||||
@@ -11,14 +11,41 @@ npx playwright install
|
||||
[[actions]]
|
||||
name = "Run dev"
|
||||
icon = "run"
|
||||
command = "npm run dev"
|
||||
command = '''
|
||||
npm run dev
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "Run all tests"
|
||||
name = "Vitest unit tests"
|
||||
icon = "test"
|
||||
command = "npm run test:all"
|
||||
command = '''
|
||||
npm run test:unit
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "Run debug"
|
||||
name = "Playwright smoke"
|
||||
icon = "test"
|
||||
command = '''
|
||||
npm run test:e2e:smoke
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "Playwright full suite"
|
||||
icon = "test"
|
||||
command = '''
|
||||
npm run test:e2e:ci
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "Playwright debug"
|
||||
icon = "debug"
|
||||
command = "npx playwright test --debug"
|
||||
command = '''
|
||||
npx playwright test --debug
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "AI workflow check"
|
||||
icon = "debug"
|
||||
command = '''
|
||||
node scripts/sync-ai-workflow.mjs --check
|
||||
'''
|
||||
|
||||
@@ -27,6 +27,9 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Check AI workflow sync
|
||||
run: node scripts/sync-ai-workflow.mjs --check
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
|
||||
+18
-68
@@ -1,77 +1,27 @@
|
||||
# Project Guidelines
|
||||
<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->
|
||||
|
||||
## Project Overview
|
||||
# Truckwash Frontend Development Guidelines
|
||||
|
||||
This repository contains a Vue 3 single-page application built with Vite. It powers dashboard-style pages and modules such as bookings and POS flows. The UI is primarily styled with Bulma. The app leverages libraries like FullCalendar and Chart.js for scheduling and analytics.
|
||||
This file is generated from the canonical AI workflow and is the supported Junie-facing reference for the frontend repository.
|
||||
|
||||
- Tech stack: Vue 3, Vite 5, Vue Router, Vuex, Bulma, FullCalendar, Chart.js
|
||||
- Notable integrations: toast notifications, QR/Barcode utilities, and PWA/devtools plugins
|
||||
- Target: modern browsers and mobile WebView contexts
|
||||
## Build And Run
|
||||
|
||||
Local development
|
||||
- Requirements: Node.js 18+ (recommended to match Vite 5), npm 9+
|
||||
- Install dependencies: `npm install`
|
||||
- Start dev server: `npm run dev` (default at http://localhost:5173)
|
||||
- Setup: `npm ci` and `npx playwright install`.
|
||||
- Start development server: `npm run dev`.
|
||||
- Production build sanity check: `npm run build`.
|
||||
|
||||
Build and preview
|
||||
- Production build: `npm run build` (output in `dist/`)
|
||||
- Preview build locally: `npm run preview`
|
||||
## Testing
|
||||
|
||||
Testing
|
||||
- Framework: WebdriverIO (wdio) with Vite service; Appium available for mobile testing
|
||||
- Common commands:
|
||||
- `npm run wdio` — run test suite per `wdio.conf.js`
|
||||
- `npm run test:mobile` — run tests excluding Safari
|
||||
- `npm run test:android` — run Android-targeted specs
|
||||
- Prerequisites for mobile tests may include Android SDK/emulator or iOS tooling and Appium drivers
|
||||
- Vitest unit tests: `npm run test:unit`.
|
||||
- Playwright smoke: `npm run test:e2e:smoke`.
|
||||
- Playwright full suite: `npm run test:e2e:ci`.
|
||||
- Playwright debug: `npx playwright test --debug`.
|
||||
- Browser validation is Playwright-first. Legacy browser-driver tooling is not part of the supported workflow.
|
||||
|
||||
Environment configuration
|
||||
- Place environment variables in `.env.*` files (Vite loads `VITE_`-prefixed vars into the client)
|
||||
## Workflow Notes
|
||||
|
||||
See the sections below for code style and detailed project structure.
|
||||
- Generated assistant metadata is checked with `node scripts/sync-ai-workflow.mjs --check`.
|
||||
- Keep frontend browser verification aligned with the existing Playwright projects and runner scripts in `package.json`.
|
||||
- Use the narrowest relevant test command first, then expand to the broader suite when the change risk requires it.
|
||||
|
||||
## Code Style and Conventions
|
||||
|
||||
### Vue Components
|
||||
|
||||
- Use PascalCase for component names (e.g., `UserProfile.vue`)
|
||||
- Implement Single File Components (SFC) pattern
|
||||
- Keep components focused and single-responsibility
|
||||
- Use composition API for new components
|
||||
- Document component props and events
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
- Use ES6+ features
|
||||
- Maintain consistent naming conventions (camelCase for variables/methods)
|
||||
- Document complex logic with clear comments
|
||||
- Use TypeScript for type safety when possible
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Root Directory Structure
|
||||
|
||||
- `/src` - Source code files
|
||||
- `/assets` - Static assets (images, fonts, etc.)
|
||||
- `/components` - Reusable Vue components
|
||||
- `/views` - Page components
|
||||
- `/router` - Vue Router configuration
|
||||
- `/store` - Vuex store modules
|
||||
- `/services` - API and external service integrations
|
||||
- `/utils` - Utility functions and helpers
|
||||
- `/styles` - Global styles and variables
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- `vite.config.js` - Vite configuration
|
||||
- `.env.*` - Environment variables
|
||||
- `package.json` - Project dependencies and scripts
|
||||
- `jsconfig.json` - JavaScript/TypeScript tooling and path aliases
|
||||
|
||||
### File Naming Conventions
|
||||
|
||||
- Components: PascalCase (e.g., `UserProfile.vue`)
|
||||
- Utilities: camelCase (e.g., `formatDate.js`)
|
||||
- Store modules: camelCase (e.g., `userStore.js`)
|
||||
- Style files: kebab-case (e.g., `main-styles.scss`)
|
||||
- Test files: `*.spec.js` or `*.test.js`
|
||||
Canonical workflow reference: `.ai-workflow/workflow.md`.
|
||||
|
||||
+13
-1
@@ -2175,6 +2175,17 @@ paths:
|
||||
two_factor_enabled:
|
||||
type: boolean
|
||||
description: Indicates if 2FA is enabled for this account
|
||||
runtime_config:
|
||||
type: object
|
||||
properties:
|
||||
economic:
|
||||
type: object
|
||||
properties:
|
||||
transaction_draft_customer_number:
|
||||
type: integer
|
||||
nullable: true
|
||||
additionalProperties: false
|
||||
additionalProperties: true
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
@@ -12011,12 +12022,13 @@ components:
|
||||
type: object
|
||||
properties:
|
||||
module: { type: string, enum: [economic] }
|
||||
variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber] }
|
||||
variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber] }
|
||||
type: { type: string, enum: [string, int] }
|
||||
value:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: integer
|
||||
nullable: true
|
||||
required: [module, variable, type, value]
|
||||
|
||||
RecaptchaConfigEntry:
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const scriptSource = await fs.readFile(__filename, "utf8");
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const workspaceRoot = path.resolve(args.root ?? path.join(__dirname, ".."));
|
||||
const workflowPath = path.join(workspaceRoot, ".ai-workflow", "workflow.md");
|
||||
const manifestPath = path.join(workspaceRoot, ".ai-workflow", "manifest.json");
|
||||
|
||||
const workflow = await fs.readFile(workflowPath, "utf8");
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
||||
|
||||
validateManifest(manifest);
|
||||
|
||||
const outputs = buildOutputs({
|
||||
manifest,
|
||||
workflow,
|
||||
scriptSource,
|
||||
});
|
||||
|
||||
if (args.mode === "check") {
|
||||
const drifted = await findDriftedOutputs(workspaceRoot, outputs);
|
||||
if (drifted.length > 0) {
|
||||
console.error("AI workflow outputs are out of sync:");
|
||||
for (const drift of drifted) {
|
||||
console.error(`- ${drift}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("AI workflow outputs are in sync.");
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = await writeOutputs(workspaceRoot, outputs);
|
||||
if (changed.length === 0) {
|
||||
console.log("AI workflow outputs are already up to date.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Updated AI workflow outputs:");
|
||||
for (const changedPath of changed) {
|
||||
console.log(`- ${changedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
mode: null,
|
||||
root: null,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (arg === "--write") {
|
||||
args.mode = "write";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--check") {
|
||||
args.mode = "check";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--root") {
|
||||
args.root = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
|
||||
if (!args.mode) {
|
||||
throw new Error("Expected --write or --check.");
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function validateManifest(manifest) {
|
||||
const requiredKeys = ["projects", "assistants", "commands", "generated_outputs", "sync_targets"];
|
||||
for (const key of requiredKeys) {
|
||||
if (!(key in manifest)) {
|
||||
throw new Error(`Manifest is missing required key: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
const seenPaths = new Set();
|
||||
for (const output of manifest.generated_outputs) {
|
||||
if (!output.path || !output.template) {
|
||||
throw new Error("Each generated output must define path and template.");
|
||||
}
|
||||
|
||||
if (seenPaths.has(output.path)) {
|
||||
throw new Error(`Duplicate generated output path: ${output.path}`);
|
||||
}
|
||||
|
||||
seenPaths.add(output.path);
|
||||
}
|
||||
}
|
||||
|
||||
function buildOutputs(context) {
|
||||
const outputs = [];
|
||||
for (const output of context.manifest.generated_outputs) {
|
||||
outputs.push({
|
||||
path: normalizeRelativePath(output.path),
|
||||
content: renderTemplate(output, context),
|
||||
});
|
||||
}
|
||||
return outputs;
|
||||
}
|
||||
|
||||
function renderTemplate(output, context) {
|
||||
const templateMap = {
|
||||
codex_workspace_environment: () => renderWorkspaceCodexEnvironment(context.manifest),
|
||||
codex_project_environment: () => renderProjectCodexEnvironment(context.manifest, output.project),
|
||||
aiassistant_backend_tests_rule: () =>
|
||||
renderAiAssistantRule(context.manifest.projects["backend-php"].generated_content.aiassistant_tests_lines),
|
||||
aiassistant_backend_routes_rule: () =>
|
||||
renderAiAssistantRule(context.manifest.projects["backend-php"].generated_content.aiassistant_routes_lines),
|
||||
aiassistant_frontend_tests_rule: () =>
|
||||
renderAiAssistantRule(context.manifest.projects["front-end-vue"].generated_content.aiassistant_tests_lines),
|
||||
junie_backend_guidelines: () =>
|
||||
renderJunieGuidelines(context.manifest.projects["backend-php"].generated_content.junie_lines),
|
||||
junie_frontend_guidelines: () =>
|
||||
renderJunieGuidelines(context.manifest.projects["front-end-vue"].generated_content.junie_lines),
|
||||
copilot_dispatcher_workflow: () => renderCopilotWorkflow(context.manifest.copilot),
|
||||
workflow_snapshot: () => renderWorkflowSnapshot(context.workflow, output.project),
|
||||
project_snapshot_manifest: () => renderProjectSnapshotManifest(context.manifest, output.project),
|
||||
project_sync_script: () => context.scriptSource,
|
||||
};
|
||||
|
||||
const renderer = templateMap[output.template];
|
||||
if (!renderer) {
|
||||
throw new Error(`Unsupported template: ${output.template}`);
|
||||
}
|
||||
|
||||
return ensureTrailingNewline(renderer());
|
||||
}
|
||||
|
||||
function renderWorkspaceCodexEnvironment(manifest) {
|
||||
return renderCodexEnvironment({
|
||||
name: manifest.workspace.name,
|
||||
setup: manifest.workspace.codex_environment.setup,
|
||||
actions: manifest.workspace.codex_environment.actions,
|
||||
manifest,
|
||||
workspaceScoped: true,
|
||||
});
|
||||
}
|
||||
|
||||
function renderProjectCodexEnvironment(manifest, projectKey) {
|
||||
const project = manifest.projects[projectKey];
|
||||
return renderCodexEnvironment({
|
||||
name: project.codex_environment.name,
|
||||
setup: project.commands.setup,
|
||||
actions: project.codex_environment.actions,
|
||||
manifest,
|
||||
projectKey,
|
||||
workspaceScoped: false,
|
||||
});
|
||||
}
|
||||
|
||||
function renderCodexEnvironment({ name, setup, actions, manifest, projectKey = null, workspaceScoped }) {
|
||||
const lines = [
|
||||
"# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY",
|
||||
"version = 1",
|
||||
`name = "${name}"`,
|
||||
"",
|
||||
];
|
||||
|
||||
appendTomlCommandBlock(lines, "setup", setup);
|
||||
|
||||
for (const action of actions) {
|
||||
lines.push("");
|
||||
lines.push("[[actions]]");
|
||||
lines.push(`name = "${escapeTomlString(action.name)}"`);
|
||||
lines.push(`icon = "${escapeTomlString(action.icon)}"`);
|
||||
lines.push(`command = ${renderTomlMultiline(resolveActionCommand(action, manifest, projectKey, workspaceScoped))}`);
|
||||
|
||||
if (action.platform) {
|
||||
lines.push(`platform = "${escapeTomlString(action.platform)}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function appendTomlCommandBlock(lines, key, commandSpec = {}) {
|
||||
lines.push(`[${key}]`);
|
||||
lines.push(`script = ${renderTomlMultiline(commandSpec.default ?? "")}`);
|
||||
|
||||
if (commandSpec.win32 !== undefined) {
|
||||
lines.push("");
|
||||
lines.push(`[${key}.win32]`);
|
||||
lines.push(`script = ${renderTomlMultiline(commandSpec.win32)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveActionCommand(action, manifest, projectKey, workspaceScoped) {
|
||||
if (action.literal_command) {
|
||||
return action.literal_command.default ?? "";
|
||||
}
|
||||
|
||||
if (!action.command_id) {
|
||||
throw new Error(`Action ${action.name} is missing command_id or literal_command.`);
|
||||
}
|
||||
|
||||
const resolvedProjectKey = action.project ?? projectKey;
|
||||
if (!resolvedProjectKey) {
|
||||
throw new Error(`Action ${action.name} does not resolve to a project.`);
|
||||
}
|
||||
|
||||
const project = manifest.projects[resolvedProjectKey];
|
||||
const command = project.commands[action.command_id]?.default ?? "";
|
||||
|
||||
if (!workspaceScoped) {
|
||||
return command;
|
||||
}
|
||||
|
||||
return joinCommands([`cd ${project.relative_root}`, command]);
|
||||
}
|
||||
|
||||
function renderAiAssistantRule(lines) {
|
||||
return [
|
||||
"---",
|
||||
"apply: always",
|
||||
"---",
|
||||
"",
|
||||
"<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->",
|
||||
"",
|
||||
...lines,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderJunieGuidelines(lines) {
|
||||
return [
|
||||
"<!-- AUTOGENERATED: Run `node scripts/sync-ai-workflow.mjs --write`. -->",
|
||||
"",
|
||||
...lines,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderCopilotWorkflow(copilot) {
|
||||
const body = copilot.body_lines
|
||||
.map((line) => line.replaceAll("{{ACTOR}}", "$ACTOR").replaceAll("{{TASK}}", "$TASK"))
|
||||
.join("\n");
|
||||
|
||||
return [
|
||||
`name: ${copilot.workflow_name}`,
|
||||
"on:",
|
||||
" workflow_dispatch:",
|
||||
" inputs:",
|
||||
" task:",
|
||||
` description: '${copilot.input_description}'`,
|
||||
" required: true",
|
||||
" type: string",
|
||||
"",
|
||||
"jobs:",
|
||||
" assign-task:",
|
||||
" runs-on: ubuntu-latest",
|
||||
" permissions:",
|
||||
" issues: write",
|
||||
" steps:",
|
||||
" - name: Checkout repository",
|
||||
" uses: actions/checkout@v4",
|
||||
"",
|
||||
" - name: Create GitHub Issue for Copilot",
|
||||
" env:",
|
||||
" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}",
|
||||
" TASK: ${{ github.event.inputs.task }}",
|
||||
" ACTOR: ${{ github.actor }}",
|
||||
" run: |",
|
||||
" BODY=\"$(cat <<EOF",
|
||||
...body.split("\n").map((line) => ` ${line}`),
|
||||
" EOF",
|
||||
" )\"",
|
||||
" gh issue create \\",
|
||||
` --title "${copilot.title_prefix} $TASK" \\`,
|
||||
" --body \"$BODY\" \\",
|
||||
` --label "${copilot.issue_label}"`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderWorkflowSnapshot(workflow, projectKey) {
|
||||
return [
|
||||
`<!-- AUTOGENERATED SNAPSHOT for ${projectKey}: refresh from the canonical workspace with \`node scripts/sync-ai-workflow.mjs --write\`. -->`,
|
||||
"",
|
||||
workflow.trimEnd(),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderProjectSnapshotManifest(manifest, projectKey) {
|
||||
const snapshotManifest = buildProjectSnapshotManifest(manifest, projectKey);
|
||||
return `${JSON.stringify(snapshotManifest, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function buildProjectSnapshotManifest(manifest, projectKey) {
|
||||
const project = JSON.parse(JSON.stringify(manifest.projects[projectKey]));
|
||||
const prefix = `${manifest.projects[projectKey].relative_root}/`;
|
||||
|
||||
project.relative_root = ".";
|
||||
|
||||
const snapshotOutputs = manifest.generated_outputs
|
||||
.filter((output) => output.project === projectKey)
|
||||
.filter((output) => output.template !== "workflow_snapshot")
|
||||
.filter((output) => output.template !== "project_snapshot_manifest")
|
||||
.filter((output) => output.template !== "project_sync_script")
|
||||
.map((output) => ({
|
||||
...output,
|
||||
path: normalizeRelativePath(output.path.slice(prefix.length)),
|
||||
}));
|
||||
|
||||
const snapshotManifest = {
|
||||
"version": manifest.version,
|
||||
"snapshot_of": projectKey,
|
||||
"projects": {
|
||||
[projectKey]: project,
|
||||
},
|
||||
"assistants": manifest.assistants,
|
||||
"commands": manifest.commands,
|
||||
"generated_outputs": snapshotOutputs,
|
||||
"sync_targets": {
|
||||
[projectKey]: {
|
||||
"source_root": ".",
|
||||
"destination": manifest.sync_targets[projectKey]?.destination ?? "",
|
||||
"supported_metadata_dirs": manifest.sync_targets[projectKey]?.supported_metadata_dirs ?? [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (projectKey === "backend-php" && manifest.copilot) {
|
||||
snapshotManifest.copilot = manifest.copilot;
|
||||
}
|
||||
|
||||
if (projectKey === "front-end-vue" && manifest.unsupported) {
|
||||
snapshotManifest.unsupported = manifest.unsupported;
|
||||
}
|
||||
|
||||
return snapshotManifest;
|
||||
}
|
||||
|
||||
async function findDriftedOutputs(workspaceRoot, outputs) {
|
||||
const drifted = [];
|
||||
for (const output of outputs) {
|
||||
const absolutePath = path.join(workspaceRoot, output.path);
|
||||
const currentContent = await readIfExists(absolutePath);
|
||||
if (currentContent !== output.content) {
|
||||
drifted.push(output.path);
|
||||
}
|
||||
}
|
||||
return drifted;
|
||||
}
|
||||
|
||||
async function writeOutputs(workspaceRoot, outputs) {
|
||||
const changed = [];
|
||||
for (const output of outputs) {
|
||||
const absolutePath = path.join(workspaceRoot, output.path);
|
||||
const currentContent = await readIfExists(absolutePath);
|
||||
if (currentContent === output.content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, output.content, "utf8");
|
||||
changed.push(output.path);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
async function readIfExists(filePath) {
|
||||
try {
|
||||
return await fs.readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function joinCommands(commands) {
|
||||
return commands.filter((command) => command && command.trim() !== "").join("\n");
|
||||
}
|
||||
|
||||
function renderTomlMultiline(value) {
|
||||
return `'''\n${value ?? ""}\n'''`;
|
||||
}
|
||||
|
||||
function escapeTomlString(value) {
|
||||
return String(value).replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
||||
}
|
||||
|
||||
function normalizeRelativePath(filePath) {
|
||||
return filePath.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function ensureTrailingNewline(value) {
|
||||
return value.endsWith("\n") ? value : `${value}\n`;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -28,6 +28,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
footerTestId: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
hasSelectedStyle: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
@@ -103,8 +107,8 @@ watch(() => props.forceState, (newVal) => {
|
||||
</div>
|
||||
</template>
|
||||
<!-- Slot for footer -->
|
||||
<template v-if="isOpen || props.forceStateFooter">
|
||||
<div class="card-footer">
|
||||
<template v-if="(isOpen || props.forceStateFooter) && $slots.footer">
|
||||
<div class="card-footer" :data-testid="props.footerTestId || undefined">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -160,4 +164,4 @@ watch(() => props.forceState, (newVal) => {
|
||||
border: 2px solid #dbdbdb;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@ const { t } = useI18n();
|
||||
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import OrderAttachmentsActionButton from "@/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue";
|
||||
import AssignDraftOrderCustomerModal from "@/components/displays/modals/AssignDraftOrderCustomerModal.vue";
|
||||
|
||||
const props = defineProps({
|
||||
orders: {
|
||||
@@ -39,8 +40,13 @@ const props = defineProps({
|
||||
type: Array, // Any order ids that should be excluded from the list, will also be excluded from calculations (e.g. hidden orders).
|
||||
default: () => [],
|
||||
},
|
||||
showDraftAssignmentActions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { departments, getDepartments, isLoading, getDepartmentName } from "@/components/pagination/departmentTabs.vue";
|
||||
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
@@ -57,6 +63,8 @@ import EditableTableColumn from "@/components/displays/buttons/EditableTableColu
|
||||
import ViewportResponsiveWrapper from "@/components/viewport/conditions/elements/ViewportResponsiveWrapper.vue";
|
||||
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const redirectDepartmentOrderPage = (orderId, departmentId, newTab = true) => {
|
||||
// Check if the user has access to the department
|
||||
if (!SessionUser.canAccessDepartment(departmentId)) {
|
||||
@@ -670,11 +678,62 @@ const closeOrderActionsModal = () => {
|
||||
const openOrderActionsModal = (order) => {
|
||||
selectedOrderForActionsMenu.value = order;
|
||||
};
|
||||
const selectedOrderForDraftAssignment = ref(null);
|
||||
const isDraftAssignmentModalOpen = computed(() => {
|
||||
return selectedOrderForDraftAssignment.value !== null;
|
||||
});
|
||||
|
||||
const canEditTransaction = (order) => {
|
||||
return SessionUser.canAccessAdmin() || SessionUser.canAccessDepartment(order.department_id);
|
||||
};
|
||||
|
||||
const currentRoutePath = computed(() => {
|
||||
const resolvedRoutePath =
|
||||
route && typeof route === "object" ? route.path || route.fullPath || route.name || "" : "";
|
||||
|
||||
if (resolvedRoutePath) {
|
||||
return String(resolvedRoutePath);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
return String(window.location.pathname || "");
|
||||
}
|
||||
|
||||
return "";
|
||||
});
|
||||
|
||||
const shouldShowDraftAssignmentActions = computed(() => {
|
||||
return Boolean(props.showDraftAssignmentActions) || currentRoutePath.value.includes("/modules/pos/drafts");
|
||||
});
|
||||
|
||||
const canShowDraftAssignmentAction = () => {
|
||||
return shouldShowDraftAssignmentActions.value;
|
||||
};
|
||||
|
||||
const closeDraftAssignmentModal = () => {
|
||||
selectedOrderForDraftAssignment.value = null;
|
||||
};
|
||||
|
||||
const openDraftAssignmentModal = (order) => {
|
||||
if (!canShowDraftAssignmentAction()) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeOrderActionsModal();
|
||||
selectedOrderForDraftAssignment.value = order;
|
||||
};
|
||||
|
||||
const handleDraftAssignmentSuccess = async () => {
|
||||
closeDraftAssignmentModal();
|
||||
await loadList();
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: t("admin.pos.drafts_assignment.success"),
|
||||
timer: 1800,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Cashier name formatting
|
||||
* This:
|
||||
@@ -1076,6 +1135,17 @@ const formatCashierName = (order) => {
|
||||
<!--<td>{{ order.completed_at }}</td>-->
|
||||
<td class="is-narrow">
|
||||
<div class="buttons pos-order-list-actions">
|
||||
<button
|
||||
v-if="shouldShowDraftAssignmentActions"
|
||||
class="button is-small is-link is-light pos-order-list-assign-customer-button"
|
||||
:data-testid="`draft-order-assign-customer-button-${order.id}`"
|
||||
@click="openDraftAssignmentModal(order)"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-user-check"></i>
|
||||
</span>
|
||||
<span>{{ t("admin.pos.drafts_assignment.button") }}</span>
|
||||
</button>
|
||||
<!-- Attachments -->
|
||||
<OrderAttachmentsActionButton
|
||||
:order="order"
|
||||
@@ -1290,6 +1360,17 @@ const formatCashierName = (order) => {
|
||||
<hr />
|
||||
<!-- Actions and attachments -->
|
||||
<div class="buttons is-right">
|
||||
<button
|
||||
v-if="shouldShowDraftAssignmentActions"
|
||||
class="button is-small is-link is-light pos-order-list-assign-customer-button"
|
||||
:data-testid="`draft-order-assign-customer-button-${order.id}`"
|
||||
@click="openDraftAssignmentModal(order)"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-user-check"></i>
|
||||
</span>
|
||||
<span>{{ t("admin.pos.drafts_assignment.button") }}</span>
|
||||
</button>
|
||||
<ActionSettingsWheelButton
|
||||
v-bind:user_id="order.user_id"
|
||||
v-bind:order_id="order.id"
|
||||
@@ -1708,6 +1789,17 @@ const formatCashierName = (order) => {
|
||||
<button class="delete" aria-label="close" @click="closeOrderActionsModal"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<button
|
||||
v-if="shouldShowDraftAssignmentActions && selectedOrderForActionsMenu"
|
||||
class="button is-link is-light is-fullwidth mb-4"
|
||||
:data-testid="`draft-order-assign-customer-modal-button-${selectedOrderForActionsMenu.id}`"
|
||||
@click="openDraftAssignmentModal(selectedOrderForActionsMenu)"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-user-check"></i>
|
||||
</span>
|
||||
<span>{{ t("admin.pos.drafts_assignment.button") }}</span>
|
||||
</button>
|
||||
<ActionSettingsWheelButton
|
||||
v-bind:user_id="selectedOrderForActionsMenu.user_id"
|
||||
v-bind:order_id="selectedOrderForActionsMenu.id"
|
||||
@@ -1731,6 +1823,12 @@ const formatCashierName = (order) => {
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<AssignDraftOrderCustomerModal
|
||||
v-if="isDraftAssignmentModalOpen && selectedOrderForDraftAssignment"
|
||||
:order="selectedOrderForDraftAssignment"
|
||||
@close="closeDraftAssignmentModal"
|
||||
@assigned="handleDraftAssignmentSuccess"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -1758,12 +1856,18 @@ const formatCashierName = (order) => {
|
||||
.buttons.pos-order-list-actions {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.buttons.pos-order-list-actions > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pos-order-list-assign-customer-button {
|
||||
border-radius: 0.8rem;
|
||||
padding-inline: 0.85rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.pos-orders-mobile-card {
|
||||
border: 1px solid #d7dee8;
|
||||
|
||||
@@ -29,8 +29,6 @@ import PosDepartmentStepMobileFixedBottomControl
|
||||
import {PosOrderItem} from "@/components/displays/department/pos/steps/mobile/objects/PosOrderItem.vue";
|
||||
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 } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||
|
||||
@@ -437,6 +435,10 @@ const layout = {
|
||||
classes: <string[]>[]
|
||||
}
|
||||
|
||||
const onCopyLastOrder = (vehicleIndex: number) => {
|
||||
lastOrders.select(vehicleIndex);
|
||||
};
|
||||
|
||||
// When the primary product is long pressed, log the primary item details
|
||||
const primaryProduct = ref(null);
|
||||
const pointerDownTime = ref<Date>(null);
|
||||
@@ -890,6 +892,9 @@ const mapAddonsWithQuantity = (sourceAddons = [], previousAddons = []) =>
|
||||
};
|
||||
});
|
||||
|
||||
const hasConfiguredAddonQuantities = (addons = []) =>
|
||||
addons.some((addon) => Number(addon?.quantity ?? addon?.product?.quantity ?? 0) > 0);
|
||||
|
||||
watch(
|
||||
() => transactionItems.primaryItem.value,
|
||||
(nextPrimary, prevPrimary) => {
|
||||
@@ -900,7 +905,9 @@ watch(
|
||||
|
||||
const lastFetched = lastFetchedPrimaryItemProduct.value;
|
||||
const isNewPrimary = nextPrimary.id !== lastFetched?.id;
|
||||
const previousAddons = prevPrimary?.addons ?? transactionItems.primaryItem.value?.addons ?? [];
|
||||
const previousAddons = hasConfiguredAddonQuantities(nextPrimary?.addons ?? [])
|
||||
? nextPrimary.addons
|
||||
: prevPrimary?.addons ?? transactionItems.primaryItem.value?.addons ?? [];
|
||||
|
||||
if (isNewPrimary) {
|
||||
lastFetchedPrimaryItemProduct.value = nextPrimary;
|
||||
@@ -1002,8 +1009,15 @@ const filteredAddons = computed(() => {
|
||||
<template v-else>
|
||||
<div class="pos-mobile-step-layout" data-testid="pos-mobile-step-2">
|
||||
<!-- Customer selection -->
|
||||
<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"/>
|
||||
<section class="pos-mobile-customer-banner">
|
||||
<span class="pos-mobile-customer-banner__label">{{ SessionUser.objects.global.language.customer }}</span>
|
||||
<div class="pos-mobile-customer-banner__content" data-testid="pos-mobile-customer-name">
|
||||
<span class="icon pos-mobile-customer-banner__icon">
|
||||
<i class="fas fa-user"></i>
|
||||
</span>
|
||||
<span class="pos-mobile-customer-banner__name">{{ customer_name }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Registration numbers -->
|
||||
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
|
||||
<!-- Product -->
|
||||
@@ -1011,7 +1025,6 @@ const filteredAddons = computed(() => {
|
||||
v-on:pointerdown="pointerDown"
|
||||
v-on:pointerup="pointerUp"
|
||||
ref="primaryProduct"
|
||||
:classes="layout.classes"
|
||||
:product="transactionItems.primaryItem.value"
|
||||
/>
|
||||
<!-- Additional items -->
|
||||
@@ -1019,8 +1032,8 @@ const filteredAddons = computed(() => {
|
||||
<!-- Last order details -->
|
||||
<PosDepartmentStepMobile2LastOrder
|
||||
v-show="lastOrders.get(1)"
|
||||
@click="lastOrders.select(1)"
|
||||
:classes="layout.classes" :label="'ordered on...'" :subtitle="'Subtitle'" :lastOrder="lastOrders.get(1)"/>
|
||||
@copy="onCopyLastOrder(1)"
|
||||
:label="'ordered on...'" :subtitle="'Subtitle'" :lastOrder="lastOrders.get(1)"/>
|
||||
<!-- Addons (filtered based on customer restrictions) -->
|
||||
<PosDepartmentStepMobile2Addons :addons="filteredAddons" :label="'Add-ons'" :compact="false"/>
|
||||
<!-- Notes -->
|
||||
@@ -1089,4 +1102,49 @@ const filteredAddons = computed(() => {
|
||||
.pos-mobile-step-layout :deep(.control-field-input) {
|
||||
scroll-margin-bottom: calc(var(--pos-mobile-fixed-bottom-height, 0px) + 1rem);
|
||||
}
|
||||
|
||||
.pos-mobile-customer-banner {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f5f9ff 100%);
|
||||
border: 1px solid #d7e2f1;
|
||||
border-radius: 1.15rem;
|
||||
box-shadow: 0 18px 34px rgba(17, 46, 92, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding: 0.95rem 1rem;
|
||||
}
|
||||
|
||||
.pos-mobile-customer-banner__label {
|
||||
color: #5a6f8f;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pos-mobile-customer-banner__content {
|
||||
align-items: center;
|
||||
color: #17325c;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.pos-mobile-customer-banner__icon {
|
||||
align-items: center;
|
||||
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
|
||||
border-radius: 999px;
|
||||
color: #ffffff;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
height: 2.2rem;
|
||||
justify-content: center;
|
||||
width: 2.2rem;
|
||||
}
|
||||
|
||||
.pos-mobile-customer-banner__name {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
+189
-133
@@ -1,179 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, ref, computed } from "vue";
|
||||
import { computed, defineEmits, defineProps } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { PosOrder } from "../objects/PosOrder.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: "Ordered on Jun 16, 2025",
|
||||
required: true
|
||||
default: "Ordered on",
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: "Puller [1] + High gloss wax seal",
|
||||
required: true
|
||||
},
|
||||
defaultChecked: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
default: "",
|
||||
required: true,
|
||||
},
|
||||
lastOrder: {
|
||||
type: Object as () => PosOrder | null,
|
||||
default: null,
|
||||
required: false
|
||||
}
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
const checked = ref(props.defaultChecked);
|
||||
// Function to generate a summary from the last order
|
||||
function generateSummary(order: PosOrder): string {
|
||||
if (!order || !order.items || order.items.length === 0) {
|
||||
return "Denne ordre har ingen varer.";
|
||||
}
|
||||
const itemNames = order.items.map(item => item?.product?.name || "Ukendt vare");
|
||||
const uniqueItems = Array.from(new Set(itemNames));
|
||||
return uniqueItems.length > 1
|
||||
? `${uniqueItems.length} varer: ${uniqueItems.slice(0, 2).join(", ")}${uniqueItems.length > 2 ? " og flere" : ""}`
|
||||
: uniqueItems[0];
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
(e: "copy"): void;
|
||||
}>();
|
||||
|
||||
// Functions to determine the display values
|
||||
const displayLabel = computed(() => {
|
||||
return props.lastOrder
|
||||
? `Vasket d. ${SessionUser.functions.date.toLocal(new Date(props?.lastOrder?.created_at))}`
|
||||
: props.label;
|
||||
const orderItems = computed(() => (Array.isArray(props.lastOrder?.items) ? props.lastOrder.items : []));
|
||||
const primaryItem = computed(() => {
|
||||
const standaloneItems = orderItems.value.filter(
|
||||
(item) => item?.related_item_id === null || item?.related_item_id === undefined
|
||||
);
|
||||
return standaloneItems[0] ?? orderItems.value[0] ?? null;
|
||||
});
|
||||
const displaySubtitle = computed(() => {
|
||||
return props.lastOrder
|
||||
? generateSummary(props.lastOrder)
|
||||
: props.subtitle;
|
||||
const secondaryItems = computed(() => {
|
||||
const primaryItemId = Number(primaryItem.value?.id ?? 0);
|
||||
return orderItems.value.filter((item) => {
|
||||
const itemId = Number(item?.id ?? 0);
|
||||
if (itemId && primaryItemId && itemId === primaryItemId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return primaryItemId
|
||||
? Number(item?.related_item_id ?? 0) === primaryItemId ||
|
||||
item?.related_item_id === null ||
|
||||
item?.related_item_id === undefined
|
||||
: true;
|
||||
});
|
||||
});
|
||||
const createdAtLabel = computed(() => {
|
||||
if (!props.lastOrder?.created_at) {
|
||||
return props.label;
|
||||
}
|
||||
|
||||
return SessionUser.functions.date.toLocal(new Date(props.lastOrder.created_at));
|
||||
});
|
||||
const referenceLabel = computed(() => String(props.lastOrder?.reference ?? "").trim());
|
||||
const copyButtonLabel = computed(() => `${t("common.copy")} ${t("common.last_wash").toLowerCase()}`);
|
||||
const totalPrice = computed(() => {
|
||||
return SessionUser.functions.currency.toLocal(props.lastOrder?.total_net_amount || 0);
|
||||
});
|
||||
|
||||
const onCopy = () => {
|
||||
emit("copy");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Box -->
|
||||
<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 -->
|
||||
<div class="column has-text-left">
|
||||
<div class="is-flex is-align-items-center">
|
||||
<div>
|
||||
<p class="custom-title">{{ displayLabel }}</p>
|
||||
</div>
|
||||
<div class="last-order-card" data-testid="pos-mobile-last-order">
|
||||
<div class="last-order-card__header">
|
||||
<div>
|
||||
<p class="last-order-card__eyebrow">{{ t("common.last_wash") }}</p>
|
||||
<h3 class="last-order-card__title">{{ createdAtLabel }}</h3>
|
||||
</div>
|
||||
<span v-if="props.lastOrder?.total_net_amount" class="last-order-card__amount">
|
||||
{{ totalPrice }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="last-order-card__body">
|
||||
<div v-if="primaryItem" class="last-order-card__section">
|
||||
<span class="last-order-card__label">{{ t("common.service") }}</span>
|
||||
<p class="last-order-card__value">{{ primaryItem?.product?.name || primaryItem?.product_id }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="secondaryItems.length > 0" class="last-order-card__section">
|
||||
<span class="last-order-card__label">{{ SessionUser.objects.global.language.additional_items }}</span>
|
||||
<div class="last-order-card__tags">
|
||||
<span
|
||||
v-for="item in secondaryItems"
|
||||
:key="`${item?.id}-${item?.product_id}-${item?.related_item_id}`"
|
||||
class="last-order-card__tag"
|
||||
>
|
||||
{{ item?.product?.name || item?.product_id }}
|
||||
<template v-if="Number(item?.quantity ?? 1) > 1"> x{{ Number(item?.quantity ?? 1) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Radio button -->
|
||||
<div class="column is-narrow has-text-right">
|
||||
<!-- Align the radio button to the right -->
|
||||
<div class="is-pulled-right">
|
||||
<div class="radio-button-container is-flex is-align-items-center is-justify-content-center"
|
||||
@click="checked = !checked">
|
||||
<div class="radio-button-circle is-clickable"> <!-- The circle for the radio button -->
|
||||
<div class="radio-button-checkmark" v-if="checked"/> <!-- The checkmark inside the circle -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Custom subtitle -->
|
||||
<div class="column is-12 has-text-left">
|
||||
<p class="custom-subtitle">{{ displaySubtitle }}</p>
|
||||
|
||||
<div v-if="referenceLabel" class="last-order-card__section">
|
||||
<span class="last-order-card__label">{{ t("common.reference") }}</span>
|
||||
<p class="last-order-card__reference">{{ referenceLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="button last-order-card__button"
|
||||
type="button"
|
||||
data-testid="pos-mobile-copy-last-wash"
|
||||
@click.stop="onCopy"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-copy"></i>
|
||||
</span>
|
||||
<span>{{ copyButtonLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-subtitle {
|
||||
/* Puller [1] + Rim flex unit [2] */
|
||||
margin: 0 auto;
|
||||
height: 12px;
|
||||
|
||||
/* sm-tx */
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
line-height: 100%;
|
||||
/* identical to box height, or 12px */
|
||||
|
||||
color: #929292;
|
||||
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 1;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
.last-order-card {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(253, 185, 39, 0.18), transparent 42%),
|
||||
linear-gradient(180deg, #ffffff 0%, #fff8ee 100%);
|
||||
border: 1px solid #ecd7b8;
|
||||
border-radius: 1.15rem;
|
||||
box-shadow: 0 18px 34px rgba(72, 44, 4, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.95rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.radio-button-container {
|
||||
/* Button */
|
||||
|
||||
.last-order-card__header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.last-order-card__eyebrow {
|
||||
color: #8d6330;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 0.35rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.last-order-card__title {
|
||||
color: #4e3313;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
padding: 0px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
border-radius: 4px;
|
||||
}
|
||||
.radio-button-container:before {
|
||||
/* Rectangle 4 */
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
border: 1px solid #000000;
|
||||
border-radius: 10px;
|
||||
|
||||
.last-order-card__amount {
|
||||
background: rgba(143, 93, 25, 0.1);
|
||||
border-radius: 999px;
|
||||
color: #6c4312;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
padding: 0.45rem 0.7rem;
|
||||
}
|
||||
.radio-button-circle {
|
||||
/* Rectangle 4 */
|
||||
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
border: 1px solid #000000;
|
||||
border-radius: 10px;
|
||||
|
||||
.last-order-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.radio-button-checkmark {
|
||||
/* Rectangle 5 */
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
|
||||
background: #000000;
|
||||
border-radius: 10px;
|
||||
margin: 2px auto;
|
||||
|
||||
.last-order-card__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.box {
|
||||
/* Frame 88 */
|
||||
|
||||
box-sizing: border-box;
|
||||
.last-order-card__label {
|
||||
color: #9a6f3b;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Auto layout */
|
||||
padding: 0px 0px 8px 12px;
|
||||
.last-order-card__value,
|
||||
.last-order-card__reference {
|
||||
color: #4a2f11;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.last-order-card__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.last-order-card__tag {
|
||||
background: rgba(143, 93, 25, 0.08);
|
||||
border-radius: 999px;
|
||||
color: #6f4919;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
padding: 0.4rem 0.7rem;
|
||||
}
|
||||
|
||||
.last-order-card__button {
|
||||
align-items: center;
|
||||
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
|
||||
border: none;
|
||||
border-radius: 0.95rem;
|
||||
color: #ffffff;
|
||||
display: inline-flex;
|
||||
font-weight: 700;
|
||||
gap: 0.55rem;
|
||||
justify-content: center;
|
||||
min-height: 3rem;
|
||||
padding: 0.85rem 1rem;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
|
||||
border: 1px solid #545353;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.custom-title {
|
||||
/* Ordered on Jun 16, 2025 */
|
||||
|
||||
margin: 0 auto;
|
||||
height: 19px;
|
||||
|
||||
/* rg-tx */
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 19px;
|
||||
line-height: 120%;
|
||||
|
||||
color: #000000;
|
||||
.last-order-card__button:hover,
|
||||
.last-order-card__button:focus-visible {
|
||||
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
|
||||
+153
-113
@@ -1,148 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from "vue";
|
||||
import {getPicture} from "@/components/displays/department/pos/displays/Piktogrammer.vue";
|
||||
import { computed, defineProps } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { getPicture } from "@/components/displays/department/pos/displays/Piktogrammer.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { PosProduct } from "../objects/PosProduct.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
product: {
|
||||
type: Object as () => PosProduct | null,
|
||||
default: null,
|
||||
required: false
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
const productName = computed(() => props?.product?.name || "");
|
||||
const productPrice = computed(() => SessionUser.functions.currency.toLocal(props?.product?.price || 0));
|
||||
const productImage = computed(() => getPicture(props?.product?.piktogram || props?.product?.id || ""));
|
||||
const productBadge = computed(() => `${t("common.selected")} ${t("common.vehicle").toLowerCase()}`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Box -->
|
||||
<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">
|
||||
</div>
|
||||
<!-- Product id -->
|
||||
<div class="tag mb-2" v-show="false">
|
||||
<span>No. {{ props?.product?.piktogram || props?.product?.id }}</span>
|
||||
</div>
|
||||
<!-- Product name and price -->
|
||||
<div class="columns is-mobile is-vcentered">
|
||||
<!-- Product name -->
|
||||
<div class="column is-narrow">
|
||||
<p class="title is-5">{{ props?.product?.name || '' }}</p>
|
||||
<div class="vehicle-card" data-testid="pos-mobile-primary-product-card">
|
||||
<div class="vehicle-card__header">
|
||||
<div>
|
||||
<p class="vehicle-card__eyebrow">{{ t("common.vehicle") }}</p>
|
||||
<h3 class="vehicle-card__title">{{ productName }}</h3>
|
||||
</div>
|
||||
<!-- Product price -->
|
||||
<div class="column has-text-right" v-show="false">
|
||||
<p class="title is-5 is-grey">{{ SessionUser.functions.currency.toLocal(props?.product?.price || 0) }}</p>
|
||||
<span v-if="props.product" class="vehicle-card__badge">
|
||||
{{ productBadge }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="vehicle-card__content">
|
||||
<div class="vehicle-card__media">
|
||||
<img :src="productImage" :alt="productName || 'N/A'" class="vehicle-card__image" />
|
||||
</div>
|
||||
|
||||
<div class="vehicle-card__details">
|
||||
<div class="vehicle-card__detail">
|
||||
<span class="vehicle-card__detail-label">{{ t("common.service") }}</span>
|
||||
<span class="vehicle-card__detail-value">{{ productName }}</span>
|
||||
</div>
|
||||
<div class="vehicle-card__detail">
|
||||
<span class="vehicle-card__detail-label">{{ t("common.price") }}</span>
|
||||
<span class="vehicle-card__detail-value">{{ productPrice }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vehicle-card__footer">
|
||||
<span class="icon vehicle-card__footer-icon">
|
||||
<i class="fas fa-hand-pointer"></i>
|
||||
</span>
|
||||
<span class="vehicle-card__footer-text">{{ productBadge }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-image {
|
||||
/* truck2 */
|
||||
height: 120px;
|
||||
width: max-content;
|
||||
object-fit: contain;
|
||||
}
|
||||
.box {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
padding: 16px;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
/* Tab */
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
/* Auto layout */
|
||||
.vehicle-card {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(31, 91, 183, 0.16), transparent 42%),
|
||||
linear-gradient(180deg, #ffffff 0%, #f5f9ff 100%);
|
||||
border: 1px solid #d7e2f1;
|
||||
border-radius: 1.15rem;
|
||||
box-shadow: 0 18px 34px rgba(17, 46, 92, 0.08);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vehicle-card__header {
|
||||
align-items: flex-start;
|
||||
padding: 6px 4px;
|
||||
gap: 8px;
|
||||
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
|
||||
background: #F1F1F1;
|
||||
border: 1px solid #D9D9D9;
|
||||
border-radius: 2px;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 0;
|
||||
flex-grow: 0;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tag span {
|
||||
/* Category Tag */
|
||||
|
||||
width: 30px;
|
||||
height: 10px;
|
||||
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 10px;
|
||||
line-height: 100%;
|
||||
/* identical to box height, or 10px */
|
||||
|
||||
color: #000000;
|
||||
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 0;
|
||||
flex-grow: 0;
|
||||
.vehicle-card__eyebrow {
|
||||
color: #5a6f8f;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 0.35rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
/* Puller */
|
||||
|
||||
margin: 0 auto;
|
||||
height: 24px;
|
||||
|
||||
/* H3 */
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 24px;
|
||||
line-height: 100%;
|
||||
/* identical to box height, or 24px */
|
||||
|
||||
color: #000000;
|
||||
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 0;
|
||||
flex-grow: 0;
|
||||
.vehicle-card__title {
|
||||
color: #112e5c;
|
||||
font-size: 1.12rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title.is-grey {
|
||||
/* 579 kr */
|
||||
.vehicle-card__badge {
|
||||
background: rgba(17, 46, 92, 0.08);
|
||||
border-radius: 999px;
|
||||
color: #17325c;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
padding: 0.45rem 0.7rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
margin: 0 auto;
|
||||
height: 24px;
|
||||
.vehicle-card__content {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* H3 */
|
||||
font-family: 'Arial';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 24px;
|
||||
line-height: 100%;
|
||||
/* identical to box height, or 24px */
|
||||
.vehicle-card__media {
|
||||
align-items: center;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95) 0%, rgba(237, 244, 255, 0.9) 100%);
|
||||
border: 1px solid #dbe5f2;
|
||||
border-radius: 1rem;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 5.75rem;
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
width: 5.75rem;
|
||||
}
|
||||
|
||||
color: #929292;
|
||||
.vehicle-card__image {
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vehicle-card__details {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 1;
|
||||
flex-grow: 0;
|
||||
.vehicle-card__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.vehicle-card__detail-label {
|
||||
color: #6c7d98;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.vehicle-card__detail-value {
|
||||
color: #16335f;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.vehicle-card__footer {
|
||||
align-items: center;
|
||||
background: rgba(17, 46, 92, 0.05);
|
||||
border-radius: 0.9rem;
|
||||
color: #17325c;
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
justify-content: flex-start;
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
|
||||
.vehicle-card__footer-icon {
|
||||
color: #1f5bb7;
|
||||
}
|
||||
|
||||
.vehicle-card__footer-text {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
+76
-22
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { popups } from '../objects/PosDepartmentStepMobileFlow.vue';
|
||||
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
||||
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
|
||||
@@ -13,13 +14,35 @@ const handleHeaderClose = () => {
|
||||
|
||||
popups.clear();
|
||||
};
|
||||
|
||||
const popupShellStyle = computed(() => {
|
||||
const popupStyle = { ...(popups.get()?.style || {}) } as Record<string, string>;
|
||||
const viewportBound = "calc(100vh - var(--popup-top-clearance) - var(--popup-bottom-clearance))";
|
||||
|
||||
if (popupStyle.height) {
|
||||
popupStyle.height = `min(${popupStyle.height}, ${viewportBound})`;
|
||||
}
|
||||
|
||||
if (popupStyle.maxHeight) {
|
||||
popupStyle.maxHeight = `min(${popupStyle.maxHeight}, ${viewportBound})`;
|
||||
if (!popupStyle.height) {
|
||||
popupStyle.height = popupStyle.maxHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return popupStyle;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 || {}) }">
|
||||
<div class="popup-content" data-testid="pos-mobile-popup-shell" :style="popupShellStyle">
|
||||
<!-- Icon? and title? -->
|
||||
<WhiteBoxCard :defaultOpen="true" :hideHeader="popups.get()?.hideHeader || false">
|
||||
<WhiteBoxCard
|
||||
:defaultOpen="true"
|
||||
:hideHeader="popups.get()?.hideHeader || false"
|
||||
footerTestId="pos-mobile-popup-footer"
|
||||
>
|
||||
<template #header>
|
||||
<div class="card-header-icon">
|
||||
<UnknownCustomer/>
|
||||
@@ -41,20 +64,24 @@ const handleHeaderClose = () => {
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<component :is="popupComponentKeyToComponent(popups.get()?.component)" @close="popups.clear()" :style="{... popups.get()?.style || {} }"/>
|
||||
<div class="popup-scroll-region" data-testid="pos-mobile-popup-scroll-region">
|
||||
<component :is="popupComponentKeyToComponent(popups.get()?.component)" @close="popups.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #footer v-if="!popups.get()?.hideFooter && (popups.get()?.actionButtons && popups.get()?.actionButtons.length > 0)">
|
||||
<template
|
||||
v-for="(actionButton, index) in popups.get().actionButtons"
|
||||
:key="index"
|
||||
>
|
||||
<a class="card-footer-item"
|
||||
:class="'is-' + (actionButton?.color || 'primary')"
|
||||
:data-testid="actionButton.testId"
|
||||
@click="actionButton.onClick ? actionButton.onClick() : null">
|
||||
{{ actionButton.label }}
|
||||
</a>
|
||||
</template>
|
||||
<div class="popup-footer-actions">
|
||||
<template
|
||||
v-for="(actionButton, index) in popups.get().actionButtons"
|
||||
:key="index"
|
||||
>
|
||||
<a class="card-footer-item"
|
||||
:class="'is-' + (actionButton?.color || 'primary')"
|
||||
:data-testid="actionButton.testId"
|
||||
@click="actionButton.onClick ? actionButton.onClick() : null">
|
||||
{{ actionButton.label }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</WhiteBoxCard>
|
||||
</div>
|
||||
@@ -63,6 +90,10 @@ const handleHeaderClose = () => {
|
||||
|
||||
<style scoped>
|
||||
.popup-container {
|
||||
--popup-top-clearance: max(4.5rem, env(safe-area-inset-top, 0px) + 1rem);
|
||||
--popup-bottom-clearance: calc(
|
||||
var(--pos-mobile-fixed-bottom-height, 0px) + max(1rem, env(safe-area-inset-bottom, 0px) + 0.75rem)
|
||||
);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -73,7 +104,7 @@ const handleHeaderClose = () => {
|
||||
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);
|
||||
padding: var(--popup-top-clearance) 1rem var(--popup-bottom-clearance);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
@@ -85,26 +116,32 @@ const handleHeaderClose = () => {
|
||||
overflow: hidden;
|
||||
width: min(92vw, 26rem);
|
||||
max-width: 26rem;
|
||||
max-height: min(80dvh, calc(100vh - 6rem));
|
||||
max-height: min(80dvh, calc(100vh - var(--popup-top-clearance) - var(--popup-bottom-clearance)));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
box-shadow: 0 20px 60px rgba(7, 18, 46, 0.32);
|
||||
}
|
||||
.popup-content h2 {
|
||||
}
|
||||
|
||||
.popup-content :deep(.white-box) {
|
||||
.popup-content > :deep(.box.has-sharp-edges) {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.popup-content :deep(.card) {
|
||||
.popup-content > :deep(.box.has-sharp-edges > .card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.popup-content :deep(.card-header) {
|
||||
.popup-content > :deep(.box.has-sharp-edges > .card > .card-header) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -128,20 +165,37 @@ const handleHeaderClose = () => {
|
||||
color: #121b29;
|
||||
}
|
||||
|
||||
.popup-content :deep(.card-content) {
|
||||
.popup-content > :deep(.box.has-sharp-edges > .card > .card-content) {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.popup-scroll-region {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.popup-content :deep(.card-footer) {
|
||||
.popup-content > :deep(.box.has-sharp-edges > .card > .card-footer) {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e5ebf3;
|
||||
}
|
||||
|
||||
.popup-content :deep(.card-footer-item) {
|
||||
.popup-footer-actions {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.popup-footer-actions .card-footer-item {
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
+429
-130
@@ -1,184 +1,483 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineEmits, watch, ref } from "vue";
|
||||
import { computed, defineEmits, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
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 { searchAndSelectCustomer } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import {
|
||||
searchAndSelectCustomer,
|
||||
customer_id,
|
||||
customer_name,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { metadata, vehicles } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import VehicleCustomerSuggestionsPos from "@/components/forms/department/pos/input/vehicleCustomerSuggestionsPos.vue";
|
||||
import {popups} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { useDraftTransactionCustomer } from "@/composables/useDraftTransactionCustomer.js";
|
||||
import { useStripeReaderAvailability } from "@/composables/useStripeReaderAvailability.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { draftTransactionCustomerNumber, hasDraftTransactionCustomer } = useDraftTransactionCustomer();
|
||||
const { isCardPaymentAvailable } = useStripeReaderAvailability();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: "close"): void;
|
||||
}>();
|
||||
// To prevent "blinking" of search results when typing, we store a local copy of the results, that's only updated when the search results change
|
||||
|
||||
const CUSTOMER_PICKER_MODE_INVOICE = "invoice";
|
||||
const CUSTOMER_PICKER_MODE_DRAFT = "draft";
|
||||
const CUSTOMER_PICKER_MODE_CARD = "card";
|
||||
|
||||
const activeQuickAction = ref(CUSTOMER_PICKER_MODE_INVOICE);
|
||||
const localSearchResults = ref<PosSearchResult[]>([]);
|
||||
|
||||
const toPositiveInteger = (value: unknown) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const getCurrentCustomerNumber = () => toPositiveInteger(customer_id.value);
|
||||
|
||||
const getResolvedQuickAction = () => {
|
||||
const currentCustomerNumber = getCurrentCustomerNumber();
|
||||
|
||||
if (currentCustomerNumber === 999) {
|
||||
return CUSTOMER_PICKER_MODE_CARD;
|
||||
}
|
||||
|
||||
if (
|
||||
hasDraftTransactionCustomer.value &&
|
||||
currentCustomerNumber === toPositiveInteger(draftTransactionCustomerNumber.value)
|
||||
) {
|
||||
return CUSTOMER_PICKER_MODE_DRAFT;
|
||||
}
|
||||
|
||||
return CUSTOMER_PICKER_MODE_INVOICE;
|
||||
};
|
||||
|
||||
const applySearchResults = (rawResults: any[]) => {
|
||||
const tmp = rawResults.map((result) => {
|
||||
localSearchResults.value = rawResults.map((result) => {
|
||||
return <PosSearchResult>{
|
||||
customerId: result.customerNumber,
|
||||
customerName: result.name,
|
||||
barred: result?.barred ?? false,
|
||||
customerStatus: result?.barred ? 'card' : 'verified',
|
||||
customerStatus: result?.barred ? "card" : "verified",
|
||||
};
|
||||
});
|
||||
localSearchResults.value = tmp;
|
||||
};
|
||||
watch(isSearching, (newVal) => {
|
||||
if (!newVal) {
|
||||
|
||||
watch(isSearching, (newValue) => {
|
||||
if (!newValue) {
|
||||
applySearchResults(searchCustomerResults.value);
|
||||
}
|
||||
});
|
||||
// Format the search results to match the PosSearchResult interface
|
||||
|
||||
const formattedSearchResults = computed(() => {
|
||||
return localSearchResults.value;
|
||||
watch(
|
||||
[() => customer_id.value, draftTransactionCustomerNumber],
|
||||
() => {
|
||||
activeQuickAction.value = getResolvedQuickAction();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const isCustomerSelected = computed(() => String(customer_name.value ?? "").trim() !== "");
|
||||
const isInvoiceMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_INVOICE);
|
||||
const isDraftMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_DRAFT);
|
||||
const isCardMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_CARD);
|
||||
const shouldShowSearchInput = computed(() => isInvoiceMode.value);
|
||||
const formattedSearchResults = computed(() => localSearchResults.value);
|
||||
const isCardPaymentDisabled = computed(() => !isCardPaymentAvailable.value);
|
||||
const selectedCustomerNumber = computed(() => getCurrentCustomerNumber());
|
||||
const selectedCustomerMode = computed(() => {
|
||||
if (!isCustomerSelected.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getResolvedQuickAction();
|
||||
});
|
||||
// Require particular fields from the search results
|
||||
const onClick = (result: Partial<PosSearchResult>) => {
|
||||
console.log("Selected customer:", result);
|
||||
searchAndSelectCustomer(result.customerId)
|
||||
const selectedCustomerModeLabel = computed(() => {
|
||||
if (selectedCustomerMode.value === CUSTOMER_PICKER_MODE_CARD) {
|
||||
return t("pos.customer_picker.select_card_payment");
|
||||
}
|
||||
|
||||
if (selectedCustomerMode.value === CUSTOMER_PICKER_MODE_DRAFT) {
|
||||
return t("pos.customer_picker.select_draft_customer");
|
||||
}
|
||||
|
||||
return t("pos.customer_picker.select_customer_invoice");
|
||||
});
|
||||
|
||||
const focusCustomerSearchInput = async () => {
|
||||
await nextTick();
|
||||
const input = document.querySelector('[data-testid="pos-mobile-customer-search-input"]');
|
||||
if (input instanceof HTMLElement) {
|
||||
input.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = async (result: Partial<PosSearchResult>) => {
|
||||
activeQuickAction.value = CUSTOMER_PICKER_MODE_INVOICE;
|
||||
await searchAndSelectCustomer(result.customerId);
|
||||
metadata.setCustomerId(result.customerId);
|
||||
emit('close');
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const onClickSuggestion = (customerId: number) => {
|
||||
console.log("Selected customer from suggestion:", customerId);
|
||||
onClick({ customerId });
|
||||
metadata.setCustomerId(customerId);
|
||||
emit('close');
|
||||
const onClickSuggestion = async (customerId: number) => {
|
||||
await onClick({ customerId });
|
||||
};
|
||||
|
||||
const onClickDirectCardPayment = () => {
|
||||
const onSelectCustomerInvoice = async () => {
|
||||
activeQuickAction.value = CUSTOMER_PICKER_MODE_INVOICE;
|
||||
await focusCustomerSearchInput();
|
||||
};
|
||||
|
||||
const onClickDirectCardPayment = async () => {
|
||||
if (isCardPaymentDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeQuickAction.value = CUSTOMER_PICKER_MODE_CARD;
|
||||
const cardCustomerId = 999;
|
||||
searchAndSelectCustomer(cardCustomerId);
|
||||
await searchAndSelectCustomer(cardCustomerId);
|
||||
metadata.setCustomerId(cardCustomerId);
|
||||
emit('close');
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const onClickDraftCustomer = async () => {
|
||||
if (!hasDraftTransactionCustomer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeQuickAction.value = CUSTOMER_PICKER_MODE_DRAFT;
|
||||
await searchAndSelectCustomer(draftTransactionCustomerNumber.value);
|
||||
metadata.setCustomerId(draftTransactionCustomerNumber.value);
|
||||
emit("close");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 testId="pos-mobile-customer-search-input"/>
|
||||
<span class="icon is-right">
|
||||
<i class="fas fa-search"></i>
|
||||
<div class="pos-mobile-customer-popup" data-testid="pos-mobile-customer-popup">
|
||||
<section
|
||||
v-if="isCustomerSelected"
|
||||
class="customer-selection-summary"
|
||||
data-testid="pos-mobile-selected-customer-summary"
|
||||
>
|
||||
<div class="customer-selection-summary__label">
|
||||
{{ t("pos.customer_picker.selected_customer") }}
|
||||
</div>
|
||||
<div class="customer-selection-summary__content">
|
||||
<span class="icon customer-selection-summary__icon">
|
||||
<i class="fas fa-user-check"></i>
|
||||
</span>
|
||||
<div class="customer-selection-summary__text">
|
||||
<p class="customer-selection-summary__name">{{ customer_name }}</p>
|
||||
<p v-if="selectedCustomerNumber" class="customer-selection-summary__number">
|
||||
#{{ selectedCustomerNumber }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="customer-selection-summary__mode">
|
||||
{{ selectedCustomerModeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="formattedSearchResults.length > 0">
|
||||
<!-- Search results (If any) -->
|
||||
<ControlFieldInputSearchResults v-bind:results="formattedSearchResults" testIdPrefix="pos-mobile-customer-search-result" @result-clicked="onClick"/>
|
||||
</template>
|
||||
<div class="mt-2">
|
||||
</section>
|
||||
|
||||
<div class="customer-quick-actions" data-testid="pos-mobile-customer-action-group">
|
||||
<button
|
||||
class="button is-text is-fullwidth"
|
||||
type="button"
|
||||
data-testid="pos-mobile-direct-card-payment"
|
||||
@click="onClickDirectCardPayment"
|
||||
class="button is-light customer-quick-action customer-quick-action--invoice"
|
||||
:class="{ 'is-selected': isInvoiceMode }"
|
||||
type="button"
|
||||
data-testid="pos-mobile-customer-invoice"
|
||||
:aria-pressed="isInvoiceMode ? 'true' : 'false'"
|
||||
@click="onSelectCustomerInvoice"
|
||||
>
|
||||
Vælg direkte betaling med betalingskort
|
||||
<span class="icon is-small customer-quick-action__icon">
|
||||
<i class="fas fa-user"></i>
|
||||
</span>
|
||||
<span>{{ t("pos.customer_picker.select_customer_invoice") }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="hasDraftTransactionCustomer"
|
||||
class="button is-light customer-quick-action customer-quick-action--draft"
|
||||
:class="{ 'is-selected': isDraftMode }"
|
||||
type="button"
|
||||
data-testid="pos-mobile-draft-customer"
|
||||
:aria-pressed="isDraftMode ? 'true' : 'false'"
|
||||
@click="onClickDraftCustomer"
|
||||
>
|
||||
<span class="icon is-small customer-quick-action__icon">
|
||||
<i class="fas fa-file-invoice"></i>
|
||||
</span>
|
||||
<span>{{ t("pos.customer_picker.select_draft_customer") }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="button is-light customer-quick-action customer-quick-action--card"
|
||||
:class="{ 'is-selected': isCardMode, 'is-disabled': isCardPaymentDisabled }"
|
||||
type="button"
|
||||
data-testid="pos-mobile-direct-card-payment"
|
||||
:aria-pressed="isCardMode ? 'true' : 'false'"
|
||||
:disabled="isCardPaymentDisabled"
|
||||
@click="onClickDirectCardPayment"
|
||||
>
|
||||
<span class="icon is-small customer-quick-action__icon">
|
||||
<i class="fas fa-credit-card"></i>
|
||||
</span>
|
||||
<span>{{ t("pos.customer_picker.select_card_payment") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Customer suggestions (If any) -->
|
||||
<VehicleCustomerSuggestionsPos v-if="vehicles?.vehicle_1?.value?.reg" :reg_1="vehicles.vehicle_1.value.reg" @customerSelected="onClickSuggestion"/>
|
||||
|
||||
<section v-if="shouldShowSearchInput" class="customer-search-panel">
|
||||
<label class="customer-search-panel__label">{{ t("common.customer") }}</label>
|
||||
<div class="field customer-search-panel__field">
|
||||
<div class="control has-icons-right" :class="{ 'is-loading': isSearching }">
|
||||
<CustomerSearchField testId="pos-mobile-customer-search-input" />
|
||||
<span class="icon is-right">
|
||||
<i class="fas fa-search"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="formattedSearchResults.length > 0">
|
||||
<div class="customer-search-results">
|
||||
<ControlFieldInputSearchResults
|
||||
v-bind:results="formattedSearchResults"
|
||||
testIdPrefix="pos-mobile-customer-search-result"
|
||||
@result-clicked="onClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<VehicleCustomerSuggestionsPos
|
||||
v-if="shouldShowSearchInput && vehicles?.vehicle_1?.value?.reg"
|
||||
class="customer-suggestions"
|
||||
:reg_1="vehicles.vehicle_1.value.reg"
|
||||
@customerSelected="onClickSuggestion"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
input {
|
||||
/* input-fields */
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
/* Auto layout */
|
||||
.pos-mobile-customer-popup {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 0px;
|
||||
border-bottom: none;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
|
||||
background: #FFFFFF;
|
||||
border-color: transparent;
|
||||
border-radius: 4px;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 1;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
input:focus-visible {
|
||||
border-color: transparent;
|
||||
.customer-selection-summary {
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%);
|
||||
border: 1px solid #d7e3f5;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 14px 28px rgba(17, 46, 92, 0.08);
|
||||
padding: 0.95rem 1rem;
|
||||
}
|
||||
|
||||
.customer-selection-summary__label {
|
||||
color: #5a6f8f;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 0.55rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.customer-selection-summary__content {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.customer-selection-summary__icon {
|
||||
align-items: center;
|
||||
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
|
||||
border-radius: 999px;
|
||||
color: #ffffff;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
height: 2.4rem;
|
||||
justify-content: center;
|
||||
width: 2.4rem;
|
||||
}
|
||||
|
||||
.customer-selection-summary__text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.customer-selection-summary__name {
|
||||
color: #17325c;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.customer-selection-summary__number {
|
||||
color: #5d7090;
|
||||
font-size: 0.82rem;
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.customer-selection-summary__mode {
|
||||
background: rgba(23, 50, 92, 0.08);
|
||||
border-radius: 999px;
|
||||
color: #17325c;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
max-width: 10.5rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.customer-quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.customer-quick-action {
|
||||
align-items: center;
|
||||
border: 1px solid #d2deee;
|
||||
border-radius: 0.95rem;
|
||||
box-shadow: 0 8px 16px rgba(21, 49, 93, 0.06);
|
||||
color: #17325c;
|
||||
display: inline-flex;
|
||||
font-weight: 600;
|
||||
gap: 0.7rem;
|
||||
justify-content: flex-start;
|
||||
min-height: 3rem;
|
||||
padding: 0.85rem 1rem;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.customer-quick-action.is-selected {
|
||||
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
|
||||
border-color: #10294b;
|
||||
box-shadow: 0 14px 24px rgba(15, 39, 72, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.customer-quick-action:hover,
|
||||
.customer-quick-action:focus-visible {
|
||||
background-color: #f1f6ff;
|
||||
border-color: #b6cae6;
|
||||
color: #0f2a55;
|
||||
}
|
||||
|
||||
.customer-quick-action.is-selected:hover,
|
||||
.customer-quick-action.is-selected:focus-visible {
|
||||
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
|
||||
border-color: #10294b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.customer-quick-action__icon {
|
||||
align-items: center;
|
||||
background: #e9f2ff;
|
||||
border-radius: 999px;
|
||||
color: #184b8a;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
height: 1.95rem;
|
||||
justify-content: center;
|
||||
width: 1.95rem;
|
||||
}
|
||||
|
||||
.customer-quick-action.is-selected .customer-quick-action__icon {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.customer-quick-action:disabled,
|
||||
.customer-quick-action.is-disabled {
|
||||
background: #f4f7fb;
|
||||
border-color: #d6dfec;
|
||||
box-shadow: none;
|
||||
color: #90a0b7;
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.customer-quick-action:disabled .customer-quick-action__icon,
|
||||
.customer-quick-action.is-disabled .customer-quick-action__icon {
|
||||
background: #edf2f8;
|
||||
color: #8ca0bc;
|
||||
}
|
||||
|
||||
.customer-quick-action:disabled:hover,
|
||||
.customer-quick-action:disabled:focus-visible,
|
||||
.customer-quick-action.is-disabled:hover,
|
||||
.customer-quick-action.is-disabled:focus-visible {
|
||||
background: #f4f7fb;
|
||||
border-color: #d6dfec;
|
||||
color: #90a0b7;
|
||||
}
|
||||
|
||||
.customer-quick-action--invoice .customer-quick-action__icon {
|
||||
background: #edf5ff;
|
||||
color: #1b4f92;
|
||||
}
|
||||
|
||||
.customer-quick-action--draft .customer-quick-action__icon {
|
||||
background: #eef0ff;
|
||||
color: #324cb1;
|
||||
}
|
||||
|
||||
.customer-quick-action--card .customer-quick-action__icon {
|
||||
background: #e4f7fa;
|
||||
color: #0f6a7b;
|
||||
}
|
||||
|
||||
.customer-search-panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe5f2;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 10px 18px rgba(21, 49, 93, 0.05);
|
||||
padding: 0.8rem 0.9rem 0.95rem;
|
||||
}
|
||||
|
||||
.customer-search-panel__label {
|
||||
color: #17325c;
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.customer-search-panel__field {
|
||||
border-bottom: 1px solid #c9d6e8;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.customer-search-panel :deep(input) {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
height: 42px;
|
||||
padding: 0.65rem 0.25rem 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.customer-search-panel :deep(input:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
.underlined-input-field {
|
||||
padding-bottom: 0;
|
||||
border-bottom: #929292 1px solid;
|
||||
|
||||
.customer-search-results {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.debug-input {
|
||||
/* input-fields */
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
/* Auto layout */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0px 8px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #A6A5A5;
|
||||
border-radius: 4px;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 1;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
}
|
||||
input.is-searched {
|
||||
/* input-fields */
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
/* Auto layout */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 12px 8px;
|
||||
gap: 10px;
|
||||
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #000000;
|
||||
border-radius: 4px;
|
||||
border-bottom: none;
|
||||
|
||||
/* Inside auto layout */
|
||||
flex: none;
|
||||
order: 1;
|
||||
align-self: stretch;
|
||||
flex-grow: 0;
|
||||
|
||||
.customer-suggestions {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
+70
-21
@@ -1309,32 +1309,81 @@ const selectLastVehicleOrder = (vehicleIndex: number) => {
|
||||
// This function is used to use the last vehicle order for the selected vehicle.
|
||||
const lastOrder = getLastVehicleOrder(vehicleIndex);
|
||||
if (lastOrder) {
|
||||
// If the last order does not have items, we cannot use it.
|
||||
if (!lastOrder.items || lastOrder.items.length === 0) {
|
||||
console.warn(`Last vehicle order for vehicle ${vehicleIndex} has no items. aborting selection.`);
|
||||
return;
|
||||
}
|
||||
const addons: Addon[] = lastOrder.items.filter(item => item.related_item_id === lastOrder.items[0].id) // Assuming the first item is the primary item for the transaction.
|
||||
.map(item => {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.product.name,
|
||||
price: item.product.price,
|
||||
quantity: item.quantity,
|
||||
related_item_id: item.related_item_id,
|
||||
min: 0,
|
||||
max: -1,
|
||||
product: item.product,
|
||||
} as Addon;
|
||||
});
|
||||
|
||||
const orderItems = lastOrder.items.filter(Boolean);
|
||||
const primaryOrderItem = orderItems.find(
|
||||
(item) => item?.related_item_id === null || item?.related_item_id === undefined
|
||||
) || orderItems[0];
|
||||
|
||||
if (!primaryOrderItem) {
|
||||
console.warn(`Last vehicle order for vehicle ${vehicleIndex} has no usable primary item.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizeOrderItemProduct = (item: any, defaultQuantity = 1): PosProduct => {
|
||||
const productId = Number(item?.product?.id ?? item?.product_id ?? item?.id ?? 0);
|
||||
const quantity = Number(item?.quantity ?? defaultQuantity);
|
||||
|
||||
return {
|
||||
...(item?.product || {}),
|
||||
id: productId,
|
||||
name: item?.product?.name ?? item?.name ?? "",
|
||||
price: Number(item?.price ?? item?.product?.price ?? 0),
|
||||
quantity,
|
||||
notes: item?.notes ?? item?.product?.notes ?? "",
|
||||
addons: Array.isArray(item?.product?.addons) ? item.product.addons : [],
|
||||
subscription_allowed: Boolean(item?.product?.subscription_allowed ?? false),
|
||||
} as PosProduct;
|
||||
};
|
||||
|
||||
const primaryOrderItemId = Number(primaryOrderItem?.id ?? 0);
|
||||
const addonItems = primaryOrderItemId
|
||||
? orderItems.filter((item) => Number(item?.related_item_id ?? 0) === primaryOrderItemId)
|
||||
: [];
|
||||
const addonIds = new Set(addonItems.map((item) => Number(item?.id ?? 0)));
|
||||
|
||||
const addons: Addon[] = addonItems.map((item) => {
|
||||
const addonProduct = normalizeOrderItemProduct(item, 1);
|
||||
return transactionItems.convertProductToAddon(addonProduct, {
|
||||
quantity: Number(item?.quantity ?? 1),
|
||||
min: 0,
|
||||
max: -1,
|
||||
});
|
||||
});
|
||||
|
||||
const additionalItems = orderItems
|
||||
.filter((item) => {
|
||||
const itemId = Number(item?.id ?? 0);
|
||||
if (itemId && itemId === Number(primaryOrderItem?.id ?? 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (itemId && addonIds.has(itemId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((item) => {
|
||||
return {
|
||||
...normalizeOrderItemProduct(item, 1),
|
||||
related_item_id: null,
|
||||
} as PosProduct;
|
||||
});
|
||||
|
||||
const primaryItem = <PosProduct>{
|
||||
...lastOrder.items[0].product,
|
||||
addons: addons,
|
||||
}; // Assuming the first item is the primary item for the transaction.
|
||||
// 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
|
||||
...normalizeOrderItemProduct(primaryOrderItem, 1),
|
||||
quantity: 1,
|
||||
addons,
|
||||
};
|
||||
|
||||
vehicles.select(vehicleIndex, { ...vehicles.get(vehicleIndex), type: primaryItem.id });
|
||||
transactionItems.setAdditionalItems(additionalItems);
|
||||
transactionItems.setPrimaryItem({ ...primaryItem });
|
||||
} else {
|
||||
console.warn(`No last vehicle order found for vehicle ${vehicleIndex}.`);
|
||||
}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
gateway: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
editableBindings: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
bindingHelperText: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
emptyBindingStateDescription: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
discoveryLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
bindingSaveLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inventoryDeviceOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
getBindingDeviceOptions: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
getBindingChannelOptions: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"queue-discovery",
|
||||
"add-binding",
|
||||
"remove-binding",
|
||||
"update-binding-field",
|
||||
"sync-binding-device",
|
||||
"save-bindings",
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="gateway" class="edge-step-stack" data-testid="gateway-step-configure-panel">
|
||||
<article class="edge-card">
|
||||
<header class="edge-card__header edge-card__header--row">
|
||||
<div>
|
||||
<p class="edge-eyebrow">Discovery</p>
|
||||
<h3>Lokale Shelly-enheder</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-dark edge-button-primary"
|
||||
:class="{ 'is-loading': discoveryLoading }"
|
||||
data-testid="gateway-discovery-action"
|
||||
@click="emit('queue-discovery')"
|
||||
>
|
||||
Kør discovery
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="edge-helper-text">
|
||||
Discovery-status: <strong>{{ gateway.discoveryStatusLabel }}</strong> ·
|
||||
{{ gateway.lastSuccessfulDiscoveryRelative }}
|
||||
</p>
|
||||
|
||||
<div v-if="!gateway.inventory.length" class="edge-empty-state edge-empty-state--inline">
|
||||
<h3>Ingen Shelly-enheder fundet endnu</h3>
|
||||
<p>Kør discovery for at scanne det lokale netværk og hente en opdateret enhedsliste.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="edge-device-grid">
|
||||
<article v-for="device in gateway.inventory" :key="device.id" class="edge-device-card">
|
||||
<div class="edge-device-card__topline">
|
||||
<strong>{{ device.model }}</strong>
|
||||
<span class="edge-pill" :data-tone="device.online === false ? 'danger' : 'success'">
|
||||
{{ device.online === false ? "Offline" : "Klar" }}
|
||||
</span>
|
||||
</div>
|
||||
<p>{{ device.device_id }}</p>
|
||||
<small>{{ device.local_ip }} · {{ device.channel_count }} kanaler</small>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="edge-card">
|
||||
<header class="edge-card__header edge-card__header--row">
|
||||
<div>
|
||||
<p class="edge-eyebrow">Bindinger</p>
|
||||
<h3>Konfigurer lokal relay-styring</h3>
|
||||
</div>
|
||||
<div class="edge-inline-actions">
|
||||
<button type="button" class="button is-light" data-testid="gateway-binding-add" @click="emit('add-binding')">
|
||||
Tilføj binding
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-dark edge-button-primary"
|
||||
:class="{ 'is-loading': bindingSaveLoading }"
|
||||
data-testid="gateway-bindings-save"
|
||||
@click="emit('save-bindings')"
|
||||
>
|
||||
Gem bindinger
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="edge-helper-text">{{ bindingHelperText }}</p>
|
||||
|
||||
<div v-if="!editableBindings.length" class="edge-empty-state edge-empty-state--inline">
|
||||
<h3>Ingen bindinger endnu</h3>
|
||||
<p>{{ emptyBindingStateDescription }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="edge-binding-stack">
|
||||
<article v-for="(binding, index) in editableBindings" :key="`${index}-${binding.relay_id}`" class="edge-binding-card">
|
||||
<div class="edge-binding-card__header">
|
||||
<div>
|
||||
<p class="edge-eyebrow">Binding {{ index + 1 }}</p>
|
||||
<h4>{{ binding.relay_id || "Nyt relay" }}</h4>
|
||||
</div>
|
||||
<button type="button" class="button is-white is-small" @click="emit('remove-binding', index)">Fjern</button>
|
||||
</div>
|
||||
|
||||
<div class="edge-form-grid edge-form-grid--binding">
|
||||
<label class="field">
|
||||
<span class="label">Relæ-id</span>
|
||||
<input
|
||||
:value="binding.relay_id"
|
||||
class="input"
|
||||
type="text"
|
||||
:data-testid="`gateway-binding-relay-${index}`"
|
||||
placeholder="F.eks. M-7"
|
||||
@input="emit('update-binding-field', index, 'relay_id', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Shelly-device</span>
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
:value="binding.device_id"
|
||||
:data-testid="`gateway-binding-device-${index}`"
|
||||
@change="
|
||||
emit('update-binding-field', index, 'device_id', $event.target.value);
|
||||
emit('sync-binding-device', { ...binding, device_id: $event.target.value }, index);
|
||||
"
|
||||
>
|
||||
<option value="">Vælg device</option>
|
||||
<option
|
||||
v-for="device in getBindingDeviceOptions(binding)"
|
||||
:key="device.value"
|
||||
:value="device.value"
|
||||
>
|
||||
{{ device.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">IP</span>
|
||||
<input :value="binding.local_ip" class="input" type="text" readonly />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Kanal</span>
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
:value="binding.channel"
|
||||
:data-testid="`gateway-binding-channel-${index}`"
|
||||
@change="emit('update-binding-field', index, 'channel', Number($event.target.value))"
|
||||
>
|
||||
<option v-for="channel in getBindingChannelOptions(binding)" :key="channel" :value="channel">
|
||||
{{ channel }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Fallback</span>
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
:value="binding.fallback_mode"
|
||||
aria-label="Fallback mode"
|
||||
:data-testid="`gateway-binding-fallback-${index}`"
|
||||
@change="emit('update-binding-field', index, 'fallback_mode', $event.target.value)"
|
||||
>
|
||||
<option value="PREFER_LOCAL">Foretræk lokal</option>
|
||||
<option value="LOCAL_ONLY">Kun lokal</option>
|
||||
<option value="CLOUD_ONLY">Kun cloud</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,121 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
summaryCards: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
fleetFilter: {
|
||||
type: String,
|
||||
default: "ALL",
|
||||
},
|
||||
fleetQuery: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
selectedGatewayView: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
filteredGatewayViews: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
initializing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
fleetDrawerOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"update:fleetFilter",
|
||||
"update:fleetQuery",
|
||||
"open-fleet-drawer",
|
||||
"close-fleet-drawer",
|
||||
"select-gateway",
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="edge-fleet-shell">
|
||||
<div class="edge-mobile-toggle" data-testid="gateway-mobile-toggle">
|
||||
<button type="button" class="button is-light" data-testid="gateway-mobile-open-rail" @click="emit('open-fleet-drawer')">
|
||||
Vælg gateway
|
||||
</button>
|
||||
<p v-if="selectedGatewayView" class="edge-mobile-toggle__selection">
|
||||
{{ selectedGatewayView.displayLabel }} · {{ selectedGatewayView.statusLabel }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="fleetDrawerOpen"
|
||||
type="button"
|
||||
class="edge-overlay"
|
||||
aria-label="Luk gatewayliste"
|
||||
@click="emit('close-fleet-drawer')"
|
||||
/>
|
||||
|
||||
<aside class="edge-fleet-rail" :class="{ 'edge-fleet-rail--open': fleetDrawerOpen }" data-testid="gateway-fleet-rail">
|
||||
<div class="edge-fleet-rail__header">
|
||||
<div>
|
||||
<p class="edge-eyebrow">Gateway-flåde</p>
|
||||
<h2>Vælg en gateway</h2>
|
||||
<p>Skift hurtigt mellem driftsstatus, Shelly-bindinger og gatewaystyring uden at miste kontekst.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="delete is-hidden-desktop"
|
||||
aria-label="Luk gatewayliste"
|
||||
@click="emit('close-fleet-drawer')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="edge-search">
|
||||
<span>Søg i gateways</span>
|
||||
<input
|
||||
:value="fleetQuery"
|
||||
class="input"
|
||||
type="search"
|
||||
data-testid="gateway-fleet-search"
|
||||
placeholder="Søg gateway, afdeling eller hostnavn"
|
||||
@input="emit('update:fleetQuery', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-if="initializing" class="edge-empty-state edge-empty-state--rail">
|
||||
<h3>Indlæser gateway-flåden</h3>
|
||||
<p>Vi henter de seneste gateways og deres afdelinger.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!filteredGatewayViews.length" class="edge-empty-state edge-empty-state--rail">
|
||||
<h3>Ingen gateways matcher</h3>
|
||||
<p>Prøv en anden status eller ryd søgningen for at se hele flåden igen.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="edge-fleet-list">
|
||||
<button
|
||||
v-for="gateway in filteredGatewayViews"
|
||||
:key="gateway.id"
|
||||
type="button"
|
||||
class="edge-fleet-item"
|
||||
:class="{ 'edge-fleet-item--selected': Number(selectedGatewayView?.id) === Number(gateway.id) }"
|
||||
:data-selected="Number(selectedGatewayView?.id) === Number(gateway.id) ? 'true' : 'false'"
|
||||
:data-testid="`gateway-fleet-item-${gateway.id}`"
|
||||
@click="emit('select-gateway', gateway.id)"
|
||||
>
|
||||
<div class="edge-fleet-item__topline">
|
||||
<strong>{{ gateway.displayLabel }}</strong>
|
||||
<span class="edge-pill" :data-tone="gateway.statusTone">
|
||||
{{ gateway.statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="edge-fleet-item__meta">{{ gateway.departmentName }} · {{ gateway.hostname }}</p>
|
||||
<p class="edge-fleet-item__meta">Heartbeat {{ gateway.heartbeatRelative }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,187 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
gateway: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["run-primary-action"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="gateway" class="edge-step-stack" data-testid="gateway-step-assess-panel">
|
||||
<article class="edge-card">
|
||||
<header class="edge-card__header edge-card__header--row">
|
||||
<div>
|
||||
<p class="edge-eyebrow">Driftsvurdering</p>
|
||||
<h3>Driftsstatus og næste handling</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-dark edge-button-primary"
|
||||
data-testid="gateway-step-primary-action"
|
||||
@click="emit('run-primary-action')"
|
||||
>
|
||||
{{ gateway.incidentPrimaryAction.label }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="edge-helper-text">{{ gateway.incidentPrimaryAction.helper }}</p>
|
||||
|
||||
<dl class="edge-definition-grid">
|
||||
<div>
|
||||
<dt>Seneste kommando</dt>
|
||||
<dd>{{ gateway.lastSuccessfulCommandRelative }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Seneste discovery</dt>
|
||||
<dd>{{ gateway.lastSuccessfulDiscoveryRelative }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Afdeling</dt>
|
||||
<dd>{{ gateway.departmentName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Gatewayrolle</dt>
|
||||
<dd>{{ gateway.primaryLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Gateway transport</dt>
|
||||
<dd>{{ gateway.transportModeLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Afdelingens cutover</dt>
|
||||
<dd>{{ gateway.departmentTransportModeLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Seneste IP</dt>
|
||||
<dd>{{ gateway.last_seen_ip || "Ikke registreret" }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Styringskanal</dt>
|
||||
<dd>{{ gateway.brokerStatusLabel }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="edge-helper-text">{{ gateway.controlChannelHelper }}</p>
|
||||
</article>
|
||||
|
||||
<div class="edge-card-grid edge-card-grid--metrics">
|
||||
<article class="edge-card edge-card--metric">
|
||||
<p class="edge-eyebrow">Netværk</p>
|
||||
<h3>Latens</h3>
|
||||
<strong class="edge-metric-value">{{ gateway.metrics.latency.value }}</strong>
|
||||
<p class="edge-helper-text">{{ gateway.metrics.latency.helper }}</p>
|
||||
</article>
|
||||
<article class="edge-card edge-card--metric">
|
||||
<p class="edge-eyebrow">System</p>
|
||||
<h3>CPU</h3>
|
||||
<strong class="edge-metric-value">{{ gateway.metrics.cpu.value }}</strong>
|
||||
<p class="edge-helper-text">{{ gateway.metrics.cpu.helper }}</p>
|
||||
</article>
|
||||
<article class="edge-card edge-card--metric">
|
||||
<p class="edge-eyebrow">System</p>
|
||||
<h3>RAM</h3>
|
||||
<strong class="edge-metric-value">{{ gateway.metrics.memory.value }}</strong>
|
||||
<p class="edge-helper-text">{{ gateway.metrics.memory.helper }}</p>
|
||||
</article>
|
||||
<article class="edge-card edge-card--metric">
|
||||
<p class="edge-eyebrow">System</p>
|
||||
<h3>Disk</h3>
|
||||
<strong class="edge-metric-value">{{ gateway.metrics.disk.value }}</strong>
|
||||
<p class="edge-helper-text">{{ gateway.metrics.disk.helper }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="edge-card edge-card--history">
|
||||
<header class="edge-card__header">
|
||||
<div>
|
||||
<p class="edge-eyebrow">Shelly-overblik</p>
|
||||
<h3>Relay health og lokal dækning</h3>
|
||||
</div>
|
||||
<p class="edge-card__hint">
|
||||
{{ gateway.bindingCount }} bindinger · {{ gateway.discoveredDeviceCount }} fundne Shelly-enheder
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div v-if="!gateway.relayHealthRows.length" class="edge-empty-state edge-empty-state--inline">
|
||||
<h3>Ingen relay health endnu</h3>
|
||||
<p>Kør discovery og opret bindinger for at se lokal kontra cloud fallback for hvert relæ.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="edge-table-shell">
|
||||
<table class="table is-fullwidth is-hoverable edge-table" data-testid="gateway-relay-health-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Binding</th>
|
||||
<th>Kørsel</th>
|
||||
<th>Fallback</th>
|
||||
<th>Enhed</th>
|
||||
<th>Årsag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="relay in gateway.relayHealthRows" :key="`relay-${relay.binding_id || relay.relay_id}`">
|
||||
<td>{{ relay.relay_id }}</td>
|
||||
<td>{{ relay.executionPathLabel }}</td>
|
||||
<td>{{ relay.fallbackModeLabel }}</td>
|
||||
<td>{{ relay.freshnessLabel }}</td>
|
||||
<td>{{ relay.reasonLabel }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="edge-card edge-card--history">
|
||||
<header class="edge-card__header">
|
||||
<div>
|
||||
<p class="edge-eyebrow">Kommandoer</p>
|
||||
<h3>Seneste lokale jobs</h3>
|
||||
</div>
|
||||
<p class="edge-card__hint">Discovery og andre gateway-kommandoer vises her, så relay-flowet kan følges.</p>
|
||||
</header>
|
||||
|
||||
<div v-if="!gateway.recent_commands?.length" class="edge-empty-state edge-empty-state--inline">
|
||||
<h3>Ingen kommandohistorik endnu</h3>
|
||||
<p>Nye discovery- og relay-jobs vises her, når gatewayen modtager kommandoer.</p>
|
||||
</div>
|
||||
|
||||
<ul v-else class="edge-history-list">
|
||||
<li v-for="job in gateway.recent_commands.slice(0, 4)" :key="job.id">
|
||||
<div>
|
||||
<strong>{{ job.command_type || "Gateway-kommando" }}</strong>
|
||||
<p>{{ job.status }}</p>
|
||||
</div>
|
||||
<time :datetime="job.created_at || gateway.last_heartbeat_at">{{ job.created_at || gateway.last_heartbeat_at }}</time>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="edge-card edge-card--history">
|
||||
<header class="edge-card__header">
|
||||
<div>
|
||||
<p class="edge-eyebrow">Historik</p>
|
||||
<h3>Seneste gateway-hændelser</h3>
|
||||
</div>
|
||||
<p class="edge-card__hint">Audit-loggen giver operatøren hurtig kontekst før næste indgreb.</p>
|
||||
</header>
|
||||
|
||||
<div v-if="!gateway.audit_logs.length" class="edge-empty-state edge-empty-state--inline">
|
||||
<h3>Ingen historik endnu</h3>
|
||||
<p>Audit-loggen bliver vist her, når gatewayen har registreret hændelser.</p>
|
||||
</div>
|
||||
|
||||
<ul v-else class="edge-history-list">
|
||||
<li v-for="entry in gateway.audit_logs.slice(0, 4)" :key="entry.id">
|
||||
<div>
|
||||
<strong>{{ entry.action }}</strong>
|
||||
<p>{{ entry.actor_type }}</p>
|
||||
</div>
|
||||
<time :datetime="entry.created_at">{{ entry.created_at }}</time>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,313 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
gateway: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
departments: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
departmentScoped: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
installerDepartmentId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
installerLabel: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
installerCommand: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
installerDepartmentName: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
installerMeta: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
installerLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
gatewayLabel: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
gatewayIsPrimary: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
metadataLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
transportMode: {
|
||||
type: String,
|
||||
default: "gateway",
|
||||
},
|
||||
cutoverLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
deleteConfirmationOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
deleteLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"update:installerDepartmentId",
|
||||
"update:installerLabel",
|
||||
"update:gatewayLabel",
|
||||
"update:gatewayIsPrimary",
|
||||
"update:transportMode",
|
||||
"generate-installer",
|
||||
"copy-installer",
|
||||
"save-metadata",
|
||||
"apply-cutover-mode",
|
||||
"open-delete-confirmation",
|
||||
"close-delete-confirmation",
|
||||
"delete-gateway",
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="edge-step-stack" data-testid="gateway-step-manage-panel">
|
||||
<article class="edge-card" data-testid="gateway-installer-card">
|
||||
<header class="edge-card__header">
|
||||
<div>
|
||||
<p class="edge-eyebrow">Installation</p>
|
||||
<h3>Énlinje-installation til Raspberry Pi</h3>
|
||||
</div>
|
||||
<p class="edge-card__hint">
|
||||
Brug kommandoen på en ny Pi på afdelingens lokale netværk. Gatewayen bliver claimet med token og kan derefter
|
||||
køre discovery mod Shelly-relæerne.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="edge-form-grid edge-form-grid--installer">
|
||||
<label class="field">
|
||||
<span class="label">Afdeling</span>
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
:value="installerDepartmentId"
|
||||
aria-label="Installer afdeling"
|
||||
data-testid="gateway-installer-department"
|
||||
:disabled="departmentScoped"
|
||||
@change="emit('update:installerDepartmentId', $event.target.value)"
|
||||
>
|
||||
<option value="">Vælg afdeling</option>
|
||||
<option v-for="department in departments" :key="department.id" :value="String(department.id)">
|
||||
{{ department.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Gateway-label</span>
|
||||
<input
|
||||
:value="installerLabel"
|
||||
class="input"
|
||||
type="text"
|
||||
data-testid="gateway-installer-label"
|
||||
placeholder="F.eks. Roskilde Pi 01"
|
||||
@input="emit('update:installerLabel', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="button is-dark edge-button-primary"
|
||||
:class="{ 'is-loading': installerLoading }"
|
||||
:disabled="!installerDepartmentId"
|
||||
data-testid="gateway-installer-generate"
|
||||
@click="emit('generate-installer')"
|
||||
>
|
||||
Generér installer
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="button is-light"
|
||||
:disabled="!installerCommand"
|
||||
data-testid="gateway-installer-copy"
|
||||
@click="emit('copy-installer')"
|
||||
>
|
||||
Kopiér kommando
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="edge-helper-text">
|
||||
Valgt afdeling: <strong>{{ installerDepartmentName }}</strong>
|
||||
<span v-if="installerMeta?.expires_at"> · token udløber {{ installerMeta.expires_at }}</span>
|
||||
</p>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Installationskommando</span>
|
||||
<textarea
|
||||
:value="installerCommand"
|
||||
class="textarea edge-command"
|
||||
aria-label="Installationskommando"
|
||||
data-testid="gateway-install-command"
|
||||
rows="4"
|
||||
readonly
|
||||
placeholder="Installationskommando vises her, når du genererer en ny token."
|
||||
/>
|
||||
</label>
|
||||
</article>
|
||||
|
||||
<article v-if="gateway" class="edge-card" data-testid="gateway-metadata-card">
|
||||
<header class="edge-card__header edge-card__header--row">
|
||||
<div>
|
||||
<p class="edge-eyebrow">Metadata</p>
|
||||
<h3>Redigér gateway-oplysninger</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-dark edge-button-primary"
|
||||
:class="{ 'is-loading': metadataLoading }"
|
||||
data-testid="gateway-metadata-save"
|
||||
@click="emit('save-metadata')"
|
||||
>
|
||||
Gem gateway
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="edge-form-grid edge-form-grid--installer">
|
||||
<label class="field">
|
||||
<span class="label">Gateway-label</span>
|
||||
<input
|
||||
:value="gatewayLabel"
|
||||
class="input"
|
||||
type="text"
|
||||
aria-label="Gateway-label"
|
||||
data-testid="gateway-metadata-label"
|
||||
placeholder="F.eks. CPH Edge 01"
|
||||
@input="emit('update:gatewayLabel', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Gatewayrolle</span>
|
||||
<label class="edge-helper-text">
|
||||
<input
|
||||
:checked="gatewayIsPrimary"
|
||||
type="checkbox"
|
||||
data-testid="gateway-primary-checkbox"
|
||||
@change="emit('update:gatewayIsPrimary', $event.target.checked)"
|
||||
/>
|
||||
Primær gateway for afdelingen
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="edge-helper-text">
|
||||
Afdelingen ændres ikke her. Denne formular styrer kun label og om gatewayen skal være primær for lokal Shelly-trafik.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article v-if="gateway" class="edge-card">
|
||||
<header class="edge-card__header edge-card__header--row">
|
||||
<div>
|
||||
<p class="edge-eyebrow">Cutover</p>
|
||||
<h3>Styr afdelingens fallback</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-light"
|
||||
:class="{ 'is-loading': cutoverLoading }"
|
||||
data-testid="gateway-cutover-apply"
|
||||
@click="emit('apply-cutover-mode')"
|
||||
>
|
||||
Anvend cutover
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="edge-form-grid edge-form-grid--cutover">
|
||||
<label class="field">
|
||||
<span class="label">Transporttilstand</span>
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
:value="transportMode"
|
||||
aria-label="Transporttilstand"
|
||||
data-testid="gateway-cutover-mode"
|
||||
@change="emit('update:transportMode', $event.target.value)"
|
||||
>
|
||||
<option value="gateway">Lokal gateway</option>
|
||||
<option value="cloud">Cloud fallback</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="edge-helper-text">
|
||||
Cloud fallback bør kun bruges ved udfald eller planlagt cutover. Normaltilstanden er lokal gateway for Shelly-relæerne.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article v-else class="edge-empty-state edge-empty-state--inline">
|
||||
<h3>Ingen gateway valgt endnu</h3>
|
||||
<p>Onboard en ny Raspberry Pi ovenfor eller vælg en eksisterende gateway for at redigere metadata, cutover og sletning.</p>
|
||||
</article>
|
||||
|
||||
<article v-if="gateway" class="edge-card edge-card--danger">
|
||||
<header class="edge-card__header edge-card__header--row">
|
||||
<div>
|
||||
<p class="edge-eyebrow">Sletning</p>
|
||||
<h3>Fjern gateway-registrering</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-danger is-light"
|
||||
:class="{ 'is-loading': deleteLoading }"
|
||||
data-testid="gateway-delete"
|
||||
@click="emit('open-delete-confirmation')"
|
||||
>
|
||||
Slet gateway
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="edge-helper-text">
|
||||
Sletning fjerner kun registreringen i TruckWash. Det bruges når Raspberry Pi’en er væk eller gatewayen ikke længere
|
||||
skal styres herfra.
|
||||
</p>
|
||||
|
||||
<div v-if="deleteConfirmationOpen" class="edge-rotation-panel" data-testid="gateway-delete-confirmation">
|
||||
<p class="edge-helper-text">
|
||||
Bekræft kun sletning når du er sikker på, at gatewayen ikke længere skal bruges til lokale Shelly-kommandoer.
|
||||
</p>
|
||||
<div class="edge-inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-danger"
|
||||
:class="{ 'is-loading': deleteLoading }"
|
||||
data-testid="gateway-delete-confirm"
|
||||
@click="emit('delete-gateway')"
|
||||
>
|
||||
Bekræft sletning
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-light"
|
||||
data-testid="gateway-delete-cancel"
|
||||
@click="emit('close-delete-confirmation')"
|
||||
>
|
||||
Annuller
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,192 +0,0 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
import {
|
||||
closeEdgeGatewayShellSession,
|
||||
createEdgeGatewayShellSession,
|
||||
pollEdgeGatewayShellSession,
|
||||
sendEdgeGatewayShellInput,
|
||||
} from "@/services/edgeGateways.js";
|
||||
|
||||
const props = defineProps({
|
||||
gatewayId: { type: Number, required: true },
|
||||
latestSession: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const unwrapData = (response, fallback = null) => response?.data?.data ?? fallback;
|
||||
|
||||
const reason = ref("Break-glass diagnostics");
|
||||
const input = ref("");
|
||||
const session = ref(null);
|
||||
const sessionToken = ref("");
|
||||
const events = ref([]);
|
||||
const eventCursor = ref(0);
|
||||
const opening = ref(false);
|
||||
const closing = ref(false);
|
||||
const sending = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
let pollToken = 0;
|
||||
|
||||
const eventLabel = (event) => {
|
||||
const payload = event.payload || {};
|
||||
if (event.event_type === "OUTPUT") return payload.output || "";
|
||||
if (event.event_type === "OPENED") return "Shell connected.";
|
||||
if (event.event_type === "CLOSED") return "Shell closed.";
|
||||
return `${event.event_type || "EVENT"} ${JSON.stringify(payload)}`;
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
pollToken += 1;
|
||||
};
|
||||
|
||||
const startPolling = (sessionId) => {
|
||||
if (!sessionId) return;
|
||||
|
||||
pollToken += 1;
|
||||
const currentToken = pollToken;
|
||||
|
||||
const poll = async () => {
|
||||
while (currentToken === pollToken && session.value?.id) {
|
||||
const response = await pollEdgeGatewayShellSession(props.gatewayId, sessionId, {
|
||||
after_id: eventCursor.value,
|
||||
wait_seconds: 5,
|
||||
});
|
||||
const payload = unwrapData(response, { session: session.value, events: [] });
|
||||
session.value = payload.session || session.value;
|
||||
|
||||
for (const event of payload.events || []) {
|
||||
eventCursor.value = Math.max(eventCursor.value, Number(event.id || 0));
|
||||
events.value.push(event);
|
||||
}
|
||||
|
||||
if (payload.session?.closed_at) break;
|
||||
}
|
||||
};
|
||||
|
||||
poll().catch((error) => {
|
||||
errorMessage.value = error?.response?.data?.message || error?.message || "Shell polling failed.";
|
||||
});
|
||||
};
|
||||
|
||||
const openSession = async () => {
|
||||
opening.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const response = await createEdgeGatewayShellSession(props.gatewayId, {
|
||||
reason: reason.value,
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
});
|
||||
const payload = unwrapData(response, null);
|
||||
session.value = payload?.session || null;
|
||||
sessionToken.value = String(payload?.session_token || "");
|
||||
events.value = [];
|
||||
eventCursor.value = 0;
|
||||
if (session.value?.id) startPolling(session.value.id);
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.response?.data?.message || error?.message || "Failed to open shell session.";
|
||||
} finally {
|
||||
opening.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const sendInput = async () => {
|
||||
if (!session.value?.id || !input.value.trim()) return;
|
||||
sending.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
await sendEdgeGatewayShellInput(props.gatewayId, session.value.id, input.value);
|
||||
input.value = "";
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.response?.data?.message || error?.message || "Failed to send shell input.";
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeSession = async () => {
|
||||
if (!session.value?.id) return;
|
||||
closing.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
await closeEdgeGatewayShellSession(props.gatewayId, session.value.id);
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.response?.data?.message || error?.message || "Failed to close shell session.";
|
||||
} finally {
|
||||
closing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.latestSession,
|
||||
(latestSession) => {
|
||||
if (!latestSession?.id || session.value?.id === latestSession.id) return;
|
||||
if (latestSession.closed_at) return;
|
||||
session.value = latestSession;
|
||||
events.value = [];
|
||||
eventCursor.value = 0;
|
||||
startPolling(latestSession.id);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.latestSession?.id && !props.latestSession?.closed_at) {
|
||||
session.value = props.latestSession;
|
||||
startPolling(props.latestSession.id);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="gateway-terminal">
|
||||
<div v-if="errorMessage" class="gateway-terminal__banner">{{ errorMessage }}</div>
|
||||
|
||||
<div class="gateway-terminal__toolbar">
|
||||
<div>
|
||||
<h4>Break-glass shell</h4>
|
||||
<p>Open a root shell over the HTTP polling transport. This is intended for diagnostics and recovery only.</p>
|
||||
</div>
|
||||
<div class="gateway-terminal__actions">
|
||||
<button type="button" class="button is-dark" :class="{ 'is-loading': opening }" data-testid="gateway-shell-open" @click="openSession">Open shell</button>
|
||||
<button type="button" class="button is-light" :class="{ 'is-loading': closing }" data-testid="gateway-shell-close" @click="closeSession">Close shell</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Reason</span>
|
||||
<input v-model="reason" class="input" type="text" data-testid="gateway-shell-reason" />
|
||||
</label>
|
||||
|
||||
<div v-if="session" class="gateway-terminal__meta" data-testid="gateway-shell-session">
|
||||
<strong>Session {{ session.id }}</strong>
|
||||
<span v-if="sessionToken">Token {{ sessionToken }}</span>
|
||||
</div>
|
||||
|
||||
<pre class="gateway-terminal__output" data-testid="gateway-shell-output">{{ events.map(eventLabel).join('\n') }}</pre>
|
||||
|
||||
<div class="gateway-terminal__composer">
|
||||
<input v-model="input" class="input" type="text" placeholder="Enter a command" data-testid="gateway-shell-input" @keyup.enter="sendInput" />
|
||||
<button type="button" class="button is-dark" :class="{ 'is-loading': sending }" data-testid="gateway-shell-send" @click="sendInput">Send</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gateway-terminal { border: 1px solid #d9dfdf; border-radius: 1rem; background: #fff; padding: 1rem; width: 100%; }
|
||||
.gateway-terminal__banner { margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: 0.75rem; background: #fff1f0; color: #983535; }
|
||||
.gateway-terminal__toolbar, .gateway-terminal__actions, .gateway-terminal__composer, .gateway-terminal__meta { display: flex; gap: 0.75rem; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
||||
.gateway-terminal__output { min-height: 16rem; margin: 1rem 0; padding: 1rem; border-radius: 0.75rem; background: #111827; color: #d8f5d0; overflow: auto; white-space: pre-wrap; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.gateway-terminal__toolbar, .gateway-terminal__composer { flex-direction: column; align-items: stretch; }
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,657 +0,0 @@
|
||||
/**
|
||||
* @typedef {"select"|"assess"|"configure"|"manage"} GatewayWorkflowStep
|
||||
*/
|
||||
|
||||
export const STATUS_META = {
|
||||
ONLINE: {
|
||||
label: "Online",
|
||||
tone: "success",
|
||||
helper: "Gatewayen svarer og er klar til lokal Shelly-trafik.",
|
||||
},
|
||||
DEGRADED: {
|
||||
label: "Degraderet",
|
||||
tone: "warning",
|
||||
helper: "Lokal kontrol virker delvist, men kræver opfølgning.",
|
||||
},
|
||||
OFFLINE: {
|
||||
label: "Offline",
|
||||
tone: "danger",
|
||||
helper: "Heartbeat er udløbet, så lokal styring er usikker.",
|
||||
},
|
||||
UNKNOWN: {
|
||||
label: "Ukendt",
|
||||
tone: "neutral",
|
||||
helper: "Gatewaystatus er ikke registreret endnu.",
|
||||
},
|
||||
};
|
||||
|
||||
export const TRANSPORT_MODE_LABELS = {
|
||||
gateway: "Lokal gateway",
|
||||
cloud: "Cloud fallback",
|
||||
};
|
||||
|
||||
export const FALLBACK_MODE_LABELS = {
|
||||
PREFER_LOCAL: "Foretræk lokal",
|
||||
LOCAL_ONLY: "Kun lokal",
|
||||
CLOUD_ONLY: "Kun cloud",
|
||||
};
|
||||
|
||||
export const DISCOVERY_STATUS_LABELS = {
|
||||
READY: "Klar",
|
||||
STALE: "Forældet",
|
||||
PENDING: "Afventer",
|
||||
FAILED: "Fejlet",
|
||||
UNKNOWN: "Ukendt",
|
||||
};
|
||||
|
||||
export const WORKFLOW_STEPS = [
|
||||
{
|
||||
key: "select",
|
||||
eyebrow: "Trin 1",
|
||||
label: "Vælg gateway",
|
||||
helper: "Skift hurtigt mellem gateways og behold rutehistorikken.",
|
||||
},
|
||||
{
|
||||
key: "assess",
|
||||
eyebrow: "Trin 2",
|
||||
label: "Driftsstatus",
|
||||
helper: "Se heartbeat, fallback, relæstatus og anbefalet næste handling.",
|
||||
},
|
||||
{
|
||||
key: "configure",
|
||||
eyebrow: "Trin 3",
|
||||
label: "Shelly-bindinger",
|
||||
helper: "Kør discovery og vedligehold relay-bindinger.",
|
||||
},
|
||||
{
|
||||
key: "manage",
|
||||
eyebrow: "Trin 4",
|
||||
label: "Gatewaystyring",
|
||||
helper: "Onboard nye gateways, opdatér metadata, cutover og slet registreringer.",
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeGatewayId(value) {
|
||||
const candidate =
|
||||
value && typeof value === "object" && "value" in value
|
||||
? value.value
|
||||
: value;
|
||||
|
||||
if (candidate === null || candidate === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = String(candidate).trim();
|
||||
return normalized === "" || normalized === "null" || normalized === "undefined" ? null : normalized;
|
||||
}
|
||||
|
||||
export function parseGatewayTimestamp(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = typeof value === "string" ? value.replace(" ", "T") : value;
|
||||
const parsed = new Date(candidate);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
export function formatAbsoluteTimestamp(value) {
|
||||
const parsed = parseGatewayTimestamp(value);
|
||||
if (!parsed) {
|
||||
return "Ikke registreret";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("da-DK", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
}).format(parsed);
|
||||
}
|
||||
|
||||
export function formatRelativeTimestamp(value, now = Date.now()) {
|
||||
const parsed = parseGatewayTimestamp(value);
|
||||
if (!parsed) {
|
||||
return "ikke registreret";
|
||||
}
|
||||
|
||||
const differenceSeconds = Math.max(0, Math.round((now - parsed.getTime()) / 1000));
|
||||
|
||||
if (differenceSeconds < 60) {
|
||||
return "for under 1 min siden";
|
||||
}
|
||||
|
||||
if (differenceSeconds < 3600) {
|
||||
return `for ${Math.floor(differenceSeconds / 60)} min siden`;
|
||||
}
|
||||
|
||||
if (differenceSeconds < 86400) {
|
||||
return `for ${Math.floor(differenceSeconds / 3600)} t siden`;
|
||||
}
|
||||
|
||||
return `for ${Math.floor(differenceSeconds / 86400)} dage siden`;
|
||||
}
|
||||
|
||||
export function formatFutureTimestamp(value, now = Date.now()) {
|
||||
const parsed = parseGatewayTimestamp(value);
|
||||
if (!parsed) {
|
||||
return "udløbstid ukendt";
|
||||
}
|
||||
|
||||
const differenceSeconds = Math.max(0, Math.round((parsed.getTime() - now) / 1000));
|
||||
|
||||
if (differenceSeconds < 60) {
|
||||
return "om under 1 min";
|
||||
}
|
||||
|
||||
if (differenceSeconds < 3600) {
|
||||
return `om ${Math.floor(differenceSeconds / 60)} min`;
|
||||
}
|
||||
|
||||
return `om ${Math.floor(differenceSeconds / 3600)} t`;
|
||||
}
|
||||
|
||||
export function getStatusMeta(status) {
|
||||
return STATUS_META[status] ?? STATUS_META.UNKNOWN;
|
||||
}
|
||||
|
||||
export function getDepartmentName(departmentId, departmentsById = {}) {
|
||||
if (!departmentId) {
|
||||
return "Ikke knyttet til afdeling";
|
||||
}
|
||||
|
||||
return departmentsById[String(departmentId)]?.name ?? `Afdeling #${departmentId}`;
|
||||
}
|
||||
|
||||
export function getControlChannelMeta(gateway) {
|
||||
const commandChannel = gateway?.channel_status?.command ?? {};
|
||||
const brokerChannel = gateway?.channel_status?.broker ?? {};
|
||||
|
||||
if (gateway?.status === "OFFLINE") {
|
||||
return {
|
||||
label: "Gateway offline",
|
||||
tone: "danger",
|
||||
helper: "Gatewayen sender ikke heartbeat nok til sikker lokal kommandoafvikling.",
|
||||
};
|
||||
}
|
||||
|
||||
if (commandChannel?.active === "BROKER_FAST_PATH" && brokerChannel?.connected) {
|
||||
return {
|
||||
label: "Broker fast path",
|
||||
tone: commandChannel?.state === "DEGRADED" ? "warning" : "success",
|
||||
helper:
|
||||
commandChannel?.state === "DEGRADED"
|
||||
? "Brokeren er forbundet, men API polling holdes varm som fallback."
|
||||
: "Brokeren leverer kommandoer med API polling som holdbar fallback.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: "API polling",
|
||||
tone: gateway?.status === "DEGRADED" ? "warning" : "neutral",
|
||||
helper:
|
||||
brokerChannel?.last_error || brokerChannel?.disconnect_reason
|
||||
? "Broker fast path er nede, så gatewayen kører videre via API polling."
|
||||
: "Gatewayen bruger API polling som primær kommandolevering.",
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRelayHealthReason(reason) {
|
||||
return (
|
||||
{
|
||||
department_cutover: "Afdelingen er tvunget over på cloud",
|
||||
binding_cloud_only: "Bindingen er låst til cloud",
|
||||
gateway_offline: "Gateway-heartbeat er udløbet",
|
||||
device_missing: "Shelly-device mangler i discovery",
|
||||
device_stale: "Shelly-device er for gammelt i discovery",
|
||||
device_offline: "Shelly-device er offline",
|
||||
local_dispatch_failed: "Lokal kommando fejlede og faldt tilbage",
|
||||
cloud_fallback_failed: "Cloud fallback fejlede",
|
||||
}[reason] ?? "Ingen aktiv fallback"
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeMetricNumber(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function formatMetricNumber(value, maximumFractionDigits = 0) {
|
||||
return new Intl.NumberFormat("da-DK", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatMetricPercent(value) {
|
||||
const parsed = normalizeMetricNumber(value);
|
||||
if (parsed === null) {
|
||||
return "Ukendt";
|
||||
}
|
||||
|
||||
return `${formatMetricNumber(parsed, parsed < 10 ? 1 : 0)}%`;
|
||||
}
|
||||
|
||||
export function formatMetricLatency(value) {
|
||||
const parsed = normalizeMetricNumber(value);
|
||||
if (parsed === null) {
|
||||
return "Ukendt";
|
||||
}
|
||||
|
||||
if (parsed < 1000) {
|
||||
return `${formatMetricNumber(parsed)} ms`;
|
||||
}
|
||||
|
||||
return `${formatMetricNumber(parsed / 1000, 1)} s`;
|
||||
}
|
||||
|
||||
export function formatMetricBytes(value) {
|
||||
const parsed = normalizeMetricNumber(value);
|
||||
if (parsed === null || parsed < 0) {
|
||||
return "Ukendt";
|
||||
}
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let currentValue = parsed;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (currentValue >= 1024 && unitIndex < units.length - 1) {
|
||||
currentValue /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
const maximumFractionDigits = currentValue >= 100 || unitIndex === 0 ? 0 : 1;
|
||||
return `${formatMetricNumber(currentValue, maximumFractionDigits)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function buildGatewayMetricView(gateway) {
|
||||
const metadata = gateway?.metadata && typeof gateway.metadata === "object" ? gateway.metadata : {};
|
||||
const systemMetrics =
|
||||
metadata.system_metrics && typeof metadata.system_metrics === "object" ? metadata.system_metrics : {};
|
||||
|
||||
const latencyMs = normalizeMetricNumber(systemMetrics.latency_ms);
|
||||
const cpuUsagePct = normalizeMetricNumber(systemMetrics.cpu_usage_pct);
|
||||
const memoryUsagePct = normalizeMetricNumber(systemMetrics.memory_usage_pct);
|
||||
const memoryUsedBytes = normalizeMetricNumber(systemMetrics.memory_used_bytes);
|
||||
const memoryTotalBytes = normalizeMetricNumber(systemMetrics.memory_total_bytes);
|
||||
const diskUsagePct = normalizeMetricNumber(systemMetrics.disk_usage_pct);
|
||||
const diskUsedBytes = normalizeMetricNumber(systemMetrics.disk_used_bytes);
|
||||
const diskTotalBytes = normalizeMetricNumber(systemMetrics.disk_total_bytes);
|
||||
const diskMount =
|
||||
typeof systemMetrics.disk_mount === "string" && systemMetrics.disk_mount.trim() !== ""
|
||||
? systemMetrics.disk_mount.trim()
|
||||
: "/";
|
||||
|
||||
return {
|
||||
latency: {
|
||||
value: formatMetricLatency(latencyMs),
|
||||
helper:
|
||||
latencyMs === null
|
||||
? "Afventer en netværksmåling fra gatewayen."
|
||||
: "Rundturstid for gatewayens heartbeat til API'et.",
|
||||
},
|
||||
cpu: {
|
||||
value: formatMetricPercent(cpuUsagePct),
|
||||
helper:
|
||||
cpuUsagePct === null ? "CPU-forbrug er ikke rapporteret endnu." : "Samlet CPU-belastning på gatewayen.",
|
||||
},
|
||||
memory: {
|
||||
value: formatMetricPercent(memoryUsagePct),
|
||||
helper:
|
||||
memoryUsedBytes !== null && memoryTotalBytes !== null
|
||||
? `${formatMetricBytes(memoryUsedBytes)} / ${formatMetricBytes(memoryTotalBytes)} brugt`
|
||||
: "RAM-forbrug er ikke rapporteret endnu.",
|
||||
},
|
||||
disk: {
|
||||
value: formatMetricPercent(diskUsagePct),
|
||||
helper:
|
||||
diskUsedBytes !== null && diskTotalBytes !== null
|
||||
? `${formatMetricBytes(diskUsedBytes)} / ${formatMetricBytes(diskTotalBytes)} brugt på ${diskMount}`
|
||||
: "Diskforbrug er ikke rapporteret endnu.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRelayHealthRows(gateway, bindings = []) {
|
||||
const relayHealth = Array.isArray(gateway?.relay_health) ? gateway.relay_health : [];
|
||||
if (relayHealth.length) {
|
||||
return relayHealth.map((entry) => ({
|
||||
...entry,
|
||||
reasonLabel: formatRelayHealthReason(entry?.reason),
|
||||
fallbackModeLabel:
|
||||
FALLBACK_MODE_LABELS[entry?.fallback_mode] ?? entry?.fallback_mode ?? FALLBACK_MODE_LABELS.PREFER_LOCAL,
|
||||
executionPathLabel: entry?.execution_path === "cloud" ? "Cloud fallback" : "Lokal kørsel",
|
||||
freshnessLabel:
|
||||
entry?.device_freshness_state === "READY"
|
||||
? "Frisk"
|
||||
: entry?.device_freshness_state === "STALE"
|
||||
? "Forældet"
|
||||
: entry?.device_freshness_state === "OFFLINE"
|
||||
? "Offline"
|
||||
: entry?.device_freshness_state === "MISSING"
|
||||
? "Mangler"
|
||||
: "Ukendt",
|
||||
}));
|
||||
}
|
||||
|
||||
return bindings.map((binding) => ({
|
||||
binding_id: binding.id,
|
||||
relay_id: binding.relay_id,
|
||||
device_id: binding.device_id,
|
||||
fallback_mode: binding.fallback_mode ?? "PREFER_LOCAL",
|
||||
fallbackModeLabel: FALLBACK_MODE_LABELS[binding.fallback_mode] ?? FALLBACK_MODE_LABELS.PREFER_LOCAL,
|
||||
execution_path: "local",
|
||||
executionPathLabel: "Lokal kørsel",
|
||||
reason: null,
|
||||
reasonLabel: "Ingen aktiv fallback",
|
||||
freshnessLabel: binding.last_success_at ? "Kendt" : "Ukendt",
|
||||
recommended_action: null,
|
||||
last_resolution: binding.last_resolution ?? null,
|
||||
last_error: binding.last_error ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeBinding(binding = {}) {
|
||||
return {
|
||||
relay_id: binding.relay_id ?? "",
|
||||
device_id: binding.device_id ?? "",
|
||||
local_ip: binding.local_ip ?? "",
|
||||
channel:
|
||||
binding.channel === "" || binding.channel === null || binding.channel === undefined ? 0 : Number(binding.channel),
|
||||
binding_source: binding.binding_source ?? "MANUAL",
|
||||
fallback_mode: binding.fallback_mode ?? "PREFER_LOCAL",
|
||||
last_resolution: binding.last_resolution ?? null,
|
||||
last_success_at: binding.last_success_at ?? null,
|
||||
last_error: binding.last_error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatInventoryChannelCount(count) {
|
||||
return `${count} kanal${count === 1 ? "" : "er"}`;
|
||||
}
|
||||
|
||||
export function getInventoryGeneration(device = {}) {
|
||||
return device?.capabilities?.generation ?? device?.metadata?.gen ?? device?.metadata?.generation ?? null;
|
||||
}
|
||||
|
||||
export function buildInventoryOptionLabel(device = {}, { missing = false } = {}) {
|
||||
const channelCount = Math.max(1, Number(device?.channel_count ?? 1));
|
||||
const segments = [
|
||||
device?.model || "Ukendt Shelly-device",
|
||||
device?.device_id || "Ukendt device-id",
|
||||
device?.local_ip || "IP ukendt",
|
||||
formatInventoryChannelCount(channelCount),
|
||||
];
|
||||
const generation = getInventoryGeneration(device);
|
||||
|
||||
if (generation !== null && generation !== undefined && generation !== "") {
|
||||
segments.push(`Gen ${generation}`);
|
||||
}
|
||||
|
||||
if (device?.online === false) {
|
||||
segments.push("Offline");
|
||||
}
|
||||
|
||||
if (missing) {
|
||||
segments.push("Mangler i discovery");
|
||||
}
|
||||
|
||||
return segments.join(" · ");
|
||||
}
|
||||
|
||||
export function buildInventoryDeviceOptions(inventory = []) {
|
||||
return inventory.map((device) => ({
|
||||
value: device.device_id,
|
||||
label: buildInventoryOptionLabel(device),
|
||||
device,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getBindingInventoryDevice(binding = {}, inventoryOptions = []) {
|
||||
return inventoryOptions.find((option) => option.value === binding.device_id)?.device ?? null;
|
||||
}
|
||||
|
||||
export function getBindingDeviceOptions(binding = {}, inventoryOptions = [], bindings = []) {
|
||||
const options = [...inventoryOptions];
|
||||
const deviceExists = options.some((option) => option.value === binding.device_id);
|
||||
|
||||
if (binding.device_id && !deviceExists) {
|
||||
const knownBinding = bindings.find((candidate) => candidate.device_id === binding.device_id);
|
||||
options.push({
|
||||
value: binding.device_id,
|
||||
label: buildInventoryOptionLabel(
|
||||
{
|
||||
model: knownBinding?.model ?? null,
|
||||
device_id: binding.device_id,
|
||||
local_ip: binding.local_ip ?? knownBinding?.local_ip ?? null,
|
||||
channel_count: Math.max(1, Number(binding.channel ?? 1) + 1),
|
||||
metadata: { generation: null },
|
||||
},
|
||||
{ missing: true }
|
||||
),
|
||||
device: {
|
||||
device_id: binding.device_id,
|
||||
local_ip: binding.local_ip ?? null,
|
||||
channel_count: Math.max(1, Number(binding.channel ?? 1) + 1),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function getBindingChannelOptions(binding = {}, inventoryOptions = []) {
|
||||
const device = getBindingInventoryDevice(binding, inventoryOptions);
|
||||
const fallbackChannelCount =
|
||||
binding?.channel === null || binding?.channel === undefined ? 1 : Number(binding.channel) + 1;
|
||||
const channelCount = Math.max(1, Number(device?.channel_count ?? fallbackChannelCount));
|
||||
return Array.from({ length: channelCount }, (_, index) => index);
|
||||
}
|
||||
|
||||
export function buildBindingHelperText({ inventoryOptions = [], editableBindings = [] } = {}) {
|
||||
const inventoryCount = inventoryOptions.length;
|
||||
const bindingCount = editableBindings.length;
|
||||
|
||||
if (!inventoryCount) {
|
||||
return "Kør discovery for at hente Shelly-enheder, før du matcher relæer.";
|
||||
}
|
||||
|
||||
if (!bindingCount) {
|
||||
return `Discovery fandt ${inventoryCount} Shelly-enheder. Tilføj de relæer, som skal køres lokalt.`;
|
||||
}
|
||||
|
||||
return `${bindingCount} bindinger klargjort mod ${inventoryCount} Shelly-enheder.`;
|
||||
}
|
||||
|
||||
export function buildEmptyBindingStateDescription({ inventoryOptions = [] } = {}) {
|
||||
if (!inventoryOptions.length) {
|
||||
return "Discovery mangler stadig. Når Shelly-enhederne er fundet, kan du oprette bindinger her.";
|
||||
}
|
||||
|
||||
return "Tilføj en binding for hvert relæ, der skal styres via den lokale gateway.";
|
||||
}
|
||||
|
||||
export function resolveIncidentPrimaryAction(gateway = {}) {
|
||||
if (gateway?.transport_health?.recommended_action === "retry_discovery") {
|
||||
return {
|
||||
label: "Kør discovery nu",
|
||||
helper: "Shelly-state er usikker. Kør en ny scanning og opdatér bindingerne.",
|
||||
targetStep: "configure",
|
||||
kind: "trigger_discovery",
|
||||
};
|
||||
}
|
||||
|
||||
if ((gateway?.fallback_summary?.cloud_relays || 0) > 0) {
|
||||
return {
|
||||
label: "Gennemgå bindinger",
|
||||
helper: "Mindst ét relæ kører via cloud fallback i stedet for lokal styring.",
|
||||
targetStep: "configure",
|
||||
kind: "switch_step",
|
||||
};
|
||||
}
|
||||
|
||||
if (gateway?.status === "OFFLINE") {
|
||||
return {
|
||||
label: "Åbn gatewaystyring",
|
||||
helper: "Bekræft metadata, afdelingens cutover og om gatewayen stadig skal være primær.",
|
||||
targetStep: "manage",
|
||||
kind: "switch_step",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: "Se Shelly-bindinger",
|
||||
helper: "Kontrollér discovery og bindinger, før du sender flere lokale relækommandoer.",
|
||||
targetStep: "configure",
|
||||
kind: "switch_step",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGatewayView(gateway, { departmentNameById = {}, now = Date.now() } = {}) {
|
||||
if (!gateway) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inventory = Array.isArray(gateway.inventory) ? gateway.inventory : [];
|
||||
const bindings = Array.isArray(gateway.bindings) ? gateway.bindings : [];
|
||||
const recentCommands = Array.isArray(gateway.recent_commands) ? gateway.recent_commands : [];
|
||||
const auditLogs = Array.isArray(gateway.audit_logs) ? gateway.audit_logs : [];
|
||||
const statusMeta = getStatusMeta(gateway.status);
|
||||
const controlChannel = getControlChannelMeta(gateway);
|
||||
const discoveryStatus = gateway.discovery_status || "UNKNOWN";
|
||||
const relayHealthRows = buildRelayHealthRows(gateway, bindings);
|
||||
const incidentPrimaryAction = resolveIncidentPrimaryAction(gateway);
|
||||
|
||||
return {
|
||||
...gateway,
|
||||
inventory,
|
||||
bindings,
|
||||
recent_commands: recentCommands,
|
||||
audit_logs: auditLogs,
|
||||
displayLabel: gateway.label || gateway.hostname || `Gateway #${gateway.id}`,
|
||||
departmentName: getDepartmentName(gateway.department_id, departmentNameById),
|
||||
primaryLabel: gateway.is_primary ? "Primær gateway" : "Sekundær gateway",
|
||||
statusLabel: statusMeta.label,
|
||||
statusTone: statusMeta.tone,
|
||||
statusHelper: statusMeta.helper,
|
||||
discoveryStatusLabel: DISCOVERY_STATUS_LABELS[discoveryStatus] ?? DISCOVERY_STATUS_LABELS.UNKNOWN,
|
||||
brokerStatusLabel: controlChannel.label,
|
||||
brokerStatusTone: controlChannel.tone,
|
||||
controlChannelHelper: controlChannel.helper,
|
||||
transportModeLabel: TRANSPORT_MODE_LABELS[gateway.transport_mode] ?? gateway.transport_mode ?? "Ukendt",
|
||||
departmentTransportModeLabel:
|
||||
TRANSPORT_MODE_LABELS[gateway.department_transport_mode] ?? gateway.department_transport_mode ?? "Ukendt",
|
||||
heartbeatRelative: formatRelativeTimestamp(gateway.last_heartbeat_at, now),
|
||||
heartbeatAbsolute: formatAbsoluteTimestamp(gateway.last_heartbeat_at),
|
||||
lastSuccessfulCommandRelative: formatRelativeTimestamp(gateway.last_successful_command_at, now),
|
||||
lastSuccessfulDiscoveryRelative: formatRelativeTimestamp(gateway.last_successful_discovery_at, now),
|
||||
metrics: buildGatewayMetricView(gateway),
|
||||
relayHealthRows,
|
||||
incidentPrimaryAction,
|
||||
incidentSummary:
|
||||
gateway.transport_health?.summary ||
|
||||
`${relayHealthRows.length} relæ${relayHealthRows.length === 1 ? "" : "er"} overvåges via gatewayen.`,
|
||||
bindingCount: Array.isArray(gateway.bindings) ? gateway.bindings.length : 0,
|
||||
discoveredDeviceCount: Array.isArray(gateway.inventory) ? gateway.inventory.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSummaryCards(gatewayViews = []) {
|
||||
const total = gatewayViews.length;
|
||||
const online = gatewayViews.filter((gateway) => gateway.status === "ONLINE").length;
|
||||
const degraded = gatewayViews.filter((gateway) => gateway.status === "DEGRADED").length;
|
||||
const offline = gatewayViews.filter((gateway) => gateway.status === "OFFLINE").length;
|
||||
const fallback = gatewayViews.filter((gateway) => (gateway.fallback_summary?.cloud_relays || 0) > 0).length;
|
||||
|
||||
return [
|
||||
{ key: "ALL", label: "Alle", count: total, helper: "Hele gateway-flåden" },
|
||||
{ key: "ONLINE", label: "Online", count: online, helper: "Klar til lokal Shelly-styring" },
|
||||
{ key: "DEGRADED", label: "Kræver handling", count: degraded, helper: "Degraderede gateways" },
|
||||
{ key: "OFFLINE", label: "Offline", count: offline, helper: "Mangler heartbeat" },
|
||||
{ key: "FALLBACK", label: "Cloud fallback", count: fallback, helper: "Relæer kører ikke lokalt" },
|
||||
];
|
||||
}
|
||||
|
||||
export function filterGatewayViews(gatewayViews = [], { fleetFilter = "ALL", query = "" } = {}) {
|
||||
const normalizedQuery = String(query || "").trim().toLowerCase();
|
||||
|
||||
return gatewayViews.filter((gateway) => {
|
||||
if (fleetFilter === "ONLINE" && gateway.status !== "ONLINE") {
|
||||
return false;
|
||||
}
|
||||
if (fleetFilter === "DEGRADED" && gateway.status !== "DEGRADED") {
|
||||
return false;
|
||||
}
|
||||
if (fleetFilter === "OFFLINE" && gateway.status !== "OFFLINE") {
|
||||
return false;
|
||||
}
|
||||
if (fleetFilter === "FALLBACK" && (gateway.fallback_summary?.cloud_relays || 0) === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
gateway.displayLabel,
|
||||
gateway.departmentName,
|
||||
gateway.hostname,
|
||||
gateway.statusLabel,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(normalizedQuery));
|
||||
});
|
||||
}
|
||||
|
||||
export function buildWorkflowStepModels({ activeStep, hasGateway }) {
|
||||
return WORKFLOW_STEPS.map((step) => ({
|
||||
...step,
|
||||
active: step.key === activeStep,
|
||||
disabled: !hasGateway && !["select", "manage"].includes(step.key),
|
||||
}));
|
||||
}
|
||||
|
||||
export function mergeWorkspaceDraftState({ gateway, currentState = {}, dirtyState = {}, force = false } = {}) {
|
||||
const gatewayBindings = Array.isArray(gateway?.bindings) ? gateway.bindings.map((binding) => normalizeBinding(binding)) : [];
|
||||
|
||||
return {
|
||||
editableBindings:
|
||||
force || !dirtyState.bindings ? gatewayBindings : currentState.editableBindings ?? gatewayBindings,
|
||||
gatewayLabel:
|
||||
force || !dirtyState.gatewayLabel ? gateway?.label ?? "" : currentState.gatewayLabel ?? gateway?.label ?? "",
|
||||
gatewayIsPrimary:
|
||||
force || !dirtyState.gatewayIsPrimary
|
||||
? Boolean(gateway?.is_primary)
|
||||
: Boolean(currentState.gatewayIsPrimary),
|
||||
transportMode:
|
||||
force || !dirtyState.transportMode
|
||||
? gateway?.department_transport_mode ?? "gateway"
|
||||
: currentState.transportMode ?? gateway?.department_transport_mode ?? "gateway",
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveEdgeGatewayErrorMessage(error) {
|
||||
const message =
|
||||
error?.response?.data?.message ??
|
||||
error?.response?.data?.data?.message ??
|
||||
error?.message ??
|
||||
error;
|
||||
|
||||
const normalized = String(message || "").trim();
|
||||
return normalized === "" ? "Gateway-handlingen fejlede. Prøv igen." : normalized;
|
||||
}
|
||||
|
||||
export function resolveNextWorkflowStep(currentStep, hasGateway) {
|
||||
if (!hasGateway) {
|
||||
return currentStep === "manage" ? "manage" : "select";
|
||||
}
|
||||
|
||||
if (currentStep === "select") {
|
||||
return "assess";
|
||||
}
|
||||
|
||||
return currentStep;
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
import { searchCustomer, searchCustomerResults, isSearching } from "@/components/search/economic/customerSearch.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const props = defineProps({
|
||||
order: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "assigned"]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const customerSearchQuery = ref("");
|
||||
const selectedCustomer = ref(null);
|
||||
const invoiceCollections = ref([]);
|
||||
const selectedInvoiceCollectionId = ref(null);
|
||||
const recalculatePrices = ref(true);
|
||||
const isLoadingInvoiceCollections = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const isCreatingInvoiceCollection = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
const normalizePositiveInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const normalizeCustomerResult = (customer) => {
|
||||
const customerNumber = normalizePositiveInteger(customer?.customerNumber ?? customer?.customer_number);
|
||||
if (!customerNumber) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
customerNumber,
|
||||
name: customer?.name ?? customer?.customer_name ?? `#${customerNumber}`,
|
||||
address: customer?.address ?? "",
|
||||
city: customer?.city ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
const buildCustomerLabel = (customer) => {
|
||||
if (!customer) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `${customer.name} (${customer.customerNumber})`;
|
||||
};
|
||||
|
||||
const formatInvoiceCollectionMeta = (invoiceCollection) => {
|
||||
if (!invoiceCollection) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const meta = [];
|
||||
if (invoiceCollection.closed_at) {
|
||||
meta.push(`${t("global.closed")}: ${invoiceCollection.closed_at}`);
|
||||
} else {
|
||||
meta.push(t("common.open"));
|
||||
}
|
||||
|
||||
if (invoiceCollection.created_at) {
|
||||
meta.push(invoiceCollection.created_at);
|
||||
}
|
||||
|
||||
return meta.join(" • ");
|
||||
};
|
||||
|
||||
const sortedInvoiceCollections = computed(() => {
|
||||
return [...invoiceCollections.value].sort((left, right) => {
|
||||
const leftIsOpen = left?.closed_at === null;
|
||||
const rightIsOpen = right?.closed_at === null;
|
||||
if (leftIsOpen !== rightIsOpen) {
|
||||
return leftIsOpen ? -1 : 1;
|
||||
}
|
||||
|
||||
return normalizePositiveInteger(right?.id) - normalizePositiveInteger(left?.id);
|
||||
});
|
||||
});
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return Boolean(selectedCustomer.value && selectedInvoiceCollectionId.value && !isSaving.value);
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
if (isSaving.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const resetInvoiceCollectionSelection = () => {
|
||||
invoiceCollections.value = [];
|
||||
selectedInvoiceCollectionId.value = null;
|
||||
};
|
||||
|
||||
const onCustomerSearchInput = async () => {
|
||||
errorMessage.value = "";
|
||||
|
||||
if (
|
||||
selectedCustomer.value
|
||||
&& customerSearchQuery.value.trim() !== buildCustomerLabel(selectedCustomer.value)
|
||||
) {
|
||||
selectedCustomer.value = null;
|
||||
resetInvoiceCollectionSelection();
|
||||
}
|
||||
|
||||
const query = customerSearchQuery.value.trim();
|
||||
if (!query) {
|
||||
searchCustomer(null);
|
||||
return;
|
||||
}
|
||||
|
||||
await searchCustomer(query);
|
||||
};
|
||||
|
||||
const loadInvoiceCollections = async (preferredInvoiceCollectionId = null) => {
|
||||
const customerNumber = normalizePositiveInteger(selectedCustomer.value?.customerNumber);
|
||||
if (!customerNumber) {
|
||||
resetInvoiceCollectionSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingInvoiceCollections.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const response = await authenticatedRequest("/collected-invoices", "GET", {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
order: "closed_at:asc",
|
||||
filters: `customer_number:${customerNumber},booked_invoice_id:is_null`,
|
||||
});
|
||||
|
||||
const resolvedCollections = Array.isArray(response?.data?.data) ? response.data.data : [];
|
||||
invoiceCollections.value = resolvedCollections;
|
||||
|
||||
const preferredId = normalizePositiveInteger(preferredInvoiceCollectionId);
|
||||
const currentOrderCollectionId = normalizePositiveInteger(props.order?.invoice_collection_id);
|
||||
const matchingCurrentCollection = currentOrderCollectionId
|
||||
? resolvedCollections.find((collection) => normalizePositiveInteger(collection?.id) === currentOrderCollectionId)
|
||||
: null;
|
||||
const firstOpenCollection = resolvedCollections.find((collection) => collection?.closed_at === null);
|
||||
|
||||
selectedInvoiceCollectionId.value = preferredId
|
||||
?? normalizePositiveInteger(matchingCurrentCollection?.id)
|
||||
?? normalizePositiveInteger(firstOpenCollection?.id)
|
||||
?? normalizePositiveInteger(resolvedCollections[0]?.id);
|
||||
} catch (error) {
|
||||
invoiceCollections.value = [];
|
||||
selectedInvoiceCollectionId.value = null;
|
||||
errorMessage.value = SessionUser.functions.parseErrorMessage(error) || t("admin.pos.drafts_assignment.error");
|
||||
} finally {
|
||||
isLoadingInvoiceCollections.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectCustomer = async (customer) => {
|
||||
const normalizedCustomer = normalizeCustomerResult(customer);
|
||||
if (!normalizedCustomer) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedCustomer.value = normalizedCustomer;
|
||||
customerSearchQuery.value = buildCustomerLabel(normalizedCustomer);
|
||||
searchCustomer(null);
|
||||
await loadInvoiceCollections();
|
||||
};
|
||||
|
||||
const createInvoiceCollection = async () => {
|
||||
const customerNumber = normalizePositiveInteger(selectedCustomer.value?.customerNumber);
|
||||
if (!customerNumber || isCreatingInvoiceCollection.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isCreatingInvoiceCollection.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const response = await SessionUser.objects.collectedOrderInvoices.add(
|
||||
customerNumber,
|
||||
t("admin.pos.drafts_assignment.new_collection_name"),
|
||||
t("admin.pos.drafts_assignment.new_collection_description"),
|
||||
null
|
||||
);
|
||||
|
||||
const createdInvoiceCollectionId = normalizePositiveInteger(response?.data?.data?.id);
|
||||
await loadInvoiceCollections(createdInvoiceCollectionId);
|
||||
} catch (error) {
|
||||
errorMessage.value = SessionUser.functions.parseErrorMessage(error) || t("admin.pos.drafts_assignment.error");
|
||||
} finally {
|
||||
isCreatingInvoiceCollection.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitAssignment = async () => {
|
||||
if (!canSubmit.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const response = await SessionUser.objects.orders.functions.assignDraftCustomer({
|
||||
order_id: normalizePositiveInteger(props.order?.id),
|
||||
customer_id: normalizePositiveInteger(selectedCustomer.value?.customerNumber),
|
||||
invoice_collection_id: normalizePositiveInteger(selectedInvoiceCollectionId.value),
|
||||
department_id: normalizePositiveInteger(props.order?.department_id),
|
||||
recalculate_prices: recalculatePrices.value,
|
||||
});
|
||||
|
||||
emit("assigned", response);
|
||||
} catch (error) {
|
||||
errorMessage.value = SessionUser.functions.parseErrorMessage(error) || t("admin.pos.drafts_assignment.error");
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
searchCustomer(null);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal is-active" data-testid="draft-order-assign-customer-modal">
|
||||
<div class="modal-background" @click="closeModal"></div>
|
||||
<div class="modal-card draft-order-assign-modal">
|
||||
<header class="modal-card-head">
|
||||
<div>
|
||||
<p class="modal-card-title">{{ t("admin.pos.drafts_assignment.title") }}</p>
|
||||
<p class="is-size-7 has-text-grey mt-2">
|
||||
{{ t("admin.pos.drafts_assignment.subtitle", { orderId: props.order.id }) }}
|
||||
</p>
|
||||
</div>
|
||||
<button class="delete" aria-label="close" @click="closeModal"></button>
|
||||
</header>
|
||||
|
||||
<section class="modal-card-body">
|
||||
<div v-if="errorMessage" class="notification is-danger is-light mb-4" data-testid="draft-order-assign-error">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="draft-order-assign-customer-search">
|
||||
{{ t("admin.pos.drafts_assignment.search_customer_label") }}
|
||||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<input
|
||||
id="draft-order-assign-customer-search"
|
||||
v-model="customerSearchQuery"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="t('admin.pos.drafts_assignment.search_customer_placeholder')"
|
||||
data-testid="draft-order-assign-customer-search"
|
||||
@input="onCustomerSearchInput"
|
||||
/>
|
||||
<span class="icon is-left">
|
||||
<i class="fas fa-search"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedCustomer" class="draft-order-assign-selected-customer mb-4" data-testid="draft-order-selected-customer">
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-start">
|
||||
<div>
|
||||
<p class="has-text-weight-semibold">{{ t("admin.pos.drafts_assignment.selected_customer") }}</p>
|
||||
<p>{{ selectedCustomer.name }}</p>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
#{{ selectedCustomer.customerNumber }}
|
||||
<span v-if="selectedCustomer.city"> • {{ selectedCustomer.city }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="tag is-link is-light">#{{ selectedCustomer.customerNumber }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="customerSearchQuery.trim() && searchCustomerResults.length > 0"
|
||||
class="draft-order-assign-search-results mb-5"
|
||||
data-testid="draft-order-customer-results"
|
||||
>
|
||||
<button
|
||||
v-for="customer in searchCustomerResults"
|
||||
:key="customer.customerNumber || customer.customer_number"
|
||||
type="button"
|
||||
class="button is-fullwidth is-justify-content-flex-start draft-order-assign-search-result"
|
||||
:data-testid="`draft-order-customer-option-${customer.customerNumber || customer.customer_number}`"
|
||||
@click="selectCustomer(customer)"
|
||||
>
|
||||
<span class="icon mr-2">
|
||||
<i class="fas fa-user"></i>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{{ customer.name || customer.customer_name }}</strong>
|
||||
<small class="is-block has-text-grey">
|
||||
#{{ customer.customerNumber || customer.customer_number }}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="customerSearchQuery.trim() && !isSearching && !selectedCustomer" class="notification is-light mb-5">
|
||||
{{ t("global.no_data") }}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ t("admin.pos.drafts_assignment.invoice_collection_label") }}</label>
|
||||
|
||||
<div v-if="!selectedCustomer" class="notification is-light">
|
||||
{{ t("admin.pos.drafts_assignment.customer_required") }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="isLoadingInvoiceCollections" class="notification is-light">
|
||||
{{ t("admin.pos.drafts_assignment.invoice_collection_loading") }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="sortedInvoiceCollections.length === 0" class="notification is-warning is-light">
|
||||
<p>{{ t("admin.pos.drafts_assignment.invoice_collection_empty") }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-warning is-light mt-3"
|
||||
:disabled="isCreatingInvoiceCollection"
|
||||
data-testid="draft-order-create-invoice-collection"
|
||||
@click="createInvoiceCollection"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-plus"></i>
|
||||
</span>
|
||||
<span>{{ t("admin.pos.drafts_assignment.create_invoice_collection") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="draft-order-assign-collections">
|
||||
<button
|
||||
v-for="invoiceCollection in sortedInvoiceCollections"
|
||||
:key="invoiceCollection.id"
|
||||
type="button"
|
||||
class="button is-fullwidth is-justify-content-space-between draft-order-assign-collection"
|
||||
:class="{ 'is-link is-light is-selected': selectedInvoiceCollectionId === normalizePositiveInteger(invoiceCollection.id) }"
|
||||
:data-testid="`draft-order-invoice-collection-option-${invoiceCollection.id}`"
|
||||
@click="selectedInvoiceCollectionId = normalizePositiveInteger(invoiceCollection.id)"
|
||||
>
|
||||
<span class="has-text-left">
|
||||
<strong>#{{ invoiceCollection.id }}</strong>
|
||||
<small class="is-block has-text-grey">
|
||||
{{ formatInvoiceCollectionMeta(invoiceCollection) }}
|
||||
</small>
|
||||
</span>
|
||||
<span class="tag" :class="invoiceCollection.closed_at ? 'is-light' : 'is-success is-light'">
|
||||
{{ invoiceCollection.closed_at ? t("global.closed") : t("common.open") }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="is-flex is-justify-content-flex-end mt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-light"
|
||||
:disabled="isCreatingInvoiceCollection"
|
||||
data-testid="draft-order-create-invoice-collection"
|
||||
@click="createInvoiceCollection"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-plus"></i>
|
||||
</span>
|
||||
<span>{{ t("admin.pos.drafts_assignment.create_invoice_collection") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="draft-order-assign-switch mt-5">
|
||||
<label class="switch-label" for="draft-order-recalculate-prices">
|
||||
<span>
|
||||
<strong>{{ t("admin.pos.drafts_assignment.recalculate_prices") }}</strong>
|
||||
<small class="is-block has-text-grey">
|
||||
{{ t("admin.pos.drafts_assignment.recalculate_prices_help") }}
|
||||
</small>
|
||||
</span>
|
||||
<span class="switch-control">
|
||||
<input
|
||||
id="draft-order-recalculate-prices"
|
||||
v-model="recalculatePrices"
|
||||
class="switch is-rounded is-success"
|
||||
type="checkbox"
|
||||
data-testid="draft-order-recalculate-prices"
|
||||
/>
|
||||
<label for="draft-order-recalculate-prices"></label>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot is-justify-content-flex-end">
|
||||
<button class="button is-light" :disabled="isSaving" @click="closeModal">
|
||||
{{ t("global.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
class="button is-link"
|
||||
:class="{ 'is-loading': isSaving }"
|
||||
:disabled="!canSubmit"
|
||||
data-testid="draft-order-assign-submit"
|
||||
@click="submitAssignment"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-user-check"></i>
|
||||
</span>
|
||||
<span>{{ t("admin.pos.drafts_assignment.submit") }}</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.draft-order-assign-modal {
|
||||
max-width: 760px;
|
||||
width: min(760px, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.draft-order-assign-search-results,
|
||||
.draft-order-assign-collections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.draft-order-assign-search-result,
|
||||
.draft-order-assign-collection {
|
||||
min-height: 3.5rem;
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.95rem 1rem;
|
||||
border-color: #d8e2ef;
|
||||
}
|
||||
|
||||
.draft-order-assign-search-result {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.draft-order-assign-collection.is-selected {
|
||||
border-color: #3273dc;
|
||||
box-shadow: 0 0 0 1px rgba(50, 115, 220, 0.14);
|
||||
}
|
||||
|
||||
.draft-order-assign-selected-customer {
|
||||
padding: 1rem 1.15rem;
|
||||
border-radius: 1rem;
|
||||
background: #f6f9ff;
|
||||
border: 1px solid #dbe6f4;
|
||||
}
|
||||
|
||||
.draft-order-assign-switch {
|
||||
padding: 1rem 1.15rem;
|
||||
border-radius: 1rem;
|
||||
background: #f9fbfd;
|
||||
border: 1px solid #dce4ee;
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.switch-control {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.draft-order-assign-modal {
|
||||
width: calc(100vw - 1rem);
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,10 @@ const props = defineProps({
|
||||
hideSearch: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showDraftAssignmentActions: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
import { defineProps } from "vue";
|
||||
@@ -105,10 +109,10 @@ if (props.autoLoad) {
|
||||
<PaginationDisplayFilters />
|
||||
</template>
|
||||
</PaginationDisplay>
|
||||
<OrdersTable :orders="list" />
|
||||
<OrdersTable :orders="list" :show-draft-assignment-actions="props.showDraftAssignmentActions" />
|
||||
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { customer_id, notes, isCustomerBarred, customer_name, department_id, reference, reg_1, reg_2, reg_3, selectCustomer, isCustomerSelected, customer_attributes } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { customer_id, notes, isCustomerBarred, customer_name, department_id, reference, reg_1, reg_2, reg_3, selectCustomer, isCustomerSelected, customer_attributes, searchAndSelectCustomer } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import CustomerSearchField from "@/components/search/economic/customerSearchField.vue";
|
||||
import { searchCustomerResults } from "@/components/search/economic/customerSearch.vue";
|
||||
import PosNotes from "@/components/displays/department/pos/PosNotes.vue";
|
||||
import { isSearching } from "@/components/search/economic/customerSearch.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import LicensePlateInput from "@/components/forms/department/pos/input/LicensePlateInput.vue";
|
||||
import { useDraftTransactionCustomer } from "@/composables/useDraftTransactionCustomer.js";
|
||||
import { useStripeReaderAvailability } from "@/composables/useStripeReaderAvailability.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { draftTransactionCustomerNumber, hasDraftTransactionCustomer } = useDraftTransactionCustomer();
|
||||
const { isCardPaymentAvailable } = useStripeReaderAvailability();
|
||||
|
||||
const selectedDropdownItem = ref(-1);
|
||||
const showSelector = ref(false);
|
||||
const isCardPaymentDisabled = computed(() => !isCardPaymentAvailable.value);
|
||||
|
||||
const arrowKeyHandler = (event) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
@@ -28,7 +36,6 @@ const arrowKeyHandler = (event) => {
|
||||
event.preventDefault();
|
||||
selectCustomer(searchCustomerResults.value[selectedDropdownItem.value]);
|
||||
selectedDropdownItem.value = -1;
|
||||
// Set the active input to the reference field
|
||||
document.getElementById('reference').focus();
|
||||
}
|
||||
};
|
||||
@@ -37,7 +44,6 @@ const getSearchIndexByCustomerNumber = (customerNumber) => {
|
||||
return searchCustomerResults.value.findIndex((result) => result.customerNumber === customerNumber);
|
||||
};
|
||||
|
||||
// This delay is needed to prevent the dropdown from closing when clicking on a dropdown item (Making the dropdown item clickable)
|
||||
const lostfocus = () => {
|
||||
setTimeout(() => {
|
||||
showSelector.value = false;
|
||||
@@ -54,13 +60,27 @@ const keyDownNextInput = (event, field_id) => {
|
||||
const keyDownNextTabIndexButton = (event, tabIndex) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
// First unfocus the input field. This is to ensure the sync between the input field and the POSDepartmentProcess.vue data.
|
||||
event.target.blur();
|
||||
document.querySelector(`button[tabindex="${tabIndex}"]`).click();
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectDraftCustomer = async () => {
|
||||
if (!hasDraftTransactionCustomer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await searchAndSelectCustomer(draftTransactionCustomerNumber.value);
|
||||
document.getElementById('reference')?.focus();
|
||||
};
|
||||
|
||||
const onSelectCardPayment = () => {
|
||||
if (isCardPaymentDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectCustomer({name: 'Stripe Payment', customerNumber: 999});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -90,10 +110,35 @@ const keyDownNextTabIndexButton = (event, tabIndex) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stripe payment button -->
|
||||
<button class="button is-text mt-1" @click="selectCustomer({name: 'Stripe Payment', customerNumber: 999})" :tabindex="isCustomerSelected() ? 1 : -1" :class="{'is-hidden': isCustomerSelected()}" style="text-decoration-line: none;">
|
||||
Vælg direkte betaling med betalingskort
|
||||
<div class="customer-quick-actions" :class="{'is-hidden': isCustomerSelected()}">
|
||||
<button
|
||||
v-if="hasDraftTransactionCustomer"
|
||||
class="button is-light customer-quick-action customer-quick-action--draft"
|
||||
type="button"
|
||||
data-testid="pos-draft-customer-quick-action"
|
||||
@click="onSelectDraftCustomer"
|
||||
:tabindex="isCustomerSelected() ? -1 : 1"
|
||||
>
|
||||
<span class="icon is-small customer-quick-action__icon">
|
||||
<i class="fas fa-file-invoice"></i>
|
||||
</span>
|
||||
<span>{{ t('pos.customer_picker.select_draft_customer') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="button is-light customer-quick-action customer-quick-action--card"
|
||||
:class="{ 'is-disabled': isCardPaymentDisabled }"
|
||||
type="button"
|
||||
data-testid="pos-card-payment-quick-action"
|
||||
:disabled="isCardPaymentDisabled"
|
||||
@click="onSelectCardPayment"
|
||||
:tabindex="isCustomerSelected() ? -1 : 1"
|
||||
>
|
||||
<span class="icon is-small customer-quick-action__icon">
|
||||
<i class="fas fa-credit-card"></i>
|
||||
</span>
|
||||
<span>{{ t('pos.customer_picker.select_card_payment') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field has-addons-right has-addons mt-1" :class="{'is-hidden': !isCustomerSelected()}">
|
||||
<p class="control is-expanded">
|
||||
@@ -126,7 +171,6 @@ const keyDownNextTabIndexButton = (event, tabIndex) => {
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<!-- If the customer has the attribute "requiresReferenceNumber", and the field is empty, show a warning -->
|
||||
<p
|
||||
class="help has-text-danger"
|
||||
v-if="customer_attributes.some((attr) => attr.attribute === 'requiresReferenceNumber') && reference === ''"
|
||||
@@ -145,20 +189,17 @@ const keyDownNextTabIndexButton = (event, tabIndex) => {
|
||||
:customer_number="parseInt(customer_id) || 0"
|
||||
class="has-sharp-edges"
|
||||
/>
|
||||
{{ reg_1}}
|
||||
{{ reg_1 }}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Reg 2</label>
|
||||
<LicensePlateInput :tab-index="4" @keydown="keyDownNextInput($event, 'reg_3')" input-id="reg_2" v-model:inputModel="reg_2" :customer_number="parseInt(customer_id) || 0" v-bind:actual-value="reg_2" />
|
||||
{{ reg_2}}
|
||||
{{ reg_2 }}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Reg 3</label>
|
||||
<!--<div class="control">
|
||||
<input class="input reg-input" type="text" v-model="reg_3" tabindex="5" @keydown="keyDownNextTabIndexButton($event, 6)" id="reg_3" />
|
||||
</div> -->
|
||||
<LicensePlateInput :tab-index="5" @keydown="keyDownNextTabIndexButton($event, 6)" input-id="reg_3" v-model:inputModel="reg_3" :customer_number="parseInt(customer_id) || 0" v-bind:actual-value="reg_3" />
|
||||
{{ reg_3}}
|
||||
{{ reg_3 }}
|
||||
</div>
|
||||
</div>
|
||||
<PosNotes :notes="notes" :is-loading="false" :isAddFormVisible="SessionUser.adminUser" :isOldNotesVisible="false" :is-label-visible="true" class="mt-3" v-if="isCustomerSelected()" />
|
||||
@@ -168,4 +209,75 @@ const keyDownNextTabIndexButton = (event, tabIndex) => {
|
||||
.input.has-sharp-edges {
|
||||
border-radius: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
.customer-quick-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.customer-quick-action {
|
||||
align-items: center;
|
||||
border: 1px solid #d2deee;
|
||||
border-radius: 0.8rem;
|
||||
box-shadow: 0 8px 16px rgba(21, 49, 93, 0.06);
|
||||
color: #17325c;
|
||||
display: inline-flex;
|
||||
font-weight: 600;
|
||||
gap: 0.7rem;
|
||||
justify-content: center;
|
||||
min-height: 2.85rem;
|
||||
padding: 0.75rem 1.2rem;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.customer-quick-action:hover,
|
||||
.customer-quick-action:focus-visible {
|
||||
background-color: #f1f6ff;
|
||||
border-color: #b6cae6;
|
||||
color: #0f2a55;
|
||||
}
|
||||
|
||||
.customer-quick-action__icon {
|
||||
align-items: center;
|
||||
background: #e9f2ff;
|
||||
border-radius: 999px;
|
||||
color: #184b8a;
|
||||
display: inline-flex;
|
||||
height: 1.85rem;
|
||||
justify-content: center;
|
||||
width: 1.85rem;
|
||||
}
|
||||
|
||||
.customer-quick-action:disabled,
|
||||
.customer-quick-action.is-disabled {
|
||||
background: #f4f7fb;
|
||||
border-color: #d6dfec;
|
||||
box-shadow: none;
|
||||
color: #90a0b7;
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.customer-quick-action:disabled .customer-quick-action__icon,
|
||||
.customer-quick-action.is-disabled .customer-quick-action__icon {
|
||||
background: #edf2f8;
|
||||
color: #8ca0bc;
|
||||
}
|
||||
|
||||
.customer-quick-action:disabled:hover,
|
||||
.customer-quick-action:disabled:focus-visible,
|
||||
.customer-quick-action.is-disabled:hover,
|
||||
.customer-quick-action.is-disabled:focus-visible {
|
||||
background: #f4f7fb;
|
||||
border-color: #d6dfec;
|
||||
color: #90a0b7;
|
||||
}
|
||||
|
||||
.customer-quick-action--card .customer-quick-action__icon {
|
||||
background: #e4f7fa;
|
||||
color: #0f6a7b;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,59 +1,127 @@
|
||||
<script setup>
|
||||
import { ref, defineEmits, defineProps } from 'vue';
|
||||
import { customer_id, isCustomerBarred, customer_name, selectCustomer, isCustomerSelected } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { computed, defineEmits, defineProps, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
customer_id,
|
||||
isCustomerBarred,
|
||||
customer_name,
|
||||
selectCustomer,
|
||||
isCustomerSelected,
|
||||
searchAndSelectCustomer,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import CustomerSearchField from "@/components/search/economic/customerSearchField.vue";
|
||||
import { searchCustomerResults } from "@/components/search/economic/customerSearch.vue";
|
||||
import { isSearching } from "@/components/search/economic/customerSearch.vue";
|
||||
import { isSearching, searchCustomerResults } from "@/components/search/economic/customerSearch.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useDraftTransactionCustomer } from "@/composables/useDraftTransactionCustomer.js";
|
||||
import { useStripeReaderAvailability } from "@/composables/useStripeReaderAvailability.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { draftTransactionCustomerNumber, hasDraftTransactionCustomer } = useDraftTransactionCustomer();
|
||||
const { isCardPaymentAvailable } = useStripeReaderAvailability();
|
||||
|
||||
const props = defineProps({
|
||||
onCustomerSelected: {
|
||||
type: Function,
|
||||
default: () => {}
|
||||
default: () => {},
|
||||
},
|
||||
showWhenCustomerSelected: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
default: false,
|
||||
},
|
||||
showCardPaymentButton: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['update:customer_id', 'onCustomerSelected']);
|
||||
|
||||
const emit = defineEmits(["update:customer_id", "onCustomerSelected"]);
|
||||
|
||||
const selectedDropdownItem = ref(-1);
|
||||
const showSelector = ref(false);
|
||||
|
||||
const CUSTOMER_PICKER_MODE_INVOICE = "invoice";
|
||||
const CUSTOMER_PICKER_MODE_DRAFT = "draft";
|
||||
const CUSTOMER_PICKER_MODE_CARD = "card";
|
||||
|
||||
const activeQuickAction = ref(CUSTOMER_PICKER_MODE_INVOICE);
|
||||
|
||||
const toPositiveInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const getCurrentCustomerNumber = () => toPositiveInteger(customer_id.value);
|
||||
|
||||
const getResolvedQuickAction = () => {
|
||||
const currentCustomerNumber = getCurrentCustomerNumber();
|
||||
|
||||
if (currentCustomerNumber === 999) {
|
||||
return CUSTOMER_PICKER_MODE_CARD;
|
||||
}
|
||||
|
||||
if (
|
||||
hasDraftTransactionCustomer.value &&
|
||||
currentCustomerNumber === toPositiveInteger(draftTransactionCustomerNumber.value)
|
||||
) {
|
||||
return CUSTOMER_PICKER_MODE_DRAFT;
|
||||
}
|
||||
|
||||
return CUSTOMER_PICKER_MODE_INVOICE;
|
||||
};
|
||||
|
||||
watch(
|
||||
[() => customer_id.value, draftTransactionCustomerNumber],
|
||||
() => {
|
||||
activeQuickAction.value = getResolvedQuickAction();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const isInvoiceMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_INVOICE);
|
||||
const isDraftMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_DRAFT);
|
||||
const isCardMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_CARD);
|
||||
const isCardPaymentDisabled = computed(
|
||||
() => props.showCardPaymentButton && !isCardPaymentAvailable.value
|
||||
);
|
||||
const shouldShowSearchInput = computed(() => isInvoiceMode.value && !isCustomerSelected());
|
||||
|
||||
const focusCustomerSearchInput = async () => {
|
||||
await nextTick();
|
||||
showSelector.value = true;
|
||||
document.getElementById("pos_select_customer_input")?.focus();
|
||||
};
|
||||
|
||||
const arrowKeyHandler = (event) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
if (selectedDropdownItem.value < searchCustomerResults.value.length - 1) {
|
||||
selectedDropdownItem.value++;
|
||||
}
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (selectedDropdownItem.value > -1) {
|
||||
selectedDropdownItem.value--;
|
||||
}
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
selectCustomer(searchCustomerResults.value[selectedDropdownItem.value]);
|
||||
// Emit the customerSelected event
|
||||
console.log('Emitting customerSelected event with:', searchCustomerResults.value[selectedDropdownItem.value]);
|
||||
emitChange(searchCustomerResults.value[selectedDropdownItem.value]);
|
||||
const selectedSearchResult = searchCustomerResults.value[selectedDropdownItem.value];
|
||||
if (!selectedSearchResult) {
|
||||
return;
|
||||
}
|
||||
selectCustomer(selectedSearchResult);
|
||||
emitChange(selectedSearchResult);
|
||||
selectedDropdownItem.value = -1;
|
||||
// Set the active input to the reference field (if it exists)
|
||||
if (document.getElementById('reference')) {
|
||||
document.getElementById('reference').focus();
|
||||
if (document.getElementById("reference")) {
|
||||
document.getElementById("reference").focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const emitChange = (customer) => {
|
||||
console.log('Emitting update:customer_id with:', customer);
|
||||
emit('update:customer_id', customer.customerNumber);
|
||||
emit('onCustomerSelected', customer);
|
||||
emit("update:customer_id", customer.customerNumber);
|
||||
emit("onCustomerSelected", customer);
|
||||
props.onCustomerSelected(customer);
|
||||
};
|
||||
|
||||
@@ -61,35 +129,114 @@ const getSearchIndexByCustomerNumber = (customerNumber) => {
|
||||
return searchCustomerResults.value.findIndex((result) => result.customerNumber === customerNumber);
|
||||
};
|
||||
|
||||
// This delay is needed to prevent the dropdown from closing when clicking on a dropdown item (Making the dropdown item clickable)
|
||||
const lostfocus = () => {
|
||||
setTimeout(() => {
|
||||
showSelector.value = false;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const keyDownNextInput = (event, field_id) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
document.getElementById(field_id).focus();
|
||||
const onSelectCustomerInvoice = async () => {
|
||||
activeQuickAction.value = CUSTOMER_PICKER_MODE_INVOICE;
|
||||
selectedDropdownItem.value = -1;
|
||||
|
||||
if (isCustomerSelected()) {
|
||||
selectCustomer(null);
|
||||
}
|
||||
|
||||
await focusCustomerSearchInput();
|
||||
};
|
||||
|
||||
const onSelectDraftCustomer = async () => {
|
||||
if (!hasDraftTransactionCustomer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeQuickAction.value = CUSTOMER_PICKER_MODE_DRAFT;
|
||||
showSelector.value = false;
|
||||
|
||||
const selectedCustomer = await searchAndSelectCustomer(draftTransactionCustomerNumber.value);
|
||||
if (selectedCustomer) {
|
||||
emitChange(selectedCustomer);
|
||||
}
|
||||
};
|
||||
|
||||
const keyDownNextTabIndexButton = (event, tabIndex) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
// First unfocus the input field. This is to ensure the sync between the input field and the POSDepartmentProcess.vue data.
|
||||
event.target.blur();
|
||||
document.querySelector(`button[tabindex="${tabIndex}"]`).click();
|
||||
const onSelectCardPayment = () => {
|
||||
if (isCardPaymentDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeQuickAction.value = CUSTOMER_PICKER_MODE_CARD;
|
||||
showSelector.value = false;
|
||||
|
||||
const cardPaymentCustomer = {
|
||||
name: "Stripe Payment",
|
||||
customerNumber: 999,
|
||||
};
|
||||
|
||||
selectCustomer(cardPaymentCustomer);
|
||||
emitChange(cardPaymentCustomer);
|
||||
};
|
||||
|
||||
const onClearSelectedCustomer = async () => {
|
||||
selectCustomer(null);
|
||||
activeQuickAction.value = CUSTOMER_PICKER_MODE_INVOICE;
|
||||
await focusCustomerSearchInput();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="field" :class="{'is-hidden': isCustomerSelected() && !props.showWhenCustomerSelected}">
|
||||
<label class="label">{{SessionUser.objects.global.language.customer}}</label>
|
||||
<div class="control" :class="{'is-loading': isSearching}">
|
||||
<CustomerSearchField
|
||||
<div class="customer-picker">
|
||||
<div class="customer-quick-actions">
|
||||
<button
|
||||
class="button is-light customer-quick-action customer-quick-action--invoice"
|
||||
:class="{ 'is-selected': isInvoiceMode }"
|
||||
type="button"
|
||||
data-testid="pos-customer-invoice-inline-action"
|
||||
:aria-pressed="isInvoiceMode ? 'true' : 'false'"
|
||||
@click="onSelectCustomerInvoice"
|
||||
>
|
||||
<span class="icon is-small customer-quick-action__icon">
|
||||
<i class="fas fa-user"></i>
|
||||
</span>
|
||||
<span>{{ t("pos.customer_picker.select_customer_invoice") }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="hasDraftTransactionCustomer"
|
||||
class="button is-light customer-quick-action customer-quick-action--draft"
|
||||
:class="{ 'is-selected': isDraftMode }"
|
||||
type="button"
|
||||
data-testid="pos-draft-customer-inline-action"
|
||||
:aria-pressed="isDraftMode ? 'true' : 'false'"
|
||||
@click="onSelectDraftCustomer"
|
||||
>
|
||||
<span class="icon is-small customer-quick-action__icon">
|
||||
<i class="fas fa-file-invoice"></i>
|
||||
</span>
|
||||
<span>{{ t("pos.customer_picker.select_draft_customer") }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="button is-light customer-quick-action customer-quick-action--card"
|
||||
:class="{
|
||||
'is-hidden': !props.showCardPaymentButton,
|
||||
'is-selected': isCardMode,
|
||||
'is-disabled': isCardPaymentDisabled,
|
||||
}"
|
||||
type="button"
|
||||
data-testid="pos-card-payment-inline-action"
|
||||
:aria-pressed="isCardMode ? 'true' : 'false'"
|
||||
:disabled="isCardPaymentDisabled"
|
||||
@click="onSelectCardPayment"
|
||||
>
|
||||
<span class="icon is-small customer-quick-action__icon">
|
||||
<i class="fas fa-credit-card"></i>
|
||||
</span>
|
||||
<span>{{ t("pos.customer_picker.select_card_payment") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="shouldShowSearchInput" class="field customer-picker__manual-search">
|
||||
<label class="label">{{ SessionUser.objects.global.language.customer }}</label>
|
||||
<div class="control" :class="{ 'is-loading': isSearching }">
|
||||
<CustomerSearchField
|
||||
:v-model="customer_id"
|
||||
class="input has-sharp-edges"
|
||||
type="text"
|
||||
@@ -100,34 +247,143 @@ const keyDownNextTabIndexButton = (event, tabIndex) => {
|
||||
:tabindex="isCustomerSelected() ? -1 : 0"
|
||||
autocomplete="off"
|
||||
id="pos_select_customer_input"
|
||||
/>
|
||||
</div>
|
||||
<div class="dropdown" :class="{'is-active': showSelector && searchCustomerResults.length > 0}">
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-content">
|
||||
<a class="dropdown-item customer-drop-down-select" v-for="result in searchCustomerResults" :key="result.id" @click="selectCustomer(result); emitChange(result)" :class="{'is-active': getSearchIndexByCustomerNumber(result.customerNumber) === selectedDropdownItem, 'is-drop-down-selected': getSearchIndexByCustomerNumber(result.customerNumber) === selectedDropdownItem, 'has-text-warning': isCustomerBarred(result)}">
|
||||
{{ result.name }} - {{ result.customerNumber }} {{ (isCustomerBarred(result) ? ' (Spærret)' : '') }}
|
||||
</a>
|
||||
/>
|
||||
</div>
|
||||
<div class="dropdown" :class="{ 'is-active': showSelector && searchCustomerResults.length > 0 && isInvoiceMode }">
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-content">
|
||||
<a
|
||||
v-for="result in searchCustomerResults"
|
||||
:key="result.id"
|
||||
class="dropdown-item customer-drop-down-select"
|
||||
@click="selectCustomer(result); emitChange(result)"
|
||||
:class="{
|
||||
'is-active': getSearchIndexByCustomerNumber(result.customerNumber) === selectedDropdownItem,
|
||||
'is-drop-down-selected': getSearchIndexByCustomerNumber(result.customerNumber) === selectedDropdownItem,
|
||||
'has-text-warning': isCustomerBarred(result),
|
||||
}"
|
||||
>
|
||||
{{ result.name }} - {{ result.customerNumber }} {{ isCustomerBarred(result) ? " (Sp\u00e6rret)" : "" }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stripe payment button -->
|
||||
<button class="button is-text mt-1" @click="selectCustomer({name: 'Stripe Payment', customerNumber: 999}); emitChange({name: 'Stripe Payment', customerNumber: 999})" :tabindex="isCustomerSelected() ? 1 : -1" :class="{'is-hidden': isCustomerSelected() || !props.showCardPaymentButton}" style="text-decoration-line: none;">
|
||||
Vælg direkte betaling med betalingskort
|
||||
</button>
|
||||
</div>
|
||||
<div class="field has-addons-right has-addons mt-1" :class="{'is-hidden': !isCustomerSelected()}">
|
||||
<div v-if="isCustomerSelected()" class="field has-addons-right has-addons mt-1">
|
||||
<p class="control is-expanded">
|
||||
<input class="input has-sharp-edges" type="text" v-model="customer_name" disabled />
|
||||
</p>
|
||||
<p class="control">
|
||||
<button class="button is-danger" @click="selectCustomer(null);" :tabindex="isCustomerSelected() ? 1 : -1">
|
||||
{{SessionUser.objects.global.language.clear}}
|
||||
<button class="button is-danger" @click="onClearSelectedCustomer" :tabindex="isCustomerSelected() ? 1 : -1">
|
||||
{{ SessionUser.objects.global.language.clear }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customer-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
.customer-quick-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.customer-quick-action {
|
||||
align-items: center;
|
||||
border: 1px solid #d2deee;
|
||||
border-radius: 0.8rem;
|
||||
box-shadow: 0 8px 16px rgba(21, 49, 93, 0.06);
|
||||
color: #17325c;
|
||||
display: inline-flex;
|
||||
font-weight: 600;
|
||||
gap: 0.7rem;
|
||||
justify-content: center;
|
||||
min-height: 2.85rem;
|
||||
padding: 0.75rem 1.2rem;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.customer-quick-action.is-selected {
|
||||
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
|
||||
border-color: #10294b;
|
||||
box-shadow: 0 14px 24px rgba(15, 39, 72, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.customer-quick-action:hover,
|
||||
.customer-quick-action:focus-visible {
|
||||
background-color: #f1f6ff;
|
||||
border-color: #b6cae6;
|
||||
color: #0f2a55;
|
||||
}
|
||||
|
||||
.customer-quick-action.is-selected:hover,
|
||||
.customer-quick-action.is-selected:focus-visible {
|
||||
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
|
||||
border-color: #10294b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.customer-quick-action__icon {
|
||||
align-items: center;
|
||||
background: #e9f2ff;
|
||||
border-radius: 999px;
|
||||
color: #184b8a;
|
||||
display: inline-flex;
|
||||
height: 1.85rem;
|
||||
justify-content: center;
|
||||
width: 1.85rem;
|
||||
}
|
||||
|
||||
.customer-quick-action.is-selected .customer-quick-action__icon {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.customer-quick-action:disabled,
|
||||
.customer-quick-action.is-disabled {
|
||||
background: #f4f7fb;
|
||||
border-color: #d6dfec;
|
||||
box-shadow: none;
|
||||
color: #90a0b7;
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.customer-quick-action:disabled .customer-quick-action__icon,
|
||||
.customer-quick-action.is-disabled .customer-quick-action__icon {
|
||||
background: #edf2f8;
|
||||
color: #8ca0bc;
|
||||
}
|
||||
|
||||
.customer-quick-action:disabled:hover,
|
||||
.customer-quick-action:disabled:focus-visible,
|
||||
.customer-quick-action.is-disabled:hover,
|
||||
.customer-quick-action.is-disabled:focus-visible {
|
||||
background: #f4f7fb;
|
||||
border-color: #d6dfec;
|
||||
color: #90a0b7;
|
||||
}
|
||||
|
||||
.customer-quick-action--invoice .customer-quick-action__icon {
|
||||
background: #edf5ff;
|
||||
color: #1b4f92;
|
||||
}
|
||||
|
||||
.customer-quick-action--card .customer-quick-action__icon {
|
||||
background: #e4f7fa;
|
||||
color: #0f6a7b;
|
||||
}
|
||||
|
||||
.customer-picker__manual-search {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,11 +2,17 @@
|
||||
import { NavigationItemProps } from "@/components/models/navigation/NavigationItem.vue";
|
||||
import {defineComponent, ref, computed, watch} from 'vue';
|
||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||
import { getDepartmentName } from "@/components/session/token/SessionUser/Objects/economicDepartments.vue";
|
||||
import { departments_cache, getDepartmentName } from "@/components/session/token/SessionUser/Objects/economicDepartments.vue";
|
||||
import i18n from '@/i18n';
|
||||
import {
|
||||
ensureDraftTransactionCustomerLoaded,
|
||||
getDraftTransactionCustomerNumber,
|
||||
} from "@/composables/useDraftTransactionCustomer.js";
|
||||
|
||||
const t = (key: string) => i18n.global.t(key);
|
||||
|
||||
const ADMIN_DEPARTMENT_SELECTION_CLASS = "js-admin-department-selection";
|
||||
|
||||
const routeContext = ref({
|
||||
path: window.location.pathname || '',
|
||||
params: {} as Record<string, unknown>
|
||||
@@ -40,6 +46,9 @@ const firstToUpperCase = (str: string) => {
|
||||
}
|
||||
const department_booking_count = ref(0); // Placeholder for actual booking count logic
|
||||
const getDepartmentIdNumber = () => Number.parseInt(String(department_id.value), 10);
|
||||
const getDepartmentById = (id: number) => {
|
||||
return departments_cache.value?.find((department: any) => Number(department?.id) === Number(id)) || null;
|
||||
};
|
||||
const hasValidDepartmentId = computed(() => {
|
||||
const id = getDepartmentIdNumber();
|
||||
return Number.isInteger(id) && id > 0;
|
||||
@@ -83,6 +92,8 @@ watch(department_id, () => {
|
||||
fetchDepartmentBookingCount();
|
||||
}, { immediate: true });
|
||||
|
||||
void ensureDraftTransactionCustomerLoaded();
|
||||
|
||||
// Fetch the booking count every 5 seconds when department context is valid
|
||||
setInterval(() => {
|
||||
if (hasValidDepartmentId.value) {
|
||||
@@ -102,12 +113,14 @@ const items = computed<NavigationItemProps[]>(() => [
|
||||
to: `/admin/departments`,
|
||||
type: 'category',
|
||||
hidden: isDepartmentSet.value,
|
||||
classes: [ADMIN_DEPARTMENT_SELECTION_CLASS],
|
||||
permissions: ['admin', 'list_departments'],
|
||||
children: SessionUser.functions.getAccessibleDepartments().map((department: any) => ({
|
||||
label: getDepartmentName(department),
|
||||
to: `/admin/${department}`,
|
||||
children: SessionUser.functions.getAccessibleDepartments().map((departmentId: any) => ({
|
||||
label: getDepartmentName(departmentId),
|
||||
to: `/admin/${departmentId}`,
|
||||
type: 'department',
|
||||
permissions: ['admin', 'view_department'],
|
||||
priority_order: getDepartmentById(departmentId)?.priority_order ?? null,
|
||||
})),
|
||||
},
|
||||
// Kassesystem
|
||||
@@ -129,6 +142,13 @@ const items = computed<NavigationItemProps[]>(() => [
|
||||
type: 'department',
|
||||
permissions: ['admin', 'list_orders'],
|
||||
},
|
||||
{
|
||||
label: t('nav.drafts'),
|
||||
to: `/admin/${department_id.value}/modules/pos/drafts`,
|
||||
type: 'department',
|
||||
permissions: ['admin', 'list_orders'],
|
||||
hidden: getDraftTransactionCustomerNumber() === null,
|
||||
},
|
||||
],
|
||||
},
|
||||
// Tidsbookinger
|
||||
@@ -235,6 +255,7 @@ export default defineComponent({
|
||||
|
||||
// Named exports if needed
|
||||
export {
|
||||
ADMIN_DEPARTMENT_SELECTION_CLASS,
|
||||
syncAdminNavigationRoute,
|
||||
items,
|
||||
parsedItems
|
||||
|
||||
@@ -43,6 +43,7 @@ const searchResults = ref<PosSearchResult[]>([]);
|
||||
//}));
|
||||
|
||||
const normalizeRegistrationNumber = (value: string | null | undefined) => String(value ?? '').replace(/\s+/g, '').toUpperCase();
|
||||
const normalizeReferenceValue = (value: string | null | undefined) => String(value ?? '').trim();
|
||||
|
||||
const applySearchResultCustomerSelection = (result: PosSearchResult) => {
|
||||
if (!props.modifyCustomerOnChange) {
|
||||
@@ -62,6 +63,34 @@ const emitSelectedResult = (result: PosSearchResult | null) => {
|
||||
emits('select', result);
|
||||
};
|
||||
|
||||
const resolveSkippedBookingReference = (result: PosSearchResult) => {
|
||||
const normalizedRegistrationNumber = normalizeRegistrationNumber(result?.registrationNumber || props.searchQuery);
|
||||
const matchingVehicle = Array.isArray(vehicles_matching.value)
|
||||
? vehicles_matching.value.find((vehicle: any) => normalizeRegistrationNumber(vehicle?.reg) === normalizedRegistrationNumber)
|
||||
: null;
|
||||
|
||||
const vehicleReference = normalizeReferenceValue(matchingVehicle?.reference);
|
||||
if (vehicleReference) {
|
||||
return vehicleReference;
|
||||
}
|
||||
|
||||
return normalizeReferenceValue(result?.reference);
|
||||
};
|
||||
|
||||
const buildSkippedBookingSelection = (result: PosSearchResult) => {
|
||||
const inheritedReference = resolveSkippedBookingReference(result);
|
||||
|
||||
if (inheritedReference) {
|
||||
pos.metadata.setReference(inheritedReference);
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
bookingId: null,
|
||||
reference: inheritedReference || null,
|
||||
} as PosSearchResult;
|
||||
};
|
||||
|
||||
const applySelectedBooking = async (booking: any, result: PosSearchResult) => {
|
||||
if (!booking?.id) {
|
||||
return;
|
||||
@@ -132,10 +161,7 @@ const continueWithoutBookingSelection = (result: PosSearchResult) => {
|
||||
metadata.setBookingSelectionSkippedPlate?.(normalizeRegistrationNumber(result?.registrationNumber || props.searchQuery));
|
||||
popups.clear();
|
||||
applySearchResultCustomerSelection(result);
|
||||
emitSelectedResult({
|
||||
...result,
|
||||
bookingId: null,
|
||||
});
|
||||
emitSelectedResult(buildSkippedBookingSelection(result));
|
||||
};
|
||||
|
||||
const refreshOrderBookingSelection = async (result: PosSearchResult) => {
|
||||
@@ -229,10 +255,7 @@ const onSelect = async (result: PosSearchResult) => {
|
||||
if (metadata.getBookingSelectionSkippedPlate?.() === normalizedRegistrationNumber) {
|
||||
metadata.setBookingId(null);
|
||||
applySearchResultCustomerSelection(result);
|
||||
emitSelectedResult({
|
||||
...result,
|
||||
bookingId: null,
|
||||
});
|
||||
emitSelectedResult(buildSkippedBookingSelection(result));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,11 @@ import Swal from "sweetalert2";
|
||||
import {SubuserGrants} from "@/components/session/token/SessionUser/Objects/SubuserGrants.vue";
|
||||
import {Subusers} from "@/components/session/token/SessionUser/Objects/Subusers.vue";
|
||||
|
||||
const normalizePositiveInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiate the user session on app start
|
||||
* @returns {Promise<void>}
|
||||
@@ -224,6 +229,9 @@ export const getSessionData = async () => {
|
||||
// Set the last cached time to now, this is used to determine if the user's data is outdated
|
||||
SessionUser.user.cached_at.value = new Date();
|
||||
SessionUser.permissions.value = response.data.data.permissions;
|
||||
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = normalizePositiveInteger(
|
||||
response?.data?.data?.runtime_config?.economic?.transaction_draft_customer_number
|
||||
);
|
||||
// E-conomic data is only fetched if the array isn't empty
|
||||
if (response.data.data.economic_customer.length > 0) {
|
||||
SessionUser.economicData.customerNumber.value = response.data.data.economic_customer.customerNumber;
|
||||
@@ -392,6 +400,11 @@ export const SessionUser = {
|
||||
availableVirtualPermissions: [
|
||||
'has_superuser_token',
|
||||
],
|
||||
runtimeConfig: {
|
||||
economic: {
|
||||
transactionDraftCustomerNumber: ref(null),
|
||||
},
|
||||
},
|
||||
virtualPermissions: computed(() => {
|
||||
/** These permissions are NOT set by default for superusers, this should ONLY be used for superusers */
|
||||
let virtualPermissions = [];
|
||||
@@ -612,6 +625,7 @@ export const SessionUser = {
|
||||
SessionUser.economicData.currency.value = null;
|
||||
SessionUser.economicData.country.value = null;
|
||||
SessionUser.economicData.cached_at.value = null;
|
||||
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = null;
|
||||
getSessionData();
|
||||
},
|
||||
auth: {
|
||||
|
||||
@@ -3,6 +3,8 @@ import Swal from "sweetalert2";
|
||||
import {ObjectsGlobal} from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import CustomerSearchFieldPos from "@/components/forms/department/pos/input/customerSearchFieldPos.vue";
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
import { editOrderItem, getOrderItems } from "@/components/shop/OrdersItems.vue";
|
||||
import {createApp} from "vue";
|
||||
import i18n from '@/i18n';
|
||||
|
||||
@@ -207,6 +209,140 @@ const showChangeOrderInvoiceCollectionForm = async (id, onAfterSubmit = null) =>
|
||||
)
|
||||
}
|
||||
|
||||
const normalizePositiveInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const getFinalProductPriceForCustomer = async (productId, departmentId, customerId) => {
|
||||
const normalizedProductId = normalizePositiveInteger(productId);
|
||||
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
|
||||
const normalizedCustomerId = normalizePositiveInteger(customerId);
|
||||
|
||||
if (!normalizedProductId || !normalizedDepartmentId || !normalizedCustomerId) {
|
||||
throw new Error("Invalid repricing context");
|
||||
}
|
||||
|
||||
const response = await authenticatedRequest("/products", "GET", {
|
||||
id: normalizedProductId,
|
||||
department_id: normalizedDepartmentId,
|
||||
customer_id: normalizedCustomerId,
|
||||
final_price: "true",
|
||||
});
|
||||
|
||||
const resolvedPrice = Number.parseInt(response?.data?.data?.price, 10);
|
||||
if (!Number.isFinite(resolvedPrice)) {
|
||||
throw new Error(`Unable to resolve price for product ${normalizedProductId}`);
|
||||
}
|
||||
|
||||
return resolvedPrice;
|
||||
};
|
||||
|
||||
const recalculateOrderItemPricesForCustomer = async ({ order_id, department_id, customer_id }) => {
|
||||
const normalizedOrderId = normalizePositiveInteger(order_id);
|
||||
const normalizedDepartmentId = normalizePositiveInteger(department_id);
|
||||
const normalizedCustomerId = normalizePositiveInteger(customer_id);
|
||||
|
||||
if (!normalizedOrderId || !normalizedDepartmentId || !normalizedCustomerId) {
|
||||
throw new Error("Invalid order repricing context");
|
||||
}
|
||||
|
||||
const response = await getOrderItems(normalizedOrderId);
|
||||
const orderItems = Array.isArray(response?.data?.data) ? response.data.data : [];
|
||||
const uniqueProductIds = [...new Set(
|
||||
orderItems
|
||||
.map((item) => normalizePositiveInteger(item?.product_id))
|
||||
.filter((value) => value !== null)
|
||||
)];
|
||||
|
||||
if (uniqueProductIds.length === 0) {
|
||||
return {
|
||||
items: orderItems,
|
||||
updated_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const priceEntries = await Promise.all(
|
||||
uniqueProductIds.map(async (productId) => {
|
||||
const finalPrice = await getFinalProductPriceForCustomer(productId, normalizedDepartmentId, normalizedCustomerId);
|
||||
return [productId, finalPrice];
|
||||
})
|
||||
);
|
||||
|
||||
const finalPriceMap = new Map(priceEntries);
|
||||
const updateRequests = orderItems
|
||||
.map((item) => {
|
||||
const normalizedItemId = normalizePositiveInteger(item?.id);
|
||||
const normalizedProductId = normalizePositiveInteger(item?.product_id);
|
||||
if (!normalizedItemId || !normalizedProductId || !finalPriceMap.has(normalizedProductId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return editOrderItem(
|
||||
normalizedItemId,
|
||||
finalPriceMap.get(normalizedProductId),
|
||||
item?.notes ?? "",
|
||||
item?.reference ?? "",
|
||||
normalizePositiveInteger(item?.quantity) ?? 1
|
||||
);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
await Promise.all(updateRequests);
|
||||
|
||||
return {
|
||||
items: orderItems,
|
||||
updated_count: updateRequests.length,
|
||||
};
|
||||
};
|
||||
|
||||
const assignDraftOrderCustomer = async ({
|
||||
order_id,
|
||||
customer_id,
|
||||
invoice_collection_id,
|
||||
department_id = null,
|
||||
recalculate_prices = true,
|
||||
}) => {
|
||||
const normalizedOrderId = normalizePositiveInteger(order_id);
|
||||
const normalizedCustomerId = normalizePositiveInteger(customer_id);
|
||||
const normalizedInvoiceCollectionId = normalizePositiveInteger(invoice_collection_id);
|
||||
|
||||
if (!normalizedOrderId || !normalizedCustomerId || !normalizedInvoiceCollectionId) {
|
||||
throw new Error("Order, customer and invoice collection are required");
|
||||
}
|
||||
|
||||
const resolvedDepartmentId = normalizePositiveInteger(department_id)
|
||||
?? await SessionUser.objects.orders.functions.get_department_id(normalizedOrderId);
|
||||
|
||||
const customerResponse = await SessionUser.objects.orders.set.customer_id(
|
||||
normalizedOrderId,
|
||||
normalizedCustomerId
|
||||
);
|
||||
|
||||
const invoiceCollectionResponse = await SessionUser.objects.orders.set.invoice_collection_id(
|
||||
normalizedOrderId,
|
||||
normalizedInvoiceCollectionId
|
||||
);
|
||||
|
||||
let repricingResponse = null;
|
||||
if (recalculate_prices) {
|
||||
repricingResponse = await recalculateOrderItemPricesForCustomer({
|
||||
order_id: normalizedOrderId,
|
||||
department_id: resolvedDepartmentId,
|
||||
customer_id: normalizedCustomerId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
customerResponse,
|
||||
invoiceCollectionResponse,
|
||||
repricingResponse,
|
||||
customer_id: normalizedCustomerId,
|
||||
invoice_collection_id: normalizedInvoiceCollectionId,
|
||||
department_id: resolvedDepartmentId,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The Orders object
|
||||
*/
|
||||
@@ -632,6 +768,7 @@ const showChangeOrderInvoiceCollectionForm = async (id, onAfterSubmit = null) =>
|
||||
* @param id (order id)
|
||||
*/
|
||||
showChangeCustomerForm: showChangeOrderCustomerForm,
|
||||
assignDraftCustomer: assignDraftOrderCustomer,
|
||||
/**
|
||||
* Show the change invoice collection form
|
||||
* @param id (order id)
|
||||
|
||||
@@ -60,7 +60,15 @@ export const Config = {
|
||||
return Config.set("feeProductId", productId);
|
||||
},
|
||||
},
|
||||
},
|
||||
transactionDraftCustomerNumber: {
|
||||
get: async () => {
|
||||
return Config.get("transactionDraftCustomerNumber");
|
||||
},
|
||||
set: async (customerNumber) => {
|
||||
return Config.set("transactionDraftCustomerNumber", customerNumber);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1036,7 +1036,7 @@ export const getCustomerName = async (customerNumber) => {
|
||||
/** Search, then select customer by customer number */
|
||||
export const searchAndSelectCustomer = async (customerNumber, options = {}) => {
|
||||
// Get the customer data
|
||||
await authenticatedRequest(`/users/customer?customer_number=${customerNumber}`, "GET")
|
||||
return await authenticatedRequest(`/users/customer?customer_number=${customerNumber}`, "GET")
|
||||
.then((response) => {
|
||||
console.log(response);
|
||||
const responseData = response?.data?.data ?? {};
|
||||
@@ -1050,6 +1050,7 @@ export const searchAndSelectCustomer = async (customerNumber, options = {}) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import { toggleExpanded, isOpen, getDepartmentSelectionVisibility } from '@/comp
|
||||
import { ref, computed } from 'vue';
|
||||
import logo from "@/assets/branding/truckwash-banner-white-compressed.png";
|
||||
import { useNavigationItems } from "@/components/models/navigation/items/NavigationMenuItems.vue";
|
||||
import { ADMIN_DEPARTMENT_SELECTION_CLASS } from "@/components/models/navigation/items/NavigationMenuItemsAdmin.vue";
|
||||
import { isDepartmentLabelValid, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
|
||||
import { isSmall } from "@/components/displays/pagination/PaginationDisplayIsSmall.vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import LanguageSelector from "@/components/i18n/LanguageSelector.vue";
|
||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||
@@ -36,6 +39,38 @@ const menuListPixelWidth = computed(() => {
|
||||
return 200; // Default width
|
||||
});
|
||||
|
||||
const navigationItems = computed(() => {
|
||||
const items = [
|
||||
...parsedItems(),
|
||||
...parsedItemsGlobal(),
|
||||
];
|
||||
|
||||
if (isSmall.value) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.flatMap((item: any) => {
|
||||
if (!item?.classes?.includes(ADMIN_DEPARTMENT_SELECTION_CLASS)) {
|
||||
return [item];
|
||||
}
|
||||
|
||||
const filteredChildren = sortByDepartmentPriorityOrder(
|
||||
(item.children || []).filter((child: any) => {
|
||||
return isDepartmentLabelValid(child?.label);
|
||||
})
|
||||
);
|
||||
|
||||
if (filteredChildren.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
...item,
|
||||
children: filteredChildren,
|
||||
}];
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if the user has permission to view an item
|
||||
* Returns true if no permissions are defined or if the user has any of the required permissions
|
||||
@@ -60,14 +95,14 @@ const visibleChildren = (item: any) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section pl-3 pr-1 pt-0">
|
||||
<div class="section pl-3 pr-1 pt-0" data-testid="desktop-buefy-navigation">
|
||||
<b-menu ref="menuList">
|
||||
<div v-if="SessionUser.canAccessSuperUser()" class="mb-4">
|
||||
<NavigationMenuGlobalSearch />
|
||||
</div>
|
||||
<b-menu-list label="">
|
||||
<template
|
||||
v-for="(item, index) in [...parsedItems(), ...parsedItemsGlobal()]"
|
||||
v-for="(item, index) in navigationItems"
|
||||
:key="index">
|
||||
<b-menu-item
|
||||
:expanded="item.children && item.children.length > 0"
|
||||
|
||||
@@ -8,7 +8,11 @@ import type { PosDepartment as department } from '@/components/displays/departme
|
||||
import { isSmall } from '@/components/displays/pagination/PaginationDisplayIsSmall.vue';
|
||||
import {BButton, BDropdownItem, BField, BIcon, BSelect, BTooltip} from "buefy";
|
||||
import { toggleExpanded, isOpen, getDepartmentSelectionVisibility } from '@/components/viewport/page/headers/ViewportHeaderSettings.vue';
|
||||
import { isAccessibleVisibleDepartment } from "@/services/departmentVisibility.js";
|
||||
import {
|
||||
isAccessibleVisibleDepartment,
|
||||
isDepartmentLabelValid,
|
||||
sortByDepartmentPriorityOrder,
|
||||
} from "@/services/departmentVisibility.js";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -30,6 +34,7 @@ const options = computed(() => {
|
||||
return {
|
||||
value: department.id,
|
||||
label: department.name,
|
||||
priority_order: department.priority_order ?? null,
|
||||
description: department.description || '',
|
||||
branding: department.branding || 0,
|
||||
latitude: department.latitude || 0,
|
||||
@@ -38,6 +43,14 @@ const options = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const mobileOptions = computed(() => {
|
||||
return options.value;
|
||||
});
|
||||
|
||||
const desktopOptions = computed(() => {
|
||||
return sortByDepartmentPriorityOrder(options.value.filter((option) => isDepartmentLabelValid(option.label)));
|
||||
});
|
||||
|
||||
const onSelect = (event: Event) => {
|
||||
const selectElement = event.target as HTMLSelectElement;
|
||||
const selectedValue = selectElement.value;
|
||||
@@ -53,12 +66,17 @@ const selectADepartmentString = computed(() => {
|
||||
return `${SessionUser.objects.global.language.select} ${SessionUser.objects.departments.meta.labels.single}`;
|
||||
});
|
||||
|
||||
const desktopSelectedDepartmentId = computed(() => {
|
||||
return desktopOptions.value.some((option) => option.value === currentDepartmentId.value)
|
||||
? currentDepartmentId.value
|
||||
: "";
|
||||
});
|
||||
|
||||
const getDistanceToDepartment = (departmentId: number | null): number => {
|
||||
const getDistanceToDepartment = (departmentId: number | null, optionList = mobileOptions.value): number => {
|
||||
if (!departmentId) {
|
||||
return 0;
|
||||
}
|
||||
const department = options.value.find((dept) => dept.value === departmentId);
|
||||
const department = optionList.find((dept) => dept.value === departmentId);
|
||||
if (department && locations.location.value?.coords) {
|
||||
return locations.getDistance(
|
||||
{ latitude: locations.location.value.coords.latitude, longitude: locations.location.value.coords.longitude },
|
||||
@@ -68,13 +86,13 @@ const getDistanceToDepartment = (departmentId: number | null): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const getDepartmentLabelWithDistance = (departmentId: number | null): string => {
|
||||
const getDepartmentLabelWithDistance = (departmentId: number | null, optionList = mobileOptions.value): string => {
|
||||
if (!departmentId) {
|
||||
return '';
|
||||
}
|
||||
const department = options.value.find((dept) => dept.value === departmentId);
|
||||
const department = optionList.find((dept) => dept.value === departmentId);
|
||||
if (department) {
|
||||
const distance = getDistanceToDepartment(departmentId).toFixed(2);
|
||||
const distance = getDistanceToDepartment(departmentId, optionList).toFixed(2);
|
||||
if (distance === '0.00') { // If the distance is zero, don't show it
|
||||
return `${department.label}`;
|
||||
}
|
||||
@@ -83,37 +101,41 @@ const getDepartmentLabelWithDistance = (departmentId: number | null): string =>
|
||||
return '';
|
||||
};
|
||||
|
||||
const getDepartmentLabel = (departmentId: number | null): string => {
|
||||
const getDepartmentLabel = (departmentId: number | null, optionList = mobileOptions.value): string => {
|
||||
if (!departmentId) {
|
||||
return '';
|
||||
}
|
||||
const department = options.value.find((dept) => dept.value === departmentId);
|
||||
const department = optionList.find((dept) => dept.value === departmentId);
|
||||
return department ? department.label : '';
|
||||
};
|
||||
|
||||
const mobileCurrentDepartmentLabel = computed(() => {
|
||||
return getDepartmentLabel(currentDepartmentId.value, mobileOptions.value) || selectADepartmentString.value;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Department selection (Mobile)-->
|
||||
<div class="field is-grouped is-grouped-multiline" v-show="options.length > 0" v-if="isSmall">
|
||||
<div class="field is-grouped is-grouped-multiline" v-show="mobileOptions.length > 0" v-if="isSmall">
|
||||
<!-- Mobile view -->
|
||||
<div class="control">
|
||||
<b-tooltip position="is-bottom" multilined type="is-info">
|
||||
<b-button icon-left="building"
|
||||
icon-pack="fas"
|
||||
size="is-small"
|
||||
:label="getDepartmentLabel(currentDepartmentId) || selectADepartmentString"
|
||||
:label="mobileCurrentDepartmentLabel"
|
||||
class="is-fullwidth is-rounded"/>
|
||||
<template v-slot:content>
|
||||
<!-- Pick a department tooltip content -->
|
||||
<!-- Scrollable list of departments -->
|
||||
<div style="max-height: 300px; overflow-y: auto;">
|
||||
<template v-for="(option, index) in options" :key="index">
|
||||
<template v-for="(option, index) in mobileOptions" :key="index">
|
||||
<div class="p-3 is-clickable"
|
||||
@click="() => { onSelect({ target: { value: option.value } } as unknown as Event); }"
|
||||
:class="{ 'mb-2': index < options.length - 1, 'pb-2': index === options.length - 1}"
|
||||
:style="{ borderBottom: index < options.length - 1 ? '1px solid #e6e6e6' : '' }">
|
||||
:class="{ 'mb-2': index < mobileOptions.length - 1, 'pb-2': index === mobileOptions.length - 1}"
|
||||
:style="{ borderBottom: index < mobileOptions.length - 1 ? '1px solid #e6e6e6' : '' }">
|
||||
<nav class="level is-mobile is-fullwidth">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
@@ -138,15 +160,19 @@ const getDepartmentLabel = (departmentId: number | null): string => {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Department selection dropdown (Desktop)-->
|
||||
<div class="field is-grouped is-grouped-multiline" v-show="options.length > 0" v-if="!isSmall">
|
||||
<div class="field is-grouped is-grouped-multiline" v-show="desktopOptions.length > 0" v-if="!isSmall">
|
||||
<!-- Desktop view -->
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth is-rounded">
|
||||
<select @change="onSelect" :value="currentDepartmentId">
|
||||
<option value="" disabled selected>{{ selectADepartmentString }}</option>
|
||||
<option v-for="option in options" :key="option.value" :value="option.value">
|
||||
<select
|
||||
data-testid="desktop-header-department-select"
|
||||
@change="onSelect"
|
||||
:value="desktopSelectedDepartmentId"
|
||||
>
|
||||
<option value="" disabled>{{ selectADepartmentString }}</option>
|
||||
<option v-for="option in desktopOptions" :key="option.value" :value="option.value">
|
||||
<!--{{ getDepartmentLabelWithDistance(option.value) }}-->
|
||||
{{ getDepartmentLabel(option.value) }}
|
||||
{{ getDepartmentLabel(option.value, desktopOptions) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const toPositiveInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const fallbackDraftTransactionCustomerNumber = ref(null);
|
||||
let draftTransactionCustomerConfigRequest = null;
|
||||
|
||||
const resolveConfiguredDraftTransactionCustomerNumber = () => {
|
||||
return (
|
||||
toPositiveInteger(SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value) ??
|
||||
fallbackDraftTransactionCustomerNumber.value
|
||||
);
|
||||
};
|
||||
|
||||
export const ensureDraftTransactionCustomerLoaded = async () => {
|
||||
if (resolveConfiguredDraftTransactionCustomerNumber() !== null) {
|
||||
return resolveConfiguredDraftTransactionCustomerNumber();
|
||||
}
|
||||
|
||||
if (!SessionUser.canAccessSuperUser || !SessionUser.canAccessSuperUser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!draftTransactionCustomerConfigRequest) {
|
||||
draftTransactionCustomerConfigRequest = SessionUser.superUser.modules.economic.config.transactionDraftCustomerNumber
|
||||
.get()
|
||||
.then((response) => {
|
||||
const configEntries = Array.isArray(response?.data?.data) ? response.data.data : [];
|
||||
const configEntry = configEntries.find(
|
||||
(entry) => entry?.variable === "transactionDraftCustomerNumber"
|
||||
);
|
||||
fallbackDraftTransactionCustomerNumber.value = toPositiveInteger(configEntry?.value);
|
||||
return fallbackDraftTransactionCustomerNumber.value;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
draftTransactionCustomerConfigRequest = null;
|
||||
});
|
||||
}
|
||||
|
||||
return draftTransactionCustomerConfigRequest;
|
||||
};
|
||||
|
||||
export const getDraftTransactionCustomerNumber = () => {
|
||||
return resolveConfiguredDraftTransactionCustomerNumber();
|
||||
};
|
||||
|
||||
export const isDraftTransactionCustomer = (customerNumber) => {
|
||||
const configuredCustomerNumber = getDraftTransactionCustomerNumber();
|
||||
return configuredCustomerNumber !== null && configuredCustomerNumber === toPositiveInteger(customerNumber);
|
||||
};
|
||||
|
||||
export const useDraftTransactionCustomer = () => {
|
||||
void ensureDraftTransactionCustomerLoaded();
|
||||
|
||||
const draftTransactionCustomerNumber = computed(() => getDraftTransactionCustomerNumber());
|
||||
const hasDraftTransactionCustomer = computed(() => draftTransactionCustomerNumber.value !== null);
|
||||
|
||||
return {
|
||||
draftTransactionCustomerNumber,
|
||||
hasDraftTransactionCustomer,
|
||||
isDraftTransactionCustomer,
|
||||
ensureDraftTransactionCustomerLoaded,
|
||||
};
|
||||
};
|
||||
@@ -1,887 +0,0 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
import {
|
||||
createEdgeGatewayInstallToken,
|
||||
deleteEdgeGateway,
|
||||
getEdgeGateway,
|
||||
listEdgeGatewayDepartments,
|
||||
listEdgeGateways,
|
||||
saveEdgeGatewayBindings,
|
||||
setDepartmentGatewayCutover,
|
||||
triggerEdgeGatewayDiscovery,
|
||||
updateEdgeGateway,
|
||||
} from "@/services/edgeGateways.js";
|
||||
import {
|
||||
buildBindingHelperText,
|
||||
buildEmptyBindingStateDescription,
|
||||
buildGatewayView,
|
||||
buildInventoryDeviceOptions,
|
||||
buildSummaryCards,
|
||||
buildWorkflowStepModels,
|
||||
filterGatewayViews,
|
||||
getBindingChannelOptions,
|
||||
getBindingDeviceOptions,
|
||||
getBindingInventoryDevice,
|
||||
getDepartmentName,
|
||||
mergeWorkspaceDraftState,
|
||||
normalizeBinding,
|
||||
normalizeGatewayId,
|
||||
resolveEdgeGatewayErrorMessage,
|
||||
resolveNextWorkflowStep,
|
||||
} from "@/components/displays/edgeGateway/edgeGatewayWorkspace.helpers.js";
|
||||
|
||||
const GATEWAY_REFRESH_INTERVAL_MS = 2_000;
|
||||
|
||||
const unwrapData = (response, fallback = null) => response?.data?.data ?? fallback;
|
||||
const waitFor = (milliseconds) =>
|
||||
new Promise((resolve) => {
|
||||
window.setTimeout(resolve, milliseconds);
|
||||
});
|
||||
|
||||
export function useEdgeGatewayWorkspace({ props, emit }) {
|
||||
const gateways = ref([]);
|
||||
const departments = ref([]);
|
||||
const activeGatewayId = ref(null);
|
||||
const selectedGateway = ref(null);
|
||||
const unavailableGatewayId = ref(null);
|
||||
|
||||
const activeStep = ref("select");
|
||||
const fleetQuery = ref("");
|
||||
const fleetFilter = ref("ALL");
|
||||
const fleetDrawerOpen = ref(false);
|
||||
|
||||
const installerDepartmentId = ref(props.departmentId ? String(props.departmentId) : "");
|
||||
const installerLabel = ref("");
|
||||
const installerCommand = ref("");
|
||||
const installerMeta = ref(null);
|
||||
|
||||
const editableBindings = ref([]);
|
||||
const gatewayLabel = ref("");
|
||||
const gatewayIsPrimary = ref(false);
|
||||
const transportMode = ref("gateway");
|
||||
|
||||
const initializing = ref(true);
|
||||
const detailLoading = ref(false);
|
||||
const installerLoading = ref(false);
|
||||
const discoveryLoading = ref(false);
|
||||
const bindingSaveLoading = ref(false);
|
||||
const metadataLoading = ref(false);
|
||||
const cutoverLoading = ref(false);
|
||||
const deleteLoading = ref(false);
|
||||
|
||||
const deleteConfirmationOpen = ref(false);
|
||||
const flashMessage = ref("");
|
||||
const errorMessage = ref("");
|
||||
const heartbeatNow = ref(Date.now());
|
||||
|
||||
const dirtyState = ref({
|
||||
bindings: false,
|
||||
gatewayLabel: false,
|
||||
gatewayIsPrimary: false,
|
||||
transportMode: false,
|
||||
installerLabel: false,
|
||||
});
|
||||
|
||||
let heartbeatTimer = null;
|
||||
let selectedGatewayRefreshTimer = null;
|
||||
|
||||
const departmentScoped = computed(() => props.departmentId !== null && props.departmentId !== undefined);
|
||||
const routeSelectedGatewayId = computed(() => normalizeGatewayId(props.selectedGatewayId));
|
||||
const routeSelectionActive = computed(() => routeSelectedGatewayId.value !== null);
|
||||
|
||||
const departmentsById = computed(() =>
|
||||
departments.value.reduce((accumulator, department) => {
|
||||
accumulator[String(department.id)] = department;
|
||||
return accumulator;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const gatewayViews = computed(() =>
|
||||
gateways.value.map((gateway) =>
|
||||
buildGatewayView(gateway, {
|
||||
departmentNameById: departmentsById.value,
|
||||
now: heartbeatNow.value,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const summaryCards = computed(() => buildSummaryCards(gatewayViews.value));
|
||||
|
||||
const filteredGatewayViews = computed(() =>
|
||||
filterGatewayViews(gatewayViews.value, {
|
||||
fleetFilter: fleetFilter.value,
|
||||
query: fleetQuery.value,
|
||||
})
|
||||
);
|
||||
|
||||
const selectedGatewayView = computed(() => {
|
||||
if (selectedGateway.value?.id) {
|
||||
return buildGatewayView(selectedGateway.value, {
|
||||
departmentNameById: departmentsById.value,
|
||||
now: heartbeatNow.value,
|
||||
});
|
||||
}
|
||||
|
||||
if (!activeGatewayId.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gateway = gateways.value.find((candidate) => Number(candidate.id) === Number(activeGatewayId.value));
|
||||
return buildGatewayView(gateway, {
|
||||
departmentNameById: departmentsById.value,
|
||||
now: heartbeatNow.value,
|
||||
});
|
||||
});
|
||||
|
||||
const workflowSteps = computed(() =>
|
||||
buildWorkflowStepModels({
|
||||
activeStep: activeStep.value,
|
||||
hasGateway: Boolean(selectedGatewayView.value),
|
||||
})
|
||||
);
|
||||
|
||||
const selectedStepState = computed(
|
||||
() => workflowSteps.value.find((step) => step.key === activeStep.value) ?? workflowSteps.value[0]
|
||||
);
|
||||
|
||||
const unavailableSelectionState = computed(() => {
|
||||
if (!routeSelectionActive.value || unavailableGatewayId.value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: "Gatewayen er ikke tilgængelig",
|
||||
description: `Gateway ${unavailableGatewayId.value} findes ikke i din tilgængelige flåde eller kunne ikke indlæses.`,
|
||||
};
|
||||
});
|
||||
|
||||
const selectedDepartmentId = computed(() => {
|
||||
if (departmentScoped.value) {
|
||||
return props.departmentId;
|
||||
}
|
||||
|
||||
return selectedGatewayView.value?.department_id ?? null;
|
||||
});
|
||||
|
||||
const installerDepartmentName = computed(() => {
|
||||
if (!installerDepartmentId.value) {
|
||||
return "Vælg afdeling";
|
||||
}
|
||||
|
||||
return getDepartmentName(Number(installerDepartmentId.value), departmentsById.value);
|
||||
});
|
||||
|
||||
const inventoryDeviceOptions = computed(() =>
|
||||
buildInventoryDeviceOptions(selectedGatewayView.value?.inventory ?? [])
|
||||
);
|
||||
|
||||
const bindingHelperText = computed(() =>
|
||||
buildBindingHelperText({
|
||||
inventoryOptions: inventoryDeviceOptions.value,
|
||||
editableBindings: editableBindings.value,
|
||||
})
|
||||
);
|
||||
|
||||
const emptyBindingStateDescription = computed(() =>
|
||||
buildEmptyBindingStateDescription({
|
||||
inventoryOptions: inventoryDeviceOptions.value,
|
||||
})
|
||||
);
|
||||
|
||||
const displayErrorMessage = computed(() => errorMessage.value);
|
||||
|
||||
const clearMessages = () => {
|
||||
flashMessage.value = "";
|
||||
errorMessage.value = "";
|
||||
};
|
||||
|
||||
const resetDirtyState = () => {
|
||||
dirtyState.value = {
|
||||
bindings: false,
|
||||
gatewayLabel: false,
|
||||
gatewayIsPrimary: false,
|
||||
transportMode: false,
|
||||
installerLabel: dirtyState.value.installerLabel,
|
||||
};
|
||||
};
|
||||
|
||||
const clearInstallerOutput = () => {
|
||||
installerCommand.value = "";
|
||||
installerMeta.value = null;
|
||||
};
|
||||
|
||||
const copyTextToClipboard = async (value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboard = document.createElement("textarea");
|
||||
clipboard.value = value;
|
||||
clipboard.setAttribute("readonly", "readonly");
|
||||
clipboard.style.position = "absolute";
|
||||
clipboard.style.left = "-9999px";
|
||||
document.body.appendChild(clipboard);
|
||||
clipboard.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(clipboard);
|
||||
};
|
||||
|
||||
const mergeGatewayIntoList = (gateway) => {
|
||||
if (!gateway?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextGateway = { ...gateway };
|
||||
const index = gateways.value.findIndex((candidate) => Number(candidate.id) === Number(nextGateway.id));
|
||||
|
||||
if (index === -1) {
|
||||
gateways.value = [nextGateway, ...gateways.value];
|
||||
return;
|
||||
}
|
||||
|
||||
gateways.value = gateways.value.map((candidate, candidateIndex) =>
|
||||
candidateIndex === index ? { ...candidate, ...nextGateway } : candidate
|
||||
);
|
||||
};
|
||||
|
||||
const syncMutableStateFromSelection = (gateway, { force = false } = {}) => {
|
||||
if (!gateway) {
|
||||
editableBindings.value = [];
|
||||
gatewayLabel.value = "";
|
||||
gatewayIsPrimary.value = false;
|
||||
transportMode.value = "gateway";
|
||||
if (force) {
|
||||
resetDirtyState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedState = mergeWorkspaceDraftState({
|
||||
gateway,
|
||||
currentState: {
|
||||
editableBindings: editableBindings.value,
|
||||
gatewayLabel: gatewayLabel.value,
|
||||
gatewayIsPrimary: gatewayIsPrimary.value,
|
||||
transportMode: transportMode.value,
|
||||
},
|
||||
dirtyState: dirtyState.value,
|
||||
force,
|
||||
});
|
||||
|
||||
editableBindings.value = mergedState.editableBindings;
|
||||
gatewayLabel.value = mergedState.gatewayLabel;
|
||||
gatewayIsPrimary.value = mergedState.gatewayIsPrimary;
|
||||
transportMode.value = mergedState.transportMode;
|
||||
|
||||
if (force) {
|
||||
resetDirtyState();
|
||||
}
|
||||
};
|
||||
|
||||
const applyGatewaySelection = (gateway, { syncState = true, clearUnavailable = true } = {}) => {
|
||||
if (clearUnavailable) {
|
||||
unavailableGatewayId.value = null;
|
||||
}
|
||||
selectedGateway.value = gateway ?? null;
|
||||
activeGatewayId.value = gateway?.id ? String(gateway.id) : null;
|
||||
activeStep.value = resolveNextWorkflowStep(activeStep.value, Boolean(gateway));
|
||||
|
||||
if (syncState) {
|
||||
syncMutableStateFromSelection(gateway, { force: true });
|
||||
}
|
||||
};
|
||||
|
||||
const syncSelectionFromState = () => {
|
||||
const availableGateways = gatewayViews.value;
|
||||
if (!availableGateways.length) {
|
||||
applyGatewaySelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (routeSelectionActive.value) {
|
||||
const routeGateway = gateways.value.find(
|
||||
(candidate) => String(candidate.id) === String(routeSelectedGatewayId.value)
|
||||
);
|
||||
if (routeGateway) {
|
||||
applyGatewaySelection(routeGateway);
|
||||
return;
|
||||
}
|
||||
|
||||
unavailableGatewayId.value = routeSelectedGatewayId.value;
|
||||
applyGatewaySelection(null, { clearUnavailable: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const existingGateway = gateways.value.find((candidate) => String(candidate.id) === String(activeGatewayId.value));
|
||||
if (existingGateway) {
|
||||
applyGatewaySelection(existingGateway);
|
||||
return;
|
||||
}
|
||||
|
||||
applyGatewaySelection(gateways.value[0] ?? null);
|
||||
};
|
||||
|
||||
const refreshSelectedGateway = async ({ silent = false } = {}) => {
|
||||
if (!activeGatewayId.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
detailLoading.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getEdgeGateway(activeGatewayId.value);
|
||||
const gateway = unwrapData(response, null);
|
||||
if (!gateway) {
|
||||
return null;
|
||||
}
|
||||
|
||||
mergeGatewayIntoList(gateway);
|
||||
selectedGateway.value = gateway;
|
||||
syncMutableStateFromSelection(gateway, { force: false });
|
||||
return gateway;
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
errorMessage.value = resolveEdgeGatewayErrorMessage(error);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (!silent) {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadWorkspace = async () => {
|
||||
initializing.value = true;
|
||||
clearMessages();
|
||||
|
||||
try {
|
||||
const [departmentsResponse, gatewaysResponse] = await Promise.all([
|
||||
listEdgeGatewayDepartments(),
|
||||
listEdgeGateways({
|
||||
departmentId: departmentScoped.value ? props.departmentId : null,
|
||||
}),
|
||||
]);
|
||||
|
||||
departments.value = unwrapData(departmentsResponse, []);
|
||||
gateways.value = unwrapData(gatewaysResponse, []);
|
||||
syncSelectionFromState();
|
||||
} catch (error) {
|
||||
errorMessage.value = resolveEdgeGatewayErrorMessage(error);
|
||||
} finally {
|
||||
initializing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshGatewayFleet = async ({ syncSelection = true } = {}) => {
|
||||
const response = await listEdgeGateways({
|
||||
departmentId: departmentScoped.value ? props.departmentId : null,
|
||||
});
|
||||
|
||||
gateways.value = unwrapData(response, []);
|
||||
if (syncSelection) {
|
||||
syncSelectionFromState();
|
||||
}
|
||||
|
||||
return gateways.value;
|
||||
};
|
||||
|
||||
const pollForClaimedGateway = async ({ departmentId, label, existingGatewayIds }) => {
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
await waitFor(750);
|
||||
|
||||
const nextGateways = await refreshGatewayFleet({ syncSelection: false });
|
||||
const claimedGateway =
|
||||
nextGateways.find((gateway) => {
|
||||
if (existingGatewayIds.has(Number(gateway.id))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (departmentId && Number(gateway.department_id) !== Number(departmentId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (label && String(gateway.label || "").trim() !== label) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}) ?? null;
|
||||
|
||||
if (!claimedGateway) {
|
||||
continue;
|
||||
}
|
||||
|
||||
applyGatewaySelection(claimedGateway);
|
||||
if (!departmentScoped.value) {
|
||||
emit("select-gateway", claimedGateway.id);
|
||||
}
|
||||
return claimedGateway;
|
||||
}
|
||||
|
||||
syncSelectionFromState();
|
||||
return null;
|
||||
};
|
||||
|
||||
const startSelectedGatewayRefresh = () => {
|
||||
if (selectedGatewayRefreshTimer !== null) {
|
||||
clearInterval(selectedGatewayRefreshTimer);
|
||||
}
|
||||
|
||||
selectedGatewayRefreshTimer = setInterval(() => {
|
||||
refreshSelectedGateway({ silent: true }).catch(() => {});
|
||||
}, GATEWAY_REFRESH_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const stopSelectedGatewayRefresh = () => {
|
||||
if (selectedGatewayRefreshTimer !== null) {
|
||||
clearInterval(selectedGatewayRefreshTimer);
|
||||
selectedGatewayRefreshTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const setActiveStep = (nextStep) => {
|
||||
const stepModel = workflowSteps.value.find((step) => step.key === nextStep);
|
||||
if (!stepModel || stepModel.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeStep.value = nextStep;
|
||||
};
|
||||
|
||||
const setFleetFilter = (value) => {
|
||||
fleetFilter.value = value;
|
||||
};
|
||||
|
||||
const setFleetQuery = (value) => {
|
||||
fleetQuery.value = value;
|
||||
};
|
||||
|
||||
const openFleetDrawer = () => {
|
||||
fleetDrawerOpen.value = true;
|
||||
};
|
||||
|
||||
const closeFleetDrawer = () => {
|
||||
fleetDrawerOpen.value = false;
|
||||
};
|
||||
|
||||
const setInstallerDepartmentId = (value) => {
|
||||
installerDepartmentId.value = value;
|
||||
clearInstallerOutput();
|
||||
};
|
||||
|
||||
const setInstallerLabel = (value) => {
|
||||
installerLabel.value = value;
|
||||
dirtyState.value = {
|
||||
...dirtyState.value,
|
||||
installerLabel: true,
|
||||
};
|
||||
clearInstallerOutput();
|
||||
};
|
||||
|
||||
const setGatewayLabel = (value) => {
|
||||
gatewayLabel.value = value;
|
||||
dirtyState.value = {
|
||||
...dirtyState.value,
|
||||
gatewayLabel: true,
|
||||
};
|
||||
};
|
||||
|
||||
const setGatewayPrimary = (value) => {
|
||||
gatewayIsPrimary.value = Boolean(value);
|
||||
dirtyState.value = {
|
||||
...dirtyState.value,
|
||||
gatewayIsPrimary: true,
|
||||
};
|
||||
};
|
||||
|
||||
const setTransportMode = (value) => {
|
||||
transportMode.value = value;
|
||||
dirtyState.value = {
|
||||
...dirtyState.value,
|
||||
transportMode: true,
|
||||
};
|
||||
};
|
||||
|
||||
const openDeleteConfirmation = () => {
|
||||
deleteConfirmationOpen.value = true;
|
||||
};
|
||||
|
||||
const closeDeleteConfirmation = () => {
|
||||
deleteConfirmationOpen.value = false;
|
||||
};
|
||||
|
||||
const selectGateway = async (gatewayId) => {
|
||||
const gateway = gateways.value.find((candidate) => Number(candidate.id) === Number(gatewayId));
|
||||
if (!gateway) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
applyGatewaySelection(gateway);
|
||||
closeFleetDrawer();
|
||||
emit("select-gateway", gateway.id);
|
||||
await refreshSelectedGateway({ silent: true });
|
||||
};
|
||||
|
||||
const openGatewayPage = () => {
|
||||
if (!selectedGatewayView.value?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("open-gateway-page", selectedGatewayView.value.id);
|
||||
};
|
||||
|
||||
const generateInstaller = async () => {
|
||||
clearMessages();
|
||||
|
||||
const departmentId = Number(installerDepartmentId.value || selectedDepartmentId.value || 0);
|
||||
if (!departmentId) {
|
||||
errorMessage.value = "Vælg en afdeling, før du genererer en installer.";
|
||||
return;
|
||||
}
|
||||
|
||||
installerLoading.value = true;
|
||||
const existingGatewayIds = new Set(gateways.value.map((gateway) => Number(gateway.id)));
|
||||
const expectedLabel = installerLabel.value.trim();
|
||||
try {
|
||||
const response = await createEdgeGatewayInstallToken({
|
||||
department_id: departmentId,
|
||||
label: expectedLabel || undefined,
|
||||
});
|
||||
const payload = unwrapData(response, null);
|
||||
installerCommand.value = payload?.install_command ?? "";
|
||||
installerMeta.value = payload;
|
||||
flashMessage.value = "Installer-token genereret.";
|
||||
pollForClaimedGateway({
|
||||
departmentId,
|
||||
label: expectedLabel || null,
|
||||
existingGatewayIds,
|
||||
}).catch(() => {});
|
||||
} catch (error) {
|
||||
errorMessage.value = resolveEdgeGatewayErrorMessage(error);
|
||||
} finally {
|
||||
installerLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyInstallerCommand = async () => {
|
||||
await copyTextToClipboard(installerCommand.value);
|
||||
if (installerCommand.value) {
|
||||
flashMessage.value = "Installationskommando kopieret.";
|
||||
}
|
||||
};
|
||||
|
||||
const queueDiscovery = async () => {
|
||||
if (!selectedGatewayView.value?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
discoveryLoading.value = true;
|
||||
try {
|
||||
const response = await triggerEdgeGatewayDiscovery(selectedGatewayView.value.id);
|
||||
const gateway = unwrapData(response, null);
|
||||
if (gateway) {
|
||||
mergeGatewayIntoList(gateway);
|
||||
applyGatewaySelection(gateway, { syncState: false });
|
||||
}
|
||||
flashMessage.value = "Discovery er sat i gang.";
|
||||
setTimeout(() => {
|
||||
refreshSelectedGateway({ silent: true }).catch(() => {});
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
errorMessage.value = resolveEdgeGatewayErrorMessage(error);
|
||||
} finally {
|
||||
discoveryLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addBindingRow = () => {
|
||||
editableBindings.value = [...editableBindings.value, normalizeBinding({})];
|
||||
dirtyState.value = {
|
||||
...dirtyState.value,
|
||||
bindings: true,
|
||||
};
|
||||
};
|
||||
|
||||
const removeBindingRow = (index) => {
|
||||
editableBindings.value = editableBindings.value.filter((_, bindingIndex) => bindingIndex !== index);
|
||||
dirtyState.value = {
|
||||
...dirtyState.value,
|
||||
bindings: true,
|
||||
};
|
||||
};
|
||||
|
||||
const updateBindingField = (index, field, value) => {
|
||||
editableBindings.value = editableBindings.value.map((binding, bindingIndex) =>
|
||||
bindingIndex === index ? { ...binding, [field]: value } : binding
|
||||
);
|
||||
dirtyState.value = {
|
||||
...dirtyState.value,
|
||||
bindings: true,
|
||||
};
|
||||
};
|
||||
|
||||
const syncBindingDevice = (binding, index) => {
|
||||
const device = getBindingInventoryDevice(binding, inventoryDeviceOptions.value);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channelCount = Math.max(1, Number(device.channel_count ?? 1));
|
||||
updateBindingField(index, "local_ip", device.local_ip ?? "");
|
||||
updateBindingField(index, "channel", Number(binding.channel ?? 0) >= channelCount ? 0 : Number(binding.channel ?? 0));
|
||||
};
|
||||
|
||||
const getBindingDeviceOptionsFor = (binding) =>
|
||||
getBindingDeviceOptions(binding, inventoryDeviceOptions.value, editableBindings.value);
|
||||
|
||||
const getBindingChannelOptionsFor = (binding) =>
|
||||
getBindingChannelOptions(binding, inventoryDeviceOptions.value);
|
||||
|
||||
const saveBindings = async () => {
|
||||
if (!selectedGatewayView.value?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
bindingSaveLoading.value = true;
|
||||
try {
|
||||
const normalizedBindings = editableBindings.value.map((binding) => normalizeBinding(binding));
|
||||
await saveEdgeGatewayBindings(selectedGatewayView.value.id, normalizedBindings);
|
||||
await refreshSelectedGateway({ silent: true });
|
||||
dirtyState.value = {
|
||||
...dirtyState.value,
|
||||
bindings: false,
|
||||
};
|
||||
flashMessage.value = "Relay-bindinger gemt.";
|
||||
} catch (error) {
|
||||
errorMessage.value = resolveEdgeGatewayErrorMessage(error);
|
||||
} finally {
|
||||
bindingSaveLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveGatewayMetadata = async () => {
|
||||
if (!selectedGatewayView.value?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
metadataLoading.value = true;
|
||||
try {
|
||||
const response = await updateEdgeGateway(selectedGatewayView.value.id, {
|
||||
label: gatewayLabel.value.trim(),
|
||||
is_primary: gatewayIsPrimary.value,
|
||||
});
|
||||
const gateway = unwrapData(response, null);
|
||||
if (gateway) {
|
||||
mergeGatewayIntoList(gateway);
|
||||
applyGatewaySelection(gateway);
|
||||
}
|
||||
flashMessage.value = "Gateway-metadata opdateret.";
|
||||
} catch (error) {
|
||||
errorMessage.value = resolveEdgeGatewayErrorMessage(error);
|
||||
} finally {
|
||||
metadataLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applyCutoverMode = async () => {
|
||||
if (!selectedDepartmentId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
cutoverLoading.value = true;
|
||||
try {
|
||||
await setDepartmentGatewayCutover(selectedDepartmentId.value, transportMode.value);
|
||||
gateways.value = gateways.value.map((gateway) =>
|
||||
Number(gateway.department_id) === Number(selectedDepartmentId.value)
|
||||
? { ...gateway, department_transport_mode: transportMode.value }
|
||||
: gateway
|
||||
);
|
||||
await refreshSelectedGateway({ silent: true });
|
||||
dirtyState.value = {
|
||||
...dirtyState.value,
|
||||
transportMode: false,
|
||||
};
|
||||
flashMessage.value = "Afdelingens cutover er opdateret.";
|
||||
} catch (error) {
|
||||
errorMessage.value = resolveEdgeGatewayErrorMessage(error);
|
||||
} finally {
|
||||
cutoverLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSelectedGateway = async () => {
|
||||
if (!selectedGatewayView.value?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearMessages();
|
||||
deleteLoading.value = true;
|
||||
try {
|
||||
const gatewayId = Number(selectedGatewayView.value.id);
|
||||
await deleteEdgeGateway(gatewayId);
|
||||
gateways.value = gateways.value.filter((gateway) => Number(gateway.id) !== gatewayId);
|
||||
const fallbackGateway = gateways.value[0] ?? null;
|
||||
closeDeleteConfirmation();
|
||||
if (routeSelectionActive.value && String(routeSelectedGatewayId.value) === String(gatewayId) && fallbackGateway?.id) {
|
||||
emit("select-gateway", fallbackGateway.id);
|
||||
}
|
||||
syncSelectionFromState();
|
||||
flashMessage.value = "Gateway slettet.";
|
||||
} catch (error) {
|
||||
errorMessage.value = resolveEdgeGatewayErrorMessage(error);
|
||||
} finally {
|
||||
deleteLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const runIncidentPrimaryAction = async () => {
|
||||
const action = selectedGatewayView.value?.incidentPrimaryAction;
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.kind === "trigger_discovery") {
|
||||
await queueDiscovery();
|
||||
setActiveStep(action.targetStep);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveStep(action.targetStep);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.departmentId,
|
||||
(nextDepartmentId) => {
|
||||
installerDepartmentId.value = nextDepartmentId ? String(nextDepartmentId) : "";
|
||||
loadWorkspace().catch(() => {});
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.selectedGatewayId,
|
||||
() => {
|
||||
syncSelectionFromState();
|
||||
refreshSelectedGateway({ silent: true }).catch(() => {});
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
[() => selectedGatewayView.value, () => gateways.value],
|
||||
([gatewayView, nextGateways]) => {
|
||||
if (gatewayView || !Array.isArray(nextGateways) || nextGateways.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncSelectionFromState();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => selectedGatewayView.value?.id,
|
||||
(nextGatewayId) => {
|
||||
if (nextGatewayId) {
|
||||
startSelectedGatewayRefresh();
|
||||
} else {
|
||||
stopSelectedGatewayRefresh();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
heartbeatTimer = setInterval(() => {
|
||||
heartbeatNow.value = Date.now();
|
||||
}, 1_000);
|
||||
|
||||
loadWorkspace().catch(() => {});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (heartbeatTimer !== null) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
|
||||
stopSelectedGatewayRefresh();
|
||||
});
|
||||
|
||||
return {
|
||||
activeStep,
|
||||
addBindingRow,
|
||||
applyCutoverMode,
|
||||
bindingHelperText,
|
||||
bindingSaveLoading,
|
||||
closeDeleteConfirmation,
|
||||
closeFleetDrawer,
|
||||
copyInstallerCommand,
|
||||
cutoverLoading,
|
||||
deleteConfirmationOpen,
|
||||
deleteLoading,
|
||||
deleteSelectedGateway,
|
||||
departments,
|
||||
departmentScoped,
|
||||
detailLoading,
|
||||
discoveryLoading,
|
||||
displayErrorMessage,
|
||||
editableBindings,
|
||||
emptyBindingStateDescription,
|
||||
filteredGatewayViews,
|
||||
flashMessage,
|
||||
fleetDrawerOpen,
|
||||
fleetFilter,
|
||||
fleetQuery,
|
||||
gatewayIsPrimary,
|
||||
gatewayLabel,
|
||||
generateInstaller,
|
||||
getBindingChannelOptionsFor,
|
||||
getBindingDeviceOptionsFor,
|
||||
initializing,
|
||||
installerCommand,
|
||||
installerDepartmentId,
|
||||
installerDepartmentName,
|
||||
installerLabel,
|
||||
installerLoading,
|
||||
installerMeta,
|
||||
inventoryDeviceOptions,
|
||||
metadataLoading,
|
||||
openDeleteConfirmation,
|
||||
openFleetDrawer,
|
||||
openGatewayPage,
|
||||
queueDiscovery,
|
||||
removeBindingRow,
|
||||
runIncidentPrimaryAction,
|
||||
saveBindings,
|
||||
saveGatewayMetadata,
|
||||
selectedGatewayView,
|
||||
selectedStepState,
|
||||
selectGateway,
|
||||
setActiveStep,
|
||||
setFleetFilter,
|
||||
setFleetQuery,
|
||||
setGatewayLabel,
|
||||
setGatewayPrimary,
|
||||
setInstallerDepartmentId,
|
||||
setInstallerLabel,
|
||||
setTransportMode,
|
||||
summaryCards,
|
||||
syncBindingDevice,
|
||||
transportMode,
|
||||
unavailableSelectionState,
|
||||
updateBindingField,
|
||||
workflowSteps,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {
|
||||
normalizeStripeTerminalReaders,
|
||||
STRIPE_TERMINAL_STATUS,
|
||||
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
|
||||
|
||||
export const STRIPE_READER_POLLING_INTERVAL_MS = 5000;
|
||||
|
||||
const storesByDepartmentId = new Map();
|
||||
|
||||
const toPositiveInteger = (value) => {
|
||||
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const resolveDepartmentId = (departmentId) => {
|
||||
return (
|
||||
toPositiveInteger(departmentId) ??
|
||||
toPositiveInteger(SessionUser.functions.getDepartmentIdFromUrl?.())
|
||||
);
|
||||
};
|
||||
|
||||
const createStripeReaderAvailabilityStore = (departmentId) => {
|
||||
const readers = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const lastError = ref(null);
|
||||
const normalizedReaders = computed(() => normalizeStripeTerminalReaders(readers.value));
|
||||
const hasAssignedStripeReaders = computed(() => normalizedReaders.value.length > 0);
|
||||
const hasOnlineStripeReaders = computed(() =>
|
||||
normalizedReaders.value.some((reader) => reader.statusKey !== STRIPE_TERMINAL_STATUS.offline)
|
||||
);
|
||||
const isCardPaymentAvailable = computed(
|
||||
() => hasAssignedStripeReaders.value && hasOnlineStripeReaders.value
|
||||
);
|
||||
|
||||
let pollIntervalId = null;
|
||||
let inFlightRequest = null;
|
||||
let subscriberCount = 0;
|
||||
|
||||
const clearPollingInterval = () => {
|
||||
if (pollIntervalId !== null) {
|
||||
window.clearInterval(pollIntervalId);
|
||||
pollIntervalId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshStripeReaders = async () => {
|
||||
if (inFlightRequest) {
|
||||
return inFlightRequest;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
inFlightRequest = SessionUser.request(
|
||||
"/modules/stripe/department/terminal/readers",
|
||||
"GET",
|
||||
{
|
||||
id: departmentId,
|
||||
}
|
||||
)
|
||||
.then((response) => {
|
||||
if (response?.status === 200) {
|
||||
readers.value = Array.isArray(response?.data?.data?.data) ? response.data.data.data : [];
|
||||
lastError.value = null;
|
||||
return readers.value;
|
||||
}
|
||||
|
||||
readers.value = [];
|
||||
lastError.value = response;
|
||||
return [];
|
||||
})
|
||||
.catch((requestError) => {
|
||||
readers.value = [];
|
||||
lastError.value = requestError;
|
||||
return [];
|
||||
})
|
||||
.finally(() => {
|
||||
isLoading.value = false;
|
||||
inFlightRequest = null;
|
||||
});
|
||||
|
||||
return inFlightRequest;
|
||||
};
|
||||
|
||||
const ensurePolling = () => {
|
||||
if (pollIntervalId !== null || subscriberCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshStripeReaders();
|
||||
pollIntervalId = window.setInterval(() => {
|
||||
void refreshStripeReaders();
|
||||
}, STRIPE_READER_POLLING_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const subscribe = () => {
|
||||
subscriberCount += 1;
|
||||
ensurePolling();
|
||||
};
|
||||
|
||||
const unsubscribe = () => {
|
||||
subscriberCount = Math.max(0, subscriberCount - 1);
|
||||
if (subscriberCount === 0) {
|
||||
clearPollingInterval();
|
||||
storesByDepartmentId.delete(departmentId);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
readers,
|
||||
normalizedReaders,
|
||||
isLoading,
|
||||
lastError,
|
||||
hasAssignedStripeReaders,
|
||||
hasOnlineStripeReaders,
|
||||
isCardPaymentAvailable,
|
||||
refreshStripeReaders,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
};
|
||||
};
|
||||
|
||||
const getStripeReaderAvailabilityStore = (departmentId) => {
|
||||
const normalizedDepartmentId = resolveDepartmentId(departmentId);
|
||||
if (!normalizedDepartmentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!storesByDepartmentId.has(normalizedDepartmentId)) {
|
||||
storesByDepartmentId.set(
|
||||
normalizedDepartmentId,
|
||||
createStripeReaderAvailabilityStore(normalizedDepartmentId)
|
||||
);
|
||||
}
|
||||
|
||||
return storesByDepartmentId.get(normalizedDepartmentId);
|
||||
};
|
||||
|
||||
export const useStripeReaderAvailability = (departmentId = null) => {
|
||||
const store = getStripeReaderAvailabilityStore(departmentId);
|
||||
|
||||
if (!store) {
|
||||
const readers = ref([]);
|
||||
const normalizedReaders = computed(() => []);
|
||||
const hasAssignedStripeReaders = computed(() => false);
|
||||
const hasOnlineStripeReaders = computed(() => false);
|
||||
const isCardPaymentAvailable = computed(() => false);
|
||||
|
||||
return {
|
||||
readers,
|
||||
normalizedReaders,
|
||||
hasAssignedStripeReaders,
|
||||
hasOnlineStripeReaders,
|
||||
isCardPaymentAvailable,
|
||||
isLoading: computed(() => false),
|
||||
lastError: computed(() => null),
|
||||
refreshStripeReaders: async () => [],
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.subscribe();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
store.unsubscribe();
|
||||
});
|
||||
|
||||
return {
|
||||
readers: store.readers,
|
||||
normalizedReaders: store.normalizedReaders,
|
||||
hasAssignedStripeReaders: store.hasAssignedStripeReaders,
|
||||
hasOnlineStripeReaders: store.hasOnlineStripeReaders,
|
||||
isCardPaymentAvailable: store.isCardPaymentAvailable,
|
||||
isLoading: store.isLoading,
|
||||
lastError: store.lastError,
|
||||
refreshStripeReaders: store.refreshStripeReaders,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
gateway: { type: Object, required: true },
|
||||
editableBindings: { type: Array, required: true },
|
||||
loadingDiscovery: { type: Boolean, default: false },
|
||||
loadingBindings: { type: Boolean, default: false },
|
||||
canEdit: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["queue-discovery", "add-binding", "remove-binding", "update-binding", "save-bindings"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="edge-page" data-testid="gateway-inventory-page">
|
||||
<div class="edge-page-header">
|
||||
<div>
|
||||
<p class="edge-page-kicker">Inventory and bindings</p>
|
||||
<h2 class="title is-5">Discovery, enheder og relay-bindinger</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-link is-light"
|
||||
data-testid="gateway-discovery-trigger"
|
||||
:class="{ 'is-loading': loadingDiscovery }"
|
||||
@click="emit('queue-discovery')"
|
||||
>
|
||||
Kør discovery
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="edge-page-columns">
|
||||
<article class="edge-panel">
|
||||
<div class="edge-panel-head">
|
||||
<h3 class="title is-6">Device inventory</h3>
|
||||
<span class="tag is-light">{{ gateway.inventory?.length || 0 }} enheder</span>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table is-fullwidth is-striped" data-testid="gateway-inventory-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>IP</th>
|
||||
<th>Model</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="device in gateway.inventory || []" :key="device.device_id">
|
||||
<td>{{ device.device_id }}</td>
|
||||
<td>{{ device.local_ip || "-" }}</td>
|
||||
<td>{{ device.model || "-" }}</td>
|
||||
<td>
|
||||
<span class="tag" :class="device.online === false ? 'is-danger is-light' : 'is-success is-light'">
|
||||
{{ device.online === false ? "Offline" : "Online" }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!(gateway.inventory || []).length">
|
||||
<td colspan="4" class="has-text-grey">Ingen enheder fundet endnu.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="edge-panel">
|
||||
<div class="edge-panel-head">
|
||||
<h3 class="title is-6">Relay bindings</h3>
|
||||
<button v-if="canEdit" type="button" class="button is-small is-light" data-testid="gateway-binding-add" @click="emit('add-binding')">
|
||||
Tilføj binding
|
||||
</button>
|
||||
</div>
|
||||
<div class="edge-binding-list" data-testid="gateway-bindings-table">
|
||||
<div v-for="(binding, index) in editableBindings" :key="`${binding.relay_id}-${index}`" class="edge-binding-row">
|
||||
<input
|
||||
class="input"
|
||||
:value="binding.relay_id"
|
||||
:data-testid="`gateway-binding-relay-${index}`"
|
||||
:disabled="!canEdit"
|
||||
placeholder="Relay id"
|
||||
@input="emit('update-binding', index, 'relay_id', $event.target.value)"
|
||||
/>
|
||||
<select
|
||||
class="input"
|
||||
:value="binding.device_id"
|
||||
:data-testid="`gateway-binding-device-${index}`"
|
||||
:disabled="!canEdit"
|
||||
@change="emit('update-binding', index, 'device_id', $event.target.value)"
|
||||
>
|
||||
<option value="">Vælg device</option>
|
||||
<option v-for="device in gateway.inventory || []" :key="device.device_id" :value="device.device_id">
|
||||
{{ device.device_id }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
class="input"
|
||||
:value="binding.channel"
|
||||
:data-testid="`gateway-binding-channel-${index}`"
|
||||
:disabled="!canEdit"
|
||||
@change="emit('update-binding', index, 'channel', Number($event.target.value))"
|
||||
>
|
||||
<option :value="0">Kanal 0</option>
|
||||
<option :value="1">Kanal 1</option>
|
||||
<option :value="2">Kanal 2</option>
|
||||
<option :value="3">Kanal 3</option>
|
||||
</select>
|
||||
<select
|
||||
class="input"
|
||||
:value="binding.fallback_mode"
|
||||
:data-testid="`gateway-binding-fallback-${index}`"
|
||||
:disabled="!canEdit"
|
||||
@change="emit('update-binding', index, 'fallback_mode', $event.target.value)"
|
||||
>
|
||||
<option value="PREFER_LOCAL">Prefer local</option>
|
||||
<option value="LOCAL_ONLY">Local only</option>
|
||||
<option value="CLOUD_ONLY">Cloud only</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="button is-light"
|
||||
:data-testid="`gateway-binding-remove-${index}`"
|
||||
@click="emit('remove-binding', index)"
|
||||
>
|
||||
Fjern
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!editableBindings.length" class="has-text-grey">Ingen bindings konfigureret endnu.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="button is-primary"
|
||||
data-testid="gateway-bindings-save"
|
||||
:class="{ 'is-loading': loadingBindings }"
|
||||
@click="emit('save-bindings')"
|
||||
>
|
||||
Gem bindings
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
gateway: { type: Object, required: true },
|
||||
loadingMetadata: { type: Boolean, default: false },
|
||||
loadingCutover: { type: Boolean, default: false },
|
||||
loadingRotate: { type: Boolean, default: false },
|
||||
loadingDelete: { type: Boolean, default: false },
|
||||
rotateBundle: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["save-metadata", "apply-cutover", "rotate-credentials", "delete-gateway"]);
|
||||
|
||||
const gatewayLabel = ref("");
|
||||
const gatewayIsPrimary = ref(false);
|
||||
const cutoverMode = ref("gateway");
|
||||
|
||||
watch(
|
||||
() => props.gateway,
|
||||
(gateway) => {
|
||||
gatewayLabel.value = gateway?.label || "";
|
||||
gatewayIsPrimary.value = Boolean(gateway?.is_primary);
|
||||
cutoverMode.value = gateway?.department_transport_mode || "gateway";
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="edge-page" data-testid="gateway-manage-page">
|
||||
<div class="edge-page-columns">
|
||||
<article class="edge-panel">
|
||||
<div class="edge-panel-head">
|
||||
<h3 class="title is-6">Metadata</h3>
|
||||
</div>
|
||||
<label class="label">Label</label>
|
||||
<input v-model="gatewayLabel" class="input" data-testid="gateway-metadata-label" />
|
||||
<label class="checkbox edge-checkbox">
|
||||
<input v-model="gatewayIsPrimary" type="checkbox" data-testid="gateway-metadata-primary" />
|
||||
Primær gateway for afdelingen
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-primary"
|
||||
data-testid="gateway-metadata-save"
|
||||
:class="{ 'is-loading': loadingMetadata }"
|
||||
@click="emit('save-metadata', { label: gatewayLabel, is_primary: gatewayIsPrimary })"
|
||||
>
|
||||
Gem metadata
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="edge-panel">
|
||||
<div class="edge-panel-head">
|
||||
<h3 class="title is-6">Cutover and credentials</h3>
|
||||
</div>
|
||||
<label class="label">Transport mode</label>
|
||||
<select v-model="cutoverMode" class="input" data-testid="gateway-cutover-mode">
|
||||
<option value="gateway">gateway</option>
|
||||
<option value="cloud">cloud</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-link is-light"
|
||||
data-testid="gateway-cutover-apply"
|
||||
:class="{ 'is-loading': loadingCutover }"
|
||||
@click="emit('apply-cutover', cutoverMode)"
|
||||
>
|
||||
Apply cutover
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="button is-warning is-light"
|
||||
data-testid="gateway-rotate-credentials"
|
||||
:class="{ 'is-loading': loadingRotate }"
|
||||
@click="emit('rotate-credentials')"
|
||||
>
|
||||
Rotate credentials
|
||||
</button>
|
||||
|
||||
<div v-if="rotateBundle" class="edge-credential-bundle" data-testid="gateway-credential-bundle">
|
||||
<p><strong>Ny agent token:</strong> {{ rotateBundle.agent_token }}</p>
|
||||
<textarea class="textarea" readonly rows="8">{{ rotateBundle.config_json }}</textarea>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="edge-panel">
|
||||
<div class="edge-panel-head">
|
||||
<h3 class="title is-6">Danger zone</h3>
|
||||
</div>
|
||||
<p class="has-text-grey">Slet kun gatewayen efter uninstall eller når registrationen er ugyldig.</p>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-danger"
|
||||
data-testid="gateway-delete"
|
||||
:class="{ 'is-loading': loadingDelete }"
|
||||
@click="emit('delete-gateway')"
|
||||
>
|
||||
Delete gateway
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
gateway: { type: Object, required: true },
|
||||
allowDestructive: { type: Boolean, default: true },
|
||||
loadingCreate: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["run-operation"]);
|
||||
|
||||
const selectedOperationId = ref(null);
|
||||
const updateTargetVersion = ref("");
|
||||
const updateReleaseChannel = ref("stable");
|
||||
|
||||
const operations = computed(() => (Array.isArray(props.gateway?.operations) ? props.gateway.operations : []));
|
||||
const selectedOperation = computed(() => {
|
||||
if (!operations.value.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
operations.value.find((operation) => Number(operation.id) === Number(selectedOperationId.value)) || operations.value[0]
|
||||
);
|
||||
});
|
||||
|
||||
watch(
|
||||
operations,
|
||||
(nextOperations) => {
|
||||
selectedOperationId.value = nextOperations[0]?.id || null;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.gateway,
|
||||
(gateway) => {
|
||||
updateTargetVersion.value = gateway?.target_version || gateway?.version_drift?.target_version || "";
|
||||
updateReleaseChannel.value = gateway?.release_channel || "stable";
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const runOperation = (type, request = {}) => emit("run-operation", { type, request });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="edge-page" data-testid="gateway-operations-page">
|
||||
<div class="edge-page-header">
|
||||
<div>
|
||||
<p class="edge-page-kicker">Operations center</p>
|
||||
<h2 class="title is-5">Queue, historik og event-stream</h2>
|
||||
</div>
|
||||
<div class="edge-action-group">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-link is-light"
|
||||
data-testid="gateway-operation-discovery"
|
||||
:class="{ 'is-loading': loadingCreate }"
|
||||
@click="runOperation('DISCOVERY', {})"
|
||||
>
|
||||
Discovery
|
||||
</button>
|
||||
<button
|
||||
v-if="allowDestructive"
|
||||
type="button"
|
||||
class="button is-warning is-light"
|
||||
data-testid="gateway-operation-update"
|
||||
:class="{ 'is-loading': loadingCreate }"
|
||||
@click="runOperation('UPDATE', { target_version: updateTargetVersion, release_channel: updateReleaseChannel })"
|
||||
>
|
||||
Queue update
|
||||
</button>
|
||||
<button
|
||||
v-if="allowDestructive"
|
||||
type="button"
|
||||
class="button is-danger is-light"
|
||||
data-testid="gateway-operation-uninstall"
|
||||
:class="{ 'is-loading': loadingCreate }"
|
||||
@click="runOperation('UNINSTALL', {})"
|
||||
>
|
||||
Queue uninstall
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="allowDestructive" class="edge-inline-form">
|
||||
<input v-model="updateTargetVersion" class="input" data-testid="gateway-update-target-version" placeholder="2.0.0" />
|
||||
<select v-model="updateReleaseChannel" class="input" data-testid="gateway-update-release-channel">
|
||||
<option value="stable">stable</option>
|
||||
<option value="canary">canary</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edge-page-columns">
|
||||
<article class="edge-panel">
|
||||
<div class="edge-panel-head">
|
||||
<h3 class="title is-6">Operations</h3>
|
||||
</div>
|
||||
<div class="edge-operation-list" data-testid="gateway-operations-list">
|
||||
<button
|
||||
v-for="operation in operations"
|
||||
:key="operation.id"
|
||||
type="button"
|
||||
class="edge-operation-item"
|
||||
:class="{ 'is-active': Number(selectedOperationId) === Number(operation.id) }"
|
||||
@click="selectedOperationId = operation.id"
|
||||
>
|
||||
<strong>{{ operation.type }}</strong>
|
||||
<span>{{ operation.status }}</span>
|
||||
</button>
|
||||
<p v-if="!operations.length" class="has-text-grey">Ingen gatewayoperationer endnu.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="edge-panel">
|
||||
<div class="edge-panel-head">
|
||||
<h3 class="title is-6">Event stream</h3>
|
||||
</div>
|
||||
<div v-if="selectedOperation" class="edge-event-list" data-testid="gateway-operation-events">
|
||||
<article v-for="event in selectedOperation.events || []" :key="event.id" class="edge-event-item">
|
||||
<strong>{{ event.code || event.level }}</strong>
|
||||
<p>{{ event.message }}</p>
|
||||
<small>{{ event.created_at }}</small>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="has-text-grey">Vælg en operation for at se events.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
gateway: { type: Object, required: true },
|
||||
showOpenFullPage: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["open-full-page"]);
|
||||
|
||||
const diagnostics = computed(() => (Array.isArray(props.gateway?.diagnostics) ? props.gateway.diagnostics : []));
|
||||
const auditLogs = computed(() => (Array.isArray(props.gateway?.audit_logs) ? props.gateway.audit_logs.slice(0, 8) : []));
|
||||
const activeOperation = computed(() => props.gateway?.active_operation || null);
|
||||
|
||||
const formatDate = (value) => (value ? String(value).replace("T", " ").slice(0, 16) : "Ingen data");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="edge-page" data-testid="gateway-overview-page">
|
||||
<header class="edge-page-header">
|
||||
<div>
|
||||
<p class="edge-page-kicker">Gateway overview</p>
|
||||
<h2 class="title is-4">{{ gateway.displayLabel }}</h2>
|
||||
<p class="edge-page-subtitle">
|
||||
{{ gateway.departmentName }} · {{ gateway.hostname || "Ingen hostnavn" }} · {{ gateway.statusLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="showOpenFullPage"
|
||||
type="button"
|
||||
class="button is-light"
|
||||
data-testid="gateway-open-full-page"
|
||||
@click="emit('open-full-page')"
|
||||
>
|
||||
Åbn fuld styring
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<article v-if="activeOperation" class="message is-warning edge-banner" data-testid="gateway-operations-banner">
|
||||
<div class="message-body">
|
||||
<strong>{{ activeOperation.type }}</strong>
|
||||
<span> · {{ activeOperation.summary?.label || activeOperation.status }}</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="edge-overview-grid">
|
||||
<article class="edge-stat-card" data-testid="gateway-overview-versions">
|
||||
<p class="edge-card-label">Version drift</p>
|
||||
<strong>{{ gateway.version_drift?.installed_version || "?" }}</strong>
|
||||
<p>
|
||||
Mål: {{ gateway.version_drift?.target_version || "?" }}
|
||||
<span v-if="gateway.version_drift?.is_drifted" class="tag is-warning is-light" data-testid="gateway-overview-drift">
|
||||
Kræver update
|
||||
</span>
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="edge-stat-card">
|
||||
<p class="edge-card-label">Transport</p>
|
||||
<strong>{{ gateway.transport_health?.status || gateway.status }}</strong>
|
||||
<p>{{ gateway.transport_health?.summary || "Ingen transportstatus" }}</p>
|
||||
</article>
|
||||
|
||||
<article class="edge-stat-card">
|
||||
<p class="edge-card-label">Credentials</p>
|
||||
<strong>{{ gateway.credential_freshness?.state || "UNKNOWN" }}</strong>
|
||||
<p>Rotated: {{ formatDate(gateway.credential_freshness?.rotated_at) }}</p>
|
||||
</article>
|
||||
|
||||
<article class="edge-stat-card">
|
||||
<p class="edge-card-label">Broker</p>
|
||||
<strong>{{ gateway.channel_status?.broker?.state || "OFFLINE" }}</strong>
|
||||
<p>{{ gateway.channel_status?.broker?.connected ? "Fast path aktiv" : "Kun API polling" }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="edge-page-columns">
|
||||
<article class="edge-panel">
|
||||
<div class="edge-panel-head">
|
||||
<h3 class="title is-6">Diagnostics</h3>
|
||||
</div>
|
||||
<div v-if="diagnostics.length" class="edge-diagnostics" data-testid="gateway-diagnostics-list">
|
||||
<article v-for="diagnostic in diagnostics" :key="diagnostic.code" class="edge-diagnostic-item">
|
||||
<div>
|
||||
<strong>{{ diagnostic.code }}</strong>
|
||||
<p>{{ diagnostic.message }}</p>
|
||||
</div>
|
||||
<span class="tag is-light">{{ diagnostic.recommended_action || "review" }}</span>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="has-text-grey">Ingen aktive diagnostiske fejl på gatewayen.</p>
|
||||
</article>
|
||||
|
||||
<article class="edge-panel">
|
||||
<div class="edge-panel-head">
|
||||
<h3 class="title is-6">Recent audit logs</h3>
|
||||
</div>
|
||||
<div v-if="auditLogs.length" class="edge-audit-list" data-testid="gateway-audit-log-list">
|
||||
<article v-for="item in auditLogs" :key="item.id" class="edge-audit-item">
|
||||
<strong>{{ item.action }}</strong>
|
||||
<p>{{ formatDate(item.created_at) }}</p>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="has-text-grey">Ingen audit logs endnu.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
const EDGE_GATEWAY_ERROR_MESSAGES = {
|
||||
EDGE_GATEWAY_OFFLINE: {
|
||||
title: "Gateway offline",
|
||||
description: "Gatewayen sender ikke heartbeat. Kontrollér service og netværk, og prøv igen.",
|
||||
},
|
||||
EDGE_GATEWAY_STALE_HEARTBEAT: {
|
||||
title: "Forældet heartbeat",
|
||||
description: "Gatewayen svarer langsomt eller ustabilt. Kontrollér forbindelsen, og prøv igen.",
|
||||
},
|
||||
EDGE_GATEWAY_INVALID_TOKEN: {
|
||||
title: "Ugyldige legitimationsoplysninger",
|
||||
description: "Gateway-token blev afvist. Rotér credentials, og genstart agenten.",
|
||||
},
|
||||
EDGE_GATEWAY_OPERATION_TIMEOUT: {
|
||||
title: "Operation timed out",
|
||||
description: "Gatewayoperationen tog for lang tid. Gennemgå logs og forsøg igen.",
|
||||
},
|
||||
EDGE_GATEWAY_UNSUPPORTED_VERSION: {
|
||||
title: "Version ikke understøttet",
|
||||
description: "Målversionen eller artefaktet understøttes ikke af den installerede gateway.",
|
||||
},
|
||||
EDGE_GATEWAY_CONFLICT: {
|
||||
title: "Operation i konflikt",
|
||||
description: "En anden gatewayoperation kører allerede. Vent til den er afsluttet eller fejlet.",
|
||||
},
|
||||
EDGE_GATEWAY_VALIDATION_FAILED: {
|
||||
title: "Ugyldig anmodning",
|
||||
description: "Kontrollér input og prøv igen.",
|
||||
},
|
||||
};
|
||||
|
||||
const MESSAGE_CODE_PATTERNS = [
|
||||
[/offline/i, "EDGE_GATEWAY_OFFLINE"],
|
||||
[/heartbeat/i, "EDGE_GATEWAY_STALE_HEARTBEAT"],
|
||||
[/token|credential/i, "EDGE_GATEWAY_INVALID_TOKEN"],
|
||||
[/timeout/i, "EDGE_GATEWAY_OPERATION_TIMEOUT"],
|
||||
[/version/i, "EDGE_GATEWAY_UNSUPPORTED_VERSION"],
|
||||
[/conflict|already active|already in progress/i, "EDGE_GATEWAY_CONFLICT"],
|
||||
[/validation|required|unsupported/i, "EDGE_GATEWAY_VALIDATION_FAILED"],
|
||||
];
|
||||
|
||||
export function inferEdgeGatewayErrorCode(error) {
|
||||
const payload = error?.response?.data?.data || error?.response?.data || {};
|
||||
const explicitCode = payload?.error_code || payload?.code || null;
|
||||
if (explicitCode) {
|
||||
return explicitCode;
|
||||
}
|
||||
|
||||
const message =
|
||||
payload?.message || error?.message || error?.response?.data?.message || error?.response?.statusText || "";
|
||||
const match = MESSAGE_CODE_PATTERNS.find(([pattern]) => pattern.test(String(message)));
|
||||
return match ? match[1] : "EDGE_GATEWAY_VALIDATION_FAILED";
|
||||
}
|
||||
|
||||
export function normalizeEdgeGatewayError(error) {
|
||||
const code = inferEdgeGatewayErrorCode(error);
|
||||
const entry = EDGE_GATEWAY_ERROR_MESSAGES[code] || EDGE_GATEWAY_ERROR_MESSAGES.EDGE_GATEWAY_VALIDATION_FAILED;
|
||||
const payload = error?.response?.data?.data || error?.response?.data || {};
|
||||
const message = payload?.message || error?.message || entry.description;
|
||||
|
||||
return {
|
||||
code,
|
||||
title: entry.title,
|
||||
description: entry.description,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export { EDGE_GATEWAY_ERROR_MESSAGES };
|
||||
@@ -373,19 +373,39 @@
|
||||
"services_label": "Ydelser"
|
||||
},
|
||||
"customer_conflict": {
|
||||
"title": "Bekraeft kunde til nummerplade",
|
||||
"help_text": "Den indtastede nummerplade tilhoerer en anden gemt kunde. Vaelg om den valgte kunde skal beholdes, eller om der skal skiftes til koeretoejets kunde.",
|
||||
"warning_text": "Den valgte kunde og koeretoejets kunde matcher ikke.",
|
||||
"title": "Bekr\u00e6ft kunde til nummerplade",
|
||||
"help_text": "Den indtastede nummerplade tilh\u00f8rer en anden gemt kunde. V\u00e6lg om den valgte kunde skal beholdes, eller om der skal skiftes til K\u00f8ret\u00f8jets kunde.",
|
||||
"warning_text": "Den valgte kunde og K\u00f8ret\u00f8jets kunde matcher ikke.",
|
||||
"current_customer_label": "Valgt kunde",
|
||||
"matched_customer_label": "Koeretoejets kunde",
|
||||
"matched_customer_label": "K\u00f8ret\u00f8jets kunde",
|
||||
"keep_selected_customer": "Behold valgt kunde",
|
||||
"use_vehicle_customer": "Brug koeretoejets kunde",
|
||||
"use_vehicle_customer": "Brug K\u00f8ret\u00f8jets kunde",
|
||||
"cancel": "Annuller"
|
||||
},
|
||||
"orders": {
|
||||
"subtitle": "Administrer ordrer",
|
||||
"title": "Ordrer"
|
||||
},
|
||||
"drafts_assignment": {
|
||||
"button": "Tildel kunde",
|
||||
"title": "Tildel kunde til kladde",
|
||||
"subtitle": "Vælg kunde og fakturasamling for at flytte kladde #{orderId} ud af kladdekunden.",
|
||||
"search_customer_label": "Søg kunde",
|
||||
"search_customer_placeholder": "Søg efter kundenavn eller kundenummer",
|
||||
"selected_customer": "Valgt kunde",
|
||||
"customer_required": "Vælg først en kunde for at se fakturasamlinger.",
|
||||
"invoice_collection_label": "Fakturasamling",
|
||||
"invoice_collection_loading": "Indlæser fakturasamlinger...",
|
||||
"invoice_collection_empty": "Der blev ikke fundet nogen ikke-bogførte fakturasamlinger for den valgte kunde.",
|
||||
"create_invoice_collection": "Opret fakturasamling",
|
||||
"recalculate_prices": "Genberegn priser med kundens rabatter",
|
||||
"recalculate_prices_help": "Anvend afdelingens priser og eventuelle kunderabatter på alle ordrelinjer.",
|
||||
"submit": "Tildel kunde",
|
||||
"success": "Kunden blev tildelt kladden.",
|
||||
"error": "Der opstod en fejl under tildeling af kunde.",
|
||||
"new_collection_name": "Manuel fakturasamling",
|
||||
"new_collection_description": "Oprettet fra kladdeoversigten"
|
||||
},
|
||||
"pay_with_stripe": "Betal med Stripe",
|
||||
"stripe": {
|
||||
"headings": {
|
||||
@@ -763,6 +783,8 @@
|
||||
"booked_desc": "Fakturaen er bogført i E-conomic, ingen yderligere handlinger er nødvendige.",
|
||||
"booked_with_economic": "Fakturaen er bogført med E-conomic",
|
||||
"create_invoice": "Opret faktura",
|
||||
"draft_customer_blocked_desc": "Denne fakturasamling bruger den konfigurerede kladdekunde og kan derfor ikke eksporteres til E-conomic.",
|
||||
"draft_customer_blocked_title": "Eksport til E-conomic er blokeret",
|
||||
"fixed_price_added": "Fast pris tilføjet",
|
||||
"fixed_price_added_desc": "Fast pris er tilføjet i E-conomic",
|
||||
"invoice_create_error": "Der skete en fejl under oprettelse af fakturaen",
|
||||
@@ -959,7 +981,7 @@
|
||||
"search": "Søg",
|
||||
"second": "Sekund",
|
||||
"see_more": "Se mere",
|
||||
"select": "Vælg",
|
||||
"select": "V\u00e6lg",
|
||||
"selected": "Valgt",
|
||||
"service": "Ydelse",
|
||||
"services": "Ydelser",
|
||||
@@ -1051,6 +1073,10 @@
|
||||
"invoice_template": "Faktura skabelon",
|
||||
"invoice_template_desc": "Vælg faktura skabelonen til E-conomic integrationen.",
|
||||
"show_payment_terms": "Vis betalingsbetingelser",
|
||||
"transaction_draft_customer_number": "Kundenummer til transaktionskladder",
|
||||
"transaction_draft_customer_number_desc": "Angiv kundenummeret der markerer ordrer og fakturasamlinger som kladder. Tom værdi deaktiverer funktionen.",
|
||||
"transaction_draft_desc": "Brug en dedikeret E-conomic kunde til transaktioner der skal forblive som kladder og aldrig eksporteres automatisk.",
|
||||
"transaction_draft_title": "Transaktionskladde-kunde",
|
||||
"subtitle": "Konfiguration af E-conomic integrationen",
|
||||
"title": "E-conomic konfiguration"
|
||||
},
|
||||
@@ -1918,7 +1944,7 @@
|
||||
"search_subuser": "Søg efter underbruger",
|
||||
"search_transactions": "Søg i transaktioner",
|
||||
"search_user": "Søg efter bruger",
|
||||
"select": "Vælg",
|
||||
"select": "V\u00e6lg",
|
||||
"selected": "Valgt",
|
||||
"selected_multiple": "Valgte",
|
||||
"send_as_is": "Send som den er",
|
||||
@@ -2200,6 +2226,7 @@
|
||||
"bookings": "Bookinger",
|
||||
"create_transaction": "Opret transaktion",
|
||||
"daily_report": "Dagsopgørelse",
|
||||
"drafts": "Kladder",
|
||||
"drivers": "Chauffører",
|
||||
"home": "Hjem",
|
||||
"invoices": "Fakturaer",
|
||||
@@ -2731,6 +2758,12 @@
|
||||
"clear": "Tøm",
|
||||
"search": "Søg"
|
||||
},
|
||||
"customer_picker": {
|
||||
"selected_customer": "Valgt kunde",
|
||||
"select_customer_invoice": "Kundefaktura",
|
||||
"select_draft_customer": "V\u00e6lg transaktionskladde-kunde",
|
||||
"select_card_payment": "V\u00e6lg direkte betaling med betalingskort"
|
||||
},
|
||||
"license_plate": "Registreringsnummer",
|
||||
"manual_entry": "Manuell registrering",
|
||||
"new_order": "Ny order",
|
||||
@@ -2751,6 +2784,7 @@
|
||||
"department_id": "Afdeling ID",
|
||||
"include_in_invoice": "Faktura medtagelse",
|
||||
"draft_check_exists": "Tjek venligst om kladde fakturaen eksisterer i e-conomic.",
|
||||
"draft_transaction_export_blocked": "Denne transaktion bruger den konfigurerede kladdekunde og kan derfor ikke eksporteres til E-conomic.",
|
||||
"draft_fetch_error": "Faktura kladden kunne ikke hentes fra e-conomic.",
|
||||
"edit_note": "Rediger note",
|
||||
"force_edit": "Gennemtving redigeringsret",
|
||||
@@ -2782,6 +2816,7 @@
|
||||
"amount_received": "Beløb modtaget",
|
||||
"amount_reserved": "Beløb reserveret",
|
||||
"draft": "Kladde",
|
||||
"draft_transaction": "Transaktionskladde",
|
||||
"not_completed": "Ikke fuldført",
|
||||
"not_invoiced": "Ikke faktureret",
|
||||
"payment_status": "Betalingsstatus",
|
||||
@@ -3187,6 +3222,26 @@
|
||||
"subtitle": "Her kan du se en liste over alle transaktioner",
|
||||
"title": "Transaktionshistorik"
|
||||
},
|
||||
"drafts_assignment": {
|
||||
"button": "Tildel kunde",
|
||||
"title": "Tildel kunde til kladde",
|
||||
"subtitle": "Vælg kunde og fakturasamling for at flytte kladde #{orderId} ud af kladdekunden.",
|
||||
"search_customer_label": "Søg kunde",
|
||||
"search_customer_placeholder": "Søg efter kundenavn eller kundenummer",
|
||||
"selected_customer": "Valgt kunde",
|
||||
"customer_required": "Vælg først en kunde for at se fakturasamlinger.",
|
||||
"invoice_collection_label": "Fakturasamling",
|
||||
"invoice_collection_loading": "Indlæser fakturasamlinger...",
|
||||
"invoice_collection_empty": "Der blev ikke fundet nogen ikke-bogførte fakturasamlinger for den valgte kunde.",
|
||||
"create_invoice_collection": "Opret fakturasamling",
|
||||
"recalculate_prices": "Genberegn priser med kundens rabatter",
|
||||
"recalculate_prices_help": "Anvend afdelingens priser og eventuelle kunderabatter på alle ordrelinjer.",
|
||||
"submit": "Tildel kunde",
|
||||
"success": "Kunden blev tildelt kladden.",
|
||||
"error": "Der opstod en fejl under tildeling af kunde.",
|
||||
"new_collection_name": "Manuel fakturasamling",
|
||||
"new_collection_description": "Oprettet fra kladdeoversigten"
|
||||
},
|
||||
"products": {
|
||||
"create": "Opret nyt produkt",
|
||||
"subtitle": "Her kan du se en liste over alle produkter i systemet",
|
||||
|
||||
@@ -386,6 +386,26 @@
|
||||
"subtitle": "?bersicht des Waschprotokolls",
|
||||
"title": "Bestellungen"
|
||||
},
|
||||
"drafts_assignment": {
|
||||
"button": "Assign customer",
|
||||
"title": "Assign customer to draft",
|
||||
"subtitle": "Choose the customer and invoice collection to move draft #{orderId} out of the draft customer.",
|
||||
"search_customer_label": "Search customer",
|
||||
"search_customer_placeholder": "Search by customer name or customer number",
|
||||
"selected_customer": "Selected customer",
|
||||
"customer_required": "Select a customer to view invoice collections.",
|
||||
"invoice_collection_label": "Invoice collection",
|
||||
"invoice_collection_loading": "Loading invoice collections...",
|
||||
"invoice_collection_empty": "No unbooked invoice collections were found for the selected customer.",
|
||||
"create_invoice_collection": "Create invoice collection",
|
||||
"recalculate_prices": "Recalculate prices with customer discounts",
|
||||
"recalculate_prices_help": "Applies the department pricing and any customer discounts to all order lines.",
|
||||
"submit": "Assign customer",
|
||||
"success": "The draft customer was assigned successfully.",
|
||||
"error": "An error occurred while assigning the customer.",
|
||||
"new_collection_name": "Manual invoice collection",
|
||||
"new_collection_description": "Created from the drafts overview"
|
||||
},
|
||||
"pay_with_stripe": "Mit Stripe bezahlen",
|
||||
"stripe": {
|
||||
"headings": {
|
||||
@@ -763,6 +783,8 @@
|
||||
"booked_desc": "Die Rechnung ist in E-conomic gebucht, keine weiteren Aktionen sind erforderlich.",
|
||||
"booked_with_economic": "Rechnung mit E-conomic gebucht",
|
||||
"create_invoice": "Rechnung erstellen",
|
||||
"draft_customer_blocked_desc": "Diese Rechnungssammlung verwendet den konfigurierten Entwurfskunden und kann daher nicht nach E-conomic exportiert werden.",
|
||||
"draft_customer_blocked_title": "E-conomic-Export ist blockiert",
|
||||
"fixed_price_added": "Festpreis hinzugef?gt",
|
||||
"fixed_price_added_desc": "Festpreis wurde in E-conomic hinzugef?gt",
|
||||
"invoice_create_error": "Beim Erstellen der Rechnung ist ein Fehler aufgetreten.",
|
||||
@@ -1051,6 +1073,10 @@
|
||||
"invoice_template": "Rechnungsvorlage",
|
||||
"invoice_template_desc": "W?hlen Sie die Rechnungsvorlage f?r die E-conomic-Integration.",
|
||||
"show_payment_terms": "Zahlungsbedingungen anzeigen",
|
||||
"transaction_draft_customer_number": "Kundennummer für Transaktionsentwürfe",
|
||||
"transaction_draft_customer_number_desc": "Legen Sie die Kundennummer fest, die Aufträge und Rechnungssammlungen als Entwürfe markiert. Leer lassen, um die Funktion zu deaktivieren.",
|
||||
"transaction_draft_desc": "Verwenden Sie einen eigenen E-conomic-Kunden für Transaktionen, die Entwürfe bleiben und nie automatisch exportiert werden sollen.",
|
||||
"transaction_draft_title": "Transaktionsentwurfskunde",
|
||||
"subtitle": "Konfiguration der E-conomic-Integration",
|
||||
"title": "E-conomic-Konfiguration"
|
||||
},
|
||||
@@ -2200,6 +2226,7 @@
|
||||
"bookings": "Buchungen",
|
||||
"create_transaction": "Transaktion erstellen",
|
||||
"daily_report": "Tagesbericht",
|
||||
"drafts": "Entwürfe",
|
||||
"drivers": "Fahrer",
|
||||
"home": "Startseite",
|
||||
"invoices": "Rechnungen",
|
||||
@@ -2731,6 +2758,12 @@
|
||||
"clear": "Tøm",
|
||||
"search": "Søk"
|
||||
},
|
||||
"customer_picker": {
|
||||
"selected_customer": "Ausgew\u00e4hlter Kunde",
|
||||
"select_customer_invoice": "Kundenrechnung",
|
||||
"select_draft_customer": "Transaktionsentwurfskunde auswählen",
|
||||
"select_card_payment": "Direkte Kartenzahlung wählen"
|
||||
},
|
||||
"license_plate": "Kennzeichen",
|
||||
"manual_entry": "Manuell registrering",
|
||||
"new_order": "Neuer Auftrag",
|
||||
@@ -2751,6 +2784,7 @@
|
||||
"department_id": "Abteilungs-ID",
|
||||
"include_in_invoice": "Rechnungsaufnahme",
|
||||
"draft_check_exists": "Bitte pr?fen Sie, ob der Rechnungsentwurf in e-conomic existiert.",
|
||||
"draft_transaction_export_blocked": "Diese Transaktion verwendet den konfigurierten Entwurfskunden und kann daher nicht nach E-conomic exportiert werden.",
|
||||
"draft_fetch_error": "Der Rechnungsentwurf konnte nicht aus e-conomic geladen werden.",
|
||||
"edit_note": "Notiz bearbeiten",
|
||||
"force_edit": "Bearbeitungsrechte erzwingen",
|
||||
@@ -2782,6 +2816,7 @@
|
||||
"amount_received": "Erhaltener Betrag",
|
||||
"amount_reserved": "Reservierter Betrag",
|
||||
"draft": "Entwurf",
|
||||
"draft_transaction": "Transaktionsentwurf",
|
||||
"not_completed": "Nicht abgeschlossen",
|
||||
"not_invoiced": "Nicht fakturiert",
|
||||
"payment_status": "Zahlungsstatus",
|
||||
@@ -3173,6 +3208,26 @@
|
||||
"subtitle": "Hier sehen Sie eine Liste aller Transaktionen",
|
||||
"title": "Transaktionsverlauf"
|
||||
},
|
||||
"drafts_assignment": {
|
||||
"button": "Assign customer",
|
||||
"title": "Assign customer to draft",
|
||||
"subtitle": "Choose the customer and invoice collection to move draft #{orderId} out of the draft customer.",
|
||||
"search_customer_label": "Search customer",
|
||||
"search_customer_placeholder": "Search by customer name or customer number",
|
||||
"selected_customer": "Selected customer",
|
||||
"customer_required": "Select a customer to view invoice collections.",
|
||||
"invoice_collection_label": "Invoice collection",
|
||||
"invoice_collection_loading": "Loading invoice collections...",
|
||||
"invoice_collection_empty": "No unbooked invoice collections were found for the selected customer.",
|
||||
"create_invoice_collection": "Create invoice collection",
|
||||
"recalculate_prices": "Recalculate prices with customer discounts",
|
||||
"recalculate_prices_help": "Applies the department pricing and any customer discounts to all order lines.",
|
||||
"submit": "Assign customer",
|
||||
"success": "The draft customer was assigned successfully.",
|
||||
"error": "An error occurred while assigning the customer.",
|
||||
"new_collection_name": "Manual invoice collection",
|
||||
"new_collection_description": "Created from the drafts overview"
|
||||
},
|
||||
"products": {
|
||||
"create": "Neues Produkt erstellen",
|
||||
"subtitle": "Hier sehen Sie eine Liste aller Produkte im System",
|
||||
|
||||
@@ -386,6 +386,26 @@
|
||||
"subtitle": "Overview of wash log",
|
||||
"title": "Wash Log"
|
||||
},
|
||||
"drafts_assignment": {
|
||||
"button": "Assign customer",
|
||||
"title": "Assign customer to draft",
|
||||
"subtitle": "Choose the customer and invoice collection to move draft #{orderId} out of the draft customer.",
|
||||
"search_customer_label": "Search customer",
|
||||
"search_customer_placeholder": "Search by customer name or customer number",
|
||||
"selected_customer": "Selected customer",
|
||||
"customer_required": "Select a customer to view invoice collections.",
|
||||
"invoice_collection_label": "Invoice collection",
|
||||
"invoice_collection_loading": "Loading invoice collections...",
|
||||
"invoice_collection_empty": "No unbooked invoice collections were found for the selected customer.",
|
||||
"create_invoice_collection": "Create invoice collection",
|
||||
"recalculate_prices": "Recalculate prices with customer discounts",
|
||||
"recalculate_prices_help": "Applies the department pricing and any customer discounts to all order lines.",
|
||||
"submit": "Assign customer",
|
||||
"success": "The draft customer was assigned successfully.",
|
||||
"error": "An error occurred while assigning the customer.",
|
||||
"new_collection_name": "Manual invoice collection",
|
||||
"new_collection_description": "Created from the drafts overview"
|
||||
},
|
||||
"pay_with_stripe": "Pay with Stripe",
|
||||
"stripe": {
|
||||
"headings": {
|
||||
@@ -763,6 +783,8 @@
|
||||
"booked_desc": "The invoice is booked in E-conomic, no further actions are required.",
|
||||
"booked_with_economic": "Invoice booked with E-conomic",
|
||||
"create_invoice": "Create invoice",
|
||||
"draft_customer_blocked_desc": "This invoice collection uses the configured draft customer and cannot be exported to E-conomic.",
|
||||
"draft_customer_blocked_title": "E-conomic export is blocked",
|
||||
"fixed_price_added": "Fixed price added",
|
||||
"fixed_price_added_desc": "Fixed price has been added in E-conomic",
|
||||
"invoice_create_error": "An error occurred while creating the invoice.",
|
||||
@@ -1051,6 +1073,10 @@
|
||||
"invoice_template": "Invoice template",
|
||||
"invoice_template_desc": "Select the invoice template for the E-conomic integration.",
|
||||
"show_payment_terms": "Show payment terms",
|
||||
"transaction_draft_customer_number": "Transaction draft customer number",
|
||||
"transaction_draft_customer_number_desc": "Set the customer number that marks orders and invoice collections as drafts. Leave empty to disable the feature.",
|
||||
"transaction_draft_desc": "Use a dedicated E-conomic customer for transactions that should remain drafts and never be exported automatically.",
|
||||
"transaction_draft_title": "Transaction draft customer",
|
||||
"subtitle": "Configuration of the E-conomic integration",
|
||||
"title": "E-conomic configuration"
|
||||
},
|
||||
@@ -2200,6 +2226,7 @@
|
||||
"bookings": "Bookings",
|
||||
"create_transaction": "Create transaction",
|
||||
"daily_report": "Daily report",
|
||||
"drafts": "Drafts",
|
||||
"drivers": "Drivers",
|
||||
"home": "Home",
|
||||
"invoices": "Invoices",
|
||||
@@ -2731,6 +2758,12 @@
|
||||
"clear": "Clear",
|
||||
"search": "Search"
|
||||
},
|
||||
"customer_picker": {
|
||||
"selected_customer": "Selected customer",
|
||||
"select_customer_invoice": "Customer invoice",
|
||||
"select_draft_customer": "Select transaction draft customer",
|
||||
"select_card_payment": "Select direct card payment"
|
||||
},
|
||||
"license_plate": "Registration number",
|
||||
"manual_entry": "Manual registration",
|
||||
"new_order": "New order",
|
||||
@@ -2751,6 +2784,7 @@
|
||||
"department_id": "Department ID",
|
||||
"include_in_invoice": "Invoice inclusion",
|
||||
"draft_check_exists": "Please check if the draft invoice exists in e-conomic.",
|
||||
"draft_transaction_export_blocked": "This transaction uses the configured draft customer and cannot be exported to E-conomic.",
|
||||
"draft_fetch_error": "The invoice draft could not be fetched from e-conomic.",
|
||||
"edit_note": "Edit note",
|
||||
"force_edit": "Force edit rights",
|
||||
@@ -2782,6 +2816,7 @@
|
||||
"amount_received": "Amount received",
|
||||
"amount_reserved": "Amount reserved",
|
||||
"draft": "Draft",
|
||||
"draft_transaction": "Draft transaction",
|
||||
"not_completed": "Not completed",
|
||||
"not_invoiced": "Not invoiced",
|
||||
"payment_status": "Payment status",
|
||||
@@ -3186,6 +3221,26 @@
|
||||
"subtitle": "Here you can see a list of all transactions",
|
||||
"title": "Transaction History"
|
||||
},
|
||||
"drafts_assignment": {
|
||||
"button": "Assign customer",
|
||||
"title": "Assign customer to draft",
|
||||
"subtitle": "Choose the customer and invoice collection to move draft #{orderId} out of the draft customer.",
|
||||
"search_customer_label": "Search customer",
|
||||
"search_customer_placeholder": "Search by customer name or customer number",
|
||||
"selected_customer": "Selected customer",
|
||||
"customer_required": "Select a customer to view invoice collections.",
|
||||
"invoice_collection_label": "Invoice collection",
|
||||
"invoice_collection_loading": "Loading invoice collections...",
|
||||
"invoice_collection_empty": "No unbooked invoice collections were found for the selected customer.",
|
||||
"create_invoice_collection": "Create invoice collection",
|
||||
"recalculate_prices": "Recalculate prices with customer discounts",
|
||||
"recalculate_prices_help": "Applies the department pricing and any customer discounts to all order lines.",
|
||||
"submit": "Assign customer",
|
||||
"success": "The draft customer was assigned successfully.",
|
||||
"error": "An error occurred while assigning the customer.",
|
||||
"new_collection_name": "Manual invoice collection",
|
||||
"new_collection_description": "Created from the drafts overview"
|
||||
},
|
||||
"products": {
|
||||
"create": "Create new product",
|
||||
"subtitle": "Here you can see a list of all products in the system",
|
||||
|
||||
@@ -386,6 +386,26 @@
|
||||
"subtitle": "Oversikt over vaskelogg",
|
||||
"title": "Ordrer"
|
||||
},
|
||||
"drafts_assignment": {
|
||||
"button": "Assign customer",
|
||||
"title": "Assign customer to draft",
|
||||
"subtitle": "Choose the customer and invoice collection to move draft #{orderId} out of the draft customer.",
|
||||
"search_customer_label": "Search customer",
|
||||
"search_customer_placeholder": "Search by customer name or customer number",
|
||||
"selected_customer": "Selected customer",
|
||||
"customer_required": "Select a customer to view invoice collections.",
|
||||
"invoice_collection_label": "Invoice collection",
|
||||
"invoice_collection_loading": "Loading invoice collections...",
|
||||
"invoice_collection_empty": "No unbooked invoice collections were found for the selected customer.",
|
||||
"create_invoice_collection": "Create invoice collection",
|
||||
"recalculate_prices": "Recalculate prices with customer discounts",
|
||||
"recalculate_prices_help": "Applies the department pricing and any customer discounts to all order lines.",
|
||||
"submit": "Assign customer",
|
||||
"success": "The draft customer was assigned successfully.",
|
||||
"error": "An error occurred while assigning the customer.",
|
||||
"new_collection_name": "Manual invoice collection",
|
||||
"new_collection_description": "Created from the drafts overview"
|
||||
},
|
||||
"pay_with_stripe": "Betal med Stripe",
|
||||
"stripe": {
|
||||
"headings": {
|
||||
@@ -763,6 +783,8 @@
|
||||
"booked_desc": "Fakturaen er bokført i E-conomic, ingen ytterligere handlinger er nødvendig.",
|
||||
"booked_with_economic": "Faktura bestilles hos E-conomic",
|
||||
"create_invoice": "Opprett faktura",
|
||||
"draft_customer_blocked_desc": "Denne fakturasamlingen bruker den konfigurerte utkastkunden og kan derfor ikke eksporteres til E-conomic.",
|
||||
"draft_customer_blocked_title": "E-conomic-eksport er blokkert",
|
||||
"fixed_price_added": "Fast pris i tillegg",
|
||||
"fixed_price_added_desc": "Fast pris er lagt til i E-conomic",
|
||||
"invoice_create_error": "Det oppstod en feil under opprettelsen av fakturaen.",
|
||||
@@ -1051,6 +1073,10 @@
|
||||
"invoice_template": "Fakturamal",
|
||||
"invoice_template_desc": "Velg fakturamal for E-conomic-integrasjonen.",
|
||||
"show_payment_terms": "Vis betalingsbetingelser",
|
||||
"transaction_draft_customer_number": "Kundenummer for transaksjonsutkast",
|
||||
"transaction_draft_customer_number_desc": "Angi kundenummeret som markerer ordre og fakturasamlinger som utkast. La feltet stå tomt for å deaktivere funksjonen.",
|
||||
"transaction_draft_desc": "Bruk en egen E-conomic-kunde for transaksjoner som skal forbli utkast og aldri eksporteres automatisk.",
|
||||
"transaction_draft_title": "Transaksjonsutkast-kunde",
|
||||
"subtitle": "Konfigurasjon av E-conomic-integrasjonen",
|
||||
"title": "E-konomisk konfigurasjon"
|
||||
},
|
||||
@@ -2200,6 +2226,7 @@
|
||||
"bookings": "Bestillinger",
|
||||
"create_transaction": "Opprett transaksjon",
|
||||
"daily_report": "Daglig rapport",
|
||||
"drafts": "Kladder",
|
||||
"drivers": "Sjåfører",
|
||||
"home": "Hjem",
|
||||
"invoices": "Fakturaer",
|
||||
@@ -2731,6 +2758,12 @@
|
||||
"clear": "Tøm",
|
||||
"search": "Søk"
|
||||
},
|
||||
"customer_picker": {
|
||||
"selected_customer": "Valgt kunde",
|
||||
"select_customer_invoice": "Kundefaktura",
|
||||
"select_draft_customer": "Velg transaksjonsutkast-kunde",
|
||||
"select_card_payment": "Velg direkte betaling med betalingskort"
|
||||
},
|
||||
"license_plate": "Registreringsnummer",
|
||||
"manual_entry": "Manuell registrering",
|
||||
"new_order": "Bestillingen",
|
||||
@@ -2751,6 +2784,7 @@
|
||||
"department_id": "Avdelings-ID",
|
||||
"include_in_invoice": "Fakturainkludering",
|
||||
"draft_check_exists": "Vennligst sjekk om utkastet til faktura finnes i e-conomic.",
|
||||
"draft_transaction_export_blocked": "Denne transaksjonen bruker den konfigurerte utkastkunden og kan derfor ikke eksporteres til E-conomic.",
|
||||
"draft_fetch_error": "Fakturautkastet kunne ikke hentes fra e-conomic.",
|
||||
"edit_note": "Rediger notat",
|
||||
"force_edit": "Tving redigeringsrettigheter",
|
||||
@@ -2782,6 +2816,7 @@
|
||||
"amount_received": "Beløp mottatt",
|
||||
"amount_reserved": "Beløp reservert",
|
||||
"draft": "Utkast",
|
||||
"draft_transaction": "Transaksjonsutkast",
|
||||
"not_completed": "Ikke fullført",
|
||||
"not_invoiced": "Ikke fakturert",
|
||||
"payment_status": "Betalingsstatus",
|
||||
@@ -3173,6 +3208,26 @@
|
||||
"subtitle": "Her kan du se en liste over alle transaksjoner",
|
||||
"title": "Transaksjonshistorikk"
|
||||
},
|
||||
"drafts_assignment": {
|
||||
"button": "Assign customer",
|
||||
"title": "Assign customer to draft",
|
||||
"subtitle": "Choose the customer and invoice collection to move draft #{orderId} out of the draft customer.",
|
||||
"search_customer_label": "Search customer",
|
||||
"search_customer_placeholder": "Search by customer name or customer number",
|
||||
"selected_customer": "Selected customer",
|
||||
"customer_required": "Select a customer to view invoice collections.",
|
||||
"invoice_collection_label": "Invoice collection",
|
||||
"invoice_collection_loading": "Loading invoice collections...",
|
||||
"invoice_collection_empty": "No unbooked invoice collections were found for the selected customer.",
|
||||
"create_invoice_collection": "Create invoice collection",
|
||||
"recalculate_prices": "Recalculate prices with customer discounts",
|
||||
"recalculate_prices_help": "Applies the department pricing and any customer discounts to all order lines.",
|
||||
"submit": "Assign customer",
|
||||
"success": "The draft customer was assigned successfully.",
|
||||
"error": "An error occurred while assigning the customer.",
|
||||
"new_collection_name": "Manual invoice collection",
|
||||
"new_collection_description": "Created from the drafts overview"
|
||||
},
|
||||
"products": {
|
||||
"create": "Opprett nytt produkt",
|
||||
"subtitle": "Her kan du se en liste over alle produktene i systemet",
|
||||
|
||||
@@ -386,6 +386,26 @@
|
||||
"subtitle": "översikt över tvättlogg",
|
||||
"title": "Ordrar"
|
||||
},
|
||||
"drafts_assignment": {
|
||||
"button": "Assign customer",
|
||||
"title": "Assign customer to draft",
|
||||
"subtitle": "Choose the customer and invoice collection to move draft #{orderId} out of the draft customer.",
|
||||
"search_customer_label": "Search customer",
|
||||
"search_customer_placeholder": "Search by customer name or customer number",
|
||||
"selected_customer": "Selected customer",
|
||||
"customer_required": "Select a customer to view invoice collections.",
|
||||
"invoice_collection_label": "Invoice collection",
|
||||
"invoice_collection_loading": "Loading invoice collections...",
|
||||
"invoice_collection_empty": "No unbooked invoice collections were found for the selected customer.",
|
||||
"create_invoice_collection": "Create invoice collection",
|
||||
"recalculate_prices": "Recalculate prices with customer discounts",
|
||||
"recalculate_prices_help": "Applies the department pricing and any customer discounts to all order lines.",
|
||||
"submit": "Assign customer",
|
||||
"success": "The draft customer was assigned successfully.",
|
||||
"error": "An error occurred while assigning the customer.",
|
||||
"new_collection_name": "Manual invoice collection",
|
||||
"new_collection_description": "Created from the drafts overview"
|
||||
},
|
||||
"pay_with_stripe": "Betala med Stripe",
|
||||
"stripe": {
|
||||
"headings": {
|
||||
@@ -763,6 +783,8 @@
|
||||
"booked_desc": "Fakturan är bokförd i E-conomic, inga ytterligare åtgärder krävs.",
|
||||
"booked_with_economic": "Faktura bokförd i E-conomic",
|
||||
"create_invoice": "Skapa faktura",
|
||||
"draft_customer_blocked_desc": "Den här fakturasamlingen använder den konfigurerade utkastkunden och kan därför inte exporteras till E-conomic.",
|
||||
"draft_customer_blocked_title": "E-conomic-export är blockerad",
|
||||
"fixed_price_added": "Fast pris tillagd",
|
||||
"fixed_price_added_desc": "Fast pris har lagts till i E-conomic",
|
||||
"invoice_create_error": "Ett fel uppstod när fakturan skapades.",
|
||||
@@ -1051,6 +1073,10 @@
|
||||
"invoice_template": "Faktura skabelon",
|
||||
"invoice_template_desc": "Välj fakturamallen för E-conomic-integrationen.",
|
||||
"show_payment_terms": "Vis betalingsbetingelser",
|
||||
"transaction_draft_customer_number": "Kundnummer för transaktionsutkast",
|
||||
"transaction_draft_customer_number_desc": "Ange kundnumret som markerar order och fakturasamlingar som utkast. Lämna tomt för att inaktivera funktionen.",
|
||||
"transaction_draft_desc": "Använd en dedikerad E-conomic-kund för transaktioner som ska förbli utkast och aldrig exporteras automatiskt.",
|
||||
"transaction_draft_title": "Transaktionsutkast-kund",
|
||||
"subtitle": "Konfiguration af E-conomic integrationen",
|
||||
"title": "E-conomic konfiguration"
|
||||
},
|
||||
@@ -2200,6 +2226,7 @@
|
||||
"bookings": "Bokningar",
|
||||
"create_transaction": "Skapa transaktion",
|
||||
"daily_report": "Daily report",
|
||||
"drafts": "Utkast",
|
||||
"drivers": "Förare",
|
||||
"home": "Home",
|
||||
"invoices": "Fakturor",
|
||||
@@ -2731,6 +2758,12 @@
|
||||
"clear": "Töm",
|
||||
"search": "Sök"
|
||||
},
|
||||
"customer_picker": {
|
||||
"selected_customer": "Vald kund",
|
||||
"select_customer_invoice": "Kundfaktura",
|
||||
"select_draft_customer": "Välj transaktionsutkast-kund",
|
||||
"select_card_payment": "Välj direkt betalning med betalkort"
|
||||
},
|
||||
"license_plate": "Registreringsnummer",
|
||||
"manual_entry": "Manuell registrering",
|
||||
"new_order": "Ny order",
|
||||
@@ -2751,6 +2784,7 @@
|
||||
"department_id": "Avdelnings-ID",
|
||||
"include_in_invoice": "Fakturainkludering",
|
||||
"draft_check_exists": "Kontrollera om utkastfakturan finns i e-conomic.",
|
||||
"draft_transaction_export_blocked": "Den här transaktionen använder den konfigurerade utkastkunden och kan därför inte exporteras till E-conomic.",
|
||||
"draft_fetch_error": "Fakturautkastet kunde inte hämtas från e-conomic.",
|
||||
"edit_note": "Edit note",
|
||||
"force_edit": "Tvinga redigeringsrättigheter",
|
||||
@@ -2782,6 +2816,7 @@
|
||||
"amount_received": "Belopp mottaget",
|
||||
"amount_reserved": "Reserverat belopp",
|
||||
"draft": "Utkast",
|
||||
"draft_transaction": "Transaktionsutkast",
|
||||
"not_completed": "Ej slutförd",
|
||||
"not_invoiced": "Ej fakturerad",
|
||||
"payment_status": "Betalningsstatus",
|
||||
@@ -3173,6 +3208,26 @@
|
||||
"subtitle": "Här kan du se en lista över alla transaktioner",
|
||||
"title": "Transaktionshistorik"
|
||||
},
|
||||
"drafts_assignment": {
|
||||
"button": "Assign customer",
|
||||
"title": "Assign customer to draft",
|
||||
"subtitle": "Choose the customer and invoice collection to move draft #{orderId} out of the draft customer.",
|
||||
"search_customer_label": "Search customer",
|
||||
"search_customer_placeholder": "Search by customer name or customer number",
|
||||
"selected_customer": "Selected customer",
|
||||
"customer_required": "Select a customer to view invoice collections.",
|
||||
"invoice_collection_label": "Invoice collection",
|
||||
"invoice_collection_loading": "Loading invoice collections...",
|
||||
"invoice_collection_empty": "No unbooked invoice collections were found for the selected customer.",
|
||||
"create_invoice_collection": "Create invoice collection",
|
||||
"recalculate_prices": "Recalculate prices with customer discounts",
|
||||
"recalculate_prices_help": "Applies the department pricing and any customer discounts to all order lines.",
|
||||
"submit": "Assign customer",
|
||||
"success": "The draft customer was assigned successfully.",
|
||||
"error": "An error occurred while assigning the customer.",
|
||||
"new_collection_name": "Manual invoice collection",
|
||||
"new_collection_description": "Created from the drafts overview"
|
||||
},
|
||||
"products": {
|
||||
"create": "Skapa ny produkt",
|
||||
"subtitle": "Här kan du se en lista över alla produkter i systemet",
|
||||
|
||||
+54
-17
@@ -33,11 +33,12 @@ import Orders from "@/views/dashboards/superUserDashboard/Orders.vue";
|
||||
import NumberPlateScanners from "@/views/dashboards/superUserDashboard/NumberPlateScanners.vue";
|
||||
import Statistics from "@/views/dashboards/superUserDashboard/statistics/Statistics.vue";
|
||||
import DepartmentSelfServe from "@/views/dashboards/superUserDashboard/DepartmentSelfServe.vue";
|
||||
import EdgeGateways from "@/views/dashboards/superUserDashboard/EdgeGateways.vue";
|
||||
import EdgeGatewaysWorkspacePage from "@/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue";
|
||||
import Invoicing from "@/views/dashboards/superUserDashboard/Invoicing.vue";
|
||||
|
||||
/** Dashboard: Department */
|
||||
import DepartmentPos from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPos.vue";
|
||||
import DepartmentPosDrafts from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosDrafts.vue";
|
||||
import DepartmentPosOrders from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosOrders.vue";
|
||||
import DepartmentPosOrder from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosOrder.vue";
|
||||
import DepartmentPosSyncDuplicates from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosSyncDuplicates.vue";
|
||||
@@ -97,7 +98,7 @@ import DepartmentCompleteBooking
|
||||
import GuestHome from "@/views/guest/GuestHome.vue";
|
||||
import GuestBookExteriorWash from "@/views/guest/book/GuestBookExteriorWash.vue";
|
||||
import DepartmentProfile from "@/views/dashboards/superUserDashboard/department/DepartmentProfile.vue";
|
||||
import DepartmentGateways from "@/views/dashboards/superUserDashboard/department/DepartmentGateways.vue";
|
||||
import DepartmentGatewaysWorkspacePage from "@/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue";
|
||||
import ConfigurationFXRatesAPI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationFXRatesAPI.vue";
|
||||
import ConfigurationWeatherAPI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationWeatherAPI.vue";
|
||||
import ConfigurationWorkfeed from "@/views/dashboards/superUserDashboard/configuration/ConfigurationWorkfeed.vue";
|
||||
@@ -443,6 +444,12 @@ export const router = createRouter({
|
||||
component: DepartmentPosOrders,
|
||||
meta: { middleware: adminMiddleware, departmentSelection: true }
|
||||
},
|
||||
{
|
||||
name: 'posdrafts',
|
||||
path: '/admin/:departmentId/modules/pos/drafts',
|
||||
component: DepartmentPosDrafts,
|
||||
meta: { middleware: adminMiddleware, departmentSelection: true }
|
||||
},
|
||||
|
||||
{
|
||||
name: 'possync',
|
||||
@@ -639,25 +646,55 @@ export const router = createRouter({
|
||||
{
|
||||
name: 'edgegateways',
|
||||
path: '/superuser/gateways',
|
||||
component: EdgeGateways,
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'edgegatewaydetailredirect',
|
||||
path: '/superuser/gateways/:id',
|
||||
redirect: (to) => ({
|
||||
name: 'edgegatewaydetail',
|
||||
params: {
|
||||
id: to.params.id,
|
||||
subPage: 'overview',
|
||||
},
|
||||
}),
|
||||
component: EdgeGatewaysWorkspacePage,
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'edgegatewaydetail',
|
||||
path: '/superuser/gateways/:id',
|
||||
redirect: (to) => `/superuser/gateways/${encodeURIComponent(String(to.params.id))}/overview`,
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'edgegatewayoverview',
|
||||
path: '/superuser/gateways/:id/overview',
|
||||
component: EdgeGatewaysWorkspacePage,
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'edgegatewayinventory',
|
||||
path: '/superuser/gateways/:id/inventory',
|
||||
component: EdgeGatewaysWorkspacePage,
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'edgegatewayoperations',
|
||||
path: '/superuser/gateways/:id/operations',
|
||||
component: EdgeGatewaysWorkspacePage,
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'edgegatewaymanage',
|
||||
path: '/superuser/gateways/:id/manage',
|
||||
component: EdgeGatewaysWorkspacePage,
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'edgegatewaydetaillegacy',
|
||||
path: '/superuser/gateways/:id/:subPage',
|
||||
component: EdgeGateways,
|
||||
redirect: (to) => {
|
||||
const mapping = {
|
||||
assess: 'overview',
|
||||
overview: 'overview',
|
||||
configure: 'inventory',
|
||||
inventory: 'inventory',
|
||||
operations: 'operations',
|
||||
history: 'operations',
|
||||
manage: 'manage',
|
||||
};
|
||||
const subPage = mapping[String(to.params.subPage || '').toLowerCase()] || 'overview';
|
||||
return `/superuser/gateways/${encodeURIComponent(String(to.params.id))}/${subPage}`;
|
||||
},
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
@@ -711,7 +748,7 @@ export const router = createRouter({
|
||||
{
|
||||
name: 'departmentsgateways',
|
||||
path: '/superuser/departments/:departmentId/gateways',
|
||||
component: DepartmentGateways,
|
||||
component: DepartmentGatewaysWorkspacePage,
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
import i18n from "@/i18n";
|
||||
|
||||
const normalizeDepartmentLabel = (label) => {
|
||||
if (typeof label !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return label.trim().toLocaleLowerCase();
|
||||
};
|
||||
|
||||
export const isDepartmentLabelValid = (label) => {
|
||||
const normalizedLabel = normalizeDepartmentLabel(label);
|
||||
if (!normalizedLabel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const noDataLabel = normalizeDepartmentLabel(String(i18n.global.t("global.no_data") || ""));
|
||||
return normalizedLabel !== noDataLabel;
|
||||
};
|
||||
|
||||
export const hasValidDepartmentName = (department) => {
|
||||
return isDepartmentLabelValid(department?.name);
|
||||
};
|
||||
|
||||
export const isDepartmentVisible = (department) => {
|
||||
return !(
|
||||
department?.visible === false
|
||||
@@ -10,3 +34,29 @@ export const isDepartmentVisible = (department) => {
|
||||
export const isAccessibleVisibleDepartment = (department, canAccessDepartment = () => true) => {
|
||||
return Boolean(department) && canAccessDepartment(department.id) && isDepartmentVisible(department);
|
||||
};
|
||||
|
||||
export const isAccessibleVisibleNamedDepartment = (department, canAccessDepartment = () => true) => {
|
||||
return isAccessibleVisibleDepartment(department, canAccessDepartment) && hasValidDepartmentName(department);
|
||||
};
|
||||
|
||||
const normalizeDepartmentPriorityOrder = (department) => {
|
||||
const parsedPriorityOrder = Number.parseInt(
|
||||
String(department?.priority_order ?? department?.priorityOrder ?? ""),
|
||||
10
|
||||
);
|
||||
|
||||
return Number.isInteger(parsedPriorityOrder) ? parsedPriorityOrder : Number.POSITIVE_INFINITY;
|
||||
};
|
||||
|
||||
export const sortByDepartmentPriorityOrder = (items = []) => {
|
||||
return [...items].sort((left, right) => {
|
||||
const leftPriorityOrder = normalizeDepartmentPriorityOrder(left);
|
||||
const rightPriorityOrder = normalizeDepartmentPriorityOrder(right);
|
||||
|
||||
if (leftPriorityOrder !== rightPriorityOrder) {
|
||||
return leftPriorityOrder - rightPriorityOrder;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
|
||||
export const listEdgeGatewayDepartments = async () =>
|
||||
authenticatedRequest("/departments", "GET", {});
|
||||
export const listEdgeGatewayDepartments = async () => authenticatedRequest("/departments", "GET", {});
|
||||
|
||||
export const listEdgeGateways = async ({ departmentId = null } = {}) =>
|
||||
authenticatedRequest("/edge-gateways", "GET", departmentId ? { department_id: departmentId } : {});
|
||||
@@ -9,62 +8,34 @@ export const listEdgeGateways = async ({ departmentId = null } = {}) =>
|
||||
export const getEdgeGateway = async (gatewayId) =>
|
||||
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}`, "GET", {});
|
||||
|
||||
export const listEdgeGatewayOperations = async (gatewayId) =>
|
||||
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}/operations`, "GET", {});
|
||||
|
||||
export const createEdgeGatewayOperation = async (gatewayId, type, request = {}) =>
|
||||
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}/operations`, "POST", { type, request });
|
||||
|
||||
export const getEdgeGatewayOperationEvents = async (gatewayId, operationId) =>
|
||||
authenticatedRequest(
|
||||
`/edge-gateways/${encodeURIComponent(gatewayId)}/operations/${encodeURIComponent(operationId)}/events`,
|
||||
"GET",
|
||||
{}
|
||||
);
|
||||
|
||||
export const rotateEdgeGatewayCredentials = async (gatewayId) =>
|
||||
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}/rotate-credentials`, "POST", {});
|
||||
|
||||
export const createEdgeGatewayInstallToken = async ({ department_id, label }) =>
|
||||
authenticatedRequest("/edge-gateways/install-token", "POST", { department_id, label });
|
||||
|
||||
export const updateEdgeGateway = async (gatewayId, payload) =>
|
||||
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}`, "PUT", payload);
|
||||
|
||||
export const listEdgeGatewayOperations = async (gatewayId) =>
|
||||
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}/operations`, "GET", {});
|
||||
|
||||
export const queueEdgeGatewayOperation = async (gatewayId, payload) =>
|
||||
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}/operations`, "POST", payload);
|
||||
|
||||
export const pollEdgeGatewayOperationEvents = async (gatewayId, operationId, params = {}) =>
|
||||
authenticatedRequest(
|
||||
`/edge-gateways/${encodeURIComponent(gatewayId)}/operations/${encodeURIComponent(operationId)}/events`,
|
||||
"GET",
|
||||
params
|
||||
);
|
||||
export const triggerEdgeGatewayDiscovery = async (gatewayId) =>
|
||||
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}/discovery`, "POST", {});
|
||||
|
||||
export const saveEdgeGatewayBindings = async (gatewayId, bindings) =>
|
||||
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}/bindings`, "PUT", { bindings });
|
||||
|
||||
export const rotateEdgeGatewayCredentials = async (gatewayId) =>
|
||||
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}/rotate-credentials`, "POST", {});
|
||||
|
||||
export const createEdgeGatewayShellSession = async (gatewayId, payload) =>
|
||||
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}/shell-sessions`, "POST", payload);
|
||||
|
||||
export const pollEdgeGatewayShellSession = async (gatewayId, sessionId, params = {}) =>
|
||||
authenticatedRequest(
|
||||
`/edge-gateways/${encodeURIComponent(gatewayId)}/shell-sessions/${encodeURIComponent(sessionId)}/events`,
|
||||
"GET",
|
||||
params
|
||||
);
|
||||
|
||||
export const sendEdgeGatewayShellInput = async (gatewayId, sessionId, data) =>
|
||||
authenticatedRequest(
|
||||
`/edge-gateways/${encodeURIComponent(gatewayId)}/shell-sessions/${encodeURIComponent(sessionId)}/input`,
|
||||
"POST",
|
||||
{ data }
|
||||
);
|
||||
|
||||
export const resizeEdgeGatewayShellSession = async (gatewayId, sessionId, cols, rows) =>
|
||||
authenticatedRequest(
|
||||
`/edge-gateways/${encodeURIComponent(gatewayId)}/shell-sessions/${encodeURIComponent(sessionId)}/resize`,
|
||||
"POST",
|
||||
{ cols, rows }
|
||||
);
|
||||
|
||||
export const closeEdgeGatewayShellSession = async (gatewayId, sessionId) =>
|
||||
authenticatedRequest(
|
||||
`/edge-gateways/${encodeURIComponent(gatewayId)}/shell-sessions/${encodeURIComponent(sessionId)}/close`,
|
||||
"POST",
|
||||
{}
|
||||
);
|
||||
|
||||
export const deleteEdgeGateway = async (gatewayId) =>
|
||||
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}`, "DELETE", {});
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import DepartmentDashboardHero from "@/views/dashboards/departmentDashboard/DepartmentDashboardHero.vue";
|
||||
import OrdersPagination from "@/components/displays/pagination/models/DepartmentPos/OrdersPagination.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import DepartmentDashboardPageWrapper from "@/views/dashboards/departmentDashboard/DepartmentDashboardPageWrapper.vue";
|
||||
import NotFoundFallBackPageWrapper from "@/components/page/wrappers/NotFoundFallBackPageWrapper.vue";
|
||||
import { useDraftTransactionCustomer } from "@/composables/useDraftTransactionCustomer.js";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { draftTransactionCustomerNumber, ensureDraftTransactionCustomerLoaded } = useDraftTransactionCustomer();
|
||||
const isResolvingDraftCustomer = ref(true);
|
||||
|
||||
const waitForSessionInitialization = async () => {
|
||||
if (SessionUser.isInitiated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const stop = watch(
|
||||
() => SessionUser.initiated.value,
|
||||
(isInitiated) => {
|
||||
if (isInitiated) {
|
||||
stop();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const resolveDraftCustomerNumber = async () => {
|
||||
let configuredDraftCustomerNumber = await ensureDraftTransactionCustomerLoaded();
|
||||
if (configuredDraftCustomerNumber !== null) {
|
||||
return configuredDraftCustomerNumber;
|
||||
}
|
||||
|
||||
if (SessionUser.hasToken?.() && !SessionUser.canAccessSuperUser()) {
|
||||
await SessionUser.getSessionData();
|
||||
configuredDraftCustomerNumber = await ensureDraftTransactionCustomerLoaded();
|
||||
}
|
||||
|
||||
return configuredDraftCustomerNumber;
|
||||
};
|
||||
|
||||
const paginationKey = computed(() => {
|
||||
return `department-pos-drafts-${route.params.departmentId}-${draftTransactionCustomerNumber.value ?? "missing"}`;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await waitForSessionInitialization();
|
||||
const configuredDraftCustomerNumber = await resolveDraftCustomerNumber();
|
||||
if (configuredDraftCustomerNumber === null) {
|
||||
await router.replace(`/admin/${route.params.departmentId}/modules/pos/orders`);
|
||||
}
|
||||
} finally {
|
||||
isResolvingDraftCustomer.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessAdmin()">
|
||||
<DepartmentDashboardPageWrapper :title="$t('nav.drafts')" :subtitle="$t('admin.pos.orders.subtitle')">
|
||||
<NotFoundFallBackPageWrapper :exists="SessionUser.functions.getDepartmentIdFromUrl() && SessionUser.canAccessDepartment(SessionUser.functions.getDepartmentIdFromUrl())" :error="$t('admin.errors.select_department')">
|
||||
<template #default>
|
||||
<div data-testid="department-pos-drafts-page">
|
||||
<DepartmentDashboardHero />
|
||||
<div v-if="isResolvingDraftCustomer" class="notification is-light is-size-7">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
<OrdersPagination
|
||||
v-else-if="draftTransactionCustomerNumber !== null"
|
||||
:key="paginationKey"
|
||||
:setCustomerFilter="draftTransactionCustomerNumber"
|
||||
:show-draft-assignment-actions="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</NotFoundFallBackPageWrapper>
|
||||
</DepartmentDashboardPageWrapper>
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -36,8 +36,10 @@ import MyOrder from "@/views/dashboards/userDashboard/orders/MyOrder.vue";
|
||||
import { getVisibility, setVisibility } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue"
|
||||
import { defineProps } from "vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useDraftTransactionCustomer } from "@/composables/useDraftTransactionCustomer.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isDraftTransactionCustomer } = useDraftTransactionCustomer();
|
||||
import PosDepartmentStepMobileAttachment
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachment.vue";
|
||||
// Props
|
||||
@@ -240,6 +242,18 @@ const isPaymentMethodStripe = () => {
|
||||
return customer_id.value === 999;
|
||||
};
|
||||
|
||||
const isDraftTransactionOrder = computed(() => {
|
||||
return isDraftTransactionCustomer(order.value?.customer_id ?? customer_id.value);
|
||||
});
|
||||
|
||||
const isDraftTransactionCollection = computed(() => {
|
||||
return isDraftTransactionCustomer(invoiceCollection.value?.customer_number ?? order.value?.customer_id);
|
||||
});
|
||||
|
||||
const isEconomicExportBlocked = computed(() => {
|
||||
return isDraftTransactionOrder.value || isDraftTransactionCollection.value;
|
||||
});
|
||||
|
||||
const invoiceCollection = ref(null);
|
||||
const getInvoiceCollection = async () => {
|
||||
if (!invoiceCollectionId.value) {
|
||||
@@ -276,6 +290,24 @@ const formatOrderMetadataValue = (value) => {
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const formatOrderCustomerFieldValue = (currentOrder) => {
|
||||
const value = formatOrderMetadataValue(currentOrder?.customer_id);
|
||||
if (!isDraftTransactionCustomer(currentOrder?.customer_id)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${value} · ${t('pos.order.badges.draft_transaction')}`;
|
||||
};
|
||||
|
||||
const formatInvoiceCollectionFieldValue = (currentOrder) => {
|
||||
const value = formatOrderMetadataValue(currentOrder?.invoice_collection_id);
|
||||
if (!isDraftTransactionCustomer(currentOrder?.customer_id)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${value} · ${t('pos.order.badges.draft_transaction')}`;
|
||||
};
|
||||
|
||||
const formatDepartmentMetadataValue = (departmentId) => {
|
||||
if (!departmentId) {
|
||||
return '-';
|
||||
@@ -587,6 +619,11 @@ const isDisplayingReceipt = () => {
|
||||
<div v-else>
|
||||
<div class="tag is-dark">{{ t('pos.order.badges.saved_for_invoicing') }} #{{ order.invoice_collection_id }}</div>
|
||||
</div>
|
||||
<div v-if="isDraftTransactionOrder">
|
||||
<div class="tag is-info" data-testid="pos-order-draft-transaction-badge">
|
||||
{{ t('pos.order.badges.draft_transaction') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #beforeItems>
|
||||
@@ -596,6 +633,12 @@ const isDisplayingReceipt = () => {
|
||||
<p>{{ $t('pos.order.draft_check_exists') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message is-warning is-hidden-touch" v-if="isEconomicExportBlocked" data-testid="pos-order-economic-export-blocked">
|
||||
<div class="message-body">
|
||||
<p>{{ $t('pos.order.badges.draft_transaction') }}</p>
|
||||
<p>{{ $t('pos.order.draft_transaction_export_blocked') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #attachments>
|
||||
@@ -628,7 +671,7 @@ const isDisplayingReceipt = () => {
|
||||
:object="order"
|
||||
:edit-function="showChangeCustomerFieldForm"
|
||||
:permission-check-function="canEditOrderMetadata"
|
||||
:parse-function="(currentOrder) => formatOrderMetadataValue(currentOrder.customer_id)"
|
||||
:parse-function="formatOrderCustomerFieldValue"
|
||||
virtual-column
|
||||
component-wrapper="td"
|
||||
theme="divided"
|
||||
@@ -642,7 +685,7 @@ const isDisplayingReceipt = () => {
|
||||
:object="order"
|
||||
:edit-function="showChangeInvoiceCollectionFieldForm"
|
||||
:permission-check-function="canEditOrderMetadata"
|
||||
:parse-function="(currentOrder) => formatOrderMetadataValue(currentOrder.invoice_collection_id)"
|
||||
:parse-function="formatInvoiceCollectionFieldValue"
|
||||
virtual-column
|
||||
component-wrapper="td"
|
||||
theme="divided"
|
||||
@@ -1021,6 +1064,31 @@ const isDisplayingReceipt = () => {
|
||||
<template #rail-actions>
|
||||
<ButtonsBox class="pos-actions pos-actions--rail">
|
||||
<GetOrderInvoicePDFButton :invoice_id="economicModule.invoice_id" class="is-fullwidth" v-if="isBookedWithEconomic()" />
|
||||
<div
|
||||
v-if="isEconomicExportBlocked"
|
||||
class="message is-warning is-light"
|
||||
data-testid="pos-order-economic-export-blocked-rail"
|
||||
>
|
||||
<div class="message-body">
|
||||
<p class="has-text-weight-semibold">{{ $t('pos.order.badges.draft_transaction') }}</p>
|
||||
<p>{{ $t('pos.order.draft_transaction_export_blocked') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<RemoveOrderDraftInvoiceButton
|
||||
:order_id="orderId"
|
||||
class="is-fullwidth"
|
||||
v-if="economicModule.invoice_draft_id && !isEconomicExportBlocked"
|
||||
/>
|
||||
<ExportOrderToDraftButton
|
||||
:order_id="orderId"
|
||||
class="is-fullwidth"
|
||||
v-if="!isInvoiced() && !invoiceUsingStripe() && !isPaymentMethodStripe() && !isEconomicExportBlocked"
|
||||
/>
|
||||
<ExportOrderToInvoiceButton
|
||||
:order_id="orderId"
|
||||
class="is-fullwidth"
|
||||
v-if="!isInvoiced() && !invoiceUsingStripe() && !isPaymentMethodStripe() && !isEconomicExportBlocked"
|
||||
/>
|
||||
<ExportOrderToInvoiceStripeButton :order_id="orderId" class="is-fullwidth" v-if="!isInvoiced() && invoiceUsingStripe() && isPaymentMethodStripe()" />
|
||||
<button class="button is-dark is-fullwidth" @click="openStripeInvoice" v-if="isInvoicedWithStripe()">
|
||||
<span class="icon"><i class="fas fa-file-invoice"></i></span>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<script setup>
|
||||
import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
|
||||
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||
<SuperUserDashboardNavigation />
|
||||
<PageTitle
|
||||
title="Edge gateways"
|
||||
subtitle="Administrer lokale Raspberry Pi-gateways til Shelly-styring på afdelingsnetværk"
|
||||
/>
|
||||
<EdgeGatewayManager />
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
|
||||
import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const selectedGatewayId = computed(() => route.params.id ?? null);
|
||||
const activeView = computed(() => {
|
||||
if (route.name === "edgegatewayinventory") return "inventory";
|
||||
if (route.name === "edgegatewayoperations") return "operations";
|
||||
if (route.name === "edgegatewaymanage") return "manage";
|
||||
return "overview";
|
||||
});
|
||||
|
||||
const canonicalPath = (gatewayId, view = "overview") =>
|
||||
gatewayId ? `/superuser/gateways/${encodeURIComponent(String(gatewayId))}/${view}` : "/superuser/gateways";
|
||||
|
||||
const handleNavigation = async ({ gatewayId, view }) => {
|
||||
const nextPath = canonicalPath(gatewayId, view || "overview");
|
||||
if (window.location.pathname === nextPath) {
|
||||
return;
|
||||
}
|
||||
await router.push(nextPath);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||
<SuperUserDashboardNavigation />
|
||||
<PageTitle
|
||||
title="Edge gateways"
|
||||
subtitle="Administrer fleet, operations, credentials og recovery for lokale gateway-installationer"
|
||||
/>
|
||||
<EdgeGatewayManager
|
||||
:selected-gateway-id="selectedGatewayId"
|
||||
:active-view="activeView"
|
||||
:route-driven="true"
|
||||
:allow-destructive="true"
|
||||
@navigate="handleNavigation"
|
||||
/>
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
+15
@@ -5,8 +5,10 @@ import Swal from "sweetalert2";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useEconomicQueueJob } from "@/composables/useEconomicQueueJob.js";
|
||||
import { buildCollectedInvoiceEconomicPayload } from "@/services/economicTransferQueue.js";
|
||||
import { useDraftTransactionCustomer } from "@/composables/useDraftTransactionCustomer.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isDraftTransactionCustomer } = useDraftTransactionCustomer();
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||
import { Colors } from "@/ThemeConfig.vue";
|
||||
@@ -21,6 +23,9 @@ const props = defineProps({
|
||||
const isCollapsed = ref(true);
|
||||
const isDraft = ref((props.collectedOrderInvoice?.economic_invoice_draft_id !== null));
|
||||
const isBooked = ref((props.collectedOrderInvoice?.economic_invoice_booked_id !== null));
|
||||
const isDraftTransactionCollection = computed(() => {
|
||||
return isDraftTransactionCustomer(props.collectedOrderInvoice?.customer_number);
|
||||
});
|
||||
|
||||
const economicTransferQueue = useEconomicQueueJob({
|
||||
enqueueEndpoint: '/collected-invoices/economic',
|
||||
@@ -370,6 +375,16 @@ const onClickResetPricesToDepartmentAndCustomerDefaults = async () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isDraftTransactionCollection"
|
||||
class="message is-warning"
|
||||
data-testid="collected-economic-draft-customer-blocked"
|
||||
>
|
||||
<div class="message-body">
|
||||
<p class="title is-5">{{ t('collected_invoice.economic.draft_customer_blocked_title') }}</p>
|
||||
<p class="subtitle is-6">{{ t('collected_invoice.economic.draft_customer_blocked_desc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- If the invoice is not a draft or booked -->
|
||||
<div v-else
|
||||
class="card"
|
||||
|
||||
@@ -80,7 +80,12 @@ const getModuleConfigValue = (variable) => {
|
||||
} else {
|
||||
console.log(`No value found for ${variable}`);
|
||||
}
|
||||
return config ? config.value : '';
|
||||
return config ? config.value : null;
|
||||
};
|
||||
|
||||
const parseNullableConfigNumber = (variable) => {
|
||||
const parsedValue = Number.parseInt(String(getModuleConfigValue(variable) ?? ''), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
@@ -169,27 +174,42 @@ load();
|
||||
icon="fas fa-cogs"
|
||||
>
|
||||
<ConfigurationInputNumber
|
||||
:label="$t('configuration.economic.admin_fee_monthly')"
|
||||
:title="$t('configuration.economic.admin_fee_monthly')"
|
||||
:description="$t('configuration.economic.admin_fee_monthly_desc')"
|
||||
:value="parseInt(getModuleConfigValue('adminFeeMonthly'))"
|
||||
:value="parseNullableConfigNumber('adminFeeMonthly')"
|
||||
:on-save="SessionUser.superUser.modules.economic.config.fees.adminFeeMonthly.set"
|
||||
:min="0"
|
||||
/>
|
||||
<ConfigurationInputNumber
|
||||
:label="$t('configuration.economic.admin_fee_order')"
|
||||
:title="$t('configuration.economic.admin_fee_order')"
|
||||
:description="$t('configuration.economic.admin_fee_order_desc')"
|
||||
:value="parseInt(getModuleConfigValue('adminFeeOrder'))"
|
||||
:value="parseNullableConfigNumber('adminFeeOrder')"
|
||||
:on-save="SessionUser.superUser.modules.economic.config.fees.adminFeeOrder.set"
|
||||
:min="0"
|
||||
/>
|
||||
<ConfigurationInputNumber
|
||||
:label="$t('configuration.economic.env_fee_product_id')"
|
||||
:title="$t('configuration.economic.env_fee_product_id')"
|
||||
:description="$t('configuration.economic.env_fee_product_id_desc')"
|
||||
:value="parseInt(getModuleConfigValue('feeProductId'))"
|
||||
:value="parseNullableConfigNumber('feeProductId')"
|
||||
:on-save="SessionUser.superUser.modules.economic.config.fees.feeProductId.set"
|
||||
:min="0"
|
||||
/>
|
||||
</ConfigurationCategory>
|
||||
<ConfigurationCategory
|
||||
class="mt-2"
|
||||
module="E-conomic"
|
||||
:title="$t('configuration.economic.transaction_draft_title')"
|
||||
:description="$t('configuration.economic.transaction_draft_desc')"
|
||||
icon="fas fa-file-pen"
|
||||
>
|
||||
<ConfigurationInputNumber
|
||||
:title="$t('configuration.economic.transaction_draft_customer_number')"
|
||||
:description="$t('configuration.economic.transaction_draft_customer_number_desc')"
|
||||
:value="parseNullableConfigNumber('transactionDraftCustomerNumber')"
|
||||
:on-save="SessionUser.superUser.modules.economic.config.transactionDraftCustomerNumber.set"
|
||||
:min="0"
|
||||
/>
|
||||
</ConfigurationCategory>
|
||||
</template>
|
||||
</ConfigurationSubPageWrapper>
|
||||
</RestrictedPageWrapper>
|
||||
@@ -197,4 +217,4 @@ load();
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+17
-4
@@ -1,15 +1,23 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
|
||||
import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
|
||||
import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const departmentId = computed(() => Number(route.params.departmentId));
|
||||
|
||||
const openGatewayPage = async (gatewayId) => {
|
||||
if (!gatewayId) {
|
||||
return;
|
||||
}
|
||||
await router.push(`/superuser/gateways/${encodeURIComponent(String(gatewayId))}/overview`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -18,10 +26,15 @@ const departmentId = computed(() => Number(route.params.departmentId));
|
||||
<template #title>
|
||||
<PageTitle
|
||||
title="Gateway for afdelingen"
|
||||
subtitle="Styr lokal Shelly-routing, onboarding og avanceret gateway-konfiguration for afdelingen"
|
||||
subtitle="Diagnostik, discovery, bindinger og operationshistorik uden destructive controls"
|
||||
/>
|
||||
</template>
|
||||
<EdgeGatewayManager :department-id="departmentId" />
|
||||
<EdgeGatewayManager
|
||||
:department-id="departmentId"
|
||||
:route-driven="false"
|
||||
:allow-destructive="false"
|
||||
@open-gateway-page="openGatewayPage"
|
||||
/>
|
||||
</DepartmentSubPageWrapper>
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
@@ -12,6 +12,7 @@ const isInvoiceExportMounted = ref(true);
|
||||
|
||||
const harnessCollectedOrderInvoice = ref({
|
||||
id: 6001,
|
||||
customer_number: 6001,
|
||||
processor: 1,
|
||||
stripe: null,
|
||||
economic_invoice_draft_id: null,
|
||||
|
||||
@@ -11,6 +11,13 @@ const adminPermissions = [
|
||||
"department_access_4",
|
||||
];
|
||||
|
||||
const defaultDepartments = [
|
||||
{ id: 1, name: "Visible North", visible: true },
|
||||
{ id: 2, name: "Hidden South", visible: false },
|
||||
{ id: 3, name: "Legacy East" },
|
||||
{ id: 4, name: "Numeric Hidden", visible: 0 },
|
||||
];
|
||||
|
||||
const json = (body: unknown, status = 200) => ({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
@@ -28,7 +35,7 @@ const buildOverviewPayload = () => ({
|
||||
state: "unavailable",
|
||||
value: null,
|
||||
out_of: null,
|
||||
message: "Kundeklager er ikke tilgængelig endnu.",
|
||||
message: "Complaints data is not available yet.",
|
||||
},
|
||||
night_washes: { state: "ready", value: 1, out_of: null, message: null },
|
||||
revenue: { state: "ready", value: 2400, out_of: null, message: null },
|
||||
@@ -42,11 +49,17 @@ const buildOverviewPayload = () => ({
|
||||
},
|
||||
});
|
||||
|
||||
async function mockAdminDepartmentDependencies(page, options: { departmentDelayMs?: number } = {}) {
|
||||
async function mockAdminDepartmentDependencies(page, options: {
|
||||
departmentDelayMs?: number;
|
||||
departments?: Array<Record<string, unknown>>;
|
||||
permissions?: string[];
|
||||
sessionData?: Record<string, unknown>;
|
||||
} = {}) {
|
||||
await seedAuthenticatedState(page);
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: adminPermissions,
|
||||
permissions: options.permissions || adminPermissions,
|
||||
sessionData: options.sessionData,
|
||||
});
|
||||
|
||||
await page.route(/\/departments(\?.*)?$/i, async (route) => {
|
||||
@@ -56,12 +69,7 @@ async function mockAdminDepartmentDependencies(page, options: { departmentDelayM
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: [
|
||||
{ id: 1, name: "Visible North", visible: true },
|
||||
{ id: 2, name: "Hidden South", visible: false },
|
||||
{ id: 3, name: "Legacy East" },
|
||||
{ id: 4, name: "Numeric Hidden", visible: 0 },
|
||||
],
|
||||
data: options.departments || defaultDepartments,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -110,6 +118,45 @@ async function expandDepartmentFiltersOnMobile(page, testInfo) {
|
||||
}
|
||||
|
||||
test.describe("Admin department visibility", () => {
|
||||
test("hides placeholder departments from desktop admin navigation pickers", async ({ page }, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
await mockAdminDepartmentDependencies(page, {
|
||||
permissions: [
|
||||
...adminPermissions,
|
||||
"department_access_5",
|
||||
],
|
||||
departments: [
|
||||
{ id: 1, name: "Visible North", visible: true, priority_order: 20 },
|
||||
{ id: 2, name: "Ingen data", visible: true, priority_order: 1 },
|
||||
{ id: 3, name: " ", visible: true, priority_order: 2 },
|
||||
{ id: 4, name: "Visible East", visible: true, priority_order: 10 },
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto("/admin");
|
||||
|
||||
await expect(page.getByTestId("daily-report-page")).toBeVisible();
|
||||
|
||||
const desktopNavigation = page.getByTestId("desktop-buefy-navigation");
|
||||
const desktopDepartmentSelect = page.getByTestId("desktop-header-department-select");
|
||||
|
||||
await expect(desktopNavigation).toContainText("Visible North");
|
||||
await expect(desktopNavigation).toContainText("Visible East");
|
||||
await expect(desktopNavigation).not.toContainText(/Ingen data/i);
|
||||
const desktopNavigationText = await desktopNavigation.innerText();
|
||||
expect(desktopNavigationText.indexOf("Visible East")).toBeLessThan(desktopNavigationText.indexOf("Visible North"));
|
||||
|
||||
await expect(desktopDepartmentSelect.locator("option")).toHaveCount(3);
|
||||
await expect(desktopDepartmentSelect.locator("option", { hasText: "Visible North" })).toHaveCount(1);
|
||||
await expect(desktopDepartmentSelect.locator("option", { hasText: "Visible East" })).toHaveCount(1);
|
||||
await expect(desktopDepartmentSelect.locator("option", { hasText: "Ingen data" })).toHaveCount(0);
|
||||
const desktopDepartmentOptionTexts = await desktopDepartmentSelect
|
||||
.locator("option")
|
||||
.evaluateAll((options) => options.map((option) => option.textContent?.trim() || ""));
|
||||
expect(desktopDepartmentOptionTexts.slice(1)).toEqual(["Visible East", "Visible North"]);
|
||||
});
|
||||
|
||||
test("hides invisible departments from both the header selector and the daily report department controls", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
@@ -127,10 +174,11 @@ test.describe("Admin department visibility", () => {
|
||||
await expect(departmentControls.getByRole("button", { name: "Numeric Hidden" })).toHaveCount(0);
|
||||
|
||||
if (isDesktopProject(test.info())) {
|
||||
await expect(page.locator("option", { hasText: "Visible North" })).toHaveCount(1);
|
||||
await expect(page.locator("option", { hasText: "Legacy East" })).toHaveCount(1);
|
||||
await expect(page.locator("option", { hasText: "Hidden South" })).toHaveCount(0);
|
||||
await expect(page.locator("option", { hasText: "Numeric Hidden" })).toHaveCount(0);
|
||||
const desktopDepartmentSelect = page.getByTestId("desktop-header-department-select");
|
||||
await expect(desktopDepartmentSelect.locator("option", { hasText: "Visible North" })).toHaveCount(1);
|
||||
await expect(desktopDepartmentSelect.locator("option", { hasText: "Legacy East" })).toHaveCount(1);
|
||||
await expect(desktopDepartmentSelect.locator("option", { hasText: "Hidden South" })).toHaveCount(0);
|
||||
await expect(desktopDepartmentSelect.locator("option", { hasText: "Numeric Hidden" })).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -145,4 +193,34 @@ test.describe("Admin department visibility", () => {
|
||||
await expect(departmentControls.getByRole("button", { name: "Visible North" })).toBeVisible();
|
||||
await expect(departmentControls.getByRole("button", { name: "Legacy East" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows the draft transactions navigation item in the desktop buefy menu when a draft customer is configured", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
await mockAdminDepartmentDependencies(page, {
|
||||
permissions: [
|
||||
...adminPermissions,
|
||||
"add_order",
|
||||
"list_orders",
|
||||
],
|
||||
sessionData: {
|
||||
runtime_config: {
|
||||
economic: {
|
||||
transaction_draft_customer_number: 6001,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto("/admin/1/modules/daily-report");
|
||||
|
||||
const desktopNavigation = page.getByTestId("desktop-buefy-navigation");
|
||||
await expect(desktopNavigation).toContainText("Kladder");
|
||||
|
||||
await desktopNavigation.getByText("Kladder", { exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/1\/modules\/pos\/drafts$/);
|
||||
await expect(page.getByTestId("department-pos-drafts-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
import { isDesktopProject } from "./support/projects";
|
||||
|
||||
const json = (body: unknown, status = 200) => ({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
test.describe("Admin POS drafts", () => {
|
||||
test("assigns a draft order to a customer, invoice collection, and recalculated pricing", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
await seedAuthenticatedState(page);
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: [
|
||||
"admin",
|
||||
"department_access_1",
|
||||
"add_order",
|
||||
"edit_order",
|
||||
"list_orders",
|
||||
"list_order_items",
|
||||
"edit_order_items",
|
||||
"list_products",
|
||||
],
|
||||
sessionData: {
|
||||
runtime_config: {
|
||||
economic: {
|
||||
transaction_draft_customer_number: 6001,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const currentOrder = {
|
||||
id: 56625,
|
||||
customer_id: 6001,
|
||||
customer_name: "Uspecificeret kunde",
|
||||
user_id: 77,
|
||||
cashier_id: 5,
|
||||
cashier_name: "Jeppe",
|
||||
department_id: 1,
|
||||
reg_1: "EC21233",
|
||||
reg_2: null,
|
||||
reg_3: null,
|
||||
reference: "reff",
|
||||
notes: "",
|
||||
po: "po00",
|
||||
total_net_amount: 111700,
|
||||
created_at: "2026-04-20 13:20:57",
|
||||
invoice_collection_id: 7001,
|
||||
invoice_collection: {
|
||||
id: 7001,
|
||||
customer_number: 6001,
|
||||
closed_at: null,
|
||||
booked_invoice_id: null,
|
||||
},
|
||||
economic_invoice_module: null,
|
||||
stripe_invoice_module: null,
|
||||
error_message: null,
|
||||
pending_handheld: false,
|
||||
completed_at: null,
|
||||
booking_id: null,
|
||||
wash_id: null,
|
||||
lane: null,
|
||||
safety_seal: null,
|
||||
};
|
||||
|
||||
const orderPutPayloads: Array<Record<string, unknown>> = [];
|
||||
const orderItemsPutPayloads: Array<Record<string, unknown>> = [];
|
||||
const productPriceRequests: Array<Record<string, string>> = [];
|
||||
|
||||
await page.route("https://api.truckwash.io:4433/departments**", async (route) => {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: [{ id: 1, name: "Demo", visible: true }],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("https://api.truckwash.io:4433/orders**", async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
if (request.method() === "PUT") {
|
||||
const payload = request.postDataJSON();
|
||||
orderPutPayloads.push(payload);
|
||||
|
||||
if (payload.customer_id) {
|
||||
currentOrder.customer_id = Number(payload.customer_id);
|
||||
currentOrder.customer_name = "Acme Logistics";
|
||||
}
|
||||
|
||||
if (payload.invoice_collection_id) {
|
||||
currentOrder.invoice_collection_id = Number(payload.invoice_collection_id);
|
||||
currentOrder.invoice_collection = {
|
||||
id: Number(payload.invoice_collection_id),
|
||||
customer_number: currentOrder.customer_id,
|
||||
closed_at: null,
|
||||
booked_invoice_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
await route.fulfill(json({ success: true, data: { message: "Order updated successfully" } }));
|
||||
return;
|
||||
}
|
||||
|
||||
const includesDraftOrder = currentOrder.customer_id === 6001;
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: includesDraftOrder ? [currentOrder] : [],
|
||||
meta: {
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
total: includesDraftOrder ? 1 : 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("https://api.truckwash.io:4433/customers?search=**", async (route) => {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
customerNumber: 424242,
|
||||
name: "Acme Logistics",
|
||||
city: "Roskilde",
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
total: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("https://api.truckwash.io:4433/collected-invoices**", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() !== "GET") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(request.url());
|
||||
const filters = url.searchParams.get("filters") || "";
|
||||
if (!filters.includes("customer_number:424242")) {
|
||||
await route.fulfill(json({ data: [], meta: { pagination: { page: 1, per_page: 100, total: 0 } } }));
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: 9001,
|
||||
customer_number: 424242,
|
||||
customer_name: "Acme Logistics",
|
||||
created_at: "2026-04-18 09:00:00",
|
||||
closed_at: null,
|
||||
booked_invoice_id: null,
|
||||
total_net_amount: 100000,
|
||||
},
|
||||
{
|
||||
id: 9002,
|
||||
customer_number: 424242,
|
||||
customer_name: "Acme Logistics",
|
||||
created_at: "2026-04-16 09:00:00",
|
||||
closed_at: "2026-04-17 00:00:00",
|
||||
booked_invoice_id: null,
|
||||
total_net_amount: 250000,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
total: 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("https://api.truckwash.io:4433/order/items**", async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
if (request.method() === "PUT") {
|
||||
orderItemsPutPayloads.push(request.postDataJSON());
|
||||
await route.fulfill(json({ success: true, data: { message: "Order item updated" } }));
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: 8001,
|
||||
order_id: currentOrder.id,
|
||||
product_id: 101,
|
||||
reference: "",
|
||||
notes: "",
|
||||
cashier_id: currentOrder.cashier_id,
|
||||
price: 111700,
|
||||
quantity: 1,
|
||||
related_item_id: 0,
|
||||
include_in_invoice: true,
|
||||
product: {
|
||||
id: 101,
|
||||
name: "Truck wash",
|
||||
price: 111700,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("https://api.truckwash.io:4433/products**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
productPriceRequests.push(Object.fromEntries(url.searchParams.entries()));
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
id: 101,
|
||||
name: "Truck wash",
|
||||
price: 99900,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.goto("/admin/1/modules/pos/drafts");
|
||||
await expect(page).toHaveURL(/\/admin\/1\/modules\/pos\/drafts$/);
|
||||
|
||||
await expect(page.getByTestId("draft-order-assign-customer-button-56625")).toBeVisible();
|
||||
|
||||
await page.getByTestId("draft-order-assign-customer-button-56625").click();
|
||||
|
||||
const modal = page.getByTestId("draft-order-assign-customer-modal");
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(page.getByTestId("draft-order-recalculate-prices")).toBeChecked();
|
||||
|
||||
await page.getByTestId("draft-order-assign-customer-search").fill("Acme");
|
||||
await page.getByTestId("draft-order-customer-option-424242").click();
|
||||
await expect(page.getByTestId("draft-order-invoice-collection-option-9001")).toBeVisible();
|
||||
await page.getByTestId("draft-order-invoice-collection-option-9002").click();
|
||||
|
||||
await page.getByTestId("draft-order-assign-submit").click();
|
||||
|
||||
await expect(modal).toHaveCount(0);
|
||||
await expect(page.getByTestId("draft-order-assign-customer-button-56625")).toHaveCount(0);
|
||||
|
||||
expect(orderPutPayloads).toEqual([
|
||||
{ id: 56625, customer_id: 424242 },
|
||||
{ id: 56625, invoice_collection_id: 9002 },
|
||||
]);
|
||||
|
||||
expect(orderItemsPutPayloads).toHaveLength(1);
|
||||
expect(orderItemsPutPayloads[0]).toMatchObject({
|
||||
id: 8001,
|
||||
price: 99900,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
expect(productPriceRequests).toHaveLength(1);
|
||||
expect(productPriceRequests[0]).toMatchObject({
|
||||
id: "101",
|
||||
customer_id: "424242",
|
||||
department_id: "1",
|
||||
final_price: "true",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,8 @@ const POS_PERMISSIONS = [
|
||||
];
|
||||
|
||||
const POS_BOOT_URL = "/admin/12/modules/pos?step=1";
|
||||
const DRAFT_TRANSACTION_CUSTOMER_ID = 44556677;
|
||||
const DRAFT_TRANSACTION_CUSTOMER_NAME = "(TEST) Draft Transaction Customer";
|
||||
|
||||
function getPosBootStep(page: Page) {
|
||||
return page.locator('[data-testid="pos-step-1"]:visible, [data-testid="pos-mobile-step-1"]:visible').first();
|
||||
@@ -393,6 +395,43 @@ function createCustomerConflictPosFixture() {
|
||||
});
|
||||
}
|
||||
|
||||
function createDraftTransactionPosFixture() {
|
||||
const baseFixture = createPosFixture();
|
||||
const defaultCustomer = baseFixture.customersByNumber[12345679];
|
||||
|
||||
return createPosFixture({
|
||||
customersByNumber: {
|
||||
[DRAFT_TRANSACTION_CUSTOMER_ID]: {
|
||||
...defaultCustomer,
|
||||
id: 77,
|
||||
customerNumber: DRAFT_TRANSACTION_CUSTOMER_ID,
|
||||
economic_customer: DRAFT_TRANSACTION_CUSTOMER_ID,
|
||||
name: DRAFT_TRANSACTION_CUSTOMER_NAME,
|
||||
email: "draft-transaction@example.com",
|
||||
mobilePhone: "44556677",
|
||||
},
|
||||
},
|
||||
collectedInvoices: [
|
||||
...baseFixture.collectedInvoices,
|
||||
{
|
||||
id: 400,
|
||||
customer_number: DRAFT_TRANSACTION_CUSTOMER_ID,
|
||||
customer_name: DRAFT_TRANSACTION_CUSTOMER_NAME,
|
||||
total_net_amount: 0,
|
||||
created_at: "2026-05-01",
|
||||
closed_at: null,
|
||||
},
|
||||
],
|
||||
ordersById: {
|
||||
54518: {
|
||||
...baseFixture.ordersById[54518],
|
||||
customer_id: DRAFT_TRANSACTION_CUSTOMER_ID,
|
||||
invoice_collection_id: 400,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Admin POS Orders - desktop settings", () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only order settings coverage");
|
||||
@@ -1305,6 +1344,66 @@ test.describe("Admin POS Orders - desktop settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Admin POS Orders - draft transaction customer", () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only draft customer coverage");
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: POS_PERMISSIONS,
|
||||
edgeGateways: false,
|
||||
pos: createDraftTransactionPosFixture(),
|
||||
sessionData: {
|
||||
runtime_config: {
|
||||
economic: {
|
||||
transaction_draft_customer_number: DRAFT_TRANSACTION_CUSTOMER_ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await primeOperatorSession(page, "pos-orders-draft-customer-token");
|
||||
});
|
||||
|
||||
test("shows the desktop quick action for the configured draft customer and allows switching back to a regular customer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(POS_BOOT_URL);
|
||||
await expect(page.getByTestId("pos-draft-customer-quick-action")).toBeVisible();
|
||||
|
||||
await page.getByTestId("pos-draft-customer-quick-action").click();
|
||||
await expect(page.locator(".field.has-addons input[disabled]").last()).toHaveValue(DRAFT_TRANSACTION_CUSTOMER_NAME);
|
||||
|
||||
await page.getByRole("button", { name: "Clear" }).click();
|
||||
await selectStepOneCustomer(page, 12345679);
|
||||
await expect(page.locator(".field.has-addons input[disabled]").last()).toHaveValue(
|
||||
/\(TEST\) Pleno Vognmandsforretning/
|
||||
);
|
||||
});
|
||||
|
||||
test("blocks economic export for draft-backed orders and restores export actions after selecting a real customer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openOrderDetail(page);
|
||||
await expect(page.getByTestId("pos-order-draft-transaction-badge")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-order-economic-export-blocked")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-order-economic-export-blocked-rail")).toBeVisible();
|
||||
await expect(page.getByTestId("economic-draft-export-flow")).toHaveCount(0);
|
||||
await expect(page.getByTestId("economic-invoice-export-flow")).toHaveCount(0);
|
||||
|
||||
await clickVisibleTestId(page, "pos-order-tab-settings");
|
||||
await expect(page.getByTestId("pos-order-panel-settings")).toBeVisible();
|
||||
|
||||
await changeOrderCustomer(page, "12345679", "2026-05-02");
|
||||
await openOrderDetailsTab(page);
|
||||
|
||||
await expect(page.getByTestId("pos-order-draft-transaction-badge")).toHaveCount(0);
|
||||
await expect(page.getByTestId("pos-order-economic-export-blocked")).toHaveCount(0);
|
||||
await expect(page.getByTestId("pos-order-economic-export-blocked-rail")).toHaveCount(0);
|
||||
await expect(page.getByTestId("economic-draft-export-flow")).toBeVisible();
|
||||
await expect(page.getByTestId("economic-invoice-export-flow")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Admin POS wash certificate completion", () => {
|
||||
test("shows and autosaves the safety seal field when the order contains a wash certificate item", async ({
|
||||
page,
|
||||
|
||||
@@ -452,6 +452,23 @@ test.describe("Economic queue async export workflow", () => {
|
||||
expect(enqueueCalls).toBe(0);
|
||||
});
|
||||
|
||||
test("collected invoice management shows a blocked state for the configured draft customer", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
sessionData: {
|
||||
runtime_config: {
|
||||
economic: {
|
||||
transaction_draft_customer_number: 6001,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await openHarness(page);
|
||||
|
||||
await expect(page.getByTestId("collected-economic-draft-customer-blocked")).toBeVisible();
|
||||
await expect(page.getByTestId("collected-economic-flow")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("invoice export polling is cancelled when component unmounts", async ({ page }) => {
|
||||
let statusCalls = 0;
|
||||
|
||||
|
||||
@@ -14,32 +14,29 @@ test.describe("Edge gateway routing and fleet navigation", () => {
|
||||
await primeSuperuserSession(page);
|
||||
});
|
||||
|
||||
test("keeps the canonical detail route on the selected gateway workspace", async ({ page }) => {
|
||||
test("redirects canonical detail routes onto overview", async ({ page }) => {
|
||||
await page.goto("/superuser/gateways/701");
|
||||
|
||||
await expect(page).toHaveURL(/\/superuser\/gateways\/701$/);
|
||||
await expect(page).toHaveURL(/\/superuser\/gateways\/701\/overview$/);
|
||||
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
|
||||
|
||||
await page.getByTestId("gateway-step-configure").click();
|
||||
await expect(page.getByTestId("gateway-step-configure-panel")).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/superuser\/gateways\/701$/);
|
||||
await expect(page.getByTestId("gateway-tab-nav")).toBeVisible();
|
||||
});
|
||||
|
||||
test("redirects legacy sub-page URLs onto the canonical detail route", async ({ page }) => {
|
||||
await page.goto("/superuser/gateways/701/discovery");
|
||||
test("redirects legacy sub-page URLs onto the mapped v2 route", async ({ page }) => {
|
||||
await page.goto("/superuser/gateways/701/configure");
|
||||
|
||||
await expect(page).toHaveURL(/\/superuser\/gateways\/701$/);
|
||||
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
|
||||
await expect(page).toHaveURL(/\/superuser\/gateways\/701\/inventory$/);
|
||||
await expect(page.getByTestId("gateway-inventory-page")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows the unavailable state for an unknown gateway id", async ({ page }) => {
|
||||
await page.goto("/superuser/gateways/999999");
|
||||
test("shows the unavailable state for unknown detail routes", async ({ page }) => {
|
||||
await page.goto("/superuser/gateways/999999/overview");
|
||||
|
||||
await expect(page).toHaveURL(/\/superuser\/gateways\/999999$/);
|
||||
await expect(page.getByTestId("gateway-unavailable-state")).toContainText("Gateway 999999");
|
||||
await expect(page).toHaveURL(/\/superuser\/gateways\/999999\/overview$/);
|
||||
await expect(page.getByTestId("gateway-unavailable-state")).toContainText("999999");
|
||||
});
|
||||
|
||||
test("filters the fleet rail with search and summary chips", async ({ page }) => {
|
||||
test("filters the fleet roster with search and summary chips", async ({ page }) => {
|
||||
await page.goto("/superuser/gateways");
|
||||
|
||||
await page.getByTestId("gateway-fleet-search").fill("ode");
|
||||
@@ -50,30 +47,19 @@ test.describe("Edge gateway routing and fleet navigation", () => {
|
||||
await page.getByTestId("gateway-summary-offline").click();
|
||||
await expect(page.getByTestId("gateway-fleet-item-702")).toBeVisible();
|
||||
await expect(page.getByTestId("gateway-fleet-item-701")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("gateway-summary-all").click();
|
||||
await expect(page.getByTestId("gateway-fleet-item-701")).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the department-scoped page wired to the same workspace and opens the canonical detail route", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("keeps the department workspace on the safe subset and links into the full page", async ({ page }) => {
|
||||
await page.goto("/superuser/departments/1/gateways");
|
||||
|
||||
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
|
||||
await expect(page.getByTestId("edge-gateway-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("gateway-fleet-item-701")).toBeVisible();
|
||||
await expect(page.getByTestId("gateway-tab-manage")).toHaveCount(0);
|
||||
await expect(page.getByTestId("gateway-operation-uninstall")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("gateway-open-full-page").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/superuser\/gateways\/701$/);
|
||||
});
|
||||
|
||||
test("moves the route to the fallback gateway after deleting the current detail selection", async ({ page }) => {
|
||||
await page.goto("/superuser/gateways/701");
|
||||
|
||||
await page.getByTestId("gateway-step-manage").click();
|
||||
await page.getByTestId("gateway-delete").click();
|
||||
await page.getByTestId("gateway-delete-confirm").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/superuser\/gateways\/702$/);
|
||||
await expect(page.getByTestId("gateway-detail-header")).toContainText("ODE Edge 01");
|
||||
await expect(page).toHaveURL(/\/superuser\/gateways\/701\/overview$/);
|
||||
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,47 @@ async function primeSuperuserSession(page) {
|
||||
}
|
||||
|
||||
test.describe("Edge gateway management smoke", () => {
|
||||
test("@smoke onboards the first gateway from the empty fleet page", async ({ page }) => {
|
||||
test("@smoke exposes a copy action for generated installer commands", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: async (text) => {
|
||||
window.__copiedInstallerCommand = text;
|
||||
},
|
||||
},
|
||||
});
|
||||
window.__copiedInstallerCommand = "";
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["superuser", "user"],
|
||||
edgeGateways: {
|
||||
empty: true,
|
||||
claimPollsRemaining: 99,
|
||||
},
|
||||
});
|
||||
await primeSuperuserSession(page);
|
||||
|
||||
await page.goto("/superuser/gateways");
|
||||
|
||||
await page.getByTestId("gateway-installer-department").selectOption("1");
|
||||
await page.getByTestId("gateway-installer-label").fill("Copy Test Pi");
|
||||
await page.getByTestId("gateway-installer-generate").click();
|
||||
|
||||
await expect(page.getByTestId("gateway-install-command")).toHaveValue(/install\.sh\?token=edge-install-token/);
|
||||
await page.waitForSelector('[data-testid="gateway-installer-copy"]');
|
||||
await page.getByTestId("gateway-installer-copy").click();
|
||||
|
||||
await expect(page.locator("body")).toContainText("Installerkommando kopieret.");
|
||||
await expect(page.getByTestId("gateway-installer-copy")).toContainText("Kopieret");
|
||||
await expect.poll(() => page.evaluate(() => window.__copiedInstallerCommand)).toContain(
|
||||
"install.sh?token=edge-install-token"
|
||||
);
|
||||
});
|
||||
|
||||
test("@smoke onboards the first gateway from the fleet landing page", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["superuser", "user"],
|
||||
@@ -19,63 +59,73 @@ test.describe("Edge gateway management smoke", () => {
|
||||
|
||||
await page.goto("/superuser/gateways");
|
||||
|
||||
await expect(page.getByTestId("gateway-empty-selection")).toContainText("Start med onboarding");
|
||||
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
|
||||
await expect(page.getByTestId("gateway-empty-state")).toContainText("Ingen gateways");
|
||||
|
||||
await page.getByTestId("gateway-step-primary-manage").click();
|
||||
await page.getByTestId("gateway-installer-department").selectOption("1");
|
||||
await page.getByTestId("gateway-installer-label").fill("Canary Pi");
|
||||
await page.getByTestId("gateway-installer-generate").click();
|
||||
|
||||
await expect(page.getByTestId("gateway-install-command")).toContainText("edge-agent/install.sh");
|
||||
await expect(page).toHaveURL(/\/superuser\/gateways\/703$/, { timeout: 8_000 });
|
||||
await expect(page).toHaveURL(/\/superuser\/gateways\/703\/overview$/, { timeout: 8_000 });
|
||||
await expect(page.getByTestId("gateway-detail-header")).toContainText("Canary Pi");
|
||||
});
|
||||
|
||||
test("@smoke manages a claimed gateway through metadata, discovery, bindings, and cutover without remote management", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("@smoke updates metadata and rotates credentials from the manage view", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["superuser", "user"],
|
||||
});
|
||||
await primeSuperuserSession(page);
|
||||
|
||||
await page.goto("/superuser/gateways/701");
|
||||
await page.goto("/superuser/gateways/701/overview");
|
||||
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
|
||||
|
||||
await expect(page.locator('[data-testid="gateway-shell-open"]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid="gateway-rotate-credentials"]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid="gateway-uninstall"]')).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("gateway-step-manage").click();
|
||||
await page.getByTestId("gateway-metadata-label").fill("CPH Relay Gateway");
|
||||
await page.getByTestId("gateway-tab-manage").click();
|
||||
await page.getByTestId("gateway-metadata-label").fill("CPH Edge Prime");
|
||||
await page.getByTestId("gateway-metadata-save").click();
|
||||
|
||||
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Relay Gateway");
|
||||
await expect(page.locator("body")).toContainText("Gateway-metadata opdateret.");
|
||||
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge Prime");
|
||||
|
||||
await page.getByTestId("gateway-cutover-mode").selectOption("cloud");
|
||||
await page.getByTestId("gateway-cutover-apply").click();
|
||||
await expect(page.locator("body")).toContainText("Afdelingens cutover er opdateret.");
|
||||
await page.getByTestId("gateway-rotate-credentials").click();
|
||||
await expect(page.getByTestId("gateway-credential-bundle")).toContainText("rotated-edge-agent-token");
|
||||
await expect(page.locator("body")).toContainText("Gateway-credentials er roteret.");
|
||||
});
|
||||
|
||||
await page.getByTestId("gateway-step-assess").click();
|
||||
await expect(page.getByTestId("gateway-step-assess-panel")).toContainText("Cloud fallback");
|
||||
|
||||
await page.getByTestId("gateway-step-configure").click();
|
||||
await page.getByTestId("gateway-discovery-action").click();
|
||||
|
||||
await expect(page.getByTestId("gateway-step-configure-panel")).toContainText("shelly-plus-new", {
|
||||
timeout: 8_000,
|
||||
test("@smoke manages discovery, bindings, operations, uninstall, and delete", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["superuser", "user"],
|
||||
});
|
||||
await primeSuperuserSession(page);
|
||||
|
||||
await page.goto("/superuser/gateways/701/inventory");
|
||||
|
||||
await page.getByTestId("gateway-discovery-trigger").click();
|
||||
await expect(page.locator("body")).toContainText("Gatewayoperation DISCOVERY er køsat.");
|
||||
|
||||
await page.getByTestId("gateway-binding-add").click();
|
||||
await page.getByTestId("gateway-binding-relay-2").fill("M-8");
|
||||
await page.getByTestId("gateway-binding-device-2").selectOption("shelly-plus-new");
|
||||
await page.getByTestId("gateway-binding-fallback-2").selectOption("LOCAL_ONLY");
|
||||
await page.locator('[data-testid^="gateway-binding-relay-"]').last().fill("M-8");
|
||||
await page.locator('[data-testid^="gateway-binding-device-"]').last().selectOption("shelly-plus-01");
|
||||
await page.locator('[data-testid^="gateway-binding-channel-"]').last().selectOption("0");
|
||||
await page.locator('[data-testid^="gateway-binding-fallback-"]').last().selectOption("CLOUD_ONLY");
|
||||
await page.getByTestId("gateway-bindings-save").click();
|
||||
|
||||
await expect(page.locator("body")).toContainText("Relay-bindinger gemt.");
|
||||
|
||||
await page.getByTestId("gateway-step-assess").click();
|
||||
await expect(page.getByTestId("gateway-relay-health-table")).toContainText("M-8");
|
||||
await page.getByTestId("gateway-tab-operations").click();
|
||||
await page.getByTestId("gateway-update-target-version").fill("php-agent-v2.0");
|
||||
await page.getByTestId("gateway-operation-update").click();
|
||||
await expect(page.getByTestId("gateway-operations-list")).toContainText("UPDATE");
|
||||
await expect(page.getByTestId("gateway-operation-events")).toContainText("Operation completed successfully");
|
||||
|
||||
await page.getByTestId("gateway-operation-uninstall").click();
|
||||
await expect(page.getByTestId("gateway-operations-list")).toContainText("UNINSTALL");
|
||||
|
||||
await page.getByTestId("gateway-tab-manage").click();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByTestId("gateway-delete").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/superuser\/gateways$/, { timeout: 8_000 });
|
||||
await expect(page.locator("body")).toContainText("Gateway slettet.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,30 +14,26 @@ test.describe("Edge gateway visuals", () => {
|
||||
await primeSuperuserSession(page);
|
||||
});
|
||||
|
||||
test("desktop fleet rail and relay-focused assess view render together", async ({ page }, testInfo) => {
|
||||
test("desktop roster and v2 overview render together", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
||||
|
||||
await page.goto("/superuser/gateways/701");
|
||||
await page.goto("/superuser/gateways/701/overview");
|
||||
|
||||
await expect(page.getByTestId("gateway-fleet-rail")).toBeVisible();
|
||||
await expect(page.getByTestId("edge-gateway-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("gateway-fleet-roster")).toBeVisible();
|
||||
await expect(page.getByTestId("gateway-summary-strip")).toBeVisible();
|
||||
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
|
||||
await expect(page.getByTestId("gateway-step-nav")).toContainText("Driftsstatus");
|
||||
|
||||
await page.getByTestId("gateway-step-assess").click();
|
||||
await expect(page.getByTestId("gateway-step-assess-panel")).toContainText("Driftsstatus og næste handling");
|
||||
await expect(page.getByTestId("gateway-relay-health-table")).toBeVisible();
|
||||
await expect(page.getByTestId("gateway-diagnostics-list")).toBeVisible();
|
||||
});
|
||||
|
||||
test("mobile workspace exposes onboarding and fleet selection controls", async ({ page }, testInfo) => {
|
||||
test("mobile landing exposes onboarding and fleet controls", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("mobile"), "Mobile only");
|
||||
|
||||
await page.goto("/superuser/gateways");
|
||||
|
||||
await expect(page.getByTestId("gateway-mobile-toggle")).toBeVisible();
|
||||
await page.getByTestId("gateway-mobile-open-rail").click();
|
||||
await expect(page.getByTestId("gateway-fleet-rail")).toBeVisible();
|
||||
|
||||
await page.getByTestId("gateway-step-manage").click();
|
||||
await expect(page.getByTestId("gateway-step-manage-panel")).toContainText("Raspberry Pi");
|
||||
await expect(page.getByTestId("edge-gateway-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("gateway-fleet-roster")).toBeVisible();
|
||||
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
|
||||
await expect(page.getByTestId("gateway-onboarding")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
+175
-9
@@ -49,7 +49,7 @@ async function expectDesktopOrderItemsTable(scope) {
|
||||
await expect(scope.locator('[data-auto-excel-export-button="1"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
function createPosFixture() {
|
||||
function createPosFixture(overrides = {}) {
|
||||
const customer = {
|
||||
id: 1,
|
||||
customerNumber: 12345,
|
||||
@@ -118,7 +118,7 @@ function createPosFixture() {
|
||||
9201: [],
|
||||
};
|
||||
|
||||
return {
|
||||
const baseFixture = {
|
||||
customer,
|
||||
products,
|
||||
departmentCategories: [
|
||||
@@ -163,9 +163,39 @@ function createPosFixture() {
|
||||
markCompletedOrderIds: [],
|
||||
completedBookingIds: [],
|
||||
bookingOrderAssignments: [],
|
||||
readers: [
|
||||
{
|
||||
id: "reader_online_1",
|
||||
label: "Mobile Reader",
|
||||
status: "online",
|
||||
action: null,
|
||||
},
|
||||
],
|
||||
readersGet: 0,
|
||||
nextOrderId: 9300,
|
||||
nextOrderItemId: 9800,
|
||||
};
|
||||
|
||||
return {
|
||||
...baseFixture,
|
||||
...overrides,
|
||||
customer: overrides.customer || baseFixture.customer,
|
||||
products: overrides.products || baseFixture.products,
|
||||
departmentCategories: overrides.departmentCategories || baseFixture.departmentCategories,
|
||||
vehicles: overrides.vehicles || baseFixture.vehicles,
|
||||
customerSuggestionsByReg: {
|
||||
...baseFixture.customerSuggestionsByReg,
|
||||
...(overrides.customerSuggestionsByReg || {}),
|
||||
},
|
||||
ordersById: {
|
||||
...baseFixture.ordersById,
|
||||
...(overrides.ordersById || {}),
|
||||
},
|
||||
orderItemsByOrderId: {
|
||||
...baseFixture.orderItemsByOrderId,
|
||||
...(overrides.orderItemsByOrderId || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildOrderItem(product, body, id) {
|
||||
@@ -772,6 +802,19 @@ async function mockPosApi(page, fixture) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/modules/stripe/department/terminal/readers") && method === "GET") {
|
||||
fixture.readersGet = Number(fixture.readersGet || 0) + 1;
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data: {
|
||||
data: fixture.readers || [],
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fallback();
|
||||
});
|
||||
}
|
||||
@@ -844,7 +887,7 @@ function seedMobilePosState(page) {
|
||||
}, state);
|
||||
}
|
||||
|
||||
async function setupDesktopPosPage(page, fixture, { token = "pos-desktop-token" } = {}) {
|
||||
async function setupDesktopPosPage(page, fixture, { token = "pos-desktop-token", sessionData = {} } = {}) {
|
||||
const permissions = ["admin", "department_access_1"];
|
||||
|
||||
await mockApi(page, {
|
||||
@@ -852,6 +895,7 @@ async function setupDesktopPosPage(page, fixture, { token = "pos-desktop-token"
|
||||
permissions,
|
||||
sessionData: {
|
||||
display_name: "POS Desktop",
|
||||
...sessionData,
|
||||
},
|
||||
});
|
||||
await mockPosApi(page, fixture);
|
||||
@@ -993,12 +1037,8 @@ test.describe("POS flow", () => {
|
||||
|
||||
const customerSearchInputs = page.locator("#pos_select_customer_input");
|
||||
const visibleCustomerSearchInputs = page.locator("#pos_select_customer_input:visible");
|
||||
const cardPaymentButtons = page.locator("button", {
|
||||
hasText: "Vælg direkte betaling med betalingskort",
|
||||
});
|
||||
const visibleCardPaymentButtons = page.locator("button:visible", {
|
||||
hasText: "Vælg direkte betaling med betalingskort",
|
||||
});
|
||||
const cardPaymentButtons = page.locator("button.customer-quick-action--card");
|
||||
const visibleCardPaymentButtons = page.locator("button.customer-quick-action--card:visible");
|
||||
|
||||
await expect(customerSearchInputs).toHaveCount(1);
|
||||
await expect(visibleCustomerSearchInputs).toHaveCount(1);
|
||||
@@ -1013,6 +1053,132 @@ test.describe("POS flow", () => {
|
||||
await expect(visibleCardPaymentButtons).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("desktop defaults the inline customer picker to customer invoice and keeps the selected action clear", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-desktop", "Desktop POS step 1 is validated on chromium-desktop.");
|
||||
|
||||
const fixture = createPosFixture();
|
||||
fixture.vehicles = [
|
||||
{
|
||||
...fixture.vehicles[0],
|
||||
reg: "AB12345",
|
||||
customer_id: null,
|
||||
customer_name: "",
|
||||
last_order_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
await setupDesktopPosPage(page, fixture, {
|
||||
token: "pos-inline-customer-picker-actions",
|
||||
sessionData: {
|
||||
runtime_config: {
|
||||
economic: {
|
||||
transaction_draft_customer_number: 6001,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.locator("#reg_1").fill("AB12345");
|
||||
|
||||
const invoiceButton = page.getByTestId("pos-customer-invoice-inline-action");
|
||||
const draftButton = page.getByTestId("pos-draft-customer-inline-action");
|
||||
const cardButton = page.getByTestId("pos-card-payment-inline-action");
|
||||
const customerSearchInput = page.locator("#pos_select_customer_input:visible");
|
||||
const clearButton = page.getByRole("button", { name: "Ryd" });
|
||||
|
||||
await expect(invoiceButton).toBeVisible();
|
||||
await expect(draftButton).toBeVisible();
|
||||
await expect(cardButton).toBeVisible();
|
||||
await expect(invoiceButton).toHaveClass(/is-selected/);
|
||||
await expect(draftButton).not.toHaveClass(/is-selected/);
|
||||
await expect(cardButton).not.toHaveClass(/is-selected/);
|
||||
await expect(customerSearchInput).toHaveCount(1);
|
||||
|
||||
const isCustomerSearchBelowActions = await page.evaluate(() => {
|
||||
const invoiceButtonElement = document.querySelector('[data-testid="pos-customer-invoice-inline-action"]');
|
||||
const customerInputElement = document.querySelector("#pos_select_customer_input");
|
||||
|
||||
if (!invoiceButtonElement || !customerInputElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const invoiceRect = invoiceButtonElement.getBoundingClientRect();
|
||||
const customerInputRect = customerInputElement.getBoundingClientRect();
|
||||
return customerInputRect.top > invoiceRect.bottom;
|
||||
});
|
||||
|
||||
expect(isCustomerSearchBelowActions).toBe(true);
|
||||
|
||||
await cardButton.click();
|
||||
|
||||
await expect(cardButton).toHaveClass(/is-selected/);
|
||||
await expect(invoiceButton).not.toHaveClass(/is-selected/);
|
||||
await expect(customerSearchInput).toHaveCount(0);
|
||||
await expect(clearButton).toBeVisible();
|
||||
|
||||
await clearButton.click();
|
||||
|
||||
await expect(invoiceButton).toHaveClass(/is-selected/);
|
||||
await expect(cardButton).not.toHaveClass(/is-selected/);
|
||||
await expect(page.locator("#pos_select_customer_input:visible")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("desktop disables the card quick action while all stripe readers are offline and re-enables it after polling", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-desktop", "Desktop POS step 1 is validated on chromium-desktop.");
|
||||
|
||||
const fixture = createPosFixture({
|
||||
readers: [
|
||||
{
|
||||
id: "reader_offline_1",
|
||||
label: "Offline reader",
|
||||
status: "offline",
|
||||
action: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
fixture.vehicles = [
|
||||
{
|
||||
...fixture.vehicles[0],
|
||||
reg: "AB12345",
|
||||
customer_id: null,
|
||||
customer_name: "",
|
||||
last_order_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
await setupDesktopPosPage(page, fixture, {
|
||||
token: "pos-inline-card-readers-offline",
|
||||
});
|
||||
|
||||
await page.locator("#reg_1").fill("AB12345");
|
||||
|
||||
const cardButton = page.getByTestId("pos-card-payment-inline-action");
|
||||
await expect(cardButton).toBeVisible();
|
||||
await expect
|
||||
.poll(() => fixture.readersGet, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0);
|
||||
await expect(cardButton).toBeDisabled();
|
||||
|
||||
fixture.readers = [
|
||||
{
|
||||
id: "reader_online_1",
|
||||
label: "Recovered reader",
|
||||
status: "online",
|
||||
action: null,
|
||||
},
|
||||
];
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await cardButton.isEnabled();
|
||||
}, { timeout: 12_000 })
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("desktop auto-applies the only previous customer suggestion while selection source is none", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
||||
@@ -54,6 +54,63 @@ test.describe("POS mobile card payments", () => {
|
||||
expect(snapshot?.metadata?.reference).toBe("CARD-CTX-REF");
|
||||
});
|
||||
|
||||
test("card mode stays disabled when no stripe readers are assigned and re-enables after polling", async ({ page }) => {
|
||||
const fixture = createMobilePosFixture({
|
||||
readers: [],
|
||||
});
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-card-reader-polling-token",
|
||||
seedState: {
|
||||
customerId: null,
|
||||
reg: "ZZ00000",
|
||||
reference: "CARD-POLL-REF",
|
||||
includePrimaryItem: false,
|
||||
vehicleType: 53,
|
||||
},
|
||||
route: {
|
||||
step: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-next-step")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
|
||||
const cardButton = page.getByTestId("pos-mobile-direct-card-payment");
|
||||
await expect(cardButton).toBeVisible({ timeout: 10_000 });
|
||||
await expect
|
||||
.poll(() => fixture.requestCounters.readersGet, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0);
|
||||
await expect(cardButton).toBeDisabled();
|
||||
|
||||
fixture.readers = [
|
||||
{
|
||||
id: "reader_online_2",
|
||||
label: "Recovered Reader",
|
||||
status: "online",
|
||||
action: null,
|
||||
},
|
||||
];
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await cardButton.isEnabled();
|
||||
}, { timeout: 12_000 })
|
||||
.toBe(true);
|
||||
|
||||
await cardButton.click();
|
||||
await expect(page.getByTestId("pos-mobile-customer-popup")).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
return snapshot?.metadata?.customerId ?? null;
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(CARD_CUSTOMER_ID);
|
||||
});
|
||||
|
||||
test("@smoke direct card flow bridges step 1 to capture and resets to scanner", async ({ page }) => {
|
||||
const fixture = createMobilePosFixture();
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
DEFAULT_BOOKING_ID,
|
||||
DEFAULT_DEPARTMENT_ID,
|
||||
DEFAULT_LAST_ORDER_ID,
|
||||
REGULAR_CUSTOMER_ID,
|
||||
buildMobilePosState,
|
||||
createAttachmentFile,
|
||||
@@ -13,6 +14,9 @@ import {
|
||||
waitForMobileNextStepCooldown,
|
||||
} from "./support/mobilePos.js";
|
||||
|
||||
const MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID = 44556677;
|
||||
const MOBILE_DRAFT_TRANSACTION_CUSTOMER_NAME = "Draft transaction customer";
|
||||
|
||||
function buildRegularOrder(orderId, overrides = {}) {
|
||||
return {
|
||||
id: orderId,
|
||||
@@ -80,6 +84,31 @@ function buildMobileMatchedVehicle(reg, overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildFixtureOrderItem(fixture, productId, overrides = {}, id = null) {
|
||||
const product = fixture.products.find((candidate) => Number(candidate.id) === Number(productId));
|
||||
if (!product) {
|
||||
throw new Error(`Product ${productId} not found in fixture.`);
|
||||
}
|
||||
|
||||
const resolvedPrice = Number(overrides.price ?? product.price ?? 0);
|
||||
|
||||
return {
|
||||
id,
|
||||
order_id: overrides.order_id ?? DEFAULT_LAST_ORDER_ID,
|
||||
product_id: productId,
|
||||
price: resolvedPrice,
|
||||
quantity: Number(overrides.quantity ?? 1),
|
||||
related_item_id: overrides.related_item_id ?? null,
|
||||
include_in_invoice: overrides.include_in_invoice ?? true,
|
||||
notes: overrides.notes ?? "",
|
||||
product: {
|
||||
...JSON.parse(JSON.stringify(product)),
|
||||
addons: [],
|
||||
price: resolvedPrice,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTodayTimestamp(time = "08:00:00.000Z") {
|
||||
const todayIsoDate = new Date().toISOString().split("T")[0];
|
||||
return `${todayIsoDate}T${time}`;
|
||||
@@ -147,6 +176,11 @@ async function openAddCustomerPopup(page) {
|
||||
await expect(page.getByTestId("pos-mobile-add-customer-popup")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function openCustomerPopupFromStep1(page) {
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
await expect(page.getByTestId("pos-mobile-customer-popup")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function waitForStepReset(page) {
|
||||
await expect
|
||||
.poll(
|
||||
@@ -196,6 +230,25 @@ async function expectAboveFixedActions(page, target, tolerance = 8) {
|
||||
expect(targetBox.y + targetBox.height).toBeLessThanOrEqual(actionsBox.y - tolerance);
|
||||
}
|
||||
|
||||
async function expectPopupFooterAlignedToShell(page, tolerance = 8) {
|
||||
const footer = page.getByTestId("pos-mobile-popup-footer");
|
||||
const shell = page.getByTestId("pos-mobile-popup-shell");
|
||||
|
||||
await expect(footer).toBeVisible({ timeout: 10_000 });
|
||||
await expect(shell).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const [shellBox, footerBox] = await Promise.all([shell.boundingBox(), footer.boundingBox()]);
|
||||
|
||||
expect(shellBox).not.toBeNull();
|
||||
expect(footerBox).not.toBeNull();
|
||||
|
||||
const shellBottom = shellBox.y + shellBox.height;
|
||||
const footerBottom = footerBox.y + footerBox.height;
|
||||
|
||||
expect(Math.abs(shellBottom - footerBottom)).toBeLessThanOrEqual(tolerance);
|
||||
expect(footerBox.y).toBeGreaterThan(shellBox.y + shellBox.height / 2);
|
||||
}
|
||||
|
||||
async function selectPrimaryProduct(page, productId = 53) {
|
||||
await expect(page.getByTestId("pos-mobile-vehicle-selection")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-mobile-category-4")).toBeVisible({ timeout: 10_000 });
|
||||
@@ -351,6 +404,118 @@ async function createOrderFromStep1(
|
||||
await waitForMobileStepTwoReady(page);
|
||||
}
|
||||
|
||||
test("mobile customer popup exposes the draft quick action and selects the configured draft customer", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "Mobile POS order suite is scoped to chromium-mobile.");
|
||||
|
||||
const fixture = createMobilePosFixture({
|
||||
customersByNumber: {
|
||||
[MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID]: {
|
||||
id: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID,
|
||||
customerNumber: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID,
|
||||
name: MOBILE_DRAFT_TRANSACTION_CUSTOMER_NAME,
|
||||
address: "Draft Street 7",
|
||||
zip: "2630",
|
||||
city: "Taastrup",
|
||||
mobilePhone: "44556677",
|
||||
email: "draft-mobile@example.com",
|
||||
corporateIdentificationNumber: "44556677",
|
||||
barred: false,
|
||||
economic_customer: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "pos-mobile-draft-customer-token",
|
||||
seedState: {
|
||||
customerId: null,
|
||||
includePrimaryItem: false,
|
||||
reg: "AB12345",
|
||||
reference: "MOBILE-DRAFT",
|
||||
},
|
||||
route: {
|
||||
step: 1,
|
||||
},
|
||||
sessionData: {
|
||||
runtime_config: {
|
||||
economic: {
|
||||
transaction_draft_customer_number: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await openCustomerPopupFromStep1(page);
|
||||
await expect(page.getByTestId("pos-mobile-draft-customer")).toBeVisible();
|
||||
await page.getByTestId("pos-mobile-draft-customer").click();
|
||||
await expect(page.getByTestId("pos-mobile-customer-popup")).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
return snapshot?.metadata?.customerId ?? snapshot?.vehicles?.vehicle_1?.customer_id ?? null;
|
||||
})
|
||||
.toBe(MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID);
|
||||
|
||||
await waitForMobileNextStepCooldown(page);
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
await waitForMobileStepTwoReady(page);
|
||||
await expect(page.getByTestId("pos-mobile-customer-name")).toContainText(MOBILE_DRAFT_TRANSACTION_CUSTOMER_NAME);
|
||||
});
|
||||
|
||||
test("mobile customer popup defaults to customer invoice mode and keeps customer search visible", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "Mobile POS order suite is scoped to chromium-mobile.");
|
||||
|
||||
const fixture = createMobilePosFixture({
|
||||
customersByNumber: {
|
||||
[MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID]: {
|
||||
id: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID,
|
||||
customerNumber: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID,
|
||||
name: MOBILE_DRAFT_TRANSACTION_CUSTOMER_NAME,
|
||||
address: "Draft Street 7",
|
||||
zip: "2630",
|
||||
city: "Taastrup",
|
||||
mobilePhone: "44556677",
|
||||
email: "draft-mobile@example.com",
|
||||
corporateIdentificationNumber: "44556677",
|
||||
barred: false,
|
||||
economic_customer: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "pos-mobile-customer-invoice-mode-token",
|
||||
seedState: {
|
||||
customerId: null,
|
||||
includePrimaryItem: false,
|
||||
reg: "AB12345",
|
||||
reference: "MOBILE-INVOICE-MODE",
|
||||
},
|
||||
route: {
|
||||
step: 1,
|
||||
},
|
||||
sessionData: {
|
||||
runtime_config: {
|
||||
economic: {
|
||||
transaction_draft_customer_number: MOBILE_DRAFT_TRANSACTION_CUSTOMER_ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await openCustomerPopupFromStep1(page);
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-customer-invoice")).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.getByTestId("pos-mobile-customer-search-input")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-mobile-draft-customer")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-mobile-direct-card-payment")).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("POS mobile order flow", () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "Mobile POS order suite is scoped to chromium-mobile.");
|
||||
@@ -488,6 +653,73 @@ test.describe("POS mobile order flow", () => {
|
||||
await expect(submitButton).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("add-customer popup keeps footer anchored while the popup body scrolls", async ({ page }) => {
|
||||
const fixture = createMobilePosFixture();
|
||||
|
||||
await page.setViewportSize({ width: 393, height: 480 });
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-add-customer-footer-anchor-token",
|
||||
seedState: {
|
||||
customerId: null,
|
||||
reg: "FREE123",
|
||||
reference: "ADD-CUSTOMER-FOOTER-REF",
|
||||
includePrimaryItem: false,
|
||||
vehicleType: null,
|
||||
lastOrderId: null,
|
||||
},
|
||||
route: {
|
||||
step: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
await openAddCustomerPopup(page);
|
||||
await page.getByTestId("pos-mobile-popup-shell").evaluate((element) => {
|
||||
element.style.height = "16rem";
|
||||
element.style.maxHeight = "16rem";
|
||||
});
|
||||
|
||||
const scrollRegion = page.getByTestId("pos-mobile-popup-scroll-region");
|
||||
const footer = page.getByTestId("pos-mobile-popup-footer");
|
||||
|
||||
await scrollRegion.evaluate((element) => {
|
||||
const spacer = document.createElement("div");
|
||||
spacer.setAttribute("data-testid", "pos-mobile-add-customer-scroll-spacer");
|
||||
spacer.style.height = "20rem";
|
||||
spacer.style.flexShrink = "0";
|
||||
element.appendChild(spacer);
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-popup-shell")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(footer).toBeVisible({ timeout: 10_000 });
|
||||
await expectAboveFixedActions(page, footer);
|
||||
|
||||
const footerBeforeScroll = await footer.boundingBox();
|
||||
|
||||
await expect
|
||||
.poll(() => scrollRegion.evaluate((element) => element.scrollHeight > element.clientHeight))
|
||||
.toBe(true);
|
||||
|
||||
await scrollRegion.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => scrollRegion.evaluate((element) => Math.round(element.scrollTop)))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const footerAfterScroll = await footer.boundingBox();
|
||||
|
||||
expect(footerBeforeScroll).not.toBeNull();
|
||||
expect(footerAfterScroll).not.toBeNull();
|
||||
expect(Math.abs(footerBeforeScroll.y - footerAfterScroll.y)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(
|
||||
footerBeforeScroll.y + footerBeforeScroll.height - (footerAfterScroll.y + footerAfterScroll.height)
|
||||
)).toBeLessThanOrEqual(2);
|
||||
await expectAboveFixedActions(page, footer);
|
||||
});
|
||||
|
||||
test("add-customer popup ignores stale delayed CVR responses after a newer lookup resolves", async ({ page }) => {
|
||||
const fixture = createMobilePosFixture({
|
||||
cvrSearchResponses: {
|
||||
@@ -1101,6 +1333,8 @@ test.describe("POS mobile order flow", () => {
|
||||
bookings: longBookingList,
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 346, height: 610 });
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-multi-booking-long-list-token",
|
||||
seedState: {
|
||||
@@ -1117,17 +1351,37 @@ test.describe("POS mobile order flow", () => {
|
||||
});
|
||||
|
||||
const popup = await waitForOrderBookingPopup(page);
|
||||
const popupContent = page.locator('[data-testid="pos-mobile-popup"] .card-content');
|
||||
await expect(page.getByTestId("pos-mobile-order-booking-skip")).toBeVisible({ timeout: 10_000 });
|
||||
const popupContent = page.getByTestId("pos-mobile-popup-scroll-region");
|
||||
const footer = page.getByTestId("pos-mobile-popup-footer");
|
||||
|
||||
await expect(footer).toBeVisible({ timeout: 10_000 });
|
||||
await expectPopupFooterAlignedToShell(page);
|
||||
await expectAboveFixedActions(page, footer);
|
||||
await expect
|
||||
.poll(async () => popupContent.evaluate((element) => element.scrollHeight > element.clientHeight), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
const footerBeforeScroll = await footer.boundingBox();
|
||||
|
||||
await popupContent.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => popupContent.evaluate((element) => Math.round(element.scrollTop)))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const footerAfterScroll = await footer.boundingBox();
|
||||
|
||||
expect(footerBeforeScroll).not.toBeNull();
|
||||
expect(footerAfterScroll).not.toBeNull();
|
||||
expect(Math.abs(footerBeforeScroll.y - footerAfterScroll.y)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(
|
||||
footerBeforeScroll.y + footerBeforeScroll.height - (footerAfterScroll.y + footerAfterScroll.height)
|
||||
)).toBeLessThanOrEqual(2);
|
||||
await expectAboveFixedActions(page, footer);
|
||||
await expect(popup.getByTestId("pos-mobile-order-booking-option-8267")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-mobile-order-booking-skip").click();
|
||||
await expect(page.getByTestId("pos-mobile-order-booking-popup")).toBeHidden({ timeout: 10_000 });
|
||||
@@ -1176,8 +1430,8 @@ test.describe("POS mobile order flow", () => {
|
||||
|
||||
await waitForOrderBookingPopup(page);
|
||||
await expectOrderBookingPopupIds(page, [8301, 8302]);
|
||||
await expect(page.getByTestId("pos-mobile-order-booking-header-close")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-mobile-order-booking-header-close").click();
|
||||
await expect(page.getByTestId("pos-mobile-order-booking-skip")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-mobile-order-booking-skip").click();
|
||||
await expect(page.getByTestId("pos-mobile-order-booking-popup")).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
await expect
|
||||
@@ -1186,6 +1440,7 @@ test.describe("POS mobile order flow", () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
return {
|
||||
bookingId: snapshot?.metadata?.bookingId ?? null,
|
||||
reference: snapshot?.metadata?.reference ?? "",
|
||||
skippedPlate: snapshot?.metadata?.bookingSelectionSkippedPlate ?? "",
|
||||
};
|
||||
},
|
||||
@@ -1193,9 +1448,14 @@ test.describe("POS mobile order flow", () => {
|
||||
)
|
||||
.toEqual({
|
||||
bookingId: null,
|
||||
reference: "SKIP-VEHICLE-REF",
|
||||
skippedPlate: "SKIP123",
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-step-1-reference-trigger")).toContainText("SKIP-VEHICLE-REF");
|
||||
await expect(page.getByTestId("pos-mobile-step-1-reference-trigger")).not.toContainText("SKIP-BOOKING-A");
|
||||
await expect(page.getByTestId("pos-mobile-step-1-reference-trigger")).not.toContainText("SKIP-BOOKING-B");
|
||||
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("SKIP-VEHICLE-REF");
|
||||
@@ -1673,6 +1933,110 @@ test.describe("POS mobile order flow", () => {
|
||||
.toBe(63);
|
||||
});
|
||||
|
||||
test("copy previous wash restores the last order primary service, addons, and standalone additional items", async ({
|
||||
page,
|
||||
}) => {
|
||||
const orderId = 9410;
|
||||
const fixture = createMobilePosFixture({
|
||||
ordersById: {
|
||||
[orderId]: buildRegularOrder(orderId, {
|
||||
reference: "COPY-LAST-WASH",
|
||||
reg_1: "AB12345",
|
||||
}),
|
||||
},
|
||||
orderItemsByOrderId: {
|
||||
[orderId]: [],
|
||||
},
|
||||
});
|
||||
|
||||
fixture.orderItemsByOrderId[DEFAULT_LAST_ORDER_ID] = [
|
||||
buildFixtureOrderItem(
|
||||
fixture,
|
||||
53,
|
||||
{
|
||||
order_id: DEFAULT_LAST_ORDER_ID,
|
||||
quantity: 1,
|
||||
},
|
||||
9501
|
||||
),
|
||||
buildFixtureOrderItem(
|
||||
fixture,
|
||||
71,
|
||||
{
|
||||
order_id: DEFAULT_LAST_ORDER_ID,
|
||||
quantity: 2,
|
||||
related_item_id: 9501,
|
||||
},
|
||||
9502
|
||||
),
|
||||
buildFixtureOrderItem(
|
||||
fixture,
|
||||
41,
|
||||
{
|
||||
order_id: DEFAULT_LAST_ORDER_ID,
|
||||
quantity: 1,
|
||||
related_item_id: null,
|
||||
},
|
||||
9503
|
||||
),
|
||||
];
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-copy-last-wash-token",
|
||||
seedState: {
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
reg: "AB12345",
|
||||
reference: "COPY-LAST-WASH",
|
||||
includePrimaryItem: true,
|
||||
primaryItemId: 63,
|
||||
vehicleType: 63,
|
||||
lastOrderId: DEFAULT_LAST_ORDER_ID,
|
||||
},
|
||||
route: {
|
||||
step: 2,
|
||||
orderId,
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-mobile-copy-last-wash")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("pos-mobile-copy-last-wash").click();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
const primaryItem = snapshot?.transactionItems?.primaryItem ?? null;
|
||||
const addonSummary = (primaryItem?.addons || [])
|
||||
.map((addon) => ({
|
||||
id: Number(addon?.product?.id ?? addon?.id ?? 0),
|
||||
quantity: Number(addon?.quantity ?? 0),
|
||||
}))
|
||||
.sort((left, right) => left.id - right.id);
|
||||
const additionalItems = (snapshot?.transactionItems?.additionalItems || [])
|
||||
.map((item) => ({
|
||||
id: Number(item?.id ?? 0),
|
||||
quantity: Number(item?.quantity ?? 0),
|
||||
}))
|
||||
.sort((left, right) => left.id - right.id);
|
||||
|
||||
return {
|
||||
primaryId: Number(primaryItem?.id ?? 0),
|
||||
addons: addonSummary,
|
||||
additionalItems,
|
||||
};
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toEqual({
|
||||
primaryId: 53,
|
||||
addons: [{ id: 71, quantity: 2 }],
|
||||
additionalItems: [{ id: 41, quantity: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
test("step 2 registration popup flushes edits on close and survives reload", async ({ page }) => {
|
||||
const orderId = 9405;
|
||||
const fixture = createMobilePosFixture({
|
||||
@@ -1707,6 +2071,8 @@ test.describe("POS mobile order flow", () => {
|
||||
await page.locator('[data-testid="pos-mobile-step-2"] .custom-button-secondary').first().click();
|
||||
await expect(page.getByTestId("pos-mobile-popup")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-mobile-manual-input")).toBeVisible({ timeout: 10_000 });
|
||||
await expectPopupFooterAlignedToShell(page);
|
||||
await expectAboveFixedActions(page, page.getByTestId("pos-mobile-popup-footer"));
|
||||
|
||||
await page.getByTestId("pos-mobile-reg-input-1").fill("cd-12 34");
|
||||
await expect(page.getByTestId("pos-mobile-select-vehicle-header-close")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -28,7 +28,7 @@ test.describe("Edge gateway live smoke gate", () => {
|
||||
"Set PLAYWRIGHT_BASE_URL, PLAYWRIGHT_SUPERUSER_USER_ID, PLAYWRIGHT_SUPERUSER_PASSWORD, and PLAYWRIGHT_EDGE_GATEWAY_ID to run the live edge gateway smoke gate."
|
||||
);
|
||||
|
||||
test("superuser gateway routes render without destructive actions", async ({ page }) => {
|
||||
test("superuser gateway routes render without executing destructive actions", async ({ page }) => {
|
||||
const { baseURL, superuserCredentials, gatewayId } = getLiveSettings();
|
||||
const guards = attachPageHealthGuards(page, baseURL);
|
||||
|
||||
@@ -38,13 +38,14 @@ test.describe("Edge gateway live smoke gate", () => {
|
||||
await settlePage(page);
|
||||
await expect(page).toHaveURL(/\/superuser\/gateways(?:\?.*)?$/);
|
||||
await expectBodyHasContent(page);
|
||||
await expect(page.getByTestId("gateway-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("edge-gateway-workspace")).toBeVisible();
|
||||
await expect(page.locator("body")).not.toContainText(/404/i);
|
||||
|
||||
await page.goto(`/superuser/gateways/${gatewayId}`);
|
||||
await page.goto(`/superuser/gateways/${gatewayId}/overview`);
|
||||
await settlePage(page);
|
||||
await expect(page).toHaveURL(new RegExp(`/superuser/gateways/${gatewayId}(?:\\?.*)?$`));
|
||||
await expect(page).toHaveURL(new RegExp(`/superuser/gateways/${gatewayId}/overview(?:\\?.*)?$`));
|
||||
await expect(page.getByTestId("gateway-detail-header")).toBeVisible();
|
||||
await expect(page.locator("body")).not.toContainText(/404/i);
|
||||
|
||||
await guards.expectHealthy();
|
||||
guards.dispose();
|
||||
|
||||
+349
-207
@@ -635,9 +635,74 @@ function mergeEdgeGatewayFixtureRecord(baseGateway, overrides = {}) {
|
||||
bindings: Array.isArray(overrides.bindings) ? overrides.bindings : baseGateway.bindings,
|
||||
recent_commands: Array.isArray(overrides.recent_commands) ? overrides.recent_commands : baseGateway.recent_commands,
|
||||
audit_logs: Array.isArray(overrides.audit_logs) ? overrides.audit_logs : baseGateway.audit_logs,
|
||||
operations: Array.isArray(overrides.operations) ? overrides.operations : baseGateway.operations || [],
|
||||
};
|
||||
}
|
||||
|
||||
function createFixtureOperation(type, request = {}, overrides = {}) {
|
||||
const startedAt = overrides.started_at || null;
|
||||
const completedAt = overrides.completed_at || null;
|
||||
return {
|
||||
id: overrides.id || Date.now(),
|
||||
type,
|
||||
status: overrides.status || "PENDING",
|
||||
request,
|
||||
summary: overrides.summary || {
|
||||
label:
|
||||
overrides.status === "COMPLETED"
|
||||
? "Completed"
|
||||
: overrides.status === "FAILED"
|
||||
? "Failed"
|
||||
: "Queued",
|
||||
progress: overrides.status === "COMPLETED" ? 100 : overrides.status === "FAILED" ? 100 : 0,
|
||||
retryable: overrides.status !== "FAILED",
|
||||
},
|
||||
result: overrides.result || {},
|
||||
error_code: overrides.error_code || null,
|
||||
error_message: overrides.error_message || null,
|
||||
requested_at: overrides.requested_at || toSqlDateTime(),
|
||||
started_at: startedAt,
|
||||
completed_at: completedAt,
|
||||
created_at: overrides.created_at || toSqlDateTime(),
|
||||
updated_at: overrides.updated_at || completedAt || startedAt || toSqlDateTime(),
|
||||
events: Array.isArray(overrides.events) ? overrides.events : [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildEdgeGatewayOperationSummary(operations = []) {
|
||||
return operations.reduce(
|
||||
(summary, operation, index) => {
|
||||
const status = String(operation.status || "PENDING");
|
||||
if (status === "PENDING") summary.pending += 1;
|
||||
if (status === "IN_PROGRESS") summary.in_progress += 1;
|
||||
if (status === "COMPLETED") {
|
||||
summary.completed += 1;
|
||||
summary.latest_completed_at = summary.latest_completed_at || operation.completed_at || null;
|
||||
}
|
||||
if (status === "FAILED") {
|
||||
summary.failed += 1;
|
||||
summary.latest_failed_at = summary.latest_failed_at || operation.completed_at || null;
|
||||
}
|
||||
if (index === 0) {
|
||||
summary.latest_type = operation.type || null;
|
||||
summary.latest_status = operation.status || null;
|
||||
}
|
||||
return summary;
|
||||
},
|
||||
{
|
||||
total: operations.length,
|
||||
pending: 0,
|
||||
in_progress: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
latest_completed_at: null,
|
||||
latest_failed_at: null,
|
||||
latest_type: null,
|
||||
latest_status: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildEdgeGatewayRuntimeFixture(gateway) {
|
||||
const relayHealth = (gateway.bindings || []).map((binding) => {
|
||||
const fallbackMode = binding.fallback_mode || "PREFER_LOCAL";
|
||||
@@ -662,6 +727,27 @@ function buildEdgeGatewayRuntimeFixture(gateway) {
|
||||
});
|
||||
|
||||
const cloudRelays = relayHealth.filter((relay) => relay.execution_path === "cloud");
|
||||
const operations = Array.isArray(gateway.operations) ? gateway.operations : [];
|
||||
const activeOperation = operations.find((operation) => ["PENDING", "IN_PROGRESS"].includes(String(operation.status))) || null;
|
||||
const versionDrift =
|
||||
gateway.installed_version && gateway.target_version && gateway.installed_version !== gateway.target_version;
|
||||
const credentialFreshnessState = gateway.metadata?.credentials_rotated_at ? "FRESH" : "UNKNOWN";
|
||||
const diagnostics = [];
|
||||
|
||||
if (gateway.status === "OFFLINE") {
|
||||
diagnostics.push({
|
||||
code: "EDGE_GATEWAY_OFFLINE",
|
||||
message: "Gateway heartbeat has expired and the gateway is offline.",
|
||||
recommended_action: "restart_agent",
|
||||
});
|
||||
}
|
||||
if (versionDrift) {
|
||||
diagnostics.push({
|
||||
code: "EDGE_GATEWAY_VERSION_DRIFT",
|
||||
message: "Installed gateway version differs from the target version.",
|
||||
recommended_action: "queue_update",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...gateway,
|
||||
@@ -697,6 +783,25 @@ function buildEdgeGatewayRuntimeFixture(gateway) {
|
||||
relay_health: gateway.relay_health || relayHealth,
|
||||
last_successful_command_at: gateway.last_successful_command_at || gateway.last_heartbeat_at,
|
||||
last_successful_discovery_at: gateway.last_successful_discovery_at || gateway.last_heartbeat_at,
|
||||
active_operation: gateway.active_operation || activeOperation,
|
||||
recent_operations_summary: gateway.recent_operations_summary || buildEdgeGatewayOperationSummary(operations),
|
||||
version_drift:
|
||||
gateway.version_drift || {
|
||||
installed_version: gateway.installed_version || null,
|
||||
target_version: gateway.target_version || null,
|
||||
release_channel: gateway.release_channel || "stable",
|
||||
is_drifted: Boolean(versionDrift),
|
||||
status: versionDrift ? "UPDATE_AVAILABLE" : "IN_SYNC",
|
||||
},
|
||||
credential_freshness:
|
||||
gateway.credential_freshness || {
|
||||
rotated_at: gateway.metadata?.credentials_rotated_at || null,
|
||||
age_days: gateway.metadata?.credentials_rotated_at ? 1 : null,
|
||||
state: credentialFreshnessState,
|
||||
},
|
||||
diagnostics: gateway.diagnostics || diagnostics,
|
||||
error_state:
|
||||
gateway.error_state || (diagnostics[0] ? { ...diagnostics[0] } : activeOperation?.error_code ? { code: activeOperation.error_code, message: activeOperation.error_message } : null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -779,6 +884,18 @@ function createEdgeGatewayFixture(options = {}) {
|
||||
},
|
||||
],
|
||||
recent_commands: [],
|
||||
operations: [
|
||||
createFixtureOperation("DISCOVERY", {}, {
|
||||
id: 8801,
|
||||
status: "COMPLETED",
|
||||
started_at: "2026-04-08 08:14:20",
|
||||
completed_at: "2026-04-08 08:14:38",
|
||||
events: [
|
||||
{ id: 1, level: "INFO", code: "OPERATION_STARTED", message: "Gateway started processing the operation", created_at: "2026-04-08 08:14:20" },
|
||||
{ id: 2, level: "INFO", code: "OPERATION_COMPLETED", message: "Operation completed successfully", created_at: "2026-04-08 08:14:38" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
audit_logs: [{ id: 501, created_at: "2026-04-08 08:16:00", action: "GATEWAY_CLAIMED", actor_type: "USER" }],
|
||||
},
|
||||
{
|
||||
@@ -813,6 +930,7 @@ function createEdgeGatewayFixture(options = {}) {
|
||||
inventory: [],
|
||||
bindings: [],
|
||||
recent_commands: [],
|
||||
operations: [],
|
||||
audit_logs: [{ id: 502, created_at: "2026-04-07 21:05:00", action: "HEARTBEAT_TIMEOUT", actor_type: "SYSTEM" }],
|
||||
},
|
||||
].map((gateway) =>
|
||||
@@ -844,10 +962,27 @@ function settleEdgeGatewayWork(edgeGatewayFixture, gatewayId) {
|
||||
if (!gateway.inventory.some((device) => device.device_id === pendingDiscovery.device.device_id)) {
|
||||
gateway.inventory = [...gateway.inventory, pendingDiscovery.device];
|
||||
}
|
||||
gateway.recent_commands = (gateway.recent_commands || []).map((job) =>
|
||||
Number(job.id) === Number(pendingDiscovery.jobId)
|
||||
? { ...job, status: "COMPLETED", completed_at: gateway.last_successful_discovery_at }
|
||||
: job
|
||||
gateway.operations = (gateway.operations || []).map((operation) =>
|
||||
Number(operation.id) === Number(pendingDiscovery.operationId)
|
||||
? {
|
||||
...operation,
|
||||
status: "COMPLETED",
|
||||
completed_at: gateway.last_successful_discovery_at,
|
||||
updated_at: gateway.last_successful_discovery_at,
|
||||
summary: { ...(operation.summary || {}), label: "Completed", progress: 100, retryable: true },
|
||||
result: { inventory: cloneJson(gateway.inventory) },
|
||||
events: [
|
||||
...(operation.events || []),
|
||||
{
|
||||
id: edgeGatewayFixture.nextOperationEventId++,
|
||||
level: "INFO",
|
||||
code: "OPERATION_COMPLETED",
|
||||
message: "Operation completed successfully",
|
||||
created_at: gateway.last_successful_discovery_at,
|
||||
},
|
||||
],
|
||||
}
|
||||
: operation
|
||||
);
|
||||
delete edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId];
|
||||
}
|
||||
@@ -862,65 +997,13 @@ function cloneJson(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function edgeGatewayOperationsFor(edgeGatewayFixture, gatewayId) {
|
||||
if (!Array.isArray(edgeGatewayFixture.operationsByGatewayId[gatewayId])) {
|
||||
edgeGatewayFixture.operationsByGatewayId[gatewayId] = [];
|
||||
}
|
||||
|
||||
return edgeGatewayFixture.operationsByGatewayId[gatewayId];
|
||||
}
|
||||
|
||||
function edgeGatewaySessionsFor(edgeGatewayFixture, gatewayId) {
|
||||
if (!Array.isArray(edgeGatewayFixture.shellSessionsByGatewayId[gatewayId])) {
|
||||
edgeGatewayFixture.shellSessionsByGatewayId[gatewayId] = [];
|
||||
}
|
||||
|
||||
return edgeGatewayFixture.shellSessionsByGatewayId[gatewayId];
|
||||
}
|
||||
|
||||
function publicEdgeGatewayOperation(operation) {
|
||||
if (!operation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { events, progress_step, ...publicFields } = operation;
|
||||
return {
|
||||
...publicFields,
|
||||
request: cloneJson(publicFields.request || {}),
|
||||
result: cloneJson(publicFields.result || {}),
|
||||
summary: cloneJson(publicFields.summary || {}),
|
||||
latest_event: Array.isArray(events) && events.length > 0 ? cloneJson(events[events.length - 1]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function publicEdgeGatewaySession(session) {
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
metadata: cloneJson(session.metadata || {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, includeDetail = false) {
|
||||
const inventory = cloneJson(gateway.inventory || []);
|
||||
const bindings = cloneJson(gateway.bindings || []);
|
||||
const operations = edgeGatewayOperationsFor(edgeGatewayFixture, gateway.id)
|
||||
.slice()
|
||||
.sort((left, right) => Number(right.id) - Number(left.id));
|
||||
const activeOperation =
|
||||
operations.find((operation) => ["PENDING", "RUNNING"].includes(String(operation.status || "").toUpperCase())) ||
|
||||
null;
|
||||
const recentShellSessions = edgeGatewaySessionsFor(edgeGatewayFixture, gateway.id)
|
||||
.slice()
|
||||
.sort((left, right) => Number(right.id) - Number(left.id))
|
||||
.map((session) => publicEdgeGatewaySession(session));
|
||||
const operations = cloneJson(gateway.operations || []);
|
||||
|
||||
return {
|
||||
...cloneJson(gateway),
|
||||
control_plane: { mode: "HTTP_POLLING" },
|
||||
readiness: {
|
||||
status: gateway.status,
|
||||
last_heartbeat_at: gateway.last_heartbeat_at,
|
||||
@@ -945,137 +1028,19 @@ function buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, includeDetail
|
||||
last_heartbeat_at: gateway.last_heartbeat_at,
|
||||
system_metrics: cloneJson(gateway.metadata?.system_metrics || {}),
|
||||
},
|
||||
active_operation: activeOperation ? publicEdgeGatewayOperation(activeOperation) : null,
|
||||
recent_operations: operations.map((operation) => publicEdgeGatewayOperation(operation)),
|
||||
operations,
|
||||
active_operation: cloneJson(gateway.active_operation || null),
|
||||
recent_operations_summary: cloneJson(gateway.recent_operations_summary || buildEdgeGatewayOperationSummary(operations)),
|
||||
version_drift: cloneJson(gateway.version_drift || null),
|
||||
credential_freshness: cloneJson(gateway.credential_freshness || null),
|
||||
diagnostics: cloneJson(gateway.diagnostics || []),
|
||||
error_state: cloneJson(gateway.error_state || null),
|
||||
inventory: includeDetail ? inventory : undefined,
|
||||
bindings: includeDetail ? bindings : undefined,
|
||||
recent_shell_sessions: includeDetail ? recentShellSessions : undefined,
|
||||
audit_logs: includeDetail ? cloneJson(gateway.audit_logs || []) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function addEdgeGatewayOperationEvent(edgeGatewayFixture, operation, stage, message, payload = {}, level = "INFO", counts = {}) {
|
||||
const event = {
|
||||
id: edgeGatewayFixture.nextOperationEventId++,
|
||||
operation_id: operation.id,
|
||||
gateway_id: operation.gateway_id,
|
||||
stage,
|
||||
level,
|
||||
message,
|
||||
counts,
|
||||
payload,
|
||||
created_at: toSqlDateTime(),
|
||||
};
|
||||
|
||||
operation.events.push(event);
|
||||
operation.summary = {
|
||||
latest_stage: stage,
|
||||
latest_message: message,
|
||||
counts,
|
||||
};
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
function advanceEdgeGatewayOperation(edgeGatewayFixture, gateway, operation) {
|
||||
if (!gateway || !operation || ["COMPLETED", "FAILED"].includes(String(operation.status || "").toUpperCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.operation_type === "DISCOVERY") {
|
||||
gateway.discovery_status = "RUNNING";
|
||||
|
||||
if (operation.progress_step === 0) {
|
||||
addEdgeGatewayOperationEvent(edgeGatewayFixture, operation, "STARTED", "Discovery started on the gateway.");
|
||||
addEdgeGatewayOperationEvent(edgeGatewayFixture, operation, "PROBE", "Probing 10.1.0.0/24 for Shelly devices.");
|
||||
operation.progress_step = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.progress_step === 1) {
|
||||
const discoveredDevice = {
|
||||
id: gateway.inventory.length + 1,
|
||||
device_id: "shelly-plus-new",
|
||||
local_ip: "10.1.0.33",
|
||||
model: "Shelly Plus 1PM",
|
||||
channel_count: 1,
|
||||
online: true,
|
||||
capabilities: { generation: 2 },
|
||||
};
|
||||
|
||||
if (!gateway.inventory.some((device) => device.device_id === discoveredDevice.device_id)) {
|
||||
gateway.inventory.push(discoveredDevice);
|
||||
}
|
||||
|
||||
addEdgeGatewayOperationEvent(
|
||||
edgeGatewayFixture,
|
||||
operation,
|
||||
"DEVICE_FOUND",
|
||||
"Found shelly-plus-new during discovery.",
|
||||
{ device: discoveredDevice },
|
||||
"INFO",
|
||||
{ devices: gateway.inventory.length }
|
||||
);
|
||||
operation.progress_step = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
operation.status = "COMPLETED";
|
||||
operation.completed_at = toSqlDateTime();
|
||||
operation.result = { inventory: cloneJson(gateway.inventory) };
|
||||
gateway.discovery_status = "READY";
|
||||
addEdgeGatewayOperationEvent(
|
||||
edgeGatewayFixture,
|
||||
operation,
|
||||
"COMPLETED",
|
||||
`Discovery completed with ${gateway.inventory.length} device(s).`,
|
||||
{},
|
||||
"INFO",
|
||||
{ devices: gateway.inventory.length }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.operation_type === "UPDATE") {
|
||||
if (operation.progress_step === 0) {
|
||||
addEdgeGatewayOperationEvent(edgeGatewayFixture, operation, "STARTED", "Update started on the gateway.");
|
||||
operation.progress_step = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
gateway.installed_version = operation.request_json?.target_version || gateway.installed_version;
|
||||
gateway.target_version = operation.request_json?.target_version || gateway.target_version;
|
||||
operation.status = "COMPLETED";
|
||||
operation.completed_at = toSqlDateTime();
|
||||
operation.result = { installed_version: gateway.installed_version };
|
||||
addEdgeGatewayOperationEvent(edgeGatewayFixture, operation, "COMPLETED", `Gateway updated to ${gateway.installed_version}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.operation_type === "UNINSTALL") {
|
||||
if (operation.progress_step === 0) {
|
||||
addEdgeGatewayOperationEvent(edgeGatewayFixture, operation, "STARTED", "Uninstall started on the gateway.");
|
||||
operation.progress_step = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
gateway.status = "OFFLINE";
|
||||
operation.status = "COMPLETED";
|
||||
operation.completed_at = toSqlDateTime();
|
||||
operation.result = { service_name: "truckwash-edge-agent.service" };
|
||||
addEdgeGatewayOperationEvent(edgeGatewayFixture, operation, "COMPLETED", "Gateway uninstall completed.");
|
||||
return;
|
||||
}
|
||||
|
||||
operation.status = "COMPLETED";
|
||||
operation.completed_at = toSqlDateTime();
|
||||
operation.result = {
|
||||
online: true,
|
||||
on: Boolean(operation.request_json?.on),
|
||||
};
|
||||
addEdgeGatewayOperationEvent(edgeGatewayFixture, operation, "COMPLETED", `${operation.operation_type} completed.`);
|
||||
}
|
||||
|
||||
function processPendingEdgeGatewayClaims(edgeGatewayFixture) {
|
||||
edgeGatewayFixture.pendingClaims = (edgeGatewayFixture.pendingClaims || []).filter((claim) => {
|
||||
claim.pollsRemaining -= 1;
|
||||
@@ -1110,6 +1075,7 @@ function processPendingEdgeGatewayClaims(edgeGatewayFixture) {
|
||||
inventory: [],
|
||||
bindings: [],
|
||||
recent_commands: [],
|
||||
operations: [],
|
||||
audit_logs: [
|
||||
{
|
||||
id: Date.now(),
|
||||
@@ -1165,6 +1131,18 @@ function createHttpEdgeGatewayFixture(options = {}) {
|
||||
{ id: 2, relay_id: "M-7-LEGACY", device_id: "shelly-missing-legacy", local_ip: "10.1.0.99", channel: 1, fallback_mode: "CLOUD_ONLY" },
|
||||
],
|
||||
recent_commands: [],
|
||||
operations: [
|
||||
createFixtureOperation("DISCOVERY", {}, {
|
||||
id: 8801,
|
||||
status: "COMPLETED",
|
||||
started_at: "2026-04-08 08:14:20",
|
||||
completed_at: "2026-04-08 08:14:38",
|
||||
events: [
|
||||
{ id: 1, level: "INFO", code: "OPERATION_STARTED", message: "Gateway started processing the operation", created_at: "2026-04-08 08:14:20" },
|
||||
{ id: 2, level: "INFO", code: "OPERATION_COMPLETED", message: "Operation completed successfully", created_at: "2026-04-08 08:14:38" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
audit_logs: [{ id: 501, created_at: "2026-04-08 08:16:00", action: "GATEWAY_CLAIMED", actor_type: "USER" }],
|
||||
},
|
||||
{
|
||||
@@ -1193,6 +1171,7 @@ function createHttpEdgeGatewayFixture(options = {}) {
|
||||
inventory: [],
|
||||
bindings: [],
|
||||
recent_commands: [],
|
||||
operations: [],
|
||||
audit_logs: [{ id: 502, created_at: "2026-04-07 21:05:00", action: "HEARTBEAT_TIMEOUT", actor_type: "SYSTEM" }],
|
||||
},
|
||||
]
|
||||
@@ -1201,15 +1180,11 @@ function createHttpEdgeGatewayFixture(options = {}) {
|
||||
|
||||
return {
|
||||
nextGatewayId: Math.max(703, ...baseGateways.map((gateway) => Number(gateway.id) + 1)),
|
||||
nextOperationId: 10001,
|
||||
nextOperationEventId: 20001,
|
||||
nextShellSessionId: 30001,
|
||||
nextShellEventId: 40001,
|
||||
nextOperationId: 9901,
|
||||
nextOperationEventId: 19901,
|
||||
claimPollsRemaining: Number(options.claimPollsRemaining || 2),
|
||||
pendingClaims: [],
|
||||
pendingDiscoveryByGatewayId: {},
|
||||
operationsByGatewayId: {},
|
||||
shellSessionsByGatewayId: {},
|
||||
gateways: baseGateways,
|
||||
};
|
||||
}
|
||||
@@ -2434,6 +2409,16 @@ async function handleEdgeGatewayRoute({
|
||||
|
||||
const findGateway = (gatewayId) =>
|
||||
edgeGatewayFixture.gateways.find((entry) => Number(entry.id) === Number(gatewayId)) || null;
|
||||
const gatewayResponse = (gateway, includeDetail = true) =>
|
||||
gateway ? json({ data: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, includeDetail) }) : json({ message: "Gateway not found" }, 404);
|
||||
const createOperation = (gateway, type, request = {}, overrides = {}) => {
|
||||
const operation = createFixtureOperation(type, request, {
|
||||
id: edgeGatewayFixture.nextOperationId++,
|
||||
...overrides,
|
||||
});
|
||||
gateway.operations = [operation, ...(gateway.operations || [])];
|
||||
return operation;
|
||||
};
|
||||
|
||||
if (pathname.endsWith("/edge-gateways") && method === "GET") {
|
||||
processPendingEdgeGatewayClaims(edgeGatewayFixture);
|
||||
@@ -2464,25 +2449,174 @@ async function handleEdgeGatewayRoute({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/\/edge-gateways\/\d+\/operations$/.test(pathname) && method === "GET") {
|
||||
const gatewayId = extractMatchId(/\/edge-gateways\/(\d+)\/operations$/);
|
||||
const gateway = settleEdgeGatewayWork(edgeGatewayFixture, gatewayId);
|
||||
await route.fulfill(json({ data: cloneJson(gateway?.operations || []) }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/\/edge-gateways\/\d+\/operations\/\d+\/events$/.test(pathname) && method === "GET") {
|
||||
const gatewayId = extractMatchId(/\/edge-gateways\/(\d+)\/operations\/\d+\/events$/);
|
||||
const operationId = extractMatchId(/\/operations\/(\d+)\/events$/);
|
||||
const gateway = settleEdgeGatewayWork(edgeGatewayFixture, gatewayId);
|
||||
const operation = (gateway?.operations || []).find((entry) => Number(entry.id) === Number(operationId)) || null;
|
||||
await route.fulfill(json({ data: cloneJson(operation?.events || []) }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/\/edge-gateways\/\d+\/operations$/.test(pathname) && method === "POST") {
|
||||
const gatewayId = extractMatchId(/\/edge-gateways\/(\d+)\/operations$/);
|
||||
const gateway = findGateway(gatewayId);
|
||||
const body = request.postDataJSON?.() || {};
|
||||
if (!gateway) {
|
||||
await route.fulfill(json({ message: "Gateway not found" }, 404));
|
||||
return true;
|
||||
}
|
||||
|
||||
const operationType = String(body.type || "").toUpperCase();
|
||||
const operationRequest = body.request || {};
|
||||
const now = toSqlDateTime();
|
||||
let operation = null;
|
||||
|
||||
if (operationType === "DISCOVERY") {
|
||||
gateway.discovery_status = "PENDING";
|
||||
operation = createOperation(gateway, "DISCOVERY", operationRequest, {
|
||||
status: "IN_PROGRESS",
|
||||
started_at: now,
|
||||
updated_at: now,
|
||||
summary: { label: "Gateway is processing the operation", progress: 20, retryable: true },
|
||||
events: [
|
||||
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_QUEUED", message: "Operation queued for gateway execution", created_at: now },
|
||||
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_STARTED", message: "Gateway started processing the operation", created_at: now },
|
||||
],
|
||||
});
|
||||
edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId] = {
|
||||
operationId: operation.id,
|
||||
fetchCount: 0,
|
||||
device: {
|
||||
id: (gateway.inventory?.length || 0) + 1,
|
||||
device_id: "shelly-plus-new",
|
||||
local_ip: "10.1.0.33",
|
||||
model: "Shelly Plus 1PM",
|
||||
channel_count: 1,
|
||||
online: true,
|
||||
capabilities: { generation: 2 },
|
||||
},
|
||||
};
|
||||
} else if (operationType === "UPDATE") {
|
||||
gateway.target_version = String(operationRequest.target_version || gateway.target_version || "");
|
||||
gateway.installed_version = gateway.target_version;
|
||||
operation = createOperation(gateway, "UPDATE", operationRequest, {
|
||||
status: "COMPLETED",
|
||||
started_at: now,
|
||||
completed_at: now,
|
||||
updated_at: now,
|
||||
summary: { label: "Completed", progress: 100, retryable: true },
|
||||
result: { installed_version: gateway.installed_version, target_version: gateway.target_version, restart_required: true },
|
||||
events: [
|
||||
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_QUEUED", message: "Operation queued for gateway execution", created_at: now },
|
||||
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_COMPLETED", message: "Operation completed successfully", created_at: now },
|
||||
],
|
||||
});
|
||||
} else if (operationType === "UNINSTALL") {
|
||||
gateway.status = "OFFLINE";
|
||||
gateway.metadata = {
|
||||
...(gateway.metadata || {}),
|
||||
uninstalled_at: now,
|
||||
};
|
||||
operation = createOperation(gateway, "UNINSTALL", operationRequest, {
|
||||
status: "COMPLETED",
|
||||
started_at: now,
|
||||
completed_at: now,
|
||||
updated_at: now,
|
||||
summary: { label: "Completed", progress: 100, retryable: false },
|
||||
result: { uninstalled: true, manual_cleanup_required: true },
|
||||
events: [
|
||||
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_QUEUED", message: "Operation queued for gateway execution", created_at: now },
|
||||
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_COMPLETED", message: "Operation completed successfully", created_at: now },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
await route.fulfill(json({ data: { message: "Unsupported gateway operation type", error_code: "EDGE_GATEWAY_VALIDATION_FAILED" } }, 422));
|
||||
return true;
|
||||
}
|
||||
|
||||
gateway.audit_logs = [
|
||||
{ id: Date.now(), created_at: now, action: "GATEWAY_OPERATION_QUEUED", actor_type: "USER" },
|
||||
...(gateway.audit_logs || []),
|
||||
];
|
||||
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
|
||||
await route.fulfill(json({ data: { operation: cloneJson(operation), gateway: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true) } }, 201));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/\/edge-gateways\/\d+\/rotate-credentials$/.test(pathname) && method === "POST") {
|
||||
const gatewayId = extractMatchId(/\/edge-gateways\/(\d+)\/rotate-credentials$/);
|
||||
const gateway = findGateway(gatewayId);
|
||||
if (!gateway) {
|
||||
await route.fulfill(json({ message: "Gateway not found" }, 404));
|
||||
return true;
|
||||
}
|
||||
|
||||
const rotatedAt = toSqlDateTime();
|
||||
gateway.metadata = {
|
||||
...(gateway.metadata || {}),
|
||||
credentials_rotated_at: rotatedAt,
|
||||
};
|
||||
gateway.audit_logs = [
|
||||
{ id: Date.now(), created_at: rotatedAt, action: "GATEWAY_CREDENTIALS_ROTATED", actor_type: "USER" },
|
||||
...(gateway.audit_logs || []),
|
||||
];
|
||||
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
gateway_id: gatewayId,
|
||||
rotated_at: rotatedAt,
|
||||
agent_token: "rotated-edge-agent-token",
|
||||
config_json: JSON.stringify(
|
||||
{
|
||||
apiUrl: "https://api.truckwash.test",
|
||||
gatewayId,
|
||||
agentToken: "rotated-edge-agent-token",
|
||||
installDir: "/opt/truckwash-edge-agent",
|
||||
serviceName: "truckwash-edge-agent.service",
|
||||
heartbeatIntervalSeconds: 15,
|
||||
operationPollTimeoutSeconds: 20,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
restart_instructions: [
|
||||
"sudo systemctl restart truckwash-edge-agent.service",
|
||||
"sudo systemctl status truckwash-edge-agent.service --no-pager",
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/\/edge-gateways\/\d+\/discovery$/.test(pathname) && method === "POST") {
|
||||
const gatewayId = extractMatchId(/\/edge-gateways\/(\d+)\/discovery$/);
|
||||
const gateway = findGateway(gatewayId);
|
||||
|
||||
if (gateway) {
|
||||
const commandId = Date.now();
|
||||
const now = toSqlDateTime();
|
||||
const operation = createOperation(gateway, "DISCOVERY", {}, {
|
||||
status: "IN_PROGRESS",
|
||||
started_at: now,
|
||||
updated_at: now,
|
||||
summary: { label: "Gateway is processing the operation", progress: 20, retryable: true },
|
||||
events: [
|
||||
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_QUEUED", message: "Operation queued for gateway execution", created_at: now },
|
||||
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_STARTED", message: "Gateway started processing the operation", created_at: now },
|
||||
],
|
||||
});
|
||||
gateway.discovery_status = "PENDING";
|
||||
gateway.recent_commands = [
|
||||
{
|
||||
id: commandId,
|
||||
command_type: "DISCOVER_SHELLY",
|
||||
status: "PENDING",
|
||||
created_at: toSqlDateTime(),
|
||||
requested_at: toSqlDateTime(),
|
||||
},
|
||||
...(gateway.recent_commands || []),
|
||||
];
|
||||
edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId] = {
|
||||
jobId: commandId,
|
||||
operationId: operation.id,
|
||||
fetchCount: 0,
|
||||
device: {
|
||||
id: (gateway.inventory?.length || 0) + 1,
|
||||
@@ -2497,11 +2631,7 @@ async function handleEdgeGatewayRoute({
|
||||
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
gateway
|
||||
? json({ data: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true) })
|
||||
: json({ message: "Gateway not found" }, 404)
|
||||
);
|
||||
await route.fulfill(gatewayResponse(gateway, true));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2560,10 +2690,12 @@ async function handleEdgeGatewayRoute({
|
||||
];
|
||||
|
||||
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
|
||||
edgeGatewayFixture.gateways = edgeGatewayFixture.gateways.map((item) => (Number(item.id) === Number(gateway.id) ? Object.assign(item, gateway) : item));
|
||||
edgeGatewayFixture.gateways = edgeGatewayFixture.gateways.map((item) =>
|
||||
Number(item.id) === Number(gateway.id) ? Object.assign(item, gateway) : item
|
||||
);
|
||||
}
|
||||
|
||||
await route.fulfill(gateway ? json({ data: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true) }) : json({ message: "Gateway not found" }, 404));
|
||||
await route.fulfill(gatewayResponse(gateway, true));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2583,6 +2715,11 @@ async function handleEdgeGatewayRoute({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/\/edge-gateways\/\d+\/(?:update-jobs|uninstall)(?:\/.*)?$/.test(pathname) || /\/edge-gateways\/\d+\/shell-sessions(?:\/.*)?$/.test(pathname)) {
|
||||
await route.fulfill(json({ message: "Route not found" }, 404));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/\/edge-gateways\/\d+$/.test(pathname) && method === "DELETE") {
|
||||
const gatewayId = Number(pathname.split("/").pop());
|
||||
const gateway = findGateway(gatewayId);
|
||||
@@ -2761,6 +2898,11 @@ export async function mockApi(page, options = {}) {
|
||||
display_name: "E2E User",
|
||||
permissions: options.permissions || ["user"],
|
||||
economic_customer: [],
|
||||
runtime_config: {
|
||||
economic: {
|
||||
transaction_draft_customer_number: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sessionData = {
|
||||
|
||||
@@ -5,19 +5,17 @@ import { describe, expect, it } from "vitest";
|
||||
const source = readFileSync(join(process.cwd(), "src/services/edgeGateways.js"), "utf8");
|
||||
|
||||
describe("edge gateway service contract", () => {
|
||||
it("wraps the relay-focused backend edge gateway REST endpoints", () => {
|
||||
it("wraps the v2 backend edge gateway REST endpoints", () => {
|
||||
expect(source).toContain('"/departments"');
|
||||
expect(source).toContain('"/edge-gateways"');
|
||||
expect(source).toContain('"/edge-gateways/install-token"');
|
||||
expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}");
|
||||
expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/operations");
|
||||
expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/operations/${encodeURIComponent(operationId)}/events");
|
||||
expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/rotate-credentials");
|
||||
expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/discovery");
|
||||
expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/bindings");
|
||||
expect(source).toContain("/departments/${encodeURIComponent(departmentId)}/gateway-cutover");
|
||||
|
||||
expect(source).not.toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/operations");
|
||||
expect(source).not.toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/update-jobs");
|
||||
expect(source).not.toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/shell-sessions");
|
||||
expect(source).not.toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/rotate-credentials");
|
||||
expect(source).not.toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/uninstall");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,95 +1,45 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildRelayHealthRows,
|
||||
mergeWorkspaceDraftState,
|
||||
resolveIncidentPrimaryAction,
|
||||
resolveNextWorkflowStep,
|
||||
} from "@/components/displays/edgeGateway/edgeGatewayWorkspace.helpers.js";
|
||||
import { inferEdgeGatewayErrorCode, normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
|
||||
|
||||
const managerSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8");
|
||||
const operationsSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOperationsPage.vue"), "utf8");
|
||||
const manageSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManagePage.vue"), "utf8");
|
||||
|
||||
describe("edge gateway workflow helpers", () => {
|
||||
it("derives the next workflow step from selection state", () => {
|
||||
expect(resolveNextWorkflowStep("select", false)).toBe("select");
|
||||
expect(resolveNextWorkflowStep("manage", false)).toBe("manage");
|
||||
expect(resolveNextWorkflowStep("select", true)).toBe("assess");
|
||||
expect(resolveNextWorkflowStep("configure", true)).toBe("configure");
|
||||
expect(resolveNextWorkflowStep("manage", true)).toBe("manage");
|
||||
it("exposes v2 tabs and management actions without shell controls", () => {
|
||||
expect(managerSource).toContain("id: \"overview\"");
|
||||
expect(managerSource).toContain("id: \"inventory\"");
|
||||
expect(managerSource).toContain("id: \"operations\"");
|
||||
expect(managerSource).toContain("id: \"manage\"");
|
||||
expect(managerSource).toContain("`gateway-tab-${tab.id}`");
|
||||
expect(operationsSource).toContain('data-testid="gateway-operation-update"');
|
||||
expect(operationsSource).toContain('data-testid="gateway-operation-uninstall"');
|
||||
expect(manageSource).toContain('data-testid="gateway-rotate-credentials"');
|
||||
expect(managerSource).not.toContain("gateway-shell");
|
||||
expect(managerSource).not.toContain("EdgeGatewayTerminal");
|
||||
});
|
||||
|
||||
it("maps backend recommended actions into guided workflow CTAs", () => {
|
||||
expect(
|
||||
resolveIncidentPrimaryAction({
|
||||
transport_health: { recommended_action: "retry_discovery" },
|
||||
fallback_summary: {},
|
||||
})
|
||||
).toMatchObject({
|
||||
label: "Kør discovery nu",
|
||||
targetStep: "configure",
|
||||
kind: "trigger_discovery",
|
||||
});
|
||||
|
||||
const offlineAction = resolveIncidentPrimaryAction({
|
||||
status: "OFFLINE",
|
||||
transport_health: {},
|
||||
fallback_summary: {},
|
||||
});
|
||||
|
||||
expect(offlineAction.targetStep).toBe("manage");
|
||||
expect(offlineAction.kind).toBe("switch_step");
|
||||
expect(offlineAction.label).toContain("gatewaystyring");
|
||||
});
|
||||
|
||||
it("formats relay health rows with fallback and execution labels", () => {
|
||||
const rows = buildRelayHealthRows(
|
||||
{
|
||||
relay_health: [
|
||||
{
|
||||
binding_id: 12,
|
||||
relay_id: "M-7",
|
||||
fallback_mode: "CLOUD_ONLY",
|
||||
execution_path: "cloud",
|
||||
device_freshness_state: "STALE",
|
||||
reason: "device_stale",
|
||||
},
|
||||
],
|
||||
},
|
||||
[]
|
||||
it("maps structured edge gateway errors into UI copy", () => {
|
||||
expect(inferEdgeGatewayErrorCode({ response: { data: { data: { error_code: "EDGE_GATEWAY_CONFLICT" } } } })).toBe(
|
||||
"EDGE_GATEWAY_CONFLICT"
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
fallbackModeLabel: "Kun cloud",
|
||||
executionPathLabel: "Cloud fallback",
|
||||
freshnessLabel: "Forældet",
|
||||
reasonLabel: "Shelly-device er for gammelt i discovery",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves dirty editor state while merging fresh server data", () => {
|
||||
const merged = mergeWorkspaceDraftState({
|
||||
gateway: {
|
||||
bindings: [{ relay_id: "M-1", device_id: "server-device", channel: 1 }],
|
||||
label: "Server label",
|
||||
is_primary: true,
|
||||
department_transport_mode: "cloud",
|
||||
},
|
||||
currentState: {
|
||||
editableBindings: [{ relay_id: "M-9", device_id: "draft-device", channel: 0 }],
|
||||
gatewayLabel: "Draft label",
|
||||
gatewayIsPrimary: false,
|
||||
transportMode: "gateway",
|
||||
},
|
||||
dirtyState: {
|
||||
bindings: true,
|
||||
gatewayLabel: true,
|
||||
gatewayIsPrimary: true,
|
||||
transportMode: false,
|
||||
const normalized = normalizeEdgeGatewayError({
|
||||
response: {
|
||||
data: {
|
||||
data: {
|
||||
error_code: "EDGE_GATEWAY_INVALID_TOKEN",
|
||||
message: "Invalid edge gateway token",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(merged.editableBindings).toEqual([{ relay_id: "M-9", device_id: "draft-device", channel: 0 }]);
|
||||
expect(merged.gatewayLabel).toBe("Draft label");
|
||||
expect(merged.gatewayIsPrimary).toBe(false);
|
||||
expect(merged.transportMode).toBe("cloud");
|
||||
expect(normalized.code).toBe("EDGE_GATEWAY_INVALID_TOKEN");
|
||||
expect(normalized.title).toMatch(/legitimationsoplysninger/i);
|
||||
expect(normalized.message).toContain("Invalid edge gateway token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,86 +1,55 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const workspaceSource = readFileSync(
|
||||
join(process.cwd(), "src/components/displays/edgeGateway/EdgeGatewayWorkspace.vue"),
|
||||
"utf8"
|
||||
);
|
||||
const maintenancePanelSource = readFileSync(
|
||||
join(process.cwd(), "src/components/displays/edgeGateway/EdgeGatewayMaintenancePanel.vue"),
|
||||
"utf8"
|
||||
);
|
||||
const healthSummarySource = readFileSync(
|
||||
join(process.cwd(), "src/components/displays/edgeGateway/EdgeGatewayHealthSummary.vue"),
|
||||
"utf8"
|
||||
);
|
||||
const managerSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8");
|
||||
const overviewSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOverviewPage.vue"), "utf8");
|
||||
const inventorySource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayInventoryPage.vue"), "utf8");
|
||||
const operationsSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOperationsPage.vue"), "utf8");
|
||||
const manageSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManagePage.vue"), "utf8");
|
||||
const routerSource = readFileSync(join(process.cwd(), "src/router.js"), "utf8");
|
||||
const navSource = readFileSync(
|
||||
join(process.cwd(), "src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue"),
|
||||
const edgeGatewaysPageSource = readFileSync(
|
||||
join(process.cwd(), "src/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue"),
|
||||
"utf8"
|
||||
);
|
||||
const edgeGatewaysViewSource = readFileSync(
|
||||
join(process.cwd(), "src/views/dashboards/superUserDashboard/EdgeGateways.vue"),
|
||||
"utf8"
|
||||
);
|
||||
const departmentGatewaysViewSource = readFileSync(
|
||||
join(process.cwd(), "src/views/dashboards/superUserDashboard/department/DepartmentGateways.vue"),
|
||||
const departmentGatewaysPageSource = readFileSync(
|
||||
join(process.cwd(), "src/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
describe("edge gateway workspace contract", () => {
|
||||
it("renders the guided workflow container through relay-focused child components", () => {
|
||||
expect(workspaceSource).toContain(
|
||||
'import { useEdgeGatewayWorkspace } from "@/composables/useEdgeGatewayWorkspace.js";'
|
||||
);
|
||||
expect(workspaceSource).toContain('import EdgeGatewayFleetRail from "./EdgeGatewayFleetRail.vue";');
|
||||
expect(workspaceSource).toContain('import EdgeGatewayHealthSummary from "./EdgeGatewayHealthSummary.vue";');
|
||||
expect(workspaceSource).toContain('import EdgeGatewayConfigurePanel from "./EdgeGatewayConfigurePanel.vue";');
|
||||
expect(workspaceSource).toContain('import EdgeGatewayMaintenancePanel from "./EdgeGatewayMaintenancePanel.vue";');
|
||||
expect(workspaceSource).not.toContain("EdgeGatewayAdvancedOperations");
|
||||
expect(workspaceSource).not.toContain("EdgeGatewayTerminal");
|
||||
expect(workspaceSource).toContain('data-testid="gateway-detail-header"');
|
||||
expect(workspaceSource).toContain('data-testid="gateway-step-nav"');
|
||||
expect(workspaceSource).toContain('data-testid="gateway-step-select-panel"');
|
||||
expect(workspaceSource).toContain('data-testid="gateway-step-primary-manage"');
|
||||
expect(workspaceSource).toContain("<EdgeGatewayMaintenancePanel");
|
||||
it("splits the v2 UI into route-driven overview, inventory, operations, and manage modules", () => {
|
||||
expect(managerSource).toContain('data-testid="edge-gateway-workspace"');
|
||||
expect(managerSource).toContain('import EdgeGatewayOverviewPage');
|
||||
expect(managerSource).toContain('import EdgeGatewayInventoryPage');
|
||||
expect(managerSource).toContain('import EdgeGatewayOperationsPage');
|
||||
expect(managerSource).toContain('import EdgeGatewayManagePage');
|
||||
expect(overviewSource).toContain('data-testid="gateway-overview-page"');
|
||||
expect(inventorySource).toContain('data-testid="gateway-inventory-page"');
|
||||
expect(operationsSource).toContain('data-testid="gateway-operations-page"');
|
||||
expect(manageSource).toContain('data-testid="gateway-manage-page"');
|
||||
});
|
||||
|
||||
it("keeps route-aware props and page-level selection wiring intact", () => {
|
||||
expect(workspaceSource).toContain("selectedGatewayId: {");
|
||||
expect(workspaceSource).toContain('defineEmits(["open-gateway-page", "select-gateway"])');
|
||||
expect(edgeGatewaysViewSource).toContain('route.name === "edgegatewaydetail"');
|
||||
expect(edgeGatewaysViewSource).toContain(':selected-gateway-id="selectedGatewayId"');
|
||||
expect(edgeGatewaysViewSource).toContain('@select-gateway="handleGatewaySelection"');
|
||||
expect(departmentGatewaysViewSource).toContain('@open-gateway-page="openGatewayPage"');
|
||||
expect(departmentGatewaysViewSource).toContain('name: "edgegatewaydetail"');
|
||||
it("mounts route-driven full-page and safe-subset department workspaces", () => {
|
||||
expect(edgeGatewaysPageSource).toContain(':route-driven="true"');
|
||||
expect(edgeGatewaysPageSource).toContain(':allow-destructive="true"');
|
||||
expect(departmentGatewaysPageSource).toContain(':route-driven="false"');
|
||||
expect(departmentGatewaysPageSource).toContain(':allow-destructive="false"');
|
||||
expect(departmentGatewaysPageSource).toContain('@open-gateway-page="openGatewayPage"');
|
||||
});
|
||||
|
||||
it("keeps canonical fleet and department routes registered", () => {
|
||||
it("registers v2 gateway routes and legacy redirects", () => {
|
||||
expect(routerSource).toContain("path: '/superuser/gateways'");
|
||||
expect(routerSource).toContain("name: 'edgegatewaydetail'");
|
||||
expect(routerSource).toContain("path: '/superuser/gateways/:id'");
|
||||
expect(routerSource).toContain("name: 'edgegatewayoverview'");
|
||||
expect(routerSource).toContain("path: '/superuser/gateways/:id/overview'");
|
||||
expect(routerSource).toContain("name: 'edgegatewayinventory'");
|
||||
expect(routerSource).toContain("path: '/superuser/gateways/:id/inventory'");
|
||||
expect(routerSource).toContain("name: 'edgegatewayoperations'");
|
||||
expect(routerSource).toContain("path: '/superuser/gateways/:id/operations'");
|
||||
expect(routerSource).toContain("name: 'edgegatewaymanage'");
|
||||
expect(routerSource).toContain("path: '/superuser/gateways/:id/manage'");
|
||||
expect(routerSource).toContain("path: '/superuser/departments/:departmentId/gateways'");
|
||||
expect(navSource).toContain("'/superuser/departments/' + departmentId.value + '/gateways'");
|
||||
});
|
||||
|
||||
it("removes remote management surfaces and keeps CRUD panels for metadata, cutover, and delete", () => {
|
||||
expect(maintenancePanelSource).toContain('data-testid="gateway-installer-card"');
|
||||
expect(maintenancePanelSource).toContain('data-testid="gateway-metadata-save"');
|
||||
expect(maintenancePanelSource).toContain('data-testid="gateway-cutover-apply"');
|
||||
expect(maintenancePanelSource).toContain('data-testid="gateway-delete-confirmation"');
|
||||
expect(healthSummarySource).toContain('data-testid="gateway-relay-health-table"');
|
||||
|
||||
expect(maintenancePanelSource).not.toContain("queue-update");
|
||||
expect(maintenancePanelSource).not.toContain("toggle-advanced");
|
||||
expect(healthSummarySource).not.toContain("selectedGatewayShellSummary");
|
||||
|
||||
expect(
|
||||
existsSync(join(process.cwd(), "src/components/displays/edgeGateway/EdgeGatewayAdvancedOperations.vue"))
|
||||
).toBe(false);
|
||||
expect(
|
||||
existsSync(join(process.cwd(), "src/components/displays/edgeGateway/EdgeGatewayTerminal.vue"))
|
||||
).toBe(false);
|
||||
expect(existsSync(join(process.cwd(), "src/composables/useEdgeGatewayTerminal.js"))).toBe(false);
|
||||
expect(routerSource).toContain("configure: 'inventory'");
|
||||
expect(routerSource).toContain("assess: 'overview'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,17 @@ vi.mock("vue-i18n", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRoute: () => ({
|
||||
path: "/admin/12/modules/pos/orders",
|
||||
fullPath: "/admin/12/modules/pos/orders",
|
||||
name: "posorders",
|
||||
params: {
|
||||
departmentId: "12",
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sweetalert2", () => ({
|
||||
default: {
|
||||
fire: vi.fn(() => Promise.resolve()),
|
||||
@@ -222,6 +233,14 @@ const PosDepartmentStepMobileAttachmentStub = {
|
||||
template: '<div class="pos-mobile-attachment-stub"></div>',
|
||||
};
|
||||
|
||||
const OrderAttachmentsActionButtonStub = {
|
||||
template: '<div class="order-attachments-action-button-stub"></div>',
|
||||
};
|
||||
|
||||
const AssignDraftOrderCustomerModalStub = {
|
||||
template: '<div class="assign-draft-order-customer-modal-stub"></div>',
|
||||
};
|
||||
|
||||
const WhiteBoxCardStub = {
|
||||
template:
|
||||
'<div class="white-box-card-stub"><slot name="header"></slot><slot></slot><slot name="footer"></slot></div>',
|
||||
@@ -283,6 +302,8 @@ const mountOrdersTable = (props = {}) => {
|
||||
ActionSettingsWheelItem: ActionSettingsWheelItemStub,
|
||||
OrderContentTable: OrderContentTableStub,
|
||||
PosDepartmentStepMobileAttachment: PosDepartmentStepMobileAttachmentStub,
|
||||
OrderAttachmentsActionButton: OrderAttachmentsActionButtonStub,
|
||||
AssignDraftOrderCustomerModal: AssignDraftOrderCustomerModalStub,
|
||||
WhiteBoxCard: WhiteBoxCardStub,
|
||||
InvoiceMultipleCollectionsModal: InvoiceMultipleCollectionsModalStub,
|
||||
InvoicingBillingPeriodInvoiceProgressBar: InvoicingBillingPeriodInvoiceProgressBarStub,
|
||||
@@ -388,4 +409,20 @@ describe("OrdersTable", () => {
|
||||
"Unhandled error during execution of component update"
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the draft assignment action when explicitly enabled", async () => {
|
||||
const wrapper = mountOrdersTable({
|
||||
showDraftAssignmentActions: true,
|
||||
orders: [
|
||||
createOrder({
|
||||
id: 56625,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="draft-order-assign-customer-button-56625"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("admin.pos.drafts_assignment.button");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mountWithApp } from "./helpers/mountWithApp.js";
|
||||
|
||||
const popupStore = vi.hoisted(() => ({
|
||||
popups: null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", async () => {
|
||||
const { computed, ref } = await import("vue");
|
||||
const activePopup = ref(null);
|
||||
|
||||
popupStore.popups = {
|
||||
isSet: computed(() => activePopup.value !== null),
|
||||
get: () => activePopup.value,
|
||||
set: (popup) => {
|
||||
activePopup.value = popup;
|
||||
return popup;
|
||||
},
|
||||
clear: () => {
|
||||
activePopup.value = null;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
popups: popupStore.popups,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosPopup.vue", async () => {
|
||||
const { defineComponent } = await import("vue");
|
||||
|
||||
return {
|
||||
popupComponentKeyToComponent: () =>
|
||||
defineComponent({
|
||||
name: "PopupInnerStub",
|
||||
template: '<div data-testid="popup-inner-component">Popup body</div>',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import PosDepartmentStepMobilePopupRenderer from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupRenderer.vue";
|
||||
import { popups } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
|
||||
function buildPopup(overrides = {}) {
|
||||
return {
|
||||
id: "select_vehicle",
|
||||
title: "Select vehicle",
|
||||
component: "select_vehicle",
|
||||
style: {
|
||||
height: "50vh",
|
||||
maxHeight: "60vh",
|
||||
},
|
||||
actionButtons: [
|
||||
{
|
||||
label: "Confirm",
|
||||
color: "primary",
|
||||
},
|
||||
{
|
||||
label: "Close",
|
||||
color: "light",
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mountRenderer() {
|
||||
return mountWithApp(PosDepartmentStepMobilePopupRenderer, {
|
||||
global: {
|
||||
stubs: {
|
||||
WhiteBoxCard: false,
|
||||
UnknownCustomer: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("POS mobile popup renderer", () => {
|
||||
beforeEach(() => {
|
||||
popups.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
popups.clear();
|
||||
});
|
||||
|
||||
it("renders popup footer actions only when action buttons exist", () => {
|
||||
popups.set(buildPopup());
|
||||
const wrapper = mountRenderer();
|
||||
|
||||
expect(wrapper.get('[data-testid="pos-mobile-popup-footer"]').text()).toContain("Confirm");
|
||||
expect(wrapper.get('[data-testid="pos-mobile-popup-footer"]').text()).toContain("Close");
|
||||
expect(wrapper.find(".card-footer").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("omits the footer entirely when the popup has no action buttons", () => {
|
||||
popups.set(buildPopup({ actionButtons: [] }));
|
||||
const wrapper = mountRenderer();
|
||||
|
||||
expect(wrapper.find('[data-testid="pos-mobile-popup-footer"]').exists()).toBe(false);
|
||||
expect(wrapper.find(".card-footer").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the footer separate from the scroll region and applies sizing only to the popup shell", () => {
|
||||
popups.set(buildPopup());
|
||||
const wrapper = mountRenderer();
|
||||
|
||||
const shell = wrapper.get('[data-testid="pos-mobile-popup-shell"]');
|
||||
const scrollRegion = wrapper.get('[data-testid="pos-mobile-popup-scroll-region"]');
|
||||
const footer = wrapper.get('[data-testid="pos-mobile-popup-footer"]');
|
||||
const innerComponent = wrapper.get('[data-testid="popup-inner-component"]');
|
||||
const contentContainer = scrollRegion.element.closest(".card-content");
|
||||
const footerContainer = footer.element.closest(".card-footer");
|
||||
|
||||
expect(shell.attributes("style")).toContain(
|
||||
"height: min(50vh, calc(100vh - var(--popup-top-clearance) - var(--popup-bottom-clearance)));"
|
||||
);
|
||||
expect(shell.attributes("style")).toContain(
|
||||
"max-height: min(60vh, calc(100vh - var(--popup-top-clearance) - var(--popup-bottom-clearance)));"
|
||||
);
|
||||
expect(innerComponent.attributes("style")).toBeUndefined();
|
||||
expect(contentContainer).not.toBeNull();
|
||||
expect(footerContainer).not.toBeNull();
|
||||
expect(contentContainer?.nextElementSibling).toBe(footerContainer);
|
||||
});
|
||||
|
||||
it("converts max-height-only popups into a constrained shell height so the footer can anchor", () => {
|
||||
popups.set(buildPopup({ style: { maxHeight: "72dvh" } }));
|
||||
const wrapper = mountRenderer();
|
||||
|
||||
const shell = wrapper.get('[data-testid="pos-mobile-popup-shell"]');
|
||||
|
||||
expect(shell.attributes("style")).toContain(
|
||||
"max-height: min(72dvh, calc(100vh - var(--popup-top-clearance) - var(--popup-bottom-clearance)));"
|
||||
);
|
||||
expect(shell.attributes("style")).toContain(
|
||||
"height: min(72dvh, calc(100vh - var(--popup-top-clearance) - var(--popup-bottom-clearance)));"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ const posState = vi.hoisted(() => ({
|
||||
getSelectedVehiclePlateBooking: vi.fn(() => null),
|
||||
loadPendingBookings: vi.fn(async () => []),
|
||||
ensureVehiclePlateBookingsLoaded: vi.fn(async () => []),
|
||||
getCurrentStep: vi.fn(() => 1),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shop/POSDepartmentProcess.vue", async () => {
|
||||
@@ -75,6 +76,7 @@ vi.mock("@/components/shop/POSDepartmentProcess.vue", async () => {
|
||||
getSelectedVehiclePlateBooking: posState.getSelectedVehiclePlateBooking,
|
||||
loadPendingBookings: posState.loadPendingBookings,
|
||||
ensureVehiclePlateBookingsLoaded: posState.ensureVehiclePlateBookingsLoaded,
|
||||
getCurrentStep: posState.getCurrentStep,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sessionState = vi.hoisted(() => ({
|
||||
runtimeConfigValue: { value: null },
|
||||
canAccessSuperUser: vi.fn(() => false),
|
||||
getTransactionDraftCustomerNumberConfig: vi.fn(async () => ({ data: { data: [] } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
SessionUser: {
|
||||
runtimeConfig: {
|
||||
economic: {
|
||||
transactionDraftCustomerNumber: sessionState.runtimeConfigValue,
|
||||
},
|
||||
},
|
||||
canAccessSuperUser: sessionState.canAccessSuperUser,
|
||||
superUser: {
|
||||
modules: {
|
||||
economic: {
|
||||
config: {
|
||||
transactionDraftCustomerNumber: {
|
||||
get: sessionState.getTransactionDraftCustomerNumberConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("useDraftTransactionCustomer", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
sessionState.runtimeConfigValue.value = null;
|
||||
sessionState.canAccessSuperUser.mockReset();
|
||||
sessionState.canAccessSuperUser.mockReturnValue(false);
|
||||
sessionState.getTransactionDraftCustomerNumberConfig.mockReset();
|
||||
sessionState.getTransactionDraftCustomerNumberConfig.mockResolvedValue({ data: { data: [] } });
|
||||
});
|
||||
|
||||
it("prefers the runtime-configured customer number when available", async () => {
|
||||
sessionState.runtimeConfigValue.value = 44556677;
|
||||
|
||||
const module = await import("@/composables/useDraftTransactionCustomer.js");
|
||||
|
||||
expect(module.getDraftTransactionCustomerNumber()).toBe(44556677);
|
||||
await expect(module.ensureDraftTransactionCustomerLoaded()).resolves.toBe(44556677);
|
||||
expect(sessionState.getTransactionDraftCustomerNumberConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the e-conomic module config when runtime config is missing", async () => {
|
||||
sessionState.canAccessSuperUser.mockReturnValue(true);
|
||||
sessionState.getTransactionDraftCustomerNumberConfig.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
variable: "transactionDraftCustomerNumber",
|
||||
value: 778899,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const module = await import("@/composables/useDraftTransactionCustomer.js");
|
||||
|
||||
await expect(module.ensureDraftTransactionCustomerLoaded()).resolves.toBe(778899);
|
||||
expect(module.getDraftTransactionCustomerNumber()).toBe(778899);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user