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"],
},
},
],
});
@@ -1,19 +1,28 @@
<script setup lang="ts">
import { IS_DEV } from '@/config.js';
import { ref, computed, defineProps, defineEmits, watch } from 'vue';
import { SessionUser } from '@/components/session/token/SessionUser.vue';
import {BField, BMessage, BSelect} from "buefy";
import { IS_DEV } from "@/config.js";
import { computed, ref, watch } from "vue";
import { useWindowSize } from "@vueuse/core";
import { BMessage } from "buefy";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
type DateRange = {
startDate: Date,
endDate: Date,
};
type Shortcut = {
label: string,
getRange: () => DateRange,
};
const props = defineProps({
selection: {
type: Object as () => ({
startDate: Date,
endDate: Date,
}),
type: Object as () => DateRange,
required: true,
default: () => ({
startDate: new Date(new Date().setHours(0, 0, 0, 0)), // Start of the day (Today at 00:00:00)
endDate: new Date(new Date().setHours(23, 59, 59, 999)), // End of the day (Today at 23:59:59)
startDate: new Date(new Date().setHours(0, 0, 0, 0)),
endDate: new Date(new Date().setHours(23, 59, 59, 999)),
}),
},
onSelectionChange: {
@@ -24,7 +33,7 @@ const props = defineProps({
endDate: Date
) => {
if (IS_DEV) {
console.warn('onSelectionChange not provided', 'startDate:', startDate, 'endDate:', endDate);
console.warn("onSelectionChange not provided", "startDate:", startDate, "endDate:", endDate);
}
},
},
@@ -35,25 +44,25 @@ const props = defineProps({
showToLabel?: boolean,
showSelectionValidity?: boolean,
showUpdateButton?: boolean,
showDailySelector?: boolean, // Whether to show the daily selector
showWeeklySelector?: boolean, // Whether to show the weekly selector
showMonthSelector?: boolean, // Whether to show the month select
showMultipleMonthWarning?: boolean, // Whether to show a warning if the selection spans multiple months
showYearSelector?: boolean, // Whether to show the year selector
showShortCuts?: boolean, // Whether to show the shortcuts
showDailySelector?: boolean,
showWeeklySelector?: boolean,
showMonthSelector?: boolean,
showMultipleMonthWarning?: boolean,
showYearSelector?: boolean,
showShortCuts?: boolean,
},
required: false,
default: () => ({
showStartDate: true,
showEndDate: true,
showToLabel: true, // Whether to show the "To" label between start and end date inputs
showSelectionValidity: true, // Whether to show the validity of the selection
showUpdateButton: true, // Whether to show the update button
showToLabel: true,
showSelectionValidity: true,
showUpdateButton: true,
}),
},
reverseLevelOrder: {
type: Boolean,
default: false, // Whether to reverse the order of left and right levels
default: false,
},
isDisabled: {
type: Boolean,
@@ -66,19 +75,17 @@ const props = defineProps({
restrictions: {
type: Object,
default: () => ({
minDate: null, // No minimum date restriction
maxDate: null, // No maximum date restriction
minDate: null,
maxDate: null,
}),
},
autoEmitChange: {
type: Boolean,
default: true, // Automatically emit changes, set to false if you want the user to manually trigger updates
default: true,
},
});
const visibility = computed(() => {
// Apply default visibility settings
return {
const visibility = computed(() => ({
showStartDate: true,
showEndDate: true,
showToLabel: true,
@@ -90,124 +97,162 @@ const visibility = computed(() => {
showDailySelector: false,
showWeeklySelector: false,
showShortCuts: true,
// Override with props visibility settings
...props.visibility,
};
})
}));
const emits = defineEmits(['update:selection']);
const emits = defineEmits(["update:selection"]);
const { width } = useWindowSize();
const startDate = ref(props.selection.startDate);
const endDate = ref(props.selection.endDate);
const currentMonthSelection = ref(startDate.value.getMonth() + 1); // Months are 0-indexed in JavaScript
const formattedStartDate = computed(() => SessionUser.functions.date.toLocal(startDate.value));
const formattedEndDate = computed(() => SessionUser.functions.date.toLocal(endDate.value));
const currentMonthSelection = ref(startDate.value.getMonth() + 1);
const isPendingEmit = ref(false);
const updateSelection = () => {
emits('update:selection', { startDate: startDate.value, endDate: endDate.value });
props.onSelectionChange(startDate.value, endDate.value);
isPendingEmit.value = false; // Reset pending state after emitting
const isMobileLayout = computed(() => width.value <= 768 || SessionUser.functions.device.isMobile());
const showMobileAdvancedControls = computed(() => visibility.value.showMonthSelector || visibility.value.showYearSelector);
const isMobileAdvancedExpanded = ref(false);
const availableYears = computed(() => (
Array.from({ length: 21 }, (_, index) => new Date().getFullYear() - 10 + index)
));
const formatDateInputValue = (date: Date) => {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return "";
}
return date.toISOString().split("T")[0];
};
const updateSelection = () => {
emits("update:selection", { startDate: startDate.value, endDate: endDate.value });
props.onSelectionChange(startDate.value, endDate.value);
isPendingEmit.value = false;
};
const applyDateRange = (newStartDate: Date, newEndDate: Date) => {
startDate.value = newStartDate;
endDate.value = newEndDate;
currentMonthSelection.value = newStartDate.getMonth() + 1;
isPendingEmit.value = true;
if (props.autoEmitChange) {
updateSelection();
}
};
watch(() => props.selection, (newSelection) => {
startDate.value = newSelection.startDate;
endDate.value = newSelection.endDate;
currentMonthSelection.value = newSelection.startDate.getMonth() + 1;
}, { immediate: true });
watch(isMobileLayout, (isMobile) => {
if (!isMobile) {
isMobileAdvancedExpanded.value = false;
return;
}
isMobileAdvancedExpanded.value = false;
}, { immediate: true });
const isStartDateValid = computed(() => {
if (props.restrictions.minDate) {
return startDate.value >= props.restrictions.minDate;
}
return true;
});
const isEndDateValid = computed(() => {
if (props.restrictions.maxDate) {
return endDate.value <= props.restrictions.maxDate;
}
return true;
});
const isSelectionValid = computed(() => {
return isStartDateValid.value && isEndDateValid.value && startDate.value <= endDate.value;
});
const isSelectionValid = computed(() => (
isStartDateValid.value
&& isEndDateValid.value
&& startDate.value <= endDate.value
));
const handleStartDateChange = (event) => {
const newDate = new Date(event.target.value);
if (isNaN(newDate.getTime())) {
console.warn('Invalid start date:', event.target.value);
if (Number.isNaN(newDate.getTime())) {
console.warn("Invalid start date:", event.target.value);
return;
}
startDate.value = newDate;
isPendingEmit.value = true;
if (props.autoEmitChange) {
// Automatically update selection if autoEmitChange is true
updateSelection();
}
applyDateRange(newDate, endDate.value);
};
const handleEndDateChange = (event) => {
const newDate = new Date(event.target.value);
if (isNaN(newDate.getTime())) {
console.warn('Invalid end date:', event.target.value);
if (Number.isNaN(newDate.getTime())) {
console.warn("Invalid end date:", event.target.value);
return;
}
endDate.value = newDate;
isPendingEmit.value = true;
if (props.autoEmitChange) {
// Automatically update selection if autoEmitChange is true
updateSelection();
}
applyDateRange(startDate.value, newDate);
};
const setMonth = (month: number) => {
if (month < 1 || month > 12) {
console.warn('Invalid month selection:', month);
console.warn("Invalid month selection:", month);
return;
}
currentMonthSelection.value = month;
// Update start date to the first day of the selected month, and end date to the last day of the selected month
const year = startDate.value.getFullYear();
const startOfMonth = new Date(year, month - 1, 2); // Months are 0-indexed
const endOfMonth = new Date(year, month, 0); // Last day of the month
startDate.value = startOfMonth;
endDate.value = endOfMonth;
isPendingEmit.value = true;
if (props.autoEmitChange) {
updateSelection();
}
const startOfMonth = new Date(year, month - 1, 1);
const endOfMonth = new Date(year, month, 0);
applyDateRange(startOfMonth, endOfMonth);
};
const months = computed(() => {
return Array.from({ length: 12 }, (_, i) => ({
value: i + 1,
label: new Date(0, i).toLocaleString('da-DK', { month: 'long' }),
}));
});
const handleMonthChange = (event) => {
const month = parseInt(event.target.value, 10);
if (Number.isNaN(month)) {
return;
}
setMonth(month);
};
const handleYearChange = (event) => {
const year = parseInt(event.target.value, 10);
if (Number.isNaN(year)) {
return;
}
const newStartDate = new Date(startDate.value);
newStartDate.setFullYear(year);
const newEndDate = new Date(endDate.value);
newEndDate.setFullYear(year);
applyDateRange(newStartDate, newEndDate);
};
const months = computed(() => (
Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: new Date(0, index).toLocaleString("da-DK", { month: "long" }),
}))
));
const isCurrentSelectionEntireMonth = computed(() => {
const isFirstDayOfMonth = new Date(startDate.value.getFullYear(), startDate.value.getMonth() - 1, 2).getDate() === startDate.value.getDate();
const isLastDayOfMonth = endDate.value.getDate() === new Date(endDate.value.getFullYear(), endDate.value.getMonth() + 1, 0).getDate();
// Debugging output to verify the logic
/**
* console.log('isCurrentSelectionEntireMonth:', {
* isFirstDayOfMonth,
* shouldMatch: new Date(startDate.value.getFullYear(), startDate.value.getMonth() - 1, 2).getDate(),
* isLastDayOfMonth,
* shouldMatchEnd: endDate.value.getDate(),
* startDate: startDate.value,
* endDate: endDate.value,
* });
*/
const isFirstDayOfMonth = startDate.value.getDate() === 1;
const isLastDayOfMonth = (
endDate.value.getDate()
=== new Date(endDate.value.getFullYear(), endDate.value.getMonth() + 1, 0).getDate()
);
return isFirstDayOfMonth && isLastDayOfMonth;
});
const isMultiMonthSelection = computed(() => {
return startDate.value.getMonth() !== endDate.value.getMonth() || startDate.value.getFullYear() !== endDate.value.getFullYear();
});
const isMultiMonthSelection = computed(() => (
startDate.value.getMonth() !== endDate.value.getMonth()
|| startDate.value.getFullYear() !== endDate.value.getFullYear()
));
type shortcut = {
label: string,
getRange: () => { startDate: Date, endDate: Date },
};
// Use computed to make shortcuts reactive to language changes
const shortcuts = computed<shortcut[]>(() => {
const dailyShortcuts: shortcut[] = [
// Today
const shortcuts = computed<Shortcut[]>(() => {
const dailyShortcuts: Shortcut[] = [
{
label: SessionUser.objects.global.language.text.today,
getRange: () => {
@@ -218,7 +263,6 @@ const shortcuts = computed<shortcut[]>(() => {
};
},
},
// Yesterday
{
label: SessionUser.objects.global.language.text.yesterday,
getRange: () => {
@@ -231,13 +275,13 @@ const shortcuts = computed<shortcut[]>(() => {
},
},
];
const weeklyShortcuts: shortcut[] = [
// This week
const weeklyShortcuts: Shortcut[] = [
{
label: SessionUser.objects.global.language.text.this_week,
getRange: () => {
const today = new Date();
const firstDayOfWeek = new Date(today.setDate(today.getDate() - today.getDay() + 1)); // Assuming week starts on Monday
const firstDayOfWeek = new Date(today.setDate(today.getDate() - today.getDay() + 1));
const lastDayOfWeek = new Date(firstDayOfWeek);
lastDayOfWeek.setDate(firstDayOfWeek.getDate() + 6);
return {
@@ -246,12 +290,11 @@ const shortcuts = computed<shortcut[]>(() => {
};
},
},
// Last week
{
label: SessionUser.objects.global.language.text.last_week,
getRange: () => {
const today = new Date();
const firstDayOfLastWeek = new Date(today.setDate(today.getDate() - today.getDay() - 6)); // Assuming week starts on Monday
const firstDayOfLastWeek = new Date(today.setDate(today.getDate() - today.getDay() - 6));
const lastDayOfLastWeek = new Date(firstDayOfLastWeek);
lastDayOfLastWeek.setDate(firstDayOfLastWeek.getDate() + 6);
return {
@@ -260,7 +303,6 @@ const shortcuts = computed<shortcut[]>(() => {
};
},
},
// This month
{
label: SessionUser.objects.global.language.text.this_month,
getRange: () => {
@@ -273,7 +315,6 @@ const shortcuts = computed<shortcut[]>(() => {
};
},
},
// Last month
{
label: SessionUser.objects.global.language.text.last_month,
getRange: () => {
@@ -286,12 +327,11 @@ const shortcuts = computed<shortcut[]>(() => {
};
},
},
// Same week last year
{
label: SessionUser.objects.global.language.text.same_week_last_year,
getRange: () => {
const today = new Date();
const firstDayOfThisWeek = new Date(today.setDate(today.getDate() - today.getDay() + 1)); // Assuming week starts on Monday
const firstDayOfThisWeek = new Date(today.setDate(today.getDate() - today.getDay() + 1));
const firstDayOfLastYearSameWeek = new Date(firstDayOfThisWeek);
firstDayOfLastYearSameWeek.setFullYear(firstDayOfThisWeek.getFullYear() - 1);
const lastDayOfLastYearSameWeek = new Date(firstDayOfLastYearSameWeek);
@@ -302,7 +342,6 @@ const shortcuts = computed<shortcut[]>(() => {
};
},
},
// Same month last year
{
label: SessionUser.objects.global.language.text.same_month_last_year,
getRange: () => {
@@ -320,122 +359,168 @@ const shortcuts = computed<shortcut[]>(() => {
},
},
];
return [
...((visibility.value.showDailySelector || visibility.value.showShortCuts) ? dailyShortcuts : []),
...((visibility.value.showWeeklySelector || visibility.value.showShortCuts) ? weeklyShortcuts : []),
];
});
const selectedShortcutLabel = computed(() => (
shortcuts.value.find((shortcut) => (
formatDateInputValue(shortcut.getRange().startDate) === formatDateInputValue(startDate.value)
&& formatDateInputValue(shortcut.getRange().endDate) === formatDateInputValue(endDate.value)
))?.label || ""
));
const applyShortcut = (shortcut: Shortcut) => {
const range = shortcut.getRange();
applyDateRange(range.startDate, range.endDate);
};
const handleShortcutSelection = (event) => {
const selectedLabel = String(event.target.value || "");
const selectedShortcut = shortcuts.value.find((shortcut) => shortcut.label === selectedLabel);
if (!selectedShortcut) {
return;
}
applyShortcut(selectedShortcut);
};
</script>
<template>
<!-- Mobile date selector -->
<div class="columns is-mobile is-multiline" v-show="SessionUser.functions.device.isMobile()">
<!-- Date from & Date to -->
<div class="column is-half">
<label class="label is-small" v-if="visibility.showStartDate">{{ SessionUser.objects.global.language.text.date_from }}</label>
<div class="date-period-selector">
<div v-if="isMobileLayout" class="date-period-selector__mobile" data-testid="date-period-mobile-layout">
<div class="field" v-if="shortcuts.length > 0">
<label class="label is-small">Periode</label>
<div class="control">
<div class="select is-fullwidth">
<select
data-testid="date-period-shortcuts"
:value="selectedShortcutLabel"
:disabled="props.isDisabled || props.isReadonly"
@change="handleShortcutSelection"
>
<option value="" disabled>Vaelg periode</option>
<option v-for="shortcut in shortcuts" :key="shortcut.label" :value="shortcut.label">
{{ shortcut.label }}
</option>
</select>
</div>
</div>
</div>
<div class="field" v-if="visibility.showStartDate">
<label class="label is-small">{{ SessionUser.objects.global.language.text.date_from }}</label>
<div class="control">
<input
type="date"
class="input"
data-testid="date-period-start"
v-model="props.selection.startDate.toISOString().split('T')[0]"
:value="formatDateInputValue(startDate)"
@change="handleStartDateChange"
v-bind:disabled="props.isDisabled || props.isReadonly"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
/>
</div>
<div class="column is-half">
<label class="label is-small" v-if="visibility.showEndDate">{{ SessionUser.objects.global.language.text.date_to }}</label>
</div>
<div class="field" v-if="visibility.showEndDate">
<label class="label is-small">{{ SessionUser.objects.global.language.text.date_to }}</label>
<div class="control">
<input
type="date"
class="input"
data-testid="date-period-end"
v-model="props.selection.endDate.toISOString().split('T')[0]"
:value="formatDateInputValue(endDate)"
@change="handleEndDateChange"
v-bind:disabled="props.isDisabled || props.isReadonly"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
/>
</div>
<!-- Month selector -->
<div class="column is-half" v-if="visibility.showMonthSelector">
<div class="field has-addons is-centered">
<p class="control is-expanded">
<span class="select is-fullwidth">
<select
class="input"
v-model="currentMonthSelection"
@change="setMonth(currentMonthSelection)"
v-bind:disabled="props.isDisabled || props.isReadonly"
</div>
<div class="field" v-if="showMobileAdvancedControls">
<div class="control">
<button
class="button is-light is-fullwidth date-period-selector__advanced-toggle"
type="button"
data-testid="date-period-advanced-toggle"
@click="isMobileAdvancedExpanded = !isMobileAdvancedExpanded"
>
<!-- Default option: Custom -->
<option value="" disabled selected>Select Month</option>
<span>Advanced period</span>
<span class="icon">
<i :class="isMobileAdvancedExpanded ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</span>
</button>
</div>
</div>
<div v-if="showMobileAdvancedControls && isMobileAdvancedExpanded" class="date-period-selector__advanced-panel">
<div class="field" v-if="visibility.showMonthSelector">
<label class="label is-small">{{ SessionUser.objects.global.language.text.month }}</label>
<div class="control">
<div class="select is-fullwidth">
<select
:value="currentMonthSelection"
:disabled="props.isDisabled || props.isReadonly"
@change="handleMonthChange"
>
<option value="" disabled>Select Month</option>
<option v-for="month in months" :key="month.value" :value="month.value">
{{ SessionUser.functions.ucFirst(month.label) }}
</option>
</select>
</span>
</p>
</div>
</div>
<!-- Year selector -->
<div class="column is-half" v-if="visibility.showYearSelector">
<div class="field has-addons is-centered">
<p class="control is-expanded">
<span class="select is-fullwidth">
</div>
<div class="field" v-if="visibility.showYearSelector">
<label class="label is-small">{{ SessionUser.objects.global.language.text.year }}</label>
<div class="control">
<div class="select is-fullwidth">
<select
class="input"
:value="startDate.getFullYear()"
@change="(e) => { const year = parseInt(e.target.value); if (!isNaN(year)) { const newStartDate = new Date(startDate); newStartDate.setFullYear(year); startDate = newStartDate; const newEndDate = new Date(endDate); newEndDate.setFullYear(year); endDate = newEndDate; isPendingEmit = true; if (props.autoEmitChange) { updateSelection(); } } }"
v-bind:disabled="props.isDisabled || props.isReadonly"
:disabled="props.isDisabled || props.isReadonly"
@change="handleYearChange"
>
<!-- Generate a range of years from current year - 10 to current year + 10 -->
<option v-for="year in Array.from({ length: 21 }, (_, i) => new Date().getFullYear() - 10 + i)" :key="year" :value="year">
<option v-for="year in availableYears" :key="year" :value="year">
{{ year }}
</option>
</select>
</span>
</p>
<p class="control">
<a class="button is-static">
{{ SessionUser.objects.global.language.text.year }}
</a>
</p>
</div>
</div>
<!-- Shortcuts -->
<div class="column is-full">
<b-field label="Periode">
<b-select placeholder="Vælg periode"
:expandable="true"
:model-value="shortcuts.find(s => (s.getRange().startDate.toISOString().split('T')[0] === startDate.toISOString().split('T')[0]) && (s.getRange().endDate.toISOString().split('T')[0] === endDate.toISOString().split('T')[0]))?.label || null"
:expanded="true"
@update:modelValue="(value) => { const selectedShortcut = shortcuts.find(s => s.label === value); if (selectedShortcut) { const range = selectedShortcut.getRange(); startDate = range.startDate; endDate = range.endDate; isPendingEmit = true; if (props.autoEmitChange) { updateSelection(); } } }" v-bind:disabled="props.isDisabled || props.isReadonly">
<option
v-for="(option, index) in shortcuts"
:key="index"
:value="option.label"
>{{ option.label }}
</option>
</b-select>
</b-field>
</div>
</div>
<!-- Desktop date selector -->
<div class="level is-align-items-center" v-show="!SessionUser.functions.device.isMobile()">
<div class="date-period-selector__mobile-actions" v-if="visibility.showUpdateButton">
<slot name="right"></slot>
<button
class="button is-primary is-fullwidth"
type="button"
@click="updateSelection"
:disabled="props.isDisabled || props.isReadonly || !isSelectionValid || !isPendingEmit"
>
Update Selection
</button>
</div>
</div>
<template v-else>
<div class="level is-align-items-center">
<div class="level-left">
<!-- Start Date Input -->
<div class="level-item" v-if="visibility.showStartDate">
<input
type="date"
class="input"
data-testid="date-period-start"
v-model="props.selection.startDate.toISOString().split('T')[0]"
:value="formatDateInputValue(startDate)"
@change="handleStartDateChange"
v-bind:disabled="props.isDisabled || props.isReadonly"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
/>
</div>
<!-- "To" visual element -->
<div class="level-item" v-if="visibility.showStartDate && visibility.showEndDate && visibility.showToLabel">
<span class="label">To</span>
</div>
@@ -444,29 +529,25 @@ const shortcuts = computed<shortcut[]>(() => {
type="date"
class="input"
data-testid="date-period-end"
v-model="props.selection.endDate.toISOString().split('T')[0]"
:value="formatDateInputValue(endDate)"
@change="handleEndDateChange"
v-bind:disabled="props.isDisabled || props.isReadonly"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
/>
</div>
<!-- Display a message if neither start nor end date is shown -->
<div class="level-item" v-if="!visibility.showStartDate && !visibility.showEndDate && visibility.showToLabel">
<span class="label">No date selection available</span>
</div>
<!-- Month and Year selectors -->
<div class="level-item" v-if="visibility.showMonthSelector">
<div class="field has-addons">
<p class="control">
<span class="select">
<select
class="input"
v-model="currentMonthSelection"
@change="setMonth(currentMonthSelection)"
v-bind:disabled="props.isDisabled || props.isReadonly"
:value="currentMonthSelection"
:disabled="props.isDisabled || props.isReadonly"
@change="handleMonthChange"
>
<!-- Default option: Custom -->
<option value="" disabled selected>Select Month</option>
<option value="" disabled>Select Month</option>
<option v-for="month in months" :key="month.value" :value="month.value">
{{ SessionUser.functions.ucFirst(month.label) }}
</option>
@@ -485,13 +566,11 @@ const shortcuts = computed<shortcut[]>(() => {
<p class="control">
<span class="select">
<select
class="input"
:value="startDate.getFullYear()"
@change="(e) => { const year = parseInt(e.target.value); if (!isNaN(year)) { const newStartDate = new Date(startDate); newStartDate.setFullYear(year); startDate = newStartDate; const newEndDate = new Date(endDate); newEndDate.setFullYear(year); endDate = newEndDate; isPendingEmit = true; if (props.autoEmitChange) { updateSelection(); } } }"
v-bind:disabled="props.isDisabled || props.isReadonly"
:disabled="props.isDisabled || props.isReadonly"
@change="handleYearChange"
>
<!-- Generate a range of years from current year - 10 to current year + 10 -->
<option v-for="year in Array.from({ length: 21 }, (_, i) => new Date().getFullYear() - 10 + i)" :key="year" :value="year">
<option v-for="year in availableYears" :key="year" :value="year">
{{ year }}
</option>
</select>
@@ -504,34 +583,34 @@ const shortcuts = computed<shortcut[]>(() => {
</p>
</div>
</div>
<!-- Slot for left side content -->
<slot name="left"></slot>
</div>
<div class="level-right"
v-if="visibility.showSelectionValidity">
<div class="level-right" v-if="visibility.showSelectionValidity">
<slot name="right"></slot>
<div class="level-item" v-if="visibility.showUpdateButton">
<button
class="button is-primary"
@click="updateSelection"
v-bind:disabled="props.isDisabled || props.isReadonly || !isSelectionValid || !isPendingEmit"
:disabled="props.isDisabled || props.isReadonly || !isSelectionValid || !isPendingEmit"
>
Update Selection
</button>
</div>
</div>
</div>
<div class="navbar is-white" v-show="!SessionUser.functions.device.isMobile()">
<div class="navbar is-white">
<div class="navbar-menu">
<!-- Date shortcuts -->
<div class="navbar-start">
<div class="navbar-item">
<div class="buttons">
<button v-for="(shortcut, index) in shortcuts"
:key="index"
@click="() => { const range = shortcut.getRange(); startDate = range.startDate; endDate = range.endDate; isPendingEmit = true; if (props.autoEmitChange) { updateSelection(); } }"
<button
v-for="shortcut in shortcuts"
:key="shortcut.label"
:disabled="props.isDisabled || props.isReadonly"
class="button is-light" :class="{ 'is-info': (startDate.toISOString().split('T')[0] === shortcut.getRange().startDate.toISOString().split('T')[0]) && (endDate.toISOString().split('T')[0] === shortcut.getRange().endDate.toISOString().split('T')[0]) }"
class="button is-light"
:class="{ 'is-info': selectedShortcutLabel === shortcut.label }"
@click="applyShortcut(shortcut)"
>
{{ shortcut.label }}
</button>
@@ -540,10 +619,11 @@ const shortcuts = computed<shortcut[]>(() => {
</div>
</div>
</div>
<!-- Warnings and messages -->
<div class="level-item" v-if="visibility.showMonthSelector">
<!-- Warning if the selection is not valid -->
<b-message v-if="!isSelectionValid"
</template>
<div class="date-period-selector__messages" v-if="visibility.showMonthSelector">
<b-message
v-if="!isSelectionValid"
type="is-warning"
iconPack="fas"
has-icon
@@ -551,7 +631,6 @@ const shortcuts = computed<shortcut[]>(() => {
Invalid selection! Start date must be before end date. Please adjust your selection to ensure the start date is not after the end date, and that both dates are within any specified restrictions.
</b-message>
<!-- Warning if the selection stretches across multiple months -->
<b-message
v-else-if="isMultiMonthSelection && visibility.showMultipleMonthWarning"
style="width: 100%;"
@@ -561,7 +640,7 @@ const shortcuts = computed<shortcut[]>(() => {
>
Selection spans multiple months! This may lead to unexpected results. Consider selecting an entire month or a shorter period for more accurate insights.
</b-message>
<!-- Warning if it's not an entire month -->
<b-message
:closable="false"
v-else-if="!isCurrentSelectionEntireMonth && visibility.showMultipleMonthWarning"
@@ -573,8 +652,32 @@ const shortcuts = computed<shortcut[]>(() => {
When a partial month selection is made, it may not accurately represent the intended time period. Consider selecting an entire month for more precise results.
</b-message>
</div>
</div>
</template>
<style scoped>
.date-period-selector__mobile {
display: grid;
gap: 0.75rem;
}
.date-period-selector__advanced-toggle {
display: flex;
align-items: center;
justify-content: space-between;
}
.date-period-selector__advanced-panel {
display: grid;
gap: 0.75rem;
}
.date-period-selector__mobile-actions {
display: grid;
gap: 0.75rem;
}
.date-period-selector__messages {
margin-top: 0.75rem;
}
</style>
@@ -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>
</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')">
<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; max-width: 90vw;"
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 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;
@@ -204,10 +204,12 @@ onMounted(() => {
<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">
<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>
</div>
@@ -156,7 +156,7 @@ 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">
@@ -235,17 +235,21 @@ 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">
<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">
<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>
</div>
@@ -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>
@@ -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,9 +127,39 @@ const toggleSelection = (id) => {
:reverse-level-order="true"
/>
</div>
<div class="column">
<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>
<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" style="flex-wrap: wrap; gap: 0.5rem 0.5rem;" data-testid="daily-report-department-controls">
<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"
@@ -117,37 +174,31 @@ const toggleSelection = (id) => {
>
{{ opt.name }}
</button>
<!-- Loading skeleton -->
<template v-for="i in 9" :key="'loading-skeleton-' + i" v-if="loadingDepartments">
<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>
<!-- 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>
</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/>
<section class="admin-overview-reports">
<button
v-if="isMobileOverview"
class="button is-light is-fullwidth admin-overview-reports__toggle"
type="button"
data-testid="admin-mobile-reports-toggle"
@click="toggleReports"
>
<span>Rapporter</span>
<span class="icon">
<i :class="isReportsExpanded ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</span>
</button>
<template v-if="isReportSectionVisible">
<hr class="admin-overview-reports__divider"/>
<DepartmentDashboardReports/>
</template>
</section>
</template>
</DepartmentDashboardPageWrapper>
</template>
<style scoped>
.admin-overview-reports__toggle {
display: flex;
align-items: center;
justify-content: space-between;
}
.admin-overview-reports__divider {
margin-top: 1rem;
}
</style>
@@ -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,7 +52,20 @@ 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>
<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>
<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"
@@ -81,10 +110,19 @@ const listDisplay = ref<listDisplayType>(listDisplayTabIndexes.cards);
<DepartmentsChartReport :departmentSpecific="false" />
</b-tab-item>
</b-tabs>
</section>
</DepartmentDashboardPageWrapper>
</RestrictedPageWrapper>
</template>
<style scoped>
.admin-overview-reports__toggle {
display: flex;
align-items: center;
justify-content: space-between;
}
.admin-overview-reports__tabs {
margin-top: 1rem;
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { SuperuserInvoicingLocalStore } from "@/views/dashboards/superUserDashboard/invoicing/SuperuserInvoicingLocalStore.vue";
import { SuperuserInvoicingLocalStore } from "@/views/dashboards/superUserDashboard/invoicing/SuperuserInvoicingLocalStore";
import {BButton, BDatepicker} from "buefy";
</script>
<template>
@@ -1,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 });
}
/**
+64 -22
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
+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'");