Refactor module paths, layout responsiveness, and testing configurations:
- Replaced repetitive department module URL logic with `buildDepartmentModulePath` utility in `DepartmentModulesDisplay.vue`. - Enhanced layouts and responsive behavior across components, including `PosDepartmentStep1MobileTransactionHistory.vue` and `DepartmentDailyReport.vue`. - Removed unused `SuperuserInvoicingLocalStore.vue` and refactored to `SuperuserInvoicingLocalStore.ts`. - Updated media query handling and card layout adjustments in `DepartmentDailyReport.vue`. - Added new Playwright configurations (`playwright.prod.config.ts` and `playwright.live.config.ts`) for e2e release workflows. - Extended e2e and unit test coverage for mobile POS and department modules.
This commit is contained in:
@@ -8,10 +8,14 @@
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"preview": "vite preview",
|
||||
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
|
||||
"test:unit": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ci": "playwright test --reporter=line,html",
|
||||
"test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile",
|
||||
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
|
||||
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
|
||||
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
|
||||
"test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"",
|
||||
"test:all": "npm run test:unit && npm run test:e2e:smoke",
|
||||
"twa:build": "bubblewrap build",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL;
|
||||
const isCI = !!process.env.CI;
|
||||
|
||||
if (!baseURL) {
|
||||
throw new Error("PLAYWRIGHT_BASE_URL is required for the live smoke gate.");
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e/release",
|
||||
testMatch: /.*\.live-smoke\.spec\.ts/,
|
||||
timeout: 60_000,
|
||||
fullyParallel: false,
|
||||
forbidOnly: isCI,
|
||||
retries: isCI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { open: "never", outputFolder: "output/playwright/live/report" }],
|
||||
],
|
||||
outputDir: "output/playwright/live/test-results",
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = "http://127.0.0.1:4173";
|
||||
const isCI = !!process.env.CI;
|
||||
|
||||
process.env.PLAYWRIGHT_BASE_URL = baseURL;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e/release",
|
||||
testMatch: /.*\.local-prod\.spec\.ts/,
|
||||
timeout: 60_000,
|
||||
fullyParallel: true,
|
||||
forbidOnly: isCI,
|
||||
retries: isCI ? 2 : 0,
|
||||
workers: isCI ? 2 : 3,
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { open: "never", outputFolder: "output/playwright/prod/report" }],
|
||||
],
|
||||
outputDir: "output/playwright/prod/test-results",
|
||||
use: {
|
||||
baseURL,
|
||||
serviceWorkers: "block",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
webServer: {
|
||||
command: "npm run preview:prod",
|
||||
url: baseURL,
|
||||
timeout: 240_000,
|
||||
reuseExistingServer: !isCI,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chromium-mobile",
|
||||
use: {
|
||||
...devices["Pixel 5"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "webkit-desktop",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,15 @@ const isLoadingSelfServeEnabled = ref(false);
|
||||
const isSavingSelfServeEnabled = ref(false);
|
||||
const bookingsCount = ref(0);
|
||||
|
||||
const getDepartmentId = () => {
|
||||
return parseInt(router.currentRoute.value.params.departmentId);
|
||||
};
|
||||
|
||||
const buildDepartmentModulePath = (modulePath = "") => {
|
||||
const departmentId = getDepartmentId();
|
||||
return `/admin/${departmentId}${modulePath}`;
|
||||
};
|
||||
|
||||
const modules = computed(() => [
|
||||
{
|
||||
id: 1,
|
||||
@@ -26,13 +35,10 @@ const modules = computed(() => [
|
||||
description: t('admin.department_modules.pos.description'),
|
||||
icon: "fas fa-book",
|
||||
onClick: () => {
|
||||
// Get the departmentId from the URL
|
||||
const departmentId = router.currentRoute.value.params.departmentId;
|
||||
// Redirect to the POS system
|
||||
router.push(`/admin/${departmentId}/modules/pos`);
|
||||
router.push(buildDepartmentModulePath('/modules/pos'));
|
||||
},
|
||||
onClickCount: () => {
|
||||
this.onClick();
|
||||
router.push(buildDepartmentModulePath('/modules/pos'));
|
||||
},
|
||||
count: 0
|
||||
},
|
||||
@@ -42,16 +48,10 @@ const modules = computed(() => [
|
||||
description: t('admin.department_modules.bookings.description'),
|
||||
icon: "fas fa-calendar-alt",
|
||||
onClick: () => {
|
||||
// Get the departmentId from the URL
|
||||
const departmentId = router.currentRoute.value.params.departmentId;
|
||||
// Redirect to the bookings
|
||||
router.push(`/admin/${departmentId}/modules/bookings`);
|
||||
router.push(buildDepartmentModulePath('/modules/bookings'));
|
||||
},
|
||||
onClickCount: () => {
|
||||
// Get the departmentId from the URL
|
||||
const departmentId = router.currentRoute.value.params.departmentId;
|
||||
// Redirect to the bookings
|
||||
router.push(`/admin/${departmentId}/modules/bookings?search=pending`);
|
||||
router.push(buildDepartmentModulePath('/modules/bookings?search=pending'));
|
||||
},
|
||||
count: bookingsCount.value
|
||||
},
|
||||
@@ -61,13 +61,10 @@ const modules = computed(() => [
|
||||
description: t('admin.department_modules.daily_report.description'),
|
||||
icon: "fas fa-book",
|
||||
onClick: () => {
|
||||
// Get the departmentId from the URL
|
||||
const departmentId = router.currentRoute.value.params.departmentId;
|
||||
// Redirect to the POS system
|
||||
router.push(`/admin/${departmentId}/modules/daily-report`);
|
||||
router.push(buildDepartmentModulePath('/modules/daily-report'));
|
||||
},
|
||||
onClickCount: () => {
|
||||
this.onClick();
|
||||
router.push(buildDepartmentModulePath('/modules/daily-report'));
|
||||
},
|
||||
count: 0
|
||||
},
|
||||
@@ -77,13 +74,10 @@ const modules = computed(() => [
|
||||
description: t('admin.department_modules.goals.description'),
|
||||
icon: "fas fa-bullseye",
|
||||
onClick: () => {
|
||||
// Get the departmentId from the URL
|
||||
const departmentId = router.currentRoute.value.params.departmentId;
|
||||
// Redirect to the goals
|
||||
router.push(`/admin/${departmentId}/modules/goals`);
|
||||
router.push(buildDepartmentModulePath('/modules/goals'));
|
||||
},
|
||||
onClickCount: () => {
|
||||
this.onClick();
|
||||
router.push(buildDepartmentModulePath('/modules/goals'));
|
||||
},
|
||||
count: 0
|
||||
},
|
||||
@@ -93,13 +87,10 @@ const modules = computed(() => [
|
||||
description: t('admin.department_modules.self_wash.description'),
|
||||
icon: "fas fa-tint",
|
||||
onClick: () => {
|
||||
// Get the departmentId from the URL
|
||||
const departmentId = router.currentRoute.value.params.departmentId;
|
||||
// Redirect to the wash lanes
|
||||
router.push(`/admin/${departmentId}/modules/wash-lanes`);
|
||||
router.push(buildDepartmentModulePath('/modules/wash-lanes'));
|
||||
},
|
||||
onClickCount: () => {
|
||||
this.onClick();
|
||||
router.push(buildDepartmentModulePath('/modules/wash-lanes'));
|
||||
},
|
||||
count: 0,
|
||||
hasSwitch: true,
|
||||
@@ -131,11 +122,10 @@ const modules = computed(() => [
|
||||
description: 'Configure questions, conditions, rules and tasks',
|
||||
icon: "fas fa-project-diagram",
|
||||
onClick: () => {
|
||||
const departmentId = router.currentRoute.value.params.departmentId;
|
||||
router.push(`/admin/${departmentId}/modules/self-serve/studio`);
|
||||
router.push(buildDepartmentModulePath('/modules/self-serve/studio'));
|
||||
},
|
||||
onClickCount: () => {
|
||||
this.onClick();
|
||||
router.push(buildDepartmentModulePath('/modules/self-serve/studio'));
|
||||
},
|
||||
count: 0
|
||||
}
|
||||
@@ -177,11 +167,6 @@ const modules = computed(() => [
|
||||
*/
|
||||
]);
|
||||
|
||||
// Get the departmentId from the URL
|
||||
const getDepartmentId = () => {
|
||||
return parseInt(router.currentRoute.value.params.departmentId);
|
||||
};
|
||||
|
||||
// Get today's bookings
|
||||
const getTodaysBookings = async () => {
|
||||
// Return, if the departmentId is not set
|
||||
@@ -231,9 +216,9 @@ setInterval(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-3" v-for="module in modules" :key="module.id">
|
||||
<div class="card is-clickable" @click="module.onClick">
|
||||
<div class="columns is-multiline department-modules" data-testid="admin-mobile-modules">
|
||||
<div class="column is-12-mobile is-6-tablet is-4-desktop is-3-widescreen" v-for="module in modules" :key="module.id">
|
||||
<div class="card is-clickable department-module-card" @click="module.onClick">
|
||||
<header class="card-header">
|
||||
<div class="card-header-icon">
|
||||
<span class="icon">
|
||||
@@ -242,8 +227,8 @@ setInterval(() => {
|
||||
</div>
|
||||
<p class="card-header-title">{{ module.name }}</p>
|
||||
<div class="is-pulled-right card-header-title pr-0">
|
||||
<span class="button is-small is-dark" @click="module.onClickCount" v-if="module.count > 0">{{ module.count }}</span>
|
||||
<span class="button is-small is-outlined" v-else-if="module?.hasSwitch" @click.stop>
|
||||
<span class="button is-small is-dark department-module-card__count" @click.stop="module.onClickCount" v-if="module.count > 0">{{ module.count }}</span>
|
||||
<span class="button is-small is-outlined department-module-card__switch" v-else-if="module?.hasSwitch" @click.stop>
|
||||
<b-switch
|
||||
size="is-small"
|
||||
type="is-link"
|
||||
@@ -255,7 +240,7 @@ setInterval(() => {
|
||||
</div>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<p class="title is-4">{{ module.name }}</p>
|
||||
<p class="title is-4 department-module-card__title">{{ module.name }}</p>
|
||||
<p class="subtitle">{{ module.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -264,5 +249,26 @@ setInterval(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.department-module-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.department-module-card .card-header-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.department-module-card__count,
|
||||
.department-module-card__switch {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.department-module-card__title {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -310,9 +310,9 @@ onUnmounted(() => {
|
||||
<!-- Take picture button (When attachment view is active) -->
|
||||
<div class="is-flex is-justify-content-center">
|
||||
<PosDepartmentStepMobileButtonNextStep :isWhite="false" v-if="views.attachmentView.value" style="width: 100%;" class="mb-2" :customDisabled="false" :customAction="() => { attachments.takePicture(); }" :buttonClasses="['has-background-primary-dark', 'has-text-black']">
|
||||
<span class="pos-mobile-cta-content">
|
||||
<span class="pos-mobile-cta-label has-text-white">{{ SessionUser.objects.global.language.take_picture }}</span>
|
||||
<span class="pos-mobile-cta-value has-text-white">
|
||||
<span class="pos-mobile-action-content" data-testid="pos-mobile-attachment-view-take-picture-action">
|
||||
<span class="pos-mobile-action-label has-text-white" data-testid="pos-mobile-attachment-view-take-picture-label">{{ SessionUser.objects.global.language.take_picture }}</span>
|
||||
<span class="pos-mobile-action-icon has-text-white" data-testid="pos-mobile-attachment-view-take-picture-icon">
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-camera"></i>
|
||||
</span>
|
||||
@@ -325,9 +325,9 @@ onUnmounted(() => {
|
||||
<PosDepartmentStepMobileButtonNextStep style="width: 100%;" v-if="!views.attachmentView.value" :buttonClasses="['has-background-primary', 'has-text-black']" :isWhite="false"/>
|
||||
<!-- Next step button (If in attachment view) -->
|
||||
<PosDepartmentStepMobileButtonNextStep style="width: 100%;" v-if="views.attachmentView.value" :customDisabled="false" :customAction="() => { views.attachmentView.value = false; }" :buttonClasses="['has-background-primary', 'has-text-black']" :isWhite="false">
|
||||
<span class="pos-mobile-cta-content">
|
||||
<span class="pos-mobile-cta-label has-text-dark">{{ SessionUser.objects.global.language.close }}</span>
|
||||
<span class="pos-mobile-cta-value has-text-dark">
|
||||
<span class="pos-mobile-action-content" data-testid="pos-mobile-attachment-view-close-action">
|
||||
<span class="pos-mobile-action-label has-text-dark" data-testid="pos-mobile-attachment-view-close-label">{{ SessionUser.objects.global.language.close }}</span>
|
||||
<span class="pos-mobile-action-icon has-text-dark" data-testid="pos-mobile-attachment-view-close-icon">
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-times"></i>
|
||||
</span>
|
||||
|
||||
+116
-20
@@ -8,10 +8,8 @@ import PosDepartmentStepMobile1RegistrationNumber3
|
||||
import { activeVehicleIndex, setActiveVehicleIndex, vehicles, popups, metadata, reference } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import PosDepartmentStepMobileButtonClearAll
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
|
||||
import PosDepartmentStepMobileReference
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileReference.vue";
|
||||
import LongPressListener from "@/components/viewport/elements/wrappers/LongPressListener.vue";
|
||||
import {ref, watch} from "vue";
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { getNotes } from "@/components/shop/CustomerNotes.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
const isActive = (number: number): boolean => {
|
||||
@@ -35,6 +33,9 @@ const onLongPress = (number: number) => {
|
||||
}
|
||||
|
||||
const showNewDemo = ref(true);
|
||||
const registrationRowElement = ref<HTMLElement | null>(null);
|
||||
const referenceWidth = ref(174);
|
||||
let registrationRowResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// Get the customers notes (if any)
|
||||
const customerNotes = ref([]); // TODO: define type
|
||||
@@ -57,10 +58,54 @@ watch(() => metadata.getCustomerId(), (newCustomerId) => {
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const updateReferenceWidth = () => {
|
||||
const rowWidth = registrationRowElement.value?.getBoundingClientRect().width ?? 0;
|
||||
referenceWidth.value = Math.max(174, Math.round(rowWidth));
|
||||
};
|
||||
|
||||
const observeRegistrationRow = () => {
|
||||
registrationRowResizeObserver?.disconnect();
|
||||
registrationRowResizeObserver = null;
|
||||
|
||||
if (!registrationRowElement.value || typeof ResizeObserver === "undefined") {
|
||||
updateReferenceWidth();
|
||||
return;
|
||||
}
|
||||
|
||||
registrationRowResizeObserver = new ResizeObserver(() => {
|
||||
updateReferenceWidth();
|
||||
});
|
||||
registrationRowResizeObserver.observe(registrationRowElement.value);
|
||||
updateReferenceWidth();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
observeRegistrationRow();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
registrationRowResizeObserver?.disconnect();
|
||||
});
|
||||
|
||||
watch(registrationRowElement, async () => {
|
||||
await nextTick();
|
||||
observeRegistrationRow();
|
||||
});
|
||||
|
||||
watch(() => [
|
||||
vehicles.get(1)?.reg?.length ?? 0,
|
||||
vehicles.get(2)?.reg?.length ?? 0,
|
||||
vehicles.get(3)?.reg?.length ?? 0,
|
||||
], async () => {
|
||||
await nextTick();
|
||||
updateReferenceWidth();
|
||||
}, { flush: "post" });
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="max-height: 25vh; overflow-y: auto; padding-bottom: 10px;">
|
||||
<div class="registration-number-list">
|
||||
<template v-if="!showNewDemo">
|
||||
<LongPressListener @long-press="onLongPress(1)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber1 :active="isActive(1)" @click="setActive(1)"/>
|
||||
@@ -74,37 +119,54 @@ watch(() => metadata.getCustomerId(), (newCustomerId) => {
|
||||
<PosDepartmentStepMobileButtonClearAll/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<div class="columns is-mobile is-vcentered is-centered is-gapless">
|
||||
<div class="column is-narrow mr-1">
|
||||
<div class="registration-summary">
|
||||
<div
|
||||
ref="registrationRowElement"
|
||||
class="registration-row"
|
||||
data-testid="pos-mobile-step-1-registration-row"
|
||||
>
|
||||
<div class="registration-slot">
|
||||
<LongPressListener @long-press="onLongPress(1)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber1 :active="isActive(1)" @click="setActive(1)" :showLabel="false" :isWider="!vehicles.get(1)?.reg?.length && !vehicles.get(2)?.reg?.length && !vehicles.get(3)?.reg?.length"/>
|
||||
</LongPressListener>
|
||||
</div>
|
||||
<div class="column is-narrow ml-1" v-if="vehicles.get(1)?.reg?.length || vehicles.get(2)?.reg?.length">
|
||||
<div class="registration-slot" v-if="vehicles.get(1)?.reg?.length || vehicles.get(2)?.reg?.length">
|
||||
<LongPressListener @long-press="onLongPress(2)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber2 :active="isActive(2)" @click="setActive(2)" :showLabel="false" :class="{'opacity-invisible': !vehicles.get(1)?.reg?.length}"/>
|
||||
</LongPressListener>
|
||||
</div>
|
||||
<div class="column is-narrow ml-1" v-if="vehicles.get(3)?.reg?.length">
|
||||
<div class="registration-slot" v-if="vehicles.get(3)?.reg?.length">
|
||||
<LongPressListener @long-press="onLongPress(3)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber3 :active="isActive(3)" @click="setActive(3)" :showLabel="false" :class="{'opacity-invisible': !vehicles.get(2)?.reg?.length}"/>
|
||||
</LongPressListener>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reference -->
|
||||
<div
|
||||
class="mt-2 mb-2 has-text-centered custom-button-secondary reference-trigger"
|
||||
:class="{'opacity-invisible': !vehicles.get(1)?.reg?.length}"
|
||||
:style="{ width: `${referenceWidth}px` }"
|
||||
data-testid="pos-mobile-step-1-reference-trigger"
|
||||
@click="popups.select('change_reference')"
|
||||
>
|
||||
<p v-if="reference.length > 0"
|
||||
class="has-overflow-ellipsis has-text-weight-bold has-text-white mx-3"
|
||||
style="max-height: 20px;"
|
||||
>{{ reference }}</p>
|
||||
<p v-else>
|
||||
<span class="is-italic">Indtast reference...</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reference -->
|
||||
<div class="mt-2 mb-2 has-text-centered custom-button-secondary" style="min-width: 174px;" :class="{'opacity-invisible': !vehicles.get(1)?.reg?.length}" @click="popups.select('change_reference')">
|
||||
<p v-if="reference.length > 0"
|
||||
class="has-overflow-ellipsis has-text-weight-bold has-text-white mx-3"
|
||||
style="max-height: 20px; max-width: 90vw;"
|
||||
>{{ reference }}</p>
|
||||
<p v-else>
|
||||
<span class="is-italic">Indtast reference...</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Reference -->
|
||||
<div class="mt-2 mb-2 has-text-centered custom-button-secondary px-2" style="min-width: 174px;" :class="{'opacity-invisible': customerNotes.length === 0, 'has-background-warning': customerNotes.length > 0}" @click="popups.select('customer_notes')" v-if="customerNotes.length > 0" data-testid="pos-mobile-customer-notes-trigger">
|
||||
<div
|
||||
class="mt-2 mb-2 has-text-centered custom-button-secondary px-2 notes-trigger"
|
||||
:class="{'opacity-invisible': customerNotes.length === 0, 'has-background-warning': customerNotes.length > 0}"
|
||||
:style="{ width: `${referenceWidth}px` }"
|
||||
@click="popups.select('customer_notes')"
|
||||
v-if="customerNotes.length > 0"
|
||||
data-testid="pos-mobile-customer-notes-trigger"
|
||||
>
|
||||
<p>
|
||||
<!-- If there are notes, show a warning icon -->
|
||||
<span v-if="customerNotes.length > 0" class="icon is-small has-text-dark pr-1">
|
||||
@@ -118,6 +180,31 @@ watch(() => metadata.getCustomerId(), (newCustomerId) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.registration-number-list {
|
||||
max-height: 25vh;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.registration-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.registration-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.registration-slot {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.is-wider {
|
||||
width: 200px!important;
|
||||
}
|
||||
@@ -153,4 +240,13 @@ watch(() => metadata.getCustomerId(), (newCustomerId) => {
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.reference-trigger,
|
||||
.notes-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 174px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
+34
@@ -587,6 +587,40 @@ const defaultLongPressBehavior = () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.pos-mobile-action-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
:deep(.pos-mobile-action-label) {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:deep(.pos-mobile-action-icon) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
min-width: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:deep(.pos-mobile-action-icon i) {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:deep(.generic-button__content) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
+10
-8
@@ -201,15 +201,17 @@ onMounted(() => {
|
||||
</button>
|
||||
</template>
|
||||
<!-- Buttons -->
|
||||
<PosDepartmentStepMobileFixedBottomControl v-if="props.showButtons">
|
||||
<!-- Close button -->
|
||||
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => emit('close')" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
|
||||
<span class="is-float-left">{{ SessionUser.objects.global.language.close }}</span>
|
||||
<span class="is-float-right">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
<PosDepartmentStepMobileFixedBottomControl v-if="props.showButtons">
|
||||
<!-- Close button -->
|
||||
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => emit('close')" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
|
||||
<span class="pos-mobile-action-content">
|
||||
<span class="pos-mobile-action-label">{{ SessionUser.objects.global.language.close }}</span>
|
||||
<span class="pos-mobile-action-icon">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</span>
|
||||
</span>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
</PosDepartmentStepMobileFixedBottomControl>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
</PosDepartmentStepMobileFixedBottomControl>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+19
-15
@@ -156,23 +156,23 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div data-testid="pos-mobile-transaction-history">
|
||||
<!-- Visibility toggles -->
|
||||
<div class="columns is-mobile is-vcentered is-multiline">
|
||||
<div class="column is-half" v-for="(visibility, index) in [showPending, showCompleted]" :key="index">
|
||||
<WhiteBoxCard :toggleable="false" :forceState="false" :defaultOpen="false" @click="() => { index === 0 ? showPending = !showPending : showCompleted = !showCompleted }">
|
||||
<template #header>
|
||||
<div class="card-header-title">
|
||||
<span v-if="index === 0">{{ SessionUser.objects.global.language.pending }}</span>
|
||||
<span v-else-if="index === 1">{{ SessionUser.objects.global.language.completed }}</span>
|
||||
<span v-else>{{ SessionUser.objects.global.language.error }}</span>
|
||||
<span v-if="index === 0">{{ SessionUser.objects.global.language.pending }}</span>
|
||||
<span v-else-if="index === 1">{{ SessionUser.objects.global.language.completed }}</span>
|
||||
<span v-else>{{ SessionUser.objects.global.language.error }}</span>
|
||||
</div>
|
||||
<div class="card-header-icon">
|
||||
<!-- Checkbox icon, if visibility is true, show checked, else show unchecked -->
|
||||
<span class="icon">
|
||||
<i v-if="visibility" class="fa-solid fa-check"></i>
|
||||
<i v-else class="fa-solid fa-square"></i>
|
||||
</span>
|
||||
<!-- Checkbox icon, if visibility is true, show checked, else show unchecked -->
|
||||
<span class="icon">
|
||||
<i v-if="visibility" class="fa-solid fa-check"></i>
|
||||
<i v-else class="fa-solid fa-square"></i>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
@@ -235,16 +235,20 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
|
||||
<PosDepartmentStepMobileFixedBottomControl>
|
||||
<!-- Refresh button -->
|
||||
<PosDepartmentStepMobileButtonNextStep class="mb-2" :isWhite="false" :customAction="() => syncListTransactionHistory()" :customDisabled="false" :buttonClasses="['has-background-primary-dark', 'has-text-black', ...isLoading ? ['is-loading'] : []]">
|
||||
<span class="is-float-left has-text-white">{{ SessionUser.objects.global.language.reload }}</span>
|
||||
<span class="is-float-right has-text-white is-loading">
|
||||
<i class="fa-solid fa-arrows-rotate"></i>
|
||||
<span class="pos-mobile-action-content" data-testid="pos-mobile-transaction-history-reload-action">
|
||||
<span class="pos-mobile-action-label has-text-white" data-testid="pos-mobile-transaction-history-reload-label">{{ SessionUser.objects.global.language.reload }}</span>
|
||||
<span class="pos-mobile-action-icon has-text-white" data-testid="pos-mobile-transaction-history-reload-icon">
|
||||
<i class="fa-solid fa-arrows-rotate"></i>
|
||||
</span>
|
||||
</span>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
<!-- Close button -->
|
||||
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => emit('close')" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
|
||||
<span class="is-float-left">{{ SessionUser.objects.global.language.close }}</span>
|
||||
<span class="is-float-right">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
<span class="pos-mobile-action-content" data-testid="pos-mobile-transaction-history-close-action">
|
||||
<span class="pos-mobile-action-label" data-testid="pos-mobile-transaction-history-close-label">{{ SessionUser.objects.global.language.close }}</span>
|
||||
<span class="pos-mobile-action-icon" data-testid="pos-mobile-transaction-history-close-icon">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</span>
|
||||
</span>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
</PosDepartmentStepMobileFixedBottomControl>
|
||||
|
||||
@@ -203,11 +203,116 @@ const getBrokerMeta = (brokerConnected) => {
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeMetricNumber = (value) => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const formatMetricNumber = (value, maximumFractionDigits = 0) =>
|
||||
new Intl.NumberFormat("da-DK", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits,
|
||||
}).format(value);
|
||||
|
||||
const formatMetricPercent = (value) => {
|
||||
const parsed = normalizeMetricNumber(value);
|
||||
if (parsed === null) {
|
||||
return "Ukendt";
|
||||
}
|
||||
|
||||
return `${formatMetricNumber(parsed, parsed < 10 ? 1 : 0)}%`;
|
||||
};
|
||||
|
||||
const formatMetricLatency = (value) => {
|
||||
const parsed = normalizeMetricNumber(value);
|
||||
if (parsed === null) {
|
||||
return "Ukendt";
|
||||
}
|
||||
|
||||
if (parsed < 1000) {
|
||||
return `${formatMetricNumber(parsed)} ms`;
|
||||
}
|
||||
|
||||
return `${formatMetricNumber(parsed / 1000, 1)} s`;
|
||||
};
|
||||
|
||||
const 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]}`;
|
||||
};
|
||||
|
||||
const 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.",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const buildGatewayView = (gateway) => {
|
||||
if (!gateway) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = gateway.metadata && typeof gateway.metadata === "object" ? gateway.metadata : {};
|
||||
const statusMeta = getStatusMeta(gateway.status);
|
||||
const inventory = Array.isArray(gateway.inventory) ? gateway.inventory : [];
|
||||
const bindings = Array.isArray(gateway.bindings) ? gateway.bindings : [];
|
||||
@@ -215,10 +320,12 @@ const buildGatewayView = (gateway) => {
|
||||
const recentShellSessions = Array.isArray(gateway.recent_shell_sessions) ? gateway.recent_shell_sessions : [];
|
||||
const recentCommands = Array.isArray(gateway.recent_commands) ? gateway.recent_commands : [];
|
||||
const auditLogs = Array.isArray(gateway.audit_logs) ? gateway.audit_logs : [];
|
||||
const brokerMeta = getBrokerMeta(gateway.metadata?.broker_connected);
|
||||
const brokerMeta = getBrokerMeta(metadata.broker_connected);
|
||||
const metrics = buildGatewayMetricView(gateway);
|
||||
|
||||
return {
|
||||
...gateway,
|
||||
metadata,
|
||||
inventory,
|
||||
bindings,
|
||||
recent_updates: recentUpdates,
|
||||
@@ -238,10 +345,11 @@ const buildGatewayView = (gateway) => {
|
||||
(gateway.transport_mode ?? null) !== null &&
|
||||
(gateway.department_transport_mode ?? null) !== null &&
|
||||
gateway.transport_mode !== gateway.department_transport_mode,
|
||||
brokerConnected: gateway.metadata?.broker_connected ?? null,
|
||||
brokerConnected: metadata.broker_connected ?? null,
|
||||
brokerStatusLabel: brokerMeta.label,
|
||||
brokerStatusTone: brokerMeta.tone,
|
||||
brokerStatusHelper: brokerMeta.helper,
|
||||
metrics,
|
||||
discoveryStatusLabel:
|
||||
DISCOVERY_STATUS_LABELS[gateway.discovery_status] ?? gateway.discovery_status ?? DISCOVERY_STATUS_LABELS.UNKNOWN,
|
||||
displayLabel: gateway.label || gateway.hostname || `Gateway #${gateway.id}`,
|
||||
@@ -342,6 +450,56 @@ const installerCardClass = computed(() =>
|
||||
departmentScoped.value ? "edge-card edge-card--installer edge-card--compact" : "edge-card edge-card--installer"
|
||||
);
|
||||
|
||||
const formatInventoryChannelCount = (count) => `${count} kanal${count === 1 ? "" : "er"}`;
|
||||
|
||||
const getInventoryGeneration = (device = {}) =>
|
||||
device?.capabilities?.generation ?? device?.metadata?.gen ?? device?.metadata?.generation ?? null;
|
||||
|
||||
const 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(" \u00b7 ");
|
||||
};
|
||||
|
||||
const compareInventoryDevices = (left, right) => {
|
||||
const leftOfflineRank = left?.online === false ? 1 : 0;
|
||||
const rightOfflineRank = right?.online === false ? 1 : 0;
|
||||
|
||||
if (leftOfflineRank !== rightOfflineRank) {
|
||||
return leftOfflineRank - rightOfflineRank;
|
||||
}
|
||||
|
||||
const modelComparison = String(left?.model ?? "").localeCompare(String(right?.model ?? ""), "da", {
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (modelComparison !== 0) {
|
||||
return modelComparison;
|
||||
}
|
||||
|
||||
return String(left?.device_id ?? "").localeCompare(String(right?.device_id ?? ""), "da", {
|
||||
sensitivity: "base",
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeBinding = (binding = {}) => ({
|
||||
relay_id: binding.relay_id ?? "",
|
||||
device_id: binding.device_id ?? "",
|
||||
@@ -620,11 +778,91 @@ const removeBindingRow = (index) => {
|
||||
editableBindings.value = editableBindings.value.filter((_, bindingIndex) => bindingIndex !== index);
|
||||
};
|
||||
|
||||
const inventoryDeviceOptions = computed(() =>
|
||||
[...(selectedGatewayView.value?.inventory ?? [])]
|
||||
.sort(compareInventoryDevices)
|
||||
.map((device) => ({
|
||||
value: device.device_id,
|
||||
label: buildInventoryOptionLabel(device),
|
||||
missing: false,
|
||||
}))
|
||||
);
|
||||
|
||||
const getInventoryDevice = (deviceId) =>
|
||||
selectedGatewayView.value?.inventory?.find((device) => device.device_id === deviceId) ?? null;
|
||||
|
||||
const findInventoryDeviceOption = (deviceId) =>
|
||||
inventoryDeviceOptions.value.find((device) => device.value === deviceId) ?? null;
|
||||
|
||||
const buildMissingBindingDevice = (binding = {}) => ({
|
||||
device_id: binding.device_id ?? "",
|
||||
local_ip: binding.local_ip ?? "",
|
||||
model: "Ukendt Shelly-device",
|
||||
channel_count: Math.max(1, Number(binding.channel ?? 0) + 1),
|
||||
online: null,
|
||||
capabilities: {},
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
const getBindingInventoryDevice = (binding) => {
|
||||
const inventoryDevice = getInventoryDevice(binding?.device_id);
|
||||
if (inventoryDevice) {
|
||||
return inventoryDevice;
|
||||
}
|
||||
|
||||
if (!binding?.device_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildMissingBindingDevice(binding);
|
||||
};
|
||||
|
||||
const buildMissingBindingOption = (binding = {}) => ({
|
||||
value: binding.device_id,
|
||||
label: buildInventoryOptionLabel(buildMissingBindingDevice(binding), { missing: true }),
|
||||
missing: true,
|
||||
});
|
||||
|
||||
const bindingHasStaleSelection = (binding) => Boolean(binding?.device_id) && !findInventoryDeviceOption(binding.device_id);
|
||||
|
||||
const getBindingDeviceOptions = (binding) => {
|
||||
if (!bindingHasStaleSelection(binding)) {
|
||||
return inventoryDeviceOptions.value;
|
||||
}
|
||||
|
||||
return [...inventoryDeviceOptions.value, buildMissingBindingOption(binding)];
|
||||
};
|
||||
|
||||
const hasDiscoveredInventory = computed(() => inventoryDeviceOptions.value.length > 0);
|
||||
|
||||
const hasStaleBindingSelections = computed(() =>
|
||||
editableBindings.value.some((binding) => bindingHasStaleSelection(binding))
|
||||
);
|
||||
|
||||
const bindingHelperText = computed(() => {
|
||||
if (hasDiscoveredInventory.value && hasStaleBindingSelections.value) {
|
||||
return "Discovery-listen mangler mindst én gemt device. Vælg en ny device eller behold den markerede, indtil discovery er opdateret.";
|
||||
}
|
||||
|
||||
if (hasDiscoveredInventory.value) {
|
||||
return "Tilføj eller justér bindinger. Gem først, når tabellen ser rigtig ud.";
|
||||
}
|
||||
|
||||
if (hasStaleBindingSelections.value) {
|
||||
return "Discovery fandt ingen aktuelle Shelly-enheder, men eksisterende bindinger kan stadig gennemgås og gemmes.";
|
||||
}
|
||||
|
||||
return "Kør discovery for at hente Shelly-enheder, og tilføj derefter bindinger.";
|
||||
});
|
||||
|
||||
const emptyBindingStateDescription = computed(() =>
|
||||
hasDiscoveredInventory.value
|
||||
? "Tilføj en binding og map relæer til fundne enheder."
|
||||
: "Kør discovery først for at hente en opdateret liste over Shelly-enheder."
|
||||
);
|
||||
|
||||
const getBindingChannelOptions = (binding) => {
|
||||
const device = getInventoryDevice(binding.device_id);
|
||||
const device = getBindingInventoryDevice(binding);
|
||||
const channelCount = Math.max(1, Number(device?.channel_count ?? 1));
|
||||
return Array.from({ length: channelCount }, (_, index) => index);
|
||||
};
|
||||
@@ -1027,6 +1265,26 @@ onBeforeUnmount(() => {
|
||||
<strong>{{ selectedGatewayShellSummary }}</strong>
|
||||
<small>Break-glass adgang er separat godkendelse</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Latens</span>
|
||||
<strong>{{ selectedGatewayView.metrics.latency.value }}</strong>
|
||||
<small>{{ selectedGatewayView.metrics.latency.helper }}</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>CPU</span>
|
||||
<strong>{{ selectedGatewayView.metrics.cpu.value }}</strong>
|
||||
<small>{{ selectedGatewayView.metrics.cpu.helper }}</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>RAM</span>
|
||||
<strong>{{ selectedGatewayView.metrics.memory.value }}</strong>
|
||||
<small>{{ selectedGatewayView.metrics.memory.helper }}</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Disk</span>
|
||||
<strong>{{ selectedGatewayView.metrics.disk.value }}</strong>
|
||||
<small>{{ selectedGatewayView.metrics.disk.helper }}</small>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1252,7 +1510,7 @@ onBeforeUnmount(() => {
|
||||
</header>
|
||||
|
||||
<div class="edge-inline-actions edge-inline-actions--between">
|
||||
<p class="edge-helper-text">Tilføj eller justér bindinger. Gem først, når tabellen ser rigtig ud.</p>
|
||||
<p class="edge-helper-text">{{ bindingHelperText }}</p>
|
||||
<button type="button" class="button is-light" @click="addBindingRow">
|
||||
Tilføj binding
|
||||
</button>
|
||||
@@ -1260,7 +1518,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div v-if="!editableBindings.length" class="edge-empty-state edge-empty-state--inline">
|
||||
<h3>Ingen bindinger endnu</h3>
|
||||
<p>Tilføj en binding eller kør discovery og map relæer til fundne enheder.</p>
|
||||
<p>{{ emptyBindingStateDescription }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="edge-table-shell">
|
||||
@@ -1292,11 +1550,11 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<option value="">Vælg device</option>
|
||||
<option
|
||||
v-for="device in selectedGatewayView.inventory"
|
||||
:key="device.device_id"
|
||||
:value="device.device_id"
|
||||
v-for="device in getBindingDeviceOptions(binding)"
|
||||
:key="device.value"
|
||||
:value="device.value"
|
||||
>
|
||||
{{ device.device_id }} · {{ device.model }}
|
||||
{{ device.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
+17
-1
@@ -2,6 +2,7 @@
|
||||
import { computed, defineProps, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useMediaQuery } from "@vueuse/core";
|
||||
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import NotFoundFallBackPageWrapper from "@/components/page/wrappers/NotFoundFallBackPageWrapper.vue";
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const isDesktopLayout = useMediaQuery("(min-width: 769px)");
|
||||
|
||||
const props = defineProps({
|
||||
departmentSpecific: {
|
||||
@@ -188,6 +190,11 @@ const formatSubtitle = (subtitle) => {
|
||||
|
||||
const minColumnHeight = ref("auto");
|
||||
const equalizeCardHeights = () => {
|
||||
if (!isDesktopLayout.value) {
|
||||
minColumnHeight.value = "auto";
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const cards = document.querySelectorAll("[data-testid^='daily-report-tile-'] .card-content");
|
||||
let maxHeight = 0;
|
||||
@@ -198,6 +205,15 @@ const equalizeCardHeights = () => {
|
||||
}, 100);
|
||||
};
|
||||
|
||||
watch(isDesktopLayout, (isDesktop) => {
|
||||
if (!isDesktop) {
|
||||
minColumnHeight.value = "auto";
|
||||
return;
|
||||
}
|
||||
|
||||
equalizeCardHeights();
|
||||
}, { immediate: true });
|
||||
|
||||
watch(
|
||||
() => [
|
||||
count_bookings.value,
|
||||
@@ -538,7 +554,7 @@ const statistics = computed(() => ([
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="column is-4" v-for="chart in section.columns" :key="chart.key">
|
||||
<div class="column is-12-mobile is-6-tablet is-4-desktop" v-for="chart in section.columns" :key="chart.key">
|
||||
<component :is="chart.component" v-bind="chart.props" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+22
-2
@@ -686,8 +686,8 @@ onBeforeUnmount(() => {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot">
|
||||
<div class="buttons is-right" style="width: 100%">
|
||||
<footer class="modal-card-foot complaints-modal-footer">
|
||||
<div class="buttons is-right complaints-modal-footer__actions">
|
||||
<button
|
||||
class="button"
|
||||
type="button"
|
||||
@@ -764,4 +764,24 @@ onBeforeUnmount(() => {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.complaints-modal-footer__actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.complaints-modal-footer {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.complaints-modal-footer__actions {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.complaints-modal-footer__actions .button {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+95
-44
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, defineProps } from 'vue';
|
||||
import { computed, defineProps, ref, watch } from "vue";
|
||||
import { useMediaQuery } from "@vueuse/core";
|
||||
import { selected_date, selected_date_to, selectDate, selected_department_ids, selectDepartments } from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue";
|
||||
import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
@@ -23,6 +24,8 @@ const props = defineProps({
|
||||
|
||||
const localSelectedIds = ref([]);
|
||||
const hasInitializedSelection = ref(false);
|
||||
const isMobileLayout = useMediaQuery("(max-width: 768px)");
|
||||
const isDepartmentFiltersExpanded = ref(false);
|
||||
const departmentOptions = computed(() => (
|
||||
[...departments.value]
|
||||
.filter((department) => isAccessibleVisibleDepartment(department, SessionUser.canAccessDepartment))
|
||||
@@ -34,6 +37,11 @@ const departmentOptions = computed(() => (
|
||||
));
|
||||
const accessibleDepartmentIds = computed(() => departmentOptions.value.map((department) => department.id));
|
||||
const loadingDepartments = computed(() => departmentsStoreLoading.value && departmentOptions.value.length === 0);
|
||||
const areAllDepartmentsSelected = computed(() => (
|
||||
departmentOptions.value.length > 0
|
||||
&& localSelectedIds.value.length === departmentOptions.value.length
|
||||
));
|
||||
const isDepartmentFilterVisible = computed(() => !isMobileLayout.value || isDepartmentFiltersExpanded.value);
|
||||
|
||||
if (departments.value.length === 0 && !departmentsStoreLoading.value) {
|
||||
getDepartments().catch(() => {});
|
||||
@@ -64,6 +72,10 @@ watch(departmentOptions, (options) => {
|
||||
hasInitializedSelection.value = true;
|
||||
}, { immediate: true });
|
||||
|
||||
watch(isMobileLayout, (isMobile) => {
|
||||
isDepartmentFiltersExpanded.value = !isMobile;
|
||||
}, { immediate: true });
|
||||
|
||||
// Handle local selection change
|
||||
const onDepartmentsChange = () => {
|
||||
// Ensure integers and only allowed departments
|
||||
@@ -88,6 +100,21 @@ const toggleSelection = (id) => {
|
||||
}
|
||||
onDepartmentsChange();
|
||||
};
|
||||
|
||||
const toggleDepartmentFilters = () => {
|
||||
if (!isMobileLayout.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDepartmentFiltersExpanded.value = !isDepartmentFiltersExpanded.value;
|
||||
};
|
||||
|
||||
const toggleAllDepartments = () => {
|
||||
localSelectedIds.value = areAllDepartmentsSelected.value
|
||||
? []
|
||||
: [...departmentOptions.value.map((department) => department.id)];
|
||||
onDepartmentsChange();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -100,54 +127,78 @@ const toggleSelection = (id) => {
|
||||
:reverse-level-order="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="column">
|
||||
<label class="label mb-2">{{ $t('department_dashboard.daily_report.departments') }}</label>
|
||||
<div class="buttons" style="flex-wrap: wrap; gap: 0.5rem 0.5rem;" data-testid="daily-report-department-controls">
|
||||
<button
|
||||
v-show="!loadingDepartments"
|
||||
v-for="opt in departmentOptions"
|
||||
:key="opt.id"
|
||||
class="button"
|
||||
:class="isSelected(opt.id) ? 'is-info' : 'is-light'"
|
||||
type="button"
|
||||
@click="toggleSelection(opt.id)"
|
||||
:aria-pressed="isSelected(opt.id)"
|
||||
:disabled="loading"
|
||||
:data-testid="`daily-report-department-${opt.id}`"
|
||||
>
|
||||
{{ opt.name }}
|
||||
</button>
|
||||
<!-- Loading skeleton -->
|
||||
<template v-for="i in 9" :key="'loading-skeleton-' + i" v-if="loadingDepartments">
|
||||
<b-button class="is-light">
|
||||
<b-skeleton width="80px" height="1rem"/>
|
||||
</b-button>
|
||||
</template>
|
||||
<div class="column is-12">
|
||||
<button
|
||||
v-if="isMobileLayout"
|
||||
class="button is-light is-fullwidth daily-report-navigation__toggle"
|
||||
type="button"
|
||||
data-testid="daily-report-mobile-departments-toggle"
|
||||
@click="toggleDepartmentFilters"
|
||||
>
|
||||
<span>{{ $t('department_dashboard.daily_report.departments') }}</span>
|
||||
<span class="icon">
|
||||
<i :class="isDepartmentFiltersExpanded ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- All -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<label class="label mb-2"> </label>
|
||||
<div class="buttons" style="flex-wrap: wrap; gap: 0.5rem 0.5rem;">
|
||||
<button
|
||||
class="button"
|
||||
:class="(localSelectedIds.length === departmentOptions.length) ? 'is-info' : 'is-light'"
|
||||
type="button"
|
||||
@click="
|
||||
( localSelectedIds.length === departmentOptions.length ? localSelectedIds = [] : localSelectedIds = [...departmentOptions.map(opt => opt.id)] );
|
||||
onDepartmentsChange();
|
||||
"
|
||||
:aria-pressed="(localSelectedIds.length === departmentOptions.length)"
|
||||
:disabled="loading"
|
||||
data-testid="daily-report-department-all"
|
||||
>
|
||||
{{ $t('common.all') }}
|
||||
</button>
|
||||
<div
|
||||
v-if="isDepartmentFilterVisible"
|
||||
class="daily-report-navigation__department-panel"
|
||||
:class="{ 'daily-report-navigation__department-panel--mobile': isMobileLayout }"
|
||||
>
|
||||
<label class="label mb-2">{{ $t('department_dashboard.daily_report.departments') }}</label>
|
||||
<div class="buttons daily-report-navigation__department-buttons" data-testid="daily-report-department-controls">
|
||||
<button
|
||||
class="button"
|
||||
:class="areAllDepartmentsSelected ? 'is-info' : 'is-light'"
|
||||
type="button"
|
||||
@click="toggleAllDepartments"
|
||||
:aria-pressed="areAllDepartmentsSelected"
|
||||
:disabled="loading"
|
||||
data-testid="daily-report-department-all"
|
||||
>
|
||||
{{ $t('common.all') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-show="!loadingDepartments"
|
||||
v-for="opt in departmentOptions"
|
||||
:key="opt.id"
|
||||
class="button"
|
||||
:class="isSelected(opt.id) ? 'is-info' : 'is-light'"
|
||||
type="button"
|
||||
@click="toggleSelection(opt.id)"
|
||||
:aria-pressed="isSelected(opt.id)"
|
||||
:disabled="loading"
|
||||
:data-testid="`daily-report-department-${opt.id}`"
|
||||
>
|
||||
{{ opt.name }}
|
||||
</button>
|
||||
|
||||
<template v-if="loadingDepartments" v-for="i in 9" :key="'loading-skeleton-' + i">
|
||||
<b-button class="is-light">
|
||||
<b-skeleton width="80px" height="1rem"/>
|
||||
</b-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.daily-report-navigation__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.daily-report-navigation__department-panel--mobile {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.daily-report-navigation__department-buttons {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
+43
-3
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useMediaQuery } from "@vueuse/core";
|
||||
|
||||
import DepartmentModulesDisplay from "@/components/displays/department/moduleNavigation/DepartmentModulesDisplay.vue";
|
||||
import { ref, watch } from 'vue';
|
||||
import { getDepartmentName, getDepartmentDescription, isLoading, getDepartments, departments, departmentExists } from "@/components/pagination/departmentTabs.vue";
|
||||
import {useRouter} from "vue-router";
|
||||
import DepartmentDashboardPageWrapper from "@/views/dashboards/departmentDashboard/DepartmentDashboardPageWrapper.vue";
|
||||
@@ -18,6 +19,21 @@ import DepartmentDashboardReports
|
||||
|
||||
const router = useRouter();
|
||||
const departmentId = ref(parseInt(router.currentRoute.value.params.departmentId));
|
||||
const isMobileOverview = useMediaQuery("(max-width: 768px)");
|
||||
const isReportsExpanded = ref(false);
|
||||
|
||||
watch(isMobileOverview, (isMobile) => {
|
||||
isReportsExpanded.value = !isMobile;
|
||||
}, { immediate: true });
|
||||
|
||||
const isReportSectionVisible = computed(() => !isMobileOverview.value || isReportsExpanded.value);
|
||||
const toggleReports = () => {
|
||||
if (!isMobileOverview.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isReportsExpanded.value = !isReportsExpanded.value;
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<DepartmentDashboardPageWrapper
|
||||
@@ -26,12 +42,36 @@ const departmentId = ref(parseInt(router.currentRoute.value.params.departmentId)
|
||||
>
|
||||
<template #default>
|
||||
<DepartmentModulesDisplay v-if="departmentId && !isLoading && departmentExists(departmentId)" class="my-3"></DepartmentModulesDisplay>
|
||||
<hr/>
|
||||
<DepartmentDashboardReports/>
|
||||
<section class="admin-overview-reports">
|
||||
<button
|
||||
v-if="isMobileOverview"
|
||||
class="button is-light is-fullwidth admin-overview-reports__toggle"
|
||||
type="button"
|
||||
data-testid="admin-mobile-reports-toggle"
|
||||
@click="toggleReports"
|
||||
>
|
||||
<span>Rapporter</span>
|
||||
<span class="icon">
|
||||
<i :class="isReportsExpanded ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</span>
|
||||
</button>
|
||||
<template v-if="isReportSectionVisible">
|
||||
<hr class="admin-overview-reports__divider"/>
|
||||
<DepartmentDashboardReports/>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
</DepartmentDashboardPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-overview-reports__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.admin-overview-reports__divider {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
+80
-42
@@ -3,7 +3,8 @@
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import DepartmentDashboardPageWrapper from "@/views/dashboards/departmentDashboard/DepartmentDashboardPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { ref } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useMediaQuery } from "@vueuse/core";
|
||||
import DepartmentDailyReportSmall
|
||||
from "@/views/dashboards/departmentDashboard/other/displays/DepartmentDailyReportSmall.vue";
|
||||
import DepartmentDailyReportThisWeek
|
||||
@@ -28,6 +29,21 @@ const listDisplayTabIndexes = {
|
||||
};
|
||||
type listDisplayType = typeof listDisplayTabIndexes[keyof typeof listDisplayTabIndexes];
|
||||
const listDisplay = ref<listDisplayType>(listDisplayTabIndexes.cards);
|
||||
const isMobileOverview = useMediaQuery("(max-width: 768px)");
|
||||
const isReportsExpanded = ref(false);
|
||||
|
||||
watch(isMobileOverview, (isMobile) => {
|
||||
isReportsExpanded.value = !isMobile;
|
||||
}, { immediate: true });
|
||||
|
||||
const isReportSectionVisible = computed(() => !isMobileOverview.value || isReportsExpanded.value);
|
||||
const toggleReports = () => {
|
||||
if (!isMobileOverview.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isReportsExpanded.value = !isReportsExpanded.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,55 +52,77 @@ const listDisplay = ref<listDisplayType>(listDisplayTabIndexes.cards);
|
||||
:title="$t('department_dashboard.overview.title')"
|
||||
:subtitle="$t('department_dashboard.overview.subtitle')"
|
||||
>
|
||||
<b-tabs type="is-boxed" v-model="listDisplay" expanded>
|
||||
<b-tab-item :label="$t('department_dashboard.overview.cards')"
|
||||
icon-pack="fas"
|
||||
icon="th-large"
|
||||
:visible="true"
|
||||
<section class="admin-overview-reports">
|
||||
<button
|
||||
v-if="isMobileOverview"
|
||||
class="button is-light is-fullwidth admin-overview-reports__toggle"
|
||||
type="button"
|
||||
data-testid="admin-mobile-reports-toggle"
|
||||
@click="toggleReports"
|
||||
>
|
||||
<template v-if="true">
|
||||
<DepartmentDailyReport :departmentSpecific="false"/>
|
||||
</template>
|
||||
</b-tab-item>
|
||||
<b-tab-item :label="$t('department_dashboard.overview.list')"
|
||||
icon-pack="fas"
|
||||
icon="list"
|
||||
>
|
||||
<template v-if="true">
|
||||
<DepartmentDailyReportThisWeek :departments="accessibleDepartments"/>
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12 pb-0" v-for="department_id in accessibleDepartments" :key="department_id">
|
||||
<WhiteBoxCard :has-hover-effect="true" class="is-clickable mb-4" :has-selected-style="false" :has-selection-style="true" @click="$router.push('/admin/' + department_id)">
|
||||
<template #header>
|
||||
<div class="card-header-title" style="width: 100%;">
|
||||
<div class="columns is-vcentered is-mobile" style="width: 100%;">
|
||||
<div class="column">
|
||||
<RequiresPermission permission="list_department_daily_reports">
|
||||
<DepartmentDailyReportSmall :department_id="department_id" />
|
||||
</RequiresPermission>
|
||||
<RequiresPermission permission="list_bookings" v-show="!SessionUser.hasPermission('list_department_daily_reports')">
|
||||
<DepartmentDailyBookingReportSmall v-bind:department_id="department_id" :options="{show_department_name: (!SessionUser.hasPermission('list_department_daily_reports'))}" />
|
||||
</RequiresPermission>
|
||||
<span>Rapporter</span>
|
||||
<span class="icon">
|
||||
<i :class="isReportsExpanded ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</span>
|
||||
</button>
|
||||
<b-tabs v-if="isReportSectionVisible" type="is-boxed" v-model="listDisplay" expanded class="admin-overview-reports__tabs">
|
||||
<b-tab-item :label="$t('department_dashboard.overview.cards')"
|
||||
icon-pack="fas"
|
||||
icon="th-large"
|
||||
:visible="true"
|
||||
>
|
||||
<template v-if="true">
|
||||
<DepartmentDailyReport :departmentSpecific="false"/>
|
||||
</template>
|
||||
</b-tab-item>
|
||||
<b-tab-item :label="$t('department_dashboard.overview.list')"
|
||||
icon-pack="fas"
|
||||
icon="list"
|
||||
>
|
||||
<template v-if="true">
|
||||
<DepartmentDailyReportThisWeek :departments="accessibleDepartments"/>
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12 pb-0" v-for="department_id in accessibleDepartments" :key="department_id">
|
||||
<WhiteBoxCard :has-hover-effect="true" class="is-clickable mb-4" :has-selected-style="false" :has-selection-style="true" @click="$router.push('/admin/' + department_id)">
|
||||
<template #header>
|
||||
<div class="card-header-title" style="width: 100%;">
|
||||
<div class="columns is-vcentered is-mobile" style="width: 100%;">
|
||||
<div class="column">
|
||||
<RequiresPermission permission="list_department_daily_reports">
|
||||
<DepartmentDailyReportSmall :department_id="department_id" />
|
||||
</RequiresPermission>
|
||||
<RequiresPermission permission="list_bookings" v-show="!SessionUser.hasPermission('list_department_daily_reports')">
|
||||
<DepartmentDailyBookingReportSmall v-bind:department_id="department_id" :options="{show_department_name: (!SessionUser.hasPermission('list_department_daily_reports'))}" />
|
||||
</RequiresPermission>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</WhiteBoxCard>
|
||||
</template>
|
||||
</WhiteBoxCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</b-tab-item>
|
||||
<b-tab-item :label="$t('department_dashboard.overview.charts')"
|
||||
icon-pack="fas"
|
||||
icon="chart-bar"
|
||||
>
|
||||
<DepartmentsChartReport :departmentSpecific="false" />
|
||||
</b-tab-item>
|
||||
</b-tabs>
|
||||
</template>
|
||||
</b-tab-item>
|
||||
<b-tab-item :label="$t('department_dashboard.overview.charts')"
|
||||
icon-pack="fas"
|
||||
icon="chart-bar"
|
||||
>
|
||||
<DepartmentsChartReport :departmentSpecific="false" />
|
||||
</b-tab-item>
|
||||
</b-tabs>
|
||||
</section>
|
||||
</DepartmentDashboardPageWrapper>
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-overview-reports__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.admin-overview-reports__tabs {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { SuperuserInvoicingLocalStore } from "@/views/dashboards/superUserDashboard/invoicing/SuperuserInvoicingLocalStore.vue";
|
||||
import { SuperuserInvoicingLocalStore } from "@/views/dashboards/superUserDashboard/invoicing/SuperuserInvoicingLocalStore";
|
||||
import {BButton, BDatepicker} from "buefy";
|
||||
</script>
|
||||
<template>
|
||||
|
||||
+1
-4
@@ -1,6 +1,3 @@
|
||||
<script lang="ts">
|
||||
// Store
|
||||
export const SuperuserInvoicingLocalStore = {
|
||||
month: null as Date | null,
|
||||
}
|
||||
</script>
|
||||
};
|
||||
@@ -441,7 +441,7 @@ const getColspan = () => {
|
||||
<hr/>
|
||||
</div>
|
||||
<div class="column is-full">
|
||||
<WhiteBoxCard :forceStateFooter="false" :forceState="false" :defaultOpen="false" :toggleable="true" class="is-clickable" :has-hover-effect="true" :hasSelectionStyle="false">
|
||||
<WhiteBoxCard :forceStateFooter="false" :forceState="false" :defaultOpen="false" :toggleable="true" class="is-clickable" :has-hover-effect="true" :hasSelectionStyle="false" :has-border="true">
|
||||
<template v-slot:header>
|
||||
<div class="card-header-title is-flex is-justify-content-space-between is-align-items-center" style="width: 100%;">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
|
||||
const adminPermissions = [
|
||||
"admin",
|
||||
"department_access_1",
|
||||
"list_bookings",
|
||||
];
|
||||
|
||||
const bookingsFixture = [
|
||||
{
|
||||
id: 1234,
|
||||
customer_number: 998877,
|
||||
customer_name: "Demo Vognmandsforretning",
|
||||
department: 1,
|
||||
reg_1: "EC21233",
|
||||
reg_2: "",
|
||||
po: "PO-42",
|
||||
reference: "REF-77",
|
||||
pickup: false,
|
||||
order_id: null,
|
||||
datetime: "2026-03-26T00:00:00.000Z",
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Forvogn",
|
||||
quantity: 1,
|
||||
is_wash: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Trailer",
|
||||
quantity: 1,
|
||||
is_wash: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("Admin bookings mobile", () => {
|
||||
test("booking cards render with a visible border on mobile", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.toLowerCase().includes("mobile"), "Mobile-only assertion");
|
||||
|
||||
await seedAuthenticatedState(page, "admin-bookings-mobile-token");
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: adminPermissions,
|
||||
edgeGateways: false,
|
||||
pos: {
|
||||
orderBookings: bookingsFixture,
|
||||
},
|
||||
sessionData: {
|
||||
id: 10,
|
||||
display_name: "Demo Operator",
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto("/admin/1/modules/bookings");
|
||||
|
||||
await expect(page.locator(".title")).toContainText("Oversigt over bookinger");
|
||||
|
||||
const bookingCard = page.locator(".box.has-custom-border").filter({
|
||||
hasText: bookingsFixture[0].customer_name,
|
||||
}).first();
|
||||
|
||||
await expect(bookingCard).toBeVisible();
|
||||
|
||||
const border = await bookingCard.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
width: Number.parseFloat(style.borderTopWidth || "0"),
|
||||
style: style.borderTopStyle,
|
||||
};
|
||||
});
|
||||
|
||||
expect(border.width).toBeGreaterThanOrEqual(1);
|
||||
expect(border.style).toBe("solid");
|
||||
});
|
||||
});
|
||||
@@ -142,6 +142,19 @@ async function mockDailyReportDependencies(
|
||||
});
|
||||
}
|
||||
|
||||
async function expandDepartmentFiltersOnMobile(page, testInfo) {
|
||||
if (!testInfo.project.use.isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await page.getByTestId("daily-report-department-controls").count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.getByTestId("daily-report-mobile-departments-toggle").click();
|
||||
await expect(page.getByTestId("daily-report-department-controls")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Admin daily report", () => {
|
||||
test("renders every daily report tile from the overview payload", async ({ page }) => {
|
||||
await mockDailyReportDependencies(page, async () => json(buildOverviewPayload()));
|
||||
@@ -370,7 +383,7 @@ test.describe("Admin daily report", () => {
|
||||
await expect(page.getByTestId("daily-report-tile-double-duty-kemi")).toContainText("Tillæg for Specialsæbe - DD");
|
||||
});
|
||||
|
||||
test("updates departments and date query params with one overview request per UI action", async ({ page }) => {
|
||||
test("updates departments and date query params with one overview request per UI action", async ({ page }, testInfo) => {
|
||||
const overviewRequests: URL[] = [];
|
||||
|
||||
await mockDailyReportDependencies(page, async (url) => {
|
||||
@@ -384,6 +397,7 @@ test.describe("Admin daily report", () => {
|
||||
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-23");
|
||||
await expect(page.getByTestId("daily-report-page")).toBeVisible();
|
||||
await expect.poll(() => overviewRequests.length).toBe(1);
|
||||
await expandDepartmentFiltersOnMobile(page, testInfo);
|
||||
|
||||
await page.getByTestId("daily-report-department-2").click();
|
||||
|
||||
|
||||
@@ -86,13 +86,27 @@ async function mockAdminDepartmentDependencies(page, options: { departmentDelayM
|
||||
});
|
||||
}
|
||||
|
||||
async function expandDepartmentFiltersOnMobile(page, testInfo) {
|
||||
if (!testInfo.project.use.isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await page.getByTestId("daily-report-department-controls").count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.getByTestId("daily-report-mobile-departments-toggle").click();
|
||||
await expect(page.getByTestId("daily-report-department-controls")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Admin department visibility", () => {
|
||||
test("hides invisible departments from both the header selector and the daily report department controls", async ({ page }) => {
|
||||
test("hides invisible departments from both the header selector and the daily report department controls", async ({ page }, testInfo) => {
|
||||
await mockAdminDepartmentDependencies(page);
|
||||
|
||||
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-04-08&dateTo=2026-04-08");
|
||||
|
||||
await expect(page.getByTestId("daily-report-page")).toBeVisible();
|
||||
await expandDepartmentFiltersOnMobile(page, testInfo);
|
||||
|
||||
const departmentControls = page.getByTestId("daily-report-department-controls");
|
||||
await expect(departmentControls.getByRole("button", { name: "Visible North" })).toBeVisible();
|
||||
@@ -108,11 +122,12 @@ test.describe("Admin department visibility", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("renders department controls after a delayed departments response", async ({ page }) => {
|
||||
test("renders department controls after a delayed departments response", async ({ page }, testInfo) => {
|
||||
await mockAdminDepartmentDependencies(page, { departmentDelayMs: 400 });
|
||||
|
||||
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-04-08&dateTo=2026-04-08");
|
||||
await expect(page.getByTestId("daily-report-page")).toBeVisible();
|
||||
await expandDepartmentFiltersOnMobile(page, testInfo);
|
||||
|
||||
const departmentControls = page.getByTestId("daily-report-department-controls");
|
||||
await expect(departmentControls.getByRole("button", { name: "Visible North" })).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
|
||||
const adminPermissions = [
|
||||
"admin",
|
||||
"list_bookings",
|
||||
"list_department_daily_reports",
|
||||
"create_department_daily_report_complaints",
|
||||
"department_access_1",
|
||||
];
|
||||
|
||||
const json = (body: unknown, status = 200) => ({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const buildOverviewPayload = () => ({
|
||||
data: {
|
||||
department_ids: [1],
|
||||
date: "2026-04-09",
|
||||
date_to: "2026-04-09",
|
||||
metrics: {
|
||||
bookings: { state: "ready", value: 4, out_of: 6, message: null },
|
||||
complaints: { state: "ready", value: 2, out_of: null, message: null },
|
||||
night_washes: { state: "ready", value: 1, out_of: null, message: null },
|
||||
revenue: { state: "ready", value: 2400, out_of: null, message: null },
|
||||
washes: { state: "ready", value: 8, out_of: null, message: null },
|
||||
products_sold: { state: "ready", value: 19, out_of: null, message: null },
|
||||
transactions: { state: "ready", value: 11, out_of: null, message: null },
|
||||
water_usage: { state: "ready", value: 32, out_of: null, message: null },
|
||||
overtime: { state: "ready", value: 1.5, out_of: null, message: null },
|
||||
},
|
||||
products: [],
|
||||
},
|
||||
});
|
||||
|
||||
async function mockOverviewPageDependencies(page) {
|
||||
await seedAuthenticatedState(page);
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: adminPermissions,
|
||||
});
|
||||
|
||||
await page.route(/\/departments(\?.*)?$/i, async (route) => {
|
||||
await route.fulfill(json({
|
||||
data: [
|
||||
{ id: 1, name: "North" },
|
||||
],
|
||||
}));
|
||||
});
|
||||
|
||||
await page.route(/\/admin\/bookings\/department\/count(\?.*)?$/i, async (route) => {
|
||||
await route.fulfill(json({
|
||||
data: {
|
||||
data: {
|
||||
message: 5,
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
await page.route(/\/departments\/self-serve\/enabled(\?.*)?$/i, async (route) => {
|
||||
await route.fulfill(json({
|
||||
data: {
|
||||
data: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
await page.route(/\/departments\/weather(\?.*)?$/i, async (route) => {
|
||||
await route.fulfill(json({ data: [] }));
|
||||
});
|
||||
|
||||
await page.route(/\/departments\/daily-reports\/get(\?.*)?$/i, async (route) => {
|
||||
await route.fulfill(json({
|
||||
data: {
|
||||
id: 10,
|
||||
department_id: 1,
|
||||
water_usage: 32,
|
||||
notes: "",
|
||||
filled_by: 1,
|
||||
created_at: "2026-04-09 00:00:00",
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
await page.route(/\/departments\/daily-reports\/complaints$/i, async (route) => {
|
||||
await route.fulfill(json({ data: { id: 1 } }));
|
||||
});
|
||||
|
||||
await page.route(/\/departments\/daily-reports(?:\/overview)?(\?.*)?$/i, async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.endsWith("/departments/daily-reports/overview")) {
|
||||
await route.fulfill(json(buildOverviewPayload()));
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill(json({ data: [] }));
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Admin overview mobile", () => {
|
||||
test("keeps modules first, collapses reports by default, and preserves mobile report actions", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.use.isMobile, "Mobile-only overview regression");
|
||||
|
||||
await mockOverviewPageDependencies(page);
|
||||
await page.goto("/admin/1");
|
||||
|
||||
const modules = page.getByTestId("admin-mobile-modules");
|
||||
const reportsToggle = page.getByTestId("admin-mobile-reports-toggle");
|
||||
|
||||
await expect(modules).toBeVisible();
|
||||
await expect(reportsToggle).toBeVisible();
|
||||
await expect(page.locator("[data-testid='daily-report-page']")).toHaveCount(0);
|
||||
|
||||
const modulesBox = await modules.boundingBox();
|
||||
const reportsBox = await reportsToggle.boundingBox();
|
||||
expect(modulesBox?.y ?? 0).toBeLessThan(reportsBox?.y ?? Number.MAX_SAFE_INTEGER);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)).toBe(false);
|
||||
|
||||
await reportsToggle.click();
|
||||
await expect(page.getByTestId("daily-report-page")).toBeVisible();
|
||||
await expect(page.getByTestId("date-period-mobile-layout")).toBeVisible();
|
||||
await expect(page.locator("[data-testid='daily-report-department-controls']")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("daily-report-mobile-departments-toggle").click();
|
||||
await expect(page.getByTestId("daily-report-department-controls")).toBeVisible();
|
||||
await expect(page.getByTestId("daily-report-department-all")).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)).toBe(false);
|
||||
|
||||
await page.getByTestId("daily-report-complaints-add-button").click();
|
||||
await expect(page.getByTestId("daily-report-complaints-modal")).toBeVisible();
|
||||
await expect(page.getByTestId("daily-report-complaints-cancel")).toBeVisible();
|
||||
await expect(page.getByTestId("daily-report-complaints-submit")).toBeVisible();
|
||||
|
||||
const footerFitsViewport = await page.evaluate(() => {
|
||||
const footer = document.querySelector(".complaints-modal-footer__actions");
|
||||
if (!(footer instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = footer.getBoundingClientRect();
|
||||
return rect.left >= 0 && rect.right <= window.innerWidth;
|
||||
});
|
||||
expect(footerFitsViewport).toBe(true);
|
||||
|
||||
await page.getByTestId("daily-report-complaints-cancel").click();
|
||||
await expect(page.locator("[data-testid='daily-report-complaints-modal']")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -292,11 +292,25 @@ async function mockOverviewDependencies(page) {
|
||||
});
|
||||
}
|
||||
|
||||
async function expandOverviewReportsOnMobile(page, testInfo) {
|
||||
if (!testInfo.project.use.isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await page.getByTestId("daily-report-page").count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.getByTestId("admin-mobile-reports-toggle").click();
|
||||
await expect(page.getByTestId("daily-report-page")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Admin overview night washes", () => {
|
||||
test("renders deduped Døgnvask totals across cards, list, and chart with stable range updates", async ({ page }, testInfo) => {
|
||||
await mockOverviewDependencies(page);
|
||||
|
||||
await page.goto("/admin?departmentIds=1,2,3&dateFrom=2026-03-23&dateTo=2026-03-24");
|
||||
await expandOverviewReportsOnMobile(page, testInfo);
|
||||
|
||||
await expect(page.getByTestId("daily-report-page")).toBeVisible();
|
||||
await expect(page.getByTestId("daily-report-tile-night-washes")).toContainText("5");
|
||||
|
||||
@@ -30,6 +30,56 @@ test.describe("Edge gateway management smoke", () => {
|
||||
await expect(page.locator("body")).toContainText("Valgt gateway");
|
||||
await expect(page.locator("body")).toContainText("CPH Edge 01");
|
||||
await expect(page.locator("body")).toContainText("Shelly Plus 2PM");
|
||||
await expect(page.locator("body")).toContainText("Latens");
|
||||
await expect(page.locator("body")).toContainText("184 ms");
|
||||
await expect(page.locator("body")).toContainText("CPU");
|
||||
await expect(page.locator("body")).toContainText("27%");
|
||||
await expect(page.locator("body")).toContainText("RAM");
|
||||
await expect(page.locator("body")).toContainText("61%");
|
||||
await expect(page.locator("body")).toContainText("Disk");
|
||||
await expect(page.locator("body")).toContainText("58%");
|
||||
|
||||
const bindingRows = page.locator(".edge-table--editor tbody tr");
|
||||
const primaryBindingRow = bindingRows.nth(0);
|
||||
const legacyBindingRow = bindingRows.nth(1);
|
||||
const primaryDeviceSelect = primaryBindingRow.locator("select").first();
|
||||
const primaryChannelSelect = primaryBindingRow.locator("select").nth(1);
|
||||
const primaryIpInput = primaryBindingRow.locator('input[type="text"]').nth(1);
|
||||
|
||||
const primaryOnlineOption = primaryDeviceSelect.locator("option").nth(1);
|
||||
await expect(primaryOnlineOption).toContainText("Shelly Plus 2PM");
|
||||
await expect(primaryOnlineOption).toContainText("shelly-plus-01");
|
||||
await expect(primaryOnlineOption).toContainText("10.1.0.31");
|
||||
await expect(primaryOnlineOption).toContainText("2 kanaler");
|
||||
await expect(primaryOnlineOption).toContainText("Gen 2");
|
||||
|
||||
const primaryOfflineOption = primaryDeviceSelect.locator("option").nth(2);
|
||||
await expect(primaryOfflineOption).toContainText("Shelly Mini 1");
|
||||
await expect(primaryOfflineOption).toContainText("shelly-mini-offline");
|
||||
await expect(primaryOfflineOption).toContainText("10.1.0.34");
|
||||
await expect(primaryOfflineOption).toContainText("1 kanal");
|
||||
await expect(primaryOfflineOption).toContainText("Gen 3");
|
||||
await expect(primaryOfflineOption).toContainText("Offline");
|
||||
|
||||
const staleOption = legacyBindingRow.locator("select").first().locator("option").last();
|
||||
await expect(staleOption).toContainText("Ukendt Shelly-device");
|
||||
await expect(staleOption).toContainText("shelly-missing-legacy");
|
||||
await expect(staleOption).toContainText("10.1.0.99");
|
||||
await expect(staleOption).toContainText("2 kanaler");
|
||||
await expect(staleOption).toContainText("Mangler i discovery");
|
||||
|
||||
await primaryChannelSelect.selectOption("1");
|
||||
await primaryDeviceSelect.selectOption("shelly-mini-offline");
|
||||
await expect(primaryIpInput).toHaveValue("10.1.0.34");
|
||||
await expect(primaryChannelSelect).toHaveValue("0");
|
||||
|
||||
const relayBindingInput = primaryBindingRow.locator('input[type="text"]').first();
|
||||
await relayBindingInput.fill("M-7-CANARY");
|
||||
await page.getByRole("button", { name: "Gem bindinger" }).click();
|
||||
await expect(relayBindingInput).toHaveValue("M-7-CANARY");
|
||||
await expect(primaryDeviceSelect).toHaveValue("shelly-mini-offline");
|
||||
await expect(primaryIpInput).toHaveValue("10.1.0.34");
|
||||
await expect(primaryChannelSelect).toHaveValue("0");
|
||||
|
||||
await page.getByLabel("Installer afdeling").selectOption("1");
|
||||
await page.locator('input[placeholder="F.eks. Roskilde Pi 01"]').fill("Canary Pi");
|
||||
@@ -37,18 +87,13 @@ test.describe("Edge gateway management smoke", () => {
|
||||
await expect(page.getByLabel("Installationskommando")).toHaveValue(/curl -fsSL/);
|
||||
|
||||
await page.getByRole("button", { name: /discovery/i }).click();
|
||||
await expect(page.locator("body")).toContainText("Discovery er køet");
|
||||
await expect(page.locator("body")).toContainText("Discovery er");
|
||||
await expect(page.locator("body")).toContainText("Afventer");
|
||||
await expect(page.locator("body")).toContainText("shelly-plus-new");
|
||||
|
||||
const relayBindingInput = page.locator(".edge-table--editor tbody tr").nth(0).locator('input[type="text"]').first();
|
||||
await relayBindingInput.fill("M-7-CANARY");
|
||||
await page.getByRole("button", { name: "Gem bindinger" }).click();
|
||||
await expect(relayBindingInput).toHaveValue("M-7-CANARY");
|
||||
|
||||
await page.locator('input[placeholder="F.eks. 1.3.0"]').fill("1.3.0");
|
||||
await page.getByRole("button", { name: /opdatering/i }).click();
|
||||
await expect(page.locator("body")).toContainText("Opdatering er køet");
|
||||
await expect(page.locator("body")).toContainText("Opdatering er");
|
||||
await expect(page.locator("body")).toContainText("1.3.0");
|
||||
await expect(page.locator("body")).toContainText("COMPLETED");
|
||||
|
||||
@@ -91,10 +136,9 @@ test.describe("Edge gateway management smoke", () => {
|
||||
await page.getByRole("button", { name: "Godkend root shell" }).click();
|
||||
|
||||
await expect(page.locator("body")).toContainText("Fejl");
|
||||
await expect(page.locator("body")).toContainText("før sessionen blev åbnet");
|
||||
await expect(page.getByRole("button", { name: "Terminate session" })).toBeDisabled();
|
||||
await expect(page.locator('[aria-label="Root shell buffer"]')).toContainText(
|
||||
"Forbindelsen til gateway-shell fejlede før sessionen blev åbnet."
|
||||
"gateway-shell fejlede"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,7 +173,7 @@ test.describe("Edge gateway management smoke", () => {
|
||||
|
||||
await expect(page.locator("body")).toContainText("Broker frakoblet");
|
||||
await page.getByRole("button", { name: /discovery/i }).click();
|
||||
await expect(page.locator("body")).toContainText("Discovery er køet");
|
||||
await expect(page.locator("body")).toContainText("Discovery er");
|
||||
await expect(page.locator("body")).toContainText("shelly-plus-new");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@ import { generateOTP } from './otp';
|
||||
|
||||
// Auth timeout in milliseconds (15 seconds)
|
||||
const AUTH_TIMEOUT = 15000;
|
||||
const USER_HOME_URL = /\/user(?:\/)?(?:[?#].*)?$/;
|
||||
const ADMIN_HOME_URL = /\/admin(?:\/)?(?:[?#].*)?$/;
|
||||
|
||||
/**
|
||||
* Reusable authentication helpers for tests
|
||||
@@ -39,7 +41,7 @@ export async function loginAsUser(
|
||||
await page.fill('input[name="2fa_code"]', generateOTP(creds.otpSecret!));
|
||||
await page.click('button[id="2fa-verify-button"]');
|
||||
}
|
||||
await expect(page).toHaveURL('/user', { timeout: AUTH_TIMEOUT });
|
||||
await expect(page).toHaveURL(USER_HOME_URL, { timeout: AUTH_TIMEOUT });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +66,7 @@ export async function loginAsSubuserByPhone(
|
||||
await page.fill('input[name="2fa_code"]', generateOTP(creds.otpSecret!));
|
||||
await page.click('button[id="2fa-verify-button"]');
|
||||
}
|
||||
await expect(page).toHaveURL('/user', { timeout: AUTH_TIMEOUT });
|
||||
await expect(page).toHaveURL(USER_HOME_URL, { timeout: AUTH_TIMEOUT });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,7 +93,7 @@ export async function loginAsSubuserByUsername(
|
||||
await page.fill('input[name="2fa_code"]', generateOTP(creds.otpSecret!));
|
||||
await page.click('button[id="2fa-verify-button"]');
|
||||
}
|
||||
await expect(page).toHaveURL('/user', { timeout: AUTH_TIMEOUT });
|
||||
await expect(page).toHaveURL(USER_HOME_URL, { timeout: AUTH_TIMEOUT });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,7 +110,7 @@ export async function loginAsOperator(
|
||||
await page.fill('input[name="user_id"]', creds.userId);
|
||||
await page.fill('input[name="password"]', creds.password);
|
||||
await page.click('button[id="operator_login_button"]');
|
||||
await expect(page).toHaveURL('/admin', { timeout: AUTH_TIMEOUT });
|
||||
await expect(page).toHaveURL(ADMIN_HOME_URL, { timeout: AUTH_TIMEOUT });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,7 +216,7 @@ export async function loginWithQRCode(
|
||||
throw new Error('QR auth token is not set. Set PLENO_QR_TOKEN environment variable or pass token directly.');
|
||||
}
|
||||
await page.goto(`/login/qr?token=${authToken}`);
|
||||
await expect(page).toHaveURL('/user', { timeout: AUTH_TIMEOUT });
|
||||
await expect(page).toHaveURL(USER_HOME_URL, { timeout: AUTH_TIMEOUT });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,41 +3,84 @@
|
||||
* Centralized location for all test constants to improve maintainability
|
||||
*/
|
||||
|
||||
const hasAnyEnv = (...names: string[]) => {
|
||||
return names.some((name) => {
|
||||
const value = process.env[name];
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
});
|
||||
};
|
||||
|
||||
const readEnv = (name: string, fallback: string) => {
|
||||
const value = process.env[name];
|
||||
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||
};
|
||||
|
||||
const customerEnvEnabled = hasAnyEnv(
|
||||
'PLAYWRIGHT_USER_CUSTOMER_NUMBER',
|
||||
'PLAYWRIGHT_USER_PASSWORD',
|
||||
'PLAYWRIGHT_USER_OTP_SECRET',
|
||||
);
|
||||
|
||||
const subuserPhoneEnvEnabled = hasAnyEnv(
|
||||
'PLAYWRIGHT_SUBUSER_PHONE_COUNTRY_CODE',
|
||||
'PLAYWRIGHT_SUBUSER_PHONE',
|
||||
'PLAYWRIGHT_SUBUSER_PASSWORD',
|
||||
'PLAYWRIGHT_SUBUSER_OTP_SECRET',
|
||||
);
|
||||
|
||||
const subuserUsernameEnvEnabled = hasAnyEnv(
|
||||
'PLAYWRIGHT_SUBUSER_USERNAME',
|
||||
'PLAYWRIGHT_SUBUSER_PASSWORD',
|
||||
'PLAYWRIGHT_SUBUSER_OTP_SECRET',
|
||||
);
|
||||
|
||||
const operatorEnvEnabled = hasAnyEnv(
|
||||
'PLAYWRIGHT_OPERATOR_USER_ID',
|
||||
'PLAYWRIGHT_OPERATOR_PASSWORD',
|
||||
);
|
||||
|
||||
// User (Customer) credentials
|
||||
export const userCredentials = {
|
||||
customerNumber: '12345679',
|
||||
password: '5679',
|
||||
twoFactorAuthentication: true,
|
||||
// otpauth://totp/Truck%20Wash:12345679?secret=TBIS7MUDZ2O2VNM5&issuer=Truck%20Wash
|
||||
otpSecret: 'TBIS7MUDZ2O2VNM5',
|
||||
customerNumber: readEnv('PLAYWRIGHT_USER_CUSTOMER_NUMBER', '12345679'),
|
||||
password: readEnv('PLAYWRIGHT_USER_PASSWORD', '5679'),
|
||||
twoFactorAuthentication: customerEnvEnabled
|
||||
? hasAnyEnv('PLAYWRIGHT_USER_OTP_SECRET')
|
||||
: true,
|
||||
otpSecret: customerEnvEnabled
|
||||
? readEnv('PLAYWRIGHT_USER_OTP_SECRET', '')
|
||||
: 'TBIS7MUDZ2O2VNM5',
|
||||
};
|
||||
|
||||
// Subuser (Driver) credentials - Phone login
|
||||
export const subuserPhoneCredentials = {
|
||||
phoneCountryCode: '45',
|
||||
phone: '42331128',
|
||||
password: 'Test1234',
|
||||
phoneCountryCode: readEnv('PLAYWRIGHT_SUBUSER_PHONE_COUNTRY_CODE', '45'),
|
||||
phone: readEnv('PLAYWRIGHT_SUBUSER_PHONE', '42331128'),
|
||||
password: readEnv('PLAYWRIGHT_SUBUSER_PASSWORD', 'Test1234'),
|
||||
twoFactorAuthentication: false,
|
||||
otpSecret: 'SUBUSER2FASECRET',
|
||||
otpSecret: subuserPhoneEnvEnabled
|
||||
? readEnv('PLAYWRIGHT_SUBUSER_OTP_SECRET', '')
|
||||
: 'SUBUSER2FASECRET',
|
||||
};
|
||||
|
||||
// Subuser (Driver) credentials - Username login
|
||||
export const subuserUsernameCredentials = {
|
||||
username: 'testsubuser',
|
||||
password: 'Test1234',
|
||||
username: readEnv('PLAYWRIGHT_SUBUSER_USERNAME', 'testsubuser'),
|
||||
password: readEnv('PLAYWRIGHT_SUBUSER_PASSWORD', 'Test1234'),
|
||||
twoFactorAuthentication: false,
|
||||
otpSecret: '',
|
||||
otpSecret: subuserUsernameEnvEnabled
|
||||
? readEnv('PLAYWRIGHT_SUBUSER_OTP_SECRET', '')
|
||||
: '',
|
||||
};
|
||||
|
||||
// Operator credentials
|
||||
export const operatorCredentials = {
|
||||
userId: '11',
|
||||
password: 'aef18KHAPGiu90',
|
||||
userId: readEnv('PLAYWRIGHT_OPERATOR_USER_ID', '11'),
|
||||
password: readEnv('PLAYWRIGHT_OPERATOR_PASSWORD', 'aef18KHAPGiu90'),
|
||||
};
|
||||
|
||||
// Test data for bookings
|
||||
export const bookingTestData = {
|
||||
departmentId: 12,
|
||||
departmentId: Number.parseInt(readEnv('PLAYWRIGHT_DEPARTMENT_ID', '12'), 10),
|
||||
registrationNumber: 'EC21233',
|
||||
reference: 'test_ref123',
|
||||
poNumber: 'test_po123',
|
||||
@@ -75,19 +118,18 @@ export const invalidCredentials = {
|
||||
|
||||
// 2FA test credentials - User with 2FA enabled
|
||||
export const user2FACredentials = {
|
||||
customerNumber: '12345680', // User with 2FA enabled
|
||||
password: '5680',
|
||||
customerNumber: readEnv('PLAYWRIGHT_USER_2FA_CUSTOMER_NUMBER', '12345680'),
|
||||
password: readEnv('PLAYWRIGHT_USER_2FA_PASSWORD', '5680'),
|
||||
validCode: '123456', // For testing with mock/TOTP
|
||||
};
|
||||
|
||||
// 2FA test credentials - Subuser with 2FA enabled
|
||||
export const subuser2FACredentials = {
|
||||
phoneCountryCode: '45',
|
||||
phone: '42331129', // Subuser with 2FA enabled
|
||||
password: 'Test1234',
|
||||
phoneCountryCode: readEnv('PLAYWRIGHT_SUBUSER_2FA_PHONE_COUNTRY_CODE', '45'),
|
||||
phone: readEnv('PLAYWRIGHT_SUBUSER_2FA_PHONE', '42331129'),
|
||||
password: readEnv('PLAYWRIGHT_SUBUSER_2FA_PASSWORD', 'Test1234'),
|
||||
twoFactorAuthentication: false,
|
||||
// otpauth://totp/Truck%20Wash:42331128?secret=SUBUSER2FASECRET&issuer=Truck%20Wash
|
||||
otpSecret: 'SUBUSER2FASECRET',
|
||||
otpSecret: readEnv('PLAYWRIGHT_SUBUSER_2FA_OTP_SECRET', 'SUBUSER2FASECRET'),
|
||||
};
|
||||
|
||||
// Passkey test data
|
||||
|
||||
@@ -184,10 +184,67 @@ test.describe("POS mobile order flow", () => {
|
||||
await expectHorizontallyCentered(page.getByTestId("pos-mobile-step-1-shell"), page.getByTestId("pos-mobile-step-1-subtitle"));
|
||||
await expectHorizontallyCentered(page.getByTestId("pos-mobile-step-1-shell"), page.getByTestId("pos-mobile-manual-input-toggle"));
|
||||
|
||||
const [registrationRowBox, referenceTriggerBox] = await Promise.all([
|
||||
page.getByTestId("pos-mobile-step-1-registration-row").boundingBox(),
|
||||
page.getByTestId("pos-mobile-step-1-reference-trigger").boundingBox(),
|
||||
]);
|
||||
|
||||
expect(registrationRowBox).not.toBeNull();
|
||||
expect(referenceTriggerBox).not.toBeNull();
|
||||
expect(Math.abs((registrationRowBox?.width ?? 0) - (referenceTriggerBox?.width ?? 0))).toBeLessThanOrEqual(4);
|
||||
expect(Math.abs((registrationRowBox?.x ?? 0) - (referenceTriggerBox?.x ?? 0))).toBeLessThanOrEqual(4);
|
||||
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
await expect(page.getByTestId("pos-mobile-customer-popup")).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("transaction history keeps each wash visually separated with a border", async ({ page }) => {
|
||||
const fixture = createMobilePosFixture({
|
||||
ordersById: {
|
||||
9201: buildRegularOrder(9201, {
|
||||
customer_name: "VOGNMAND JIMMY CHRISTENSEN ApS",
|
||||
total_net_amount: 746,
|
||||
pending_handheld: true,
|
||||
created_at: "2026-04-09T12:08:00.000Z",
|
||||
}),
|
||||
9202: buildRegularOrder(9202, {
|
||||
customer_name: "BYGMA Roskilde A/S",
|
||||
total_net_amount: 507,
|
||||
pending_handheld: true,
|
||||
created_at: "2026-04-09T11:51:00.000Z",
|
||||
}),
|
||||
9203: buildRegularOrder(9203, {
|
||||
customer_name: "VOLVO ENTREPRENØRMASKINER A/S",
|
||||
total_net_amount: 512,
|
||||
pending_handheld: true,
|
||||
created_at: "2026-04-09T10:10:00.000Z",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-transaction-history-token",
|
||||
seedState: {
|
||||
customerId: null,
|
||||
reg: "FREE123",
|
||||
reference: "HISTORY-REF",
|
||||
includePrimaryItem: false,
|
||||
vehicleType: null,
|
||||
lastOrderId: null,
|
||||
transactionHistoryView: true,
|
||||
},
|
||||
route: {
|
||||
step: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const cards = page.locator('[data-testid^="pos-mobile-transaction-history-card-"]');
|
||||
await expect(cards.first()).toBeVisible({ timeout: 10_000 });
|
||||
expect(await cards.count()).toBe(3);
|
||||
await expect(cards.first()).toHaveCSS("border-top-style", "solid");
|
||||
await expect(cards.first()).toHaveCSS("border-top-width", "1px");
|
||||
});
|
||||
|
||||
test("customer notes popup shows the translated created-by label", async ({ page }) => {
|
||||
const fixture = createMobilePosFixture({
|
||||
customerNotesByNumber: {
|
||||
@@ -224,6 +281,17 @@ test.describe("POS mobile order flow", () => {
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-customer-notes-trigger")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const [registrationRowBox, notesTriggerBox] = await Promise.all([
|
||||
page.getByTestId("pos-mobile-step-1-registration-row").boundingBox(),
|
||||
page.getByTestId("pos-mobile-customer-notes-trigger").boundingBox(),
|
||||
]);
|
||||
|
||||
expect(registrationRowBox).not.toBeNull();
|
||||
expect(notesTriggerBox).not.toBeNull();
|
||||
expect(Math.abs((registrationRowBox?.width ?? 0) - (notesTriggerBox?.width ?? 0))).toBeLessThanOrEqual(4);
|
||||
expect(Math.abs((registrationRowBox?.x ?? 0) - (notesTriggerBox?.x ?? 0))).toBeLessThanOrEqual(4);
|
||||
|
||||
await page.getByTestId("pos-mobile-customer-notes-trigger").click();
|
||||
|
||||
const popup = page.locator('[data-testid="pos-mobile-popup"][data-popup-id="customer_notes"]');
|
||||
|
||||
@@ -168,6 +168,21 @@ async function getSidebarTabListMetrics(locator) {
|
||||
});
|
||||
}
|
||||
|
||||
async function getHorizontalBounds(locator) {
|
||||
await expect(locator).toBeVisible();
|
||||
return locator.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
height: rect.height,
|
||||
width: rect.width,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function openOrderDetailAddItems(page) {
|
||||
const addItemsPanel = page.locator('[data-testid="pos-order-add-items-panel"]:visible').first();
|
||||
const addItemsBackButton = page.locator('[data-testid="pos-order-add-items-back"]:visible').first();
|
||||
@@ -521,6 +536,113 @@ test.describe("POS visuals", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("mobile transaction history actions keep text left and icons right", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("mobile"), "Mobile only");
|
||||
|
||||
const fixture = createMobilePosFixture();
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "pos-visual-mobile-transaction-history-token",
|
||||
permissions: ["admin", "department_access_1"],
|
||||
seedState: {
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
reg: "AB12345",
|
||||
includePrimaryItem: false,
|
||||
transactionHistoryView: true,
|
||||
lastOrderId: 9201,
|
||||
},
|
||||
route: {
|
||||
step: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-transaction-history")).toBeVisible();
|
||||
const reloadAction = page.getByTestId("pos-mobile-transaction-history-reload-action");
|
||||
const reloadLabel = page.getByTestId("pos-mobile-transaction-history-reload-label");
|
||||
const reloadIcon = page.getByTestId("pos-mobile-transaction-history-reload-icon");
|
||||
const closeAction = page.getByTestId("pos-mobile-transaction-history-close-action");
|
||||
const closeLabel = page.getByTestId("pos-mobile-transaction-history-close-label");
|
||||
const closeIcon = page.getByTestId("pos-mobile-transaction-history-close-icon");
|
||||
|
||||
const [
|
||||
reloadActionBounds,
|
||||
reloadLabelBounds,
|
||||
reloadIconBounds,
|
||||
closeActionBounds,
|
||||
closeLabelBounds,
|
||||
closeIconBounds,
|
||||
] = await Promise.all([
|
||||
getHorizontalBounds(reloadAction),
|
||||
getHorizontalBounds(reloadLabel),
|
||||
getHorizontalBounds(reloadIcon),
|
||||
getHorizontalBounds(closeAction),
|
||||
getHorizontalBounds(closeLabel),
|
||||
getHorizontalBounds(closeIcon),
|
||||
]);
|
||||
|
||||
expect(reloadIconBounds.left).toBeGreaterThan(reloadLabelBounds.right + 8);
|
||||
expect(reloadActionBounds.right - reloadIconBounds.right).toBeLessThan(24);
|
||||
expect(Math.abs(((reloadIconBounds.top + reloadIconBounds.bottom) / 2) - ((reloadActionBounds.top + reloadActionBounds.bottom) / 2))).toBeLessThan(6);
|
||||
|
||||
expect(closeIconBounds.left).toBeGreaterThan(closeLabelBounds.right + 8);
|
||||
expect(closeActionBounds.right - closeIconBounds.right).toBeLessThan(24);
|
||||
expect(Math.abs(((closeIconBounds.top + closeIconBounds.bottom) / 2) - ((closeActionBounds.top + closeActionBounds.bottom) / 2))).toBeLessThan(6);
|
||||
});
|
||||
|
||||
test("mobile attachment view actions keep the same side inset", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("mobile"), "Mobile only");
|
||||
|
||||
const fixture = createMobilePosFixture();
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "pos-visual-mobile-attachment-view-token",
|
||||
permissions: ["admin", "department_access_1"],
|
||||
seedState: {
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
reg: "AB12345",
|
||||
includePrimaryItem: false,
|
||||
lastOrderId: null,
|
||||
},
|
||||
route: {
|
||||
step: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-step-1")).toBeVisible();
|
||||
await page.getByTestId("pos-mobile-attachments-toggle").click();
|
||||
|
||||
const takePictureAction = page.getByTestId("pos-mobile-attachment-view-take-picture-action");
|
||||
const takePictureLabel = page.getByTestId("pos-mobile-attachment-view-take-picture-label");
|
||||
const takePictureIcon = page.getByTestId("pos-mobile-attachment-view-take-picture-icon");
|
||||
const closeAction = page.getByTestId("pos-mobile-attachment-view-close-action");
|
||||
const closeLabel = page.getByTestId("pos-mobile-attachment-view-close-label");
|
||||
const closeIcon = page.getByTestId("pos-mobile-attachment-view-close-icon");
|
||||
|
||||
const [
|
||||
takePictureActionBounds,
|
||||
takePictureLabelBounds,
|
||||
takePictureIconBounds,
|
||||
closeActionBounds,
|
||||
closeLabelBounds,
|
||||
closeIconBounds,
|
||||
] = await Promise.all([
|
||||
getHorizontalBounds(takePictureAction),
|
||||
getHorizontalBounds(takePictureLabel),
|
||||
getHorizontalBounds(takePictureIcon),
|
||||
getHorizontalBounds(closeAction),
|
||||
getHorizontalBounds(closeLabel),
|
||||
getHorizontalBounds(closeIcon),
|
||||
]);
|
||||
|
||||
expect(takePictureIconBounds.left).toBeGreaterThan(takePictureLabelBounds.right + 8);
|
||||
expect(takePictureActionBounds.right - takePictureIconBounds.right).toBeLessThan(24);
|
||||
expect(Math.abs(((takePictureIconBounds.top + takePictureIconBounds.bottom) / 2) - ((takePictureActionBounds.top + takePictureActionBounds.bottom) / 2))).toBeLessThan(6);
|
||||
|
||||
expect(closeIconBounds.left).toBeGreaterThan(closeLabelBounds.right + 8);
|
||||
expect(closeActionBounds.right - closeIconBounds.right).toBeLessThan(24);
|
||||
expect(Math.abs(((closeIconBounds.top + closeIconBounds.bottom) / 2) - ((closeActionBounds.top + closeActionBounds.bottom) / 2))).toBeLessThan(6);
|
||||
});
|
||||
|
||||
test("mobile order detail add-items workspace snapshot", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("mobile"), "Mobile only");
|
||||
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,129 @@
|
||||
import { expect, type Locator, type Page, type Request, type Response } from "@playwright/test";
|
||||
|
||||
const CRITICAL_RESOURCE_TYPES = new Set(["document", "stylesheet", "script", "image", "font"]);
|
||||
const ABORTED_REQUEST_PATTERNS = ["ERR_ABORTED", "NS_BINDING_ABORTED"];
|
||||
const BENIGN_PAGE_ERRORS = ["ResizeObserver loop limit exceeded"];
|
||||
|
||||
function isSameOrigin(url: string, baseOrigin: string) {
|
||||
try {
|
||||
return new URL(url).origin === baseOrigin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCriticalResource(url: string, resourceType: string) {
|
||||
if (CRITICAL_RESOURCE_TYPES.has(resourceType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
return /manifest(\.webmanifest|\.json)$/i.test(pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function attachPageHealthGuards(page: Page, baseURL: string) {
|
||||
const baseOrigin = new URL(baseURL).origin;
|
||||
const pageErrors: string[] = [];
|
||||
const failedResources: string[] = [];
|
||||
|
||||
const onPageError = (error: Error) => {
|
||||
if (BENIGN_PAGE_ERRORS.some((pattern) => error.message.includes(pattern))) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageErrors.push(error.message);
|
||||
};
|
||||
|
||||
const onRequestFailed = (request: Request) => {
|
||||
const errorText = request.failure()?.errorText || "Request failed";
|
||||
if (ABORTED_REQUEST_PATTERNS.some((pattern) => errorText.includes(pattern))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSameOrigin(request.url(), baseOrigin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceType = request.resourceType();
|
||||
if (!isCriticalResource(request.url(), resourceType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
failedResources.push(`${resourceType} ${request.url()} (${errorText})`);
|
||||
};
|
||||
|
||||
const onResponse = (response: Response) => {
|
||||
if (response.status() < 400) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = response.request();
|
||||
if (!isSameOrigin(response.url(), baseOrigin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceType = request.resourceType();
|
||||
if (!isCriticalResource(response.url(), resourceType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
failedResources.push(`${response.status()} ${resourceType} ${response.url()}`);
|
||||
};
|
||||
|
||||
page.on("pageerror", onPageError);
|
||||
page.on("requestfailed", onRequestFailed);
|
||||
page.on("response", onResponse);
|
||||
|
||||
return {
|
||||
async expectHealthy() {
|
||||
expect(pageErrors, "Unexpected uncaught browser errors were recorded.").toEqual([]);
|
||||
expect(failedResources, "Critical same-origin resources failed to load.").toEqual([]);
|
||||
},
|
||||
dispose() {
|
||||
page.off("pageerror", onPageError);
|
||||
page.off("requestfailed", onRequestFailed);
|
||||
page.off("response", onResponse);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function expectBodyHasContent(page: Page, minimumLength = 20) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const text = await page.locator("body").innerText().catch(() => "");
|
||||
return text.replace(/\s+/g, " ").trim().length;
|
||||
}, { timeout: 15_000 })
|
||||
.toBeGreaterThan(minimumLength);
|
||||
}
|
||||
|
||||
export async function settlePage(page: Page) {
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
|
||||
}
|
||||
|
||||
export async function expectOneVisible(locators: Locator[], timeout = 15_000) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
for (const locator of locators) {
|
||||
if (await locator.isVisible().catch(() => false)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}, { timeout })
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export function requiredEnv(name: string) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required for the live smoke gate.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { loginAsOperator, loginAsUser } from "../fixtures/authHelpers";
|
||||
import {
|
||||
attachPageHealthGuards,
|
||||
expectBodyHasContent,
|
||||
expectOneVisible,
|
||||
requiredEnv,
|
||||
settlePage,
|
||||
} from "./helpers";
|
||||
|
||||
const liveSmokeEnabled = Boolean(
|
||||
process.env.PLAYWRIGHT_BASE_URL
|
||||
&& process.env.PLAYWRIGHT_USER_CUSTOMER_NUMBER
|
||||
&& process.env.PLAYWRIGHT_USER_PASSWORD
|
||||
&& process.env.PLAYWRIGHT_OPERATOR_USER_ID
|
||||
&& process.env.PLAYWRIGHT_OPERATOR_PASSWORD,
|
||||
);
|
||||
|
||||
function getLiveSettings() {
|
||||
return {
|
||||
baseURL: requiredEnv("PLAYWRIGHT_BASE_URL"),
|
||||
customerCredentials: {
|
||||
customerNumber: requiredEnv("PLAYWRIGHT_USER_CUSTOMER_NUMBER"),
|
||||
password: requiredEnv("PLAYWRIGHT_USER_PASSWORD"),
|
||||
otpSecret: process.env.PLAYWRIGHT_USER_OTP_SECRET || "",
|
||||
twoFactorAuthentication: Boolean(process.env.PLAYWRIGHT_USER_OTP_SECRET),
|
||||
},
|
||||
operatorCredentials: {
|
||||
userId: requiredEnv("PLAYWRIGHT_OPERATOR_USER_ID"),
|
||||
password: requiredEnv("PLAYWRIGHT_OPERATOR_PASSWORD"),
|
||||
},
|
||||
departmentId: Number.parseInt(process.env.PLAYWRIGHT_DEPARTMENT_ID || "12", 10),
|
||||
};
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.describe("Live smoke release gate", () => {
|
||||
test.skip(!liveSmokeEnabled, "Set PLAYWRIGHT_BASE_URL and seeded live credentials to run the live smoke gate.");
|
||||
|
||||
test("guest flow renders on the deployed environment", async ({ page }) => {
|
||||
const { baseURL } = getLiveSettings();
|
||||
const guards = attachPageHealthGuards(page, baseURL);
|
||||
|
||||
await page.goto("/guest/book/wash");
|
||||
await settlePage(page);
|
||||
await expect(page).toHaveURL(/\/guest\/book\/wash(?:\?.*)?$/);
|
||||
await expectBodyHasContent(page);
|
||||
await expect(page.locator("body")).not.toContainText(/404/i);
|
||||
|
||||
await guards.expectHealthy();
|
||||
guards.dispose();
|
||||
});
|
||||
|
||||
test("customer login reaches dashboard, profile, and bookings", async ({ page }) => {
|
||||
const { baseURL, customerCredentials } = getLiveSettings();
|
||||
const guards = attachPageHealthGuards(page, baseURL);
|
||||
|
||||
await loginAsUser(page, customerCredentials);
|
||||
|
||||
await expect(page.locator("#book-wash-button")).toBeVisible();
|
||||
await expect(page.locator("#download-invoices-button")).toBeVisible();
|
||||
|
||||
await page.goto("/user/profile");
|
||||
await settlePage(page);
|
||||
await expect(page).toHaveURL(/\/user\/profile(?:\?.*)?$/);
|
||||
await expect(page.locator(".card-header").first()).toBeVisible();
|
||||
|
||||
await page.goto("/user/bookings");
|
||||
await settlePage(page);
|
||||
await expect(page).toHaveURL(/\/user\/bookings(?:\?.*)?$/);
|
||||
await expect(page.locator(".title").first()).toBeVisible();
|
||||
|
||||
await guards.expectHealthy();
|
||||
guards.dispose();
|
||||
});
|
||||
|
||||
test("operator login reaches the seeded department route", async ({ page }) => {
|
||||
const { baseURL, operatorCredentials, departmentId } = getLiveSettings();
|
||||
const guards = attachPageHealthGuards(page, baseURL);
|
||||
|
||||
await loginAsOperator(page, operatorCredentials);
|
||||
|
||||
await page.goto(`/admin/${departmentId}/modules/pos`);
|
||||
await settlePage(page);
|
||||
await expect(page).toHaveURL(new RegExp(`/admin/${departmentId}/modules/pos(?:\\?.*)?$`));
|
||||
await expectOneVisible([
|
||||
page.getByTestId("pos-step-1"),
|
||||
page.getByTestId("pos-mobile-step-1-shell"),
|
||||
]);
|
||||
|
||||
await guards.expectHealthy();
|
||||
guards.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { loginAsUser } from "../fixtures/authHelpers";
|
||||
import { createPosFixture, mockApi, seedAuthenticatedState } from "../support/network.js";
|
||||
import {
|
||||
attachPageHealthGuards,
|
||||
expectBodyHasContent,
|
||||
expectOneVisible,
|
||||
settlePage,
|
||||
} from "./helpers";
|
||||
|
||||
const LOCAL_PROD_BASE_URL = "http://127.0.0.1:4173";
|
||||
const LOGIN_URL = /\/login(?:\?.*)?$/;
|
||||
const POS_PERMISSIONS = [
|
||||
"admin",
|
||||
"department_access_12",
|
||||
"edit_order_items",
|
||||
"get_user",
|
||||
"list_customer_notes",
|
||||
"list_customer_attributes",
|
||||
"get_custom_prices_other",
|
||||
];
|
||||
|
||||
async function gotoHealthyRoute(page, path: string) {
|
||||
await page.goto(path);
|
||||
await settlePage(page);
|
||||
await expectBodyHasContent(page);
|
||||
await expect(page.locator("body")).not.toContainText(/404/i);
|
||||
}
|
||||
|
||||
async function seedAuthenticatedRole(page, {
|
||||
token,
|
||||
permissions,
|
||||
sessionData = {},
|
||||
pos = undefined,
|
||||
}: {
|
||||
token: string;
|
||||
permissions: string[];
|
||||
sessionData?: Record<string, unknown>;
|
||||
pos?: unknown;
|
||||
}) {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions,
|
||||
sessionData,
|
||||
pos,
|
||||
});
|
||||
await seedAuthenticatedState(page, token);
|
||||
}
|
||||
|
||||
test.describe("Local production release gate", () => {
|
||||
test.describe("PWA sanity", () => {
|
||||
test.use({ serviceWorkers: "allow" });
|
||||
|
||||
test("PWA bootstrap registers on the production build", async ({ page }, testInfo) => {
|
||||
test.skip(
|
||||
!testInfo.project.name.startsWith("chromium"),
|
||||
"Service worker registration is release-gated on Chromium where Playwright exposes stable registrations.",
|
||||
);
|
||||
|
||||
const guards = attachPageHealthGuards(page, LOCAL_PROD_BASE_URL);
|
||||
|
||||
await mockApi(page, { authenticated: false });
|
||||
|
||||
await gotoHealthyRoute(page, "/");
|
||||
await expect(page.getByTestId("landing-title")).toContainText("Truck Wash");
|
||||
|
||||
await page.waitForFunction(async () => {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
return registrations.some((registration) => Boolean(
|
||||
registration.active || registration.installing || registration.waiting,
|
||||
));
|
||||
}, undefined, { timeout: 20_000 });
|
||||
|
||||
await guards.expectHealthy();
|
||||
guards.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mocked local-prod browser coverage", () => {
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
test("public shell routes render on the production build", async ({ page }) => {
|
||||
const guards = attachPageHealthGuards(page, LOCAL_PROD_BASE_URL);
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: false,
|
||||
selfServe: true,
|
||||
});
|
||||
|
||||
await gotoHealthyRoute(page, "/");
|
||||
await expect(page.getByTestId("landing-title")).toContainText("Truck Wash");
|
||||
|
||||
const manifestHref = await page.locator('link[rel="manifest"]').first().getAttribute("href");
|
||||
expect(manifestHref).toBeTruthy();
|
||||
const manifestResponse = await page.request.get(new URL(manifestHref!, LOCAL_PROD_BASE_URL).toString());
|
||||
expect(manifestResponse.ok()).toBeTruthy();
|
||||
|
||||
await page.reload();
|
||||
await settlePage(page);
|
||||
await expect(page.getByTestId("landing-title")).toBeVisible();
|
||||
|
||||
for (const path of ["/about-us", "/privacy-policy", "/guest/home", "/guest/book/wash"]) {
|
||||
await gotoHealthyRoute(page, path);
|
||||
}
|
||||
|
||||
await guards.expectHealthy();
|
||||
guards.dispose();
|
||||
});
|
||||
|
||||
test("auth routes render and protected routes redirect when unauthenticated", async ({ page }) => {
|
||||
const guards = attachPageHealthGuards(page, LOCAL_PROD_BASE_URL);
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: false,
|
||||
selfServe: true,
|
||||
});
|
||||
|
||||
await gotoHealthyRoute(page, "/login");
|
||||
await expect(page.getByTestId("login-submit")).toBeVisible();
|
||||
|
||||
await gotoHealthyRoute(page, "/login/driver");
|
||||
await expect(page.locator('button[id="subuser-login-button"]')).toBeVisible();
|
||||
|
||||
await gotoHealthyRoute(page, "/admin/login");
|
||||
await expect(page.locator('button[id="operator_login_button"]')).toBeVisible();
|
||||
|
||||
for (const path of ["/user", "/admin/12/modules/pos", "/superuser"]) {
|
||||
await page.goto(path);
|
||||
await settlePage(page);
|
||||
await expect(page).toHaveURL(LOGIN_URL);
|
||||
await expect(page.getByTestId("login-submit")).toBeVisible();
|
||||
}
|
||||
|
||||
await guards.expectHealthy();
|
||||
guards.dispose();
|
||||
});
|
||||
|
||||
test("seeded user flow reaches dashboard, profile, and bookings", async ({ page }) => {
|
||||
const guards = attachPageHealthGuards(page, LOCAL_PROD_BASE_URL);
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: {
|
||||
display_name: "Release User",
|
||||
},
|
||||
});
|
||||
|
||||
await loginAsUser(page, {
|
||||
customerNumber: "release-user",
|
||||
password: "release-password",
|
||||
twoFactorAuthentication: false,
|
||||
});
|
||||
|
||||
await expect(page.locator("#book-wash-button")).toBeVisible();
|
||||
await expect(page.locator("#download-invoices-button")).toBeVisible();
|
||||
|
||||
await gotoHealthyRoute(page, "/user/profile");
|
||||
await expect(page.locator(".card-header").first()).toBeVisible();
|
||||
|
||||
await gotoHealthyRoute(page, "/user/bookings");
|
||||
await expect(page.locator(".title").first()).toBeVisible();
|
||||
|
||||
await guards.expectHealthy();
|
||||
guards.dispose();
|
||||
});
|
||||
|
||||
test("seeded department admin flow renders the POS shell", async ({ page }) => {
|
||||
const guards = attachPageHealthGuards(page, LOCAL_PROD_BASE_URL);
|
||||
|
||||
await seedAuthenticatedRole(page, {
|
||||
token: "release-admin-token",
|
||||
permissions: POS_PERMISSIONS,
|
||||
sessionData: {
|
||||
display_name: "Release Admin",
|
||||
},
|
||||
pos: createPosFixture(),
|
||||
});
|
||||
|
||||
await gotoHealthyRoute(page, "/admin/12/modules/pos");
|
||||
await expectOneVisible([
|
||||
page.getByTestId("pos-step-1"),
|
||||
page.getByTestId("pos-mobile-step-1-shell"),
|
||||
]);
|
||||
|
||||
await guards.expectHealthy();
|
||||
guards.dispose();
|
||||
});
|
||||
|
||||
test("seeded superuser flow renders the vehicles page", async ({ page }) => {
|
||||
const guards = attachPageHealthGuards(page, LOCAL_PROD_BASE_URL);
|
||||
|
||||
await seedAuthenticatedRole(page, {
|
||||
token: "release-superuser-token",
|
||||
permissions: ["superuser", "user"],
|
||||
sessionData: {
|
||||
display_name: "Release Superuser",
|
||||
},
|
||||
});
|
||||
|
||||
await gotoHealthyRoute(page, "/superuser/vehicles");
|
||||
await expect(page.locator("h2.title").first()).toBeVisible();
|
||||
await expect(page.locator("body")).toContainText(/registrerede/i);
|
||||
await expect(page.locator("body")).not.toContainText(/Order ID is required/i);
|
||||
|
||||
await guards.expectHealthy();
|
||||
guards.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -456,13 +456,48 @@ function createEdgeGatewayFixture(options = {}) {
|
||||
discovery_status: "READY",
|
||||
metadata: {
|
||||
broker_connected: primaryBrokerConnected,
|
||||
system_metrics: {
|
||||
latency_ms: 184,
|
||||
cpu_usage_pct: 27,
|
||||
memory_usage_pct: 61,
|
||||
memory_used_bytes: 2621440000,
|
||||
memory_total_bytes: 4294967296,
|
||||
disk_usage_pct: 58,
|
||||
disk_used_bytes: 249108103168,
|
||||
disk_total_bytes: 429496729600,
|
||||
disk_mount: "/",
|
||||
},
|
||||
},
|
||||
inventory: [
|
||||
{ id: 1, device_id: "shelly-plus-01", local_ip: "10.1.0.31", model: "Shelly Plus 2PM", channel_count: 2 },
|
||||
{ id: 2, device_id: "shelly-plus-02", local_ip: "10.1.0.32", model: "Shelly Plus 1", channel_count: 1 }
|
||||
{
|
||||
id: 1,
|
||||
device_id: "shelly-plus-01",
|
||||
local_ip: "10.1.0.31",
|
||||
model: "Shelly Plus 2PM",
|
||||
channel_count: 2,
|
||||
online: true,
|
||||
capabilities: { generation: 2 }
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
device_id: "shelly-mini-offline",
|
||||
local_ip: "10.1.0.34",
|
||||
model: "Shelly Mini 1",
|
||||
channel_count: 1,
|
||||
online: false,
|
||||
capabilities: { generation: 3 }
|
||||
}
|
||||
],
|
||||
bindings: [
|
||||
{ id: 1, relay_id: "M-7", device_id: "shelly-plus-01", local_ip: "10.1.0.31", channel: 0, binding_source: "MANUAL" }
|
||||
{ id: 1, relay_id: "M-7", device_id: "shelly-plus-01", local_ip: "10.1.0.31", channel: 0, binding_source: "MANUAL" },
|
||||
{
|
||||
id: 2,
|
||||
relay_id: "M-7-LEGACY",
|
||||
device_id: "shelly-missing-legacy",
|
||||
local_ip: "10.1.0.99",
|
||||
channel: 1,
|
||||
binding_source: "MANUAL"
|
||||
}
|
||||
],
|
||||
recent_updates: [
|
||||
{ id: 11, target_version: "1.2.1", status: "COMPLETED" }
|
||||
@@ -489,6 +524,17 @@ function createEdgeGatewayFixture(options = {}) {
|
||||
discovery_status: "STALE",
|
||||
metadata: {
|
||||
broker_connected: false,
|
||||
system_metrics: {
|
||||
latency_ms: 412,
|
||||
cpu_usage_pct: 9,
|
||||
memory_usage_pct: 42,
|
||||
memory_used_bytes: 1073741824,
|
||||
memory_total_bytes: 2147483648,
|
||||
disk_usage_pct: 76,
|
||||
disk_used_bytes: 163208757248,
|
||||
disk_total_bytes: 214748364800,
|
||||
disk_mount: "/",
|
||||
},
|
||||
},
|
||||
inventory: [],
|
||||
bindings: [],
|
||||
@@ -1581,7 +1627,15 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
|
||||
edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId] = {
|
||||
jobId: commandId,
|
||||
fetchCount: 0,
|
||||
device: { id: 3, device_id: "shelly-plus-new", local_ip: "10.1.0.33", model: "Shelly Plus 1PM", channel_count: 1 },
|
||||
device: {
|
||||
id: 3,
|
||||
device_id: "shelly-plus-new",
|
||||
local_ip: "10.1.0.33",
|
||||
model: "Shelly Plus 1PM",
|
||||
channel_count: 1,
|
||||
online: true,
|
||||
capabilities: { generation: 2 }
|
||||
},
|
||||
};
|
||||
}
|
||||
await route.fulfill(json({ data: gateway }));
|
||||
@@ -1718,6 +1772,49 @@ export async function mockApi(page, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.includes("/auth/reCAPTCHA/public") && method === "GET") {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
recaptcha: {
|
||||
enabled: false,
|
||||
site_key: "",
|
||||
},
|
||||
rate_limit: {
|
||||
enabled: false,
|
||||
limit: 0,
|
||||
remaining: 0,
|
||||
reset: 0,
|
||||
warning: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/ping") && method === "GET") {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
ok: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/worker/version") && method === "GET") {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
version: options.workerVersion || "unknown",
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.includes("/auth/login") && method === "POST") {
|
||||
await route.fulfill(
|
||||
json({
|
||||
|
||||
@@ -18,12 +18,15 @@ import {
|
||||
} from './fixtures';
|
||||
|
||||
/** My wash start tests */
|
||||
const LIVE_WASH_START_ENABLED = process.env.PLAYWRIGHT_LIVE === '1';
|
||||
|
||||
/**
|
||||
* Test that the dynamic machine status image loads successfully after lane selection.
|
||||
* Fails if ERR_BLOCKED_BY_ORB or other load error hides the image.
|
||||
*/
|
||||
test('dynamic machine status image renders after lane selection @smoke', async ({ browser }) => {
|
||||
test('dynamic machine status image renders after lane selection @live', async ({ browser }) => {
|
||||
test.skip(!LIVE_WASH_START_ENABLED, 'Set PLAYWRIGHT_LIVE=1 to run the live wash-start machine image check.');
|
||||
|
||||
// Mock geolocation to Copenhagen area for nearest department with lanes
|
||||
const context = await browser.newContext({
|
||||
geolocation: {
|
||||
@@ -52,4 +55,3 @@ test('dynamic machine status image renders after lane selection @smoke', async (
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
import { defineComponent, ref } from "vue";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sharedState = vi.hoisted(() => ({
|
||||
width: null,
|
||||
}));
|
||||
|
||||
vi.mock("@vueuse/core", async () => {
|
||||
const { ref: vueRef } = await import("vue");
|
||||
const width = vueRef(1024);
|
||||
sharedState.width = width;
|
||||
|
||||
return {
|
||||
useWindowSize: () => ({ width }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("buefy", () => ({
|
||||
BMessage: defineComponent({
|
||||
name: "BMessage",
|
||||
template: "<div><slot /></div>",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
SessionUser: {
|
||||
functions: {
|
||||
device: {
|
||||
isMobile: () => false,
|
||||
},
|
||||
ucFirst: (value) => String(value).charAt(0).toUpperCase() + String(value).slice(1),
|
||||
},
|
||||
objects: {
|
||||
global: {
|
||||
language: {
|
||||
text: {
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
this_week: "This week",
|
||||
last_week: "Last week",
|
||||
this_month: "This month",
|
||||
last_month: "Last month",
|
||||
same_week_last_year: "Same week last year",
|
||||
same_month_last_year: "Same month last year",
|
||||
date_from: "From",
|
||||
date_to: "To",
|
||||
month: "Month",
|
||||
year: "Year",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
|
||||
|
||||
describe("DatePeriodSelector mobile layout", () => {
|
||||
beforeEach(() => {
|
||||
sharedState.width.value = 480;
|
||||
});
|
||||
|
||||
it("renders stacked mobile controls and hides advanced month and year selectors by default", async () => {
|
||||
const wrapper = mount(DatePeriodSelector, {
|
||||
props: {
|
||||
selection: {
|
||||
startDate: new Date("2026-03-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-03-03T00:00:00.000Z"),
|
||||
},
|
||||
onSelectionChange: vi.fn(),
|
||||
visibility: {
|
||||
showMonthSelector: true,
|
||||
showYearSelector: true,
|
||||
showUpdateButton: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.get("[data-testid='date-period-mobile-layout']").exists()).toBe(true);
|
||||
expect(wrapper.get("[data-testid='date-period-shortcuts']").exists()).toBe(true);
|
||||
expect(wrapper.get("[data-testid='date-period-start']").exists()).toBe(true);
|
||||
expect(wrapper.get("[data-testid='date-period-end']").exists()).toBe(true);
|
||||
expect(wrapper.findAll("select")).toHaveLength(1);
|
||||
|
||||
await wrapper.get("[data-testid='date-period-advanced-toggle']").trigger("click");
|
||||
|
||||
expect(wrapper.findAll("select")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("preserves the update:selection and onSelectionChange contract on mobile", async () => {
|
||||
const onSelectionChange = vi.fn();
|
||||
const wrapper = mount(DatePeriodSelector, {
|
||||
props: {
|
||||
selection: {
|
||||
startDate: new Date("2026-03-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-03-03T00:00:00.000Z"),
|
||||
},
|
||||
onSelectionChange,
|
||||
visibility: {
|
||||
showMonthSelector: true,
|
||||
showYearSelector: true,
|
||||
showUpdateButton: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.get("[data-testid='date-period-end']").setValue("2026-03-05");
|
||||
|
||||
const emittedSelection = wrapper.emitted("update:selection")?.at(-1)?.[0];
|
||||
expect(emittedSelection.startDate.toISOString().split("T")[0]).toBe("2026-03-01");
|
||||
expect(emittedSelection.endDate.toISOString().split("T")[0]).toBe("2026-03-05");
|
||||
expect(onSelectionChange).toHaveBeenCalledTimes(1);
|
||||
expect(onSelectionChange.mock.calls[0][0].toISOString().split("T")[0]).toBe("2026-03-01");
|
||||
expect(onSelectionChange.mock.calls[0][1].toISOString().split("T")[0]).toBe("2026-03-05");
|
||||
});
|
||||
});
|
||||
@@ -71,7 +71,7 @@ vi.mock("buefy", () => ({
|
||||
}));
|
||||
|
||||
import DepartmentModulesDisplay from "@/components/displays/department/moduleNavigation/DepartmentModulesDisplay.vue";
|
||||
import { __routeRef } from "vue-router";
|
||||
import { __pushMock, __routeRef } from "vue-router";
|
||||
|
||||
const flushMicrotasks = async () => {
|
||||
await Promise.resolve();
|
||||
@@ -149,6 +149,19 @@ describe("DepartmentModulesDisplay self-serve management", () => {
|
||||
expect(wrapper.get("[data-testid='self-serve-switch']").attributes("data-state")).toBe("off");
|
||||
});
|
||||
|
||||
it("renders responsive overview columns for each module card", async () => {
|
||||
const wrapper = mount(DepartmentModulesDisplay);
|
||||
await flushMicrotasks();
|
||||
|
||||
const firstColumn = wrapper.find(".column");
|
||||
expect(firstColumn.classes()).toEqual(expect.arrayContaining([
|
||||
"is-12-mobile",
|
||||
"is-6-tablet",
|
||||
"is-4-desktop",
|
||||
"is-3-widescreen",
|
||||
]));
|
||||
});
|
||||
|
||||
it("updates self-serve status and keeps UI in sync on success", async () => {
|
||||
const wrapper = mount(DepartmentModulesDisplay);
|
||||
await flushMicrotasks();
|
||||
@@ -164,6 +177,52 @@ describe("DepartmentModulesDisplay self-serve management", () => {
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith("admin.department_modules.self_wash.enabled");
|
||||
});
|
||||
|
||||
it("keeps the self-serve switch isolated from card navigation", async () => {
|
||||
const wrapper = mount(DepartmentModulesDisplay);
|
||||
await flushMicrotasks();
|
||||
__pushMock.mockClear();
|
||||
|
||||
await wrapper.get("[data-testid='self-serve-switch']").trigger("click");
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(__pushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the bookings count badge isolated from the card click handler", async () => {
|
||||
mocks.authenticatedRequest.mockImplementation(async (url, method) => {
|
||||
if (String(url).startsWith("/admin/bookings/department/count?department_id=12")) {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
message: 5,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (url === "/departments/self-serve/enabled?id=12" && method === "GET") {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected request: ${method} ${url}`);
|
||||
});
|
||||
|
||||
const wrapper = mount(DepartmentModulesDisplay);
|
||||
await flushMicrotasks();
|
||||
__pushMock.mockClear();
|
||||
|
||||
await wrapper.get(".department-module-card__count").trigger("click");
|
||||
|
||||
expect(__pushMock).toHaveBeenCalledTimes(1);
|
||||
expect(__pushMock).toHaveBeenCalledWith("/admin/12/modules/bookings?search=pending");
|
||||
});
|
||||
|
||||
it("reverts self-serve status and shows an error when update fails", async () => {
|
||||
mocks.authenticatedRequest.mockImplementation(async (url, method) => {
|
||||
if (String(url).startsWith("/admin/bookings/department/count?department_id=12")) {
|
||||
|
||||
@@ -27,12 +27,18 @@ describe("edge gateway workspace contract", () => {
|
||||
});
|
||||
|
||||
it("renders queued discovery/update flows and broker connectivity as separate UI signals", () => {
|
||||
expect(workspaceSource).toContain("Generér installer");
|
||||
expect(workspaceSource).toContain("Generér installer");
|
||||
expect(workspaceSource).toContain('aria-label="Installer afdeling"');
|
||||
expect(workspaceSource).toContain("Kør discovery");
|
||||
expect(workspaceSource).toContain("Kør discovery");
|
||||
expect(workspaceSource).toContain("Brokerforbindelse");
|
||||
expect(workspaceSource).toContain("Latens");
|
||||
expect(workspaceSource).toContain("CPU");
|
||||
expect(workspaceSource).toContain("RAM");
|
||||
expect(workspaceSource).toContain("Disk");
|
||||
expect(workspaceSource).toContain("getBrokerMeta");
|
||||
expect(workspaceSource).toContain("gateway.metadata?.broker_connected");
|
||||
expect(workspaceSource).toContain("buildGatewayMetricView");
|
||||
expect(workspaceSource).toContain("system_metrics");
|
||||
expect(workspaceSource).toContain("metadata.broker_connected");
|
||||
expect(workspaceSource).toContain("startGatewayRefreshLoop");
|
||||
expect(workspaceSource).toContain("stopGatewayRefreshLoop");
|
||||
expect(workspaceSource).toContain("Discovery er køet");
|
||||
@@ -44,7 +50,7 @@ describe("edge gateway workspace contract", () => {
|
||||
expect(workspaceSource).toContain("{{ displayErrorMessage }}");
|
||||
expect(workspaceSource).toContain("Gateway-agenten er offline og kan ikke hente nye kommandoer lige nu.");
|
||||
expect(workspaceSource).toContain("Gem bindinger");
|
||||
expect(workspaceSource).toContain("Planlæg opdatering");
|
||||
expect(workspaceSource).toContain("Planlæg opdatering");
|
||||
expect(workspaceSource).toContain("Transporttilstand");
|
||||
expect(workspaceSource).toContain("Break-glass adgang");
|
||||
expect(workspaceSource).toContain("Gateway transport");
|
||||
@@ -53,6 +59,18 @@ describe("edge gateway workspace contract", () => {
|
||||
expect(workspaceSource).toContain(".edge-form-grid--installer");
|
||||
});
|
||||
|
||||
it("builds richer binding device options and keeps stale selections editable", () => {
|
||||
expect(workspaceSource).toContain("const inventoryDeviceOptions = computed(() =>");
|
||||
expect(workspaceSource).toContain("const buildInventoryOptionLabel =");
|
||||
expect(workspaceSource).toContain("const getBindingInventoryDevice = (binding) =>");
|
||||
expect(workspaceSource).toContain("const getBindingDeviceOptions = (binding) =>");
|
||||
expect(workspaceSource).toContain("Mangler i discovery");
|
||||
expect(workspaceSource).toContain("{{ bindingHelperText }}");
|
||||
expect(workspaceSource).toContain("{{ emptyBindingStateDescription }}");
|
||||
expect(workspaceSource).toContain('binding.local_ip = device.local_ip ?? "";');
|
||||
expect(workspaceSource).toContain("binding.channel = channelOptions[0];");
|
||||
});
|
||||
|
||||
it("registers fleet and department gateway routes in the router and navigation", () => {
|
||||
expect(routerSource).toContain("path: '/superuser/gateways'");
|
||||
expect(routerSource).toContain("path: '/superuser/departments/:departmentId/gateways'");
|
||||
|
||||
Reference in New Issue
Block a user