Remove edge gateway components and templates associated with outdated workflows.

This commit is contained in:
Jeppe Bundgaard
2026-04-20 14:51:53 +02:00
parent 5d30690dfd
commit 1a784bbbfd
79 changed files with 7228 additions and 6241 deletions
+186
View File
@@ -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"
]
}
}
+74
View File
@@ -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`.
+32 -5
View File
@@ -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
'''
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+414
View File
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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, 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 en ny Pi 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 Pien 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 , 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>
+2 -1
View File
@@ -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,
};
};
-887
View File
@@ -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 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 };
+62 -7
View File
@@ -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",
+55
View File
@@ -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",
+55
View File
@@ -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",
+55
View File
@@ -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",
+55
View File
@@ -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
View File
@@ -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 }
},
{
+50
View File
@@ -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;
});
};
+19 -48
View File
@@ -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>
@@ -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>
@@ -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,
+91 -13
View File
@@ -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();
});
});
+285
View File
@@ -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",
});
});
});
+99
View File
@@ -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,
+17
View File
@@ -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;
+20 -34
View File
@@ -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");
});
});
+83 -33
View File
@@ -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.");
});
});
+11 -15
View File
@@ -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
View File
@@ -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, {
+370 -4
View File
@@ -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
View File
@@ -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 = {
+4 -6
View File
@@ -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");
});
});
+37 -68
View File
@@ -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'");
});
});
+37
View File
@@ -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);
});
});