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:
Jeppe Bundgaard
2026-04-09 16:32:25 +02:00
parent e8bd2f5478
commit f040614f46
41 changed files with 2685 additions and 644 deletions
+4
View File
@@ -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",
+37
View File
@@ -0,0 +1,37 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.PLAYWRIGHT_BASE_URL;
const isCI = !!process.env.CI;
if (!baseURL) {
throw new Error("PLAYWRIGHT_BASE_URL is required for the live smoke gate.");
}
export default defineConfig({
testDir: "./tests/e2e/release",
testMatch: /.*\.live-smoke\.spec\.ts/,
timeout: 60_000,
fullyParallel: false,
forbidOnly: isCI,
retries: isCI ? 1 : 0,
workers: 1,
reporter: [
["list"],
["html", { open: "never", outputFolder: "output/playwright/live/report" }],
],
outputDir: "output/playwright/live/test-results",
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
},
},
],
});
+54
View File
@@ -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>
@@ -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>
@@ -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;
@@ -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>
@@ -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>
@@ -186,4 +186,4 @@ const showProfile = computed(() => {
/* Invert colors and apply a red tint */
filter: invert(27%) sepia(96%) saturate(7493%) hue-rotate(357deg) brightness(103%) contrast(101%);
}
</style>
</style>
@@ -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>
@@ -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>
@@ -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">&nbsp;</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>
@@ -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;
}
</style>
.admin-overview-reports__divider {
margin-top: 1rem;
}
</style>
@@ -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>
@@ -41,4 +41,4 @@ import {BButton, BDatepicker} from "buefy";
</div>
</b-datepicker>
</div>
</template>
</template>
@@ -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>
+79
View File
@@ -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");
});
});
+15 -1
View File
@@ -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();
+17 -2
View File
@@ -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();
+153
View File
@@ -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");
+54 -10
View File
@@ -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");
});
});
+7 -5
View File
@@ -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 });
}
/**
+65 -23
View File
@@ -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
@@ -108,4 +150,4 @@ export const vehicleTestData = {
brand: 'Volvo',
model: 'V70',
nickname: 'Test Car',
};
};
+68
View File
@@ -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"]');
+122
View File
@@ -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");
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

+129
View File
@@ -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();
});
});
});
+101 -4
View File
@@ -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({
+4 -2
View File
@@ -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();
});
+118
View File
@@ -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")) {
+22 -4
View File
@@ -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'");