Add gateway health handling to unit and E2E tests, refine department lanes table logic, and enhance advanced view behavior.

This commit is contained in:
Jeppe Bundgaard
2026-04-23 18:45:01 +02:00
parent cc0b238de9
commit 04467316c2
29 changed files with 5106 additions and 1058 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,133 @@
<script setup>
import { computed, ref } from "vue";
const props = defineProps({
checked: {
type: Boolean,
default: false,
},
clickAction: {
type: Function,
default: null,
},
description: {
type: String,
default: "",
},
disabled: {
type: Boolean,
default: false,
},
label: {
type: String,
default: "",
},
testId: {
type: String,
default: "",
},
});
const isProcessing = ref(false);
const isDisabled = computed(() => props.disabled || isProcessing.value);
const title = computed(() => props.description || props.label);
const onClick = async () => {
if (isDisabled.value || !props.clickAction) {
return;
}
isProcessing.value = true;
try {
await props.clickAction();
} finally {
isProcessing.value = false;
}
};
</script>
<template>
<button
type="button"
class="dropdown-item dropdown-item-action action-settings-wheel-toggle-item"
:class="{
'is-active': checked,
'is-disabled': isDisabled,
}"
:aria-pressed="checked ? 'true' : 'false'"
:data-testid="testId || undefined"
:disabled="isDisabled"
:title="title"
@click.stop.prevent="onClick"
>
<span class="action-settings-wheel-toggle-item__label">{{ label }}</span>
<span class="action-settings-wheel-toggle-item__switch" :class="{ 'is-checked': checked }" aria-hidden="true">
<span class="action-settings-wheel-toggle-item__knob"></span>
</span>
</button>
</template>
<style scoped>
.action-settings-wheel-toggle-item {
width: 100%;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
text-align: left;
background: transparent;
border: 0;
white-space: normal;
}
.action-settings-wheel-toggle-item__label {
flex: 1;
min-width: 0;
color: #25344d;
font-weight: 500;
line-height: 1.35;
white-space: normal;
overflow-wrap: anywhere;
}
.action-settings-wheel-toggle-item__switch {
width: 2.5rem;
height: 1.45rem;
border-radius: 999px;
background: #d5deea;
padding: 0.16rem;
display: inline-flex;
align-items: center;
transition: background-color 0.15s ease;
flex-shrink: 0;
margin-top: 0.05rem;
}
.action-settings-wheel-toggle-item__switch.is-checked {
background: #2f66f3;
}
.action-settings-wheel-toggle-item__knob {
width: 1.1rem;
height: 1.1rem;
border-radius: 999px;
background: #ffffff;
box-shadow: 0 2px 6px rgba(19, 35, 57, 0.22);
transform: translateX(0);
transition: transform 0.15s ease;
}
.action-settings-wheel-toggle-item__switch.is-checked .action-settings-wheel-toggle-item__knob {
transform: translateX(1rem);
}
.action-settings-wheel-toggle-item.is-disabled {
opacity: 0.55;
}
.action-settings-wheel-toggle-item.is-disabled:hover {
background-color: transparent;
}
</style>
@@ -19,6 +19,7 @@ import { computed, ref, watch } from "vue";
import "bulma-switch/dist/css/bulma-switch.min.css";
import "bulma-block-list/src/block-list.scss";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useI18n } from "vue-i18n";
import {
popperBox,
popper,
@@ -29,6 +30,9 @@ import {
import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue";
import CustomerDiscountsDepartmentDisplay from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentBox.vue";
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
const { t } = useI18n();
const panel_tabs = ref([
{
@@ -99,80 +103,14 @@ const details = ref([
},
]);
const attributes = ref([
{
name: "Kræver reference nr.",
prop: "requiresReferenceNumber",
description: "Når denne er sat, kræves der et reference nr. på ordren før den kan oprettes",
icon: "fas fa-cogs",
},
{
name: "Må ikke ydes tillægsydelser",
prop: "restrictAdditionalServices",
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "addons" til ordren',
icon: "fas fa-cogs",
},
{
name: "Registreringsnumre på faktura linjer",
prop: "requiresRegistrationNumbersInvoice",
description: "Når denne er sat, bliver der sendt registreringsnumre med på alle faktura linjer",
icon: "fas fa-cogs",
},
{
name: "Fakturer alle ordrer individuelt",
prop: "invoiceAllOrdersIndividually",
description: "Når denne er sat, faktureres alle ordrer individuelt og ikke samlet",
icon: "fas fa-cogs",
},
{
name: "Må ikke ydes tank cleaning",
prop: "restrictTankCleaning",
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "tank cleaning" til ordren',
icon: "fas fa-cogs",
},
{
name: "Må ikke ydes spot free",
prop: "restrictSpotFree",
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "spot free" til ordren',
icon: "fas fa-cogs",
},
{
name: "Må ikke ydes indvendig vask",
prop: "restrictInteriorCleaning",
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "interior cleaning" til ordren',
icon: "fas fa-cogs",
},
{
name: "Faktureres med Stripe",
prop: "invoiceWithStripe",
description: "Når denne er sat, faktureres ordren med Stripe",
icon: "fas fa-cogs",
},
{
name: "Only tank cleaning",
prop: "onlyTankCleaning",
description: 'Når denne er sat, bliver kunden kategoriseret som "Tank cleaning" kunde.',
icon: "fas fa-cogs",
},
{
name: "Vises priser på kunde og bookingside",
prop: "showPricesOnBookingPage",
description: "Når denne er sat, bliver kunden vist priser på kunde og bookingsiden.",
icon: "fas fa-cogs",
},
{
name: "Bruger PO nummer",
prop: "usePONumbers",
description: "Når denne er sat, kan kunden angive et PO nummer på ordrer.",
icon: "fas fa-cogs",
},
{
name: "Undtaget fra månedligt administrations- og miljøgebyr",
prop: "exemptFromAdministrationFee",
description: "Når denne er sat, bliver kunden undtaget fra månedligt administrations- og miljøgebyr.",
icon: "fas fa-cogs",
},
]);
const attributes = computed(() =>
getCustomerRuleDefinitions().map((attribute) => ({
...attribute,
name: t(attribute.labelKey),
prop: attribute.attribute,
description: t(attribute.descriptionKey),
}))
);
const shortcuts = ref([
{
@@ -321,7 +259,7 @@ const customer_data_has_empty_details = () => {
class="panel-block pos-selected-customer__row"
v-if="panel_tabs[1].active"
v-for="attribute in attributes"
:key="attribute.name"
:key="attribute.attribute"
>
<span class="panel-icon pos-selected-customer__icon">
<i :class="attribute.icon" aria-hidden="true"></i>
@@ -6,9 +6,22 @@ import {
SuperUserSystemStatusObject,
getSuperuserSystemStatus,
} from "@/components/session/token/superUser/systemStatus.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
listEdgeGatewayDepartments,
listEdgeGateways,
unwrapEdgeGatewayMeta,
} from "@/services/edgeGateways.js";
const { t, te, locale } = useI18n();
const MAX_GATEWAY_CARDS = 8;
const gatewayStatusPriority = Object.freeze({
OFFLINE: 0,
DEGRADED: 1,
ONLINE: 2,
});
const nowTick = ref(Date.now());
let pollTimeout = null;
let clockInterval = null;
@@ -54,6 +67,63 @@ const error = computed(() => SuperUserSystemStatusObject.error.value);
const lastLoadedAt = computed(() => SuperUserSystemStatusObject.lastLoadedAt.value);
const refreshAfterSeconds = computed(() => SuperUserSystemStatusObject.refreshAfterSeconds.value || 30);
const overallStatus = computed(() => SuperUserSystemStatusObject.overallStatus.value);
const canViewGateways = computed(() => SessionUser.hasPermission("modules_shelly_config"));
const gatewayLoading = ref(false);
const gatewayError = ref(null);
const gatewayRows = ref([]);
const gatewayFleetUsage = ref(createEmptyGatewayFleetUsage());
const gatewaySectionSuppressed = ref(false);
const gatewayDepartments = ref({});
const gatewayDepartmentsLoaded = ref(false);
const showGatewaySection = computed(() => canViewGateways.value && !gatewaySectionSuppressed.value);
const gatewaySummaryCards = computed(() => [
{
id: "total",
title: t("system_status.gateways.summary.total"),
value: gatewayFleetUsage.value.total,
toneClass: null,
},
{
id: "online",
title: t("system_status.gateways.summary.online"),
value: gatewayFleetUsage.value.online,
toneClass: "is-ok",
},
{
id: "degraded",
title: t("system_status.gateways.summary.degraded"),
value: gatewayFleetUsage.value.degraded,
toneClass: "is-degraded",
},
{
id: "offline",
title: t("system_status.gateways.summary.offline"),
value: gatewayFleetUsage.value.offline,
toneClass: "is-down",
},
]);
const gatewayCards = computed(() => {
return [...gatewayRows.value]
.sort(compareGateways)
.slice(0, MAX_GATEWAY_CARDS)
.map((gateway) => {
const activeOperation = gateway?.active_operation || null;
return {
id: gateway?.id ?? null,
displayLabel: gatewayDisplayLabel(gateway),
departmentName: gatewayDepartmentName(gateway),
toneClass: gatewayToneClass(gateway?.status),
statusLabel: gatewayStatusLabel(gateway?.status),
discoveryLabel: gatewayDiscoveryLabel(gateway?.discovery_status),
lastHeartbeatLabel: formatDate(gateway?.last_heartbeat_at),
activeOperationLabel: gatewayOperationLabel(activeOperation),
message: gatewayPrimaryMessage(gateway),
link: `/superuser/configuration/edgegateway/${encodeURIComponent(String(gateway?.id ?? ""))}/overview`,
};
})
.filter((gateway) => gateway.id !== null);
});
const statusCards = computed(() => [
{
@@ -114,7 +184,12 @@ const isStale = computed(() => {
});
const loadStatus = async ({ force = false } = {}) => {
await getSuperuserSystemStatus({ force });
const [snapshotValue] = await Promise.all([
getSuperuserSystemStatus({ force }),
loadGatewayFleet({ force }),
]);
return snapshotValue;
};
const clearPolling = () => {
@@ -233,6 +308,83 @@ function moduleReasonText(module) {
);
}
function createEmptyGatewayFleetUsage() {
return {
total: 0,
online: 0,
degraded: 0,
offline: 0,
};
}
function resetGatewayState({ suppress = false } = {}) {
gatewayRows.value = [];
gatewayFleetUsage.value = createEmptyGatewayFleetUsage();
gatewayError.value = null;
gatewaySectionSuppressed.value = suppress;
}
async function loadGatewayFleet({ force = false } = {}) {
if (!canViewGateways.value) {
resetGatewayState();
return [];
}
gatewayLoading.value = true;
gatewayError.value = null;
try {
const response = await listEdgeGateways({ view: "summary", forceRefresh: force });
const rows = Array.isArray(response?.data?.data) ? response.data.data : [];
const fleetUsage = unwrapEdgeGatewayMeta(response)?.fleet_usage;
gatewayRows.value = rows;
gatewayFleetUsage.value = normalizeGatewayFleetUsage(rows, fleetUsage);
gatewaySectionSuppressed.value = false;
await ensureGatewayDepartmentsLoaded();
return rows;
} catch (requestError) {
const statusCode = Number(requestError?.response?.status ?? 0);
if (statusCode === 403) {
resetGatewayState({ suppress: true });
return [];
}
gatewayError.value = requestError;
gatewaySectionSuppressed.value = false;
return gatewayRows.value;
} finally {
gatewayLoading.value = false;
}
}
async function ensureGatewayDepartmentsLoaded() {
if (gatewayDepartmentsLoaded.value) {
return gatewayDepartments.value;
}
try {
const response = await listEdgeGatewayDepartments();
const rows = Array.isArray(response?.data?.data) ? response.data.data : [];
gatewayDepartments.value = rows.reduce((lookup, department) => {
const departmentId = Number(department?.id ?? 0);
const name = String(department?.name || department?.label || department?.title || "").trim();
if (departmentId > 0 && name !== "") {
lookup[departmentId] = name;
}
return lookup;
}, {});
gatewayDepartmentsLoaded.value = true;
} catch (_error) {
gatewayDepartmentsLoaded.value = false;
}
return gatewayDepartments.value;
}
function warningText(warning) {
return translateSystemStatusText(
"warnings",
@@ -268,6 +420,42 @@ function formatDeviceType(deviceType) {
return translateSystemStatusText("devices", deviceType, {}, deviceType || t("system_status.status.unknown"));
}
function normalizeGatewayStatus(status) {
const normalizedStatus = String(status || "").trim().toUpperCase();
return normalizedStatus || "UNKNOWN";
}
function gatewayToneClass(status) {
switch (normalizeGatewayStatus(status)) {
case "ONLINE":
return "is-ok";
case "DEGRADED":
return "is-degraded";
default:
return "is-down";
}
}
function gatewayStatusLabel(status) {
const normalizedStatus = normalizeGatewayStatus(status).toLowerCase();
return translateSystemStatusText(
"gateways.status",
normalizedStatus,
{},
t("system_status.gateways.status.unknown")
);
}
function gatewayDiscoveryLabel(status) {
const normalizedStatus = String(status || "unknown").trim().toLowerCase();
return translateSystemStatusText(
"gateways.discovery_status",
normalizedStatus,
{},
t("system_status.gateways.discovery_status.unknown")
);
}
function formatDate(value) {
if (!value) {
return "--";
@@ -318,6 +506,110 @@ function formatMinioBuckets(buckets) {
return t("system_status.labels.buckets_available", { available, total: buckets.length });
}
function gatewayDisplayLabel(gateway) {
const label = String(gateway?.label || "").trim();
if (label !== "") {
return label;
}
const hostname = String(gateway?.hostname || "").trim();
if (hostname !== "") {
return hostname;
}
return `Gateway #${gateway?.id ?? "?"}`;
}
function gatewayDepartmentName(gateway) {
const departmentId = Number(gateway?.department_id ?? 0);
if (departmentId > 0 && gatewayDepartments.value[departmentId]) {
return gatewayDepartments.value[departmentId];
}
return t("system_status.gateways.department_fallback", {
id: departmentId > 0 ? departmentId : "?",
});
}
function gatewayOperationLabel(operation) {
const summaryLabel = String(operation?.summary?.label || "").trim();
if (summaryLabel !== "") {
return summaryLabel;
}
const operationType = String(operation?.type || "").trim();
if (operationType === "") {
return null;
}
return operationType
.toLowerCase()
.split("_")
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(" ");
}
function gatewayPrimaryMessage(gateway) {
const errorMessage = String(gateway?.error_state?.message || "").trim();
if (errorMessage !== "") {
return errorMessage;
}
const diagnosticMessage = String(gateway?.diagnostics?.[0]?.message || "").trim();
return diagnosticMessage !== "" ? diagnosticMessage : null;
}
function normalizeGatewayFleetUsage(rows, fleetUsage) {
const gatewayCounts = fleetUsage && typeof fleetUsage === "object" ? fleetUsage.gateways || {} : {};
return {
total: toFiniteNumber(gatewayCounts.total, rows.length),
online: toFiniteNumber(
gatewayCounts.online,
rows.filter((gateway) => normalizeGatewayStatus(gateway?.status) === "ONLINE").length
),
degraded: toFiniteNumber(
gatewayCounts.degraded,
rows.filter((gateway) => normalizeGatewayStatus(gateway?.status) === "DEGRADED").length
),
offline: toFiniteNumber(
gatewayCounts.offline,
rows.filter((gateway) => normalizeGatewayStatus(gateway?.status) === "OFFLINE").length
),
};
}
function toFiniteNumber(value, fallback = 0) {
return Number.isFinite(Number(value)) ? Number(value) : Number(fallback || 0);
}
function gatewaySortRank(status) {
return gatewayStatusPriority[normalizeGatewayStatus(status)] ?? Number.MAX_SAFE_INTEGER;
}
function compareGateways(left, right) {
const rankDifference = gatewaySortRank(left?.status) - gatewaySortRank(right?.status);
if (rankDifference !== 0) {
return rankDifference;
}
const heartbeatDifference = gatewayHeartbeatTimestamp(left?.last_heartbeat_at) - gatewayHeartbeatTimestamp(right?.last_heartbeat_at);
if (heartbeatDifference !== 0) {
return heartbeatDifference;
}
return Number(left?.id ?? 0) - Number(right?.id ?? 0);
}
function gatewayHeartbeatTimestamp(value) {
if (!value) {
return 0;
}
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
}
function modulePath(key) {
return moduleConfigPaths[key] || null;
}
@@ -400,6 +692,96 @@ function modulePath(key) {
</div>
</section>
<section
v-if="showGatewaySection"
class="system-status-section"
data-testid="system-status-gateways"
>
<div class="section-heading">
<div>
<h3>{{ $t("system_status.sections.gateways") }}</h3>
<p class="section-subtitle">{{ $t("system_status.gateways.description") }}</p>
</div>
<RouterLink class="section-link" to="/superuser/configuration/edgegateway">
{{ $t("system_status.gateways.actions.open_fleet") }}
</RouterLink>
</div>
<div class="gateway-summary-grid">
<article
v-for="card in gatewaySummaryCards"
:key="card.id"
class="summary-card gateway-summary-card"
:class="card.toneClass"
:data-testid="`gateway-summary-card-${card.id}`"
>
<p class="summary-label">{{ card.title }}</p>
<strong>{{ card.value }}</strong>
</article>
</div>
<div
v-if="gatewayError"
class="notification is-warning is-light gateway-notification"
data-testid="gateway-section-error"
>
{{ $t("system_status.gateways.error") }}
</div>
<div
v-if="gatewayLoading && !gatewayCards.length"
class="notification is-light gateway-notification"
>
{{ $t("system_status.gateways.loading") }}
</div>
<div v-if="gatewayCards.length" class="gateway-grid">
<article
v-for="gateway in gatewayCards"
:key="gateway.id"
class="gateway-card"
:class="gateway.toneClass"
:data-testid="`gateway-card-${gateway.id}`"
>
<div class="gateway-card__top">
<div>
<strong class="gateway-card__title">{{ gateway.displayLabel }}</strong>
<p class="gateway-card__subtitle">{{ gateway.departmentName }}</p>
</div>
<span class="status-pill" :class="gateway.toneClass">{{ gateway.statusLabel }}</span>
</div>
<div class="gateway-card__meta">
<span>
{{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
</span>
<span>
{{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
</span>
<span v-if="gateway.activeOperationLabel">
{{ $t("system_status.gateways.labels.active_operation") }}: {{ gateway.activeOperationLabel }}
</span>
</div>
<p v-if="gateway.message" class="gateway-card__message">
{{ gateway.message }}
</p>
<RouterLink :to="gateway.link" class="module-card__link">
{{ $t("system_status.gateways.actions.open_gateway") }}
</RouterLink>
</article>
</div>
<div
v-else-if="!gatewayLoading && !gatewayError"
class="gateway-empty"
data-testid="gateway-empty-state"
>
{{ $t("system_status.gateways.empty") }}
</div>
</section>
<section class="system-status-section">
<div class="section-heading">
<h3>{{ $t("system_status.sections.modules") }}</h3>
@@ -491,7 +873,8 @@ function modulePath(key) {
.summary-card,
.status-card,
.module-card {
.module-card,
.gateway-card {
border: 1px solid #d7dde7;
border-radius: 18px;
padding: 1rem 1.1rem;
@@ -535,13 +918,31 @@ function modulePath(key) {
}
.status-card-grid,
.module-grid {
.module-grid,
.gateway-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
align-items: stretch;
}
.gateway-summary-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
align-items: stretch;
}
.section-subtitle {
margin: 0.2rem 0 0;
color: #64748b;
}
.section-link {
font-weight: 600;
color: #0f4c81;
}
.status-card__top,
.module-card__top {
display: flex;
@@ -571,6 +972,13 @@ function modulePath(key) {
align-content: start;
}
.gateway-card {
display: grid;
gap: 0.85rem;
height: 100%;
align-content: start;
}
.module-card__top {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -579,6 +987,14 @@ function modulePath(key) {
row-gap: 0.35rem;
}
.gateway-card__top {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
column-gap: 0.75rem;
row-gap: 0.35rem;
}
.module-card__title {
margin: 0;
min-width: 0;
@@ -593,6 +1009,18 @@ function modulePath(key) {
align-self: start;
}
.gateway-card__title {
display: block;
color: #0f172a;
overflow-wrap: anywhere;
}
.gateway-card__subtitle {
margin: 0.2rem 0 0;
color: #475569;
overflow-wrap: anywhere;
}
.module-card__reason {
margin: 0;
color: #475569;
@@ -606,12 +1034,37 @@ function modulePath(key) {
font-size: 0.85rem;
}
.gateway-card__meta {
display: grid;
gap: 0.25rem;
font-size: 0.85rem;
color: #475569;
}
.gateway-card__message {
margin: 0;
color: #7c2d12;
line-height: 1.5;
}
.module-card__link {
margin-top: auto;
font-weight: 600;
color: #0f4c81;
}
.gateway-empty {
border: 1px dashed #cbd5e1;
border-radius: 18px;
padding: 1rem 1.1rem;
color: #475569;
background: rgba(248, 250, 252, 0.8);
}
.gateway-notification {
margin-bottom: 0;
}
.status-pill {
display: inline-flex;
align-items: center;
@@ -1,17 +1,15 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
const { loadList } = usePaginatedListInstance();
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
// Define the props
const { t } = useI18n();
const { loadList } = usePaginatedListInstance();
const props = defineProps({
objects: {
type: Array,
@@ -24,6 +22,11 @@ const props = defineProps({
},
});
const expandedLaneIds = ref([]);
const machineTypeNames = ref({});
const visibleColumnCount = computed(() => (props.advancedView ? 7 : 3));
const redirect = (path) => {
window.location = path;
};
@@ -33,12 +36,17 @@ const openDepartmentWorkspace = (departmentId, tab = "lanes") => {
return;
}
redirect(`/superuser/departments/${encodeURIComponent(String(departmentId))}/gateways?tab=${encodeURIComponent(tab)}`);
redirect(
`/superuser/departments/${encodeURIComponent(String(departmentId))}/gateways?tab=${encodeURIComponent(tab)}`
);
};
const machineTypeNames = ref({});
const loadMachineTypes = async () => {
if (!props.advancedView) {
machineTypeNames.value = {};
return;
}
const machineTypes = await SessionUser.objects.self_serve_machine_types.get.all();
machineTypeNames.value = machineTypes.reduce((accumulator, entry) => {
accumulator[entry.id] = entry.name;
@@ -46,157 +54,425 @@ const loadMachineTypes = async () => {
}, {});
};
watch(() => props.objects, () => {
loadMachineTypes();
}, { immediate: true, deep: true });
watch(
() => props.objects,
() => {
loadMachineTypes();
},
{ immediate: true, deep: true }
);
const isExpanded = (laneId) => expandedLaneIds.value.includes(laneId);
const toggleExpanded = (laneId) => {
if (isExpanded(laneId)) {
expandedLaneIds.value = expandedLaneIds.value.filter((id) => id !== laneId);
return;
}
expandedLaneIds.value = [...expandedLaneIds.value, laneId];
};
const getStatusInfo = (lane) => {
if (
SessionUser.objects.department_lanes.functions.isOperational(lane) &&
SessionUser.objects.department_lanes.functions.isConfigured(lane)
) {
return {
iconClass: "fas fa-check-circle has-text-success",
label: t("global.active"),
};
}
if (
!SessionUser.objects.department_lanes.functions.isOperational(lane) &&
SessionUser.objects.department_lanes.functions.isConfigured(lane)
) {
return {
iconClass: "fas fa-times-circle has-text-danger",
label: t("global.inactive"),
};
}
return {
iconClass: "fas fa-exclamation-triangle has-text-warning",
label: t("global.not_configured"),
};
};
const getRelayDisplayValue = (value) => {
if (value === null || value === undefined || value === "") {
return t("global.not_configured");
}
return value;
};
const getOptionalNumberDisplayValue = (value) => {
if (value === null || value === undefined || value === "") {
return t("global.none");
}
return value;
};
const getMachineTypeDisplayValue = (machineTypeId) => {
if (!machineTypeId) {
return t("global.none");
}
return machineTypeNames.value[machineTypeId] || machineTypeId;
};
</script>
<template>
<table class="table is-fullwidth is-bordered">
<div class="department-lanes-table__wrapper" data-testid="department-lanes-table-wrapper">
<table class="table is-fullwidth is-bordered department-lanes-table" data-testid="department-lanes-table">
<thead>
<tr>
<th>{{ SessionUser.objects.department_lanes.columns.name.label }}</th>
<th>{{ SessionUser.objects.department_lanes.columns.department.label }}</th>
<template v-if="advancedView">
<th>{{ $t('objects.columns.status') }}</th>
<th>{{ SessionUser.objects.department_lanes.columns.id.label }}</th>
<th>{{ SessionUser.objects.department_lanes.columns.relay_in_id.label }}</th>
<th>{{ SessionUser.objects.department_lanes.columns.relay_out_id.label }}</th>
<th>{{ SessionUser.objects.department_lanes.columns.relay_machine_id.label }}</th>
<th>{{ SessionUser.objects.department_lanes.columns.relay_machine_program_picker_id.label }}</th>
<th>{{ SessionUser.objects.department_lanes.columns.relay_machine_cleaner_id.label }}</th>
<th>{{ SessionUser.objects.department_lanes.columns.dynamic_image_id.label }}</th>
<th>{{ SessionUser.objects.department_lanes.columns.machine_type_id.label }}</th>
</template>
<th class="has-text-right">{{ $t('tables.actions') }}</th>
</tr>
<tr>
<th v-if="advancedView" class="is-narrow department-lanes-table__toggle-header"></th>
<th>{{ SessionUser.objects.department_lanes.columns.name.label }}</th>
<th>{{ SessionUser.objects.department_lanes.columns.department.label }}</th>
<template v-if="advancedView">
<th>{{ $t("objects.columns.status") }}</th>
<th class="is-narrow">{{ SessionUser.objects.department_lanes.columns.id.label }}</th>
<th>{{ SessionUser.objects.department_lanes.columns.machine_type_id.label }}</th>
</template>
<th class="has-text-right">{{ $t("tables.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="object in objects" :key="object.id">
<EditableTableColumn
:object="object"
:loadList="loadList"
column="name"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
/>
<EditableTableColumn
:object="object"
:loadList="loadList"
column="department"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
:parse-function="SessionUser.objects.economic_departments.functions.getDepartmentName"
/>
<template v-if="advancedView">
<td>
<span class="icon">
<i
:class="{
'fas fa-check-circle has-text-success': SessionUser.objects.department_lanes.functions.isOperational(object) && SessionUser.objects.department_lanes.functions.isConfigured(object),
'fas fa-times-circle has-text-danger': !SessionUser.objects.department_lanes.functions.isOperational(object) && SessionUser.objects.department_lanes.functions.isConfigured(object),
'fas fa-exclamation-triangle has-text-warning': !SessionUser.objects.department_lanes.functions.isConfigured(object),
}"
></i>
</span>
<span class="">
{{
SessionUser.objects.department_lanes.functions.isOperational(object) && SessionUser.objects.department_lanes.functions.isConfigured(object)
? $t('global.active')
: !SessionUser.objects.department_lanes.functions.isOperational(object) && SessionUser.objects.department_lanes.functions.isConfigured(object)
? $t('global.inactive')
: $t('global.not_configured')
}}
</span>
</td>
<td>{{ object.id }}</td>
<EditableTableColumn
:object="object"
:loadList="loadList"
column="relay_in_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
/>
<EditableTableColumn
:object="object"
:loadList="loadList"
column="relay_out_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
/>
<EditableTableColumn
:object="object"
:loadList="loadList"
column="relay_machine_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
/>
<EditableTableColumn
:object="object"
:loadList="loadList"
column="relay_machine_program_picker_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
/>
<EditableTableColumn
:object="object"
:loadList="loadList"
column="relay_machine_cleaner_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
/>
<EditableTableColumn
:object="object"
:loadList="loadList"
column="dynamic_image_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
/>
<EditableTableColumn
:object="object"
:loadList="loadList"
column="machine_type_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
:parse-function="(machineTypeId) => machineTypeId ? (machineTypeNames[machineTypeId] || machineTypeId) : $t('global.none')"
/>
</template>
<!-- Actions -->
<td class="has-text-right">
<div class="columns is-mobile is-vcentered is-gapless is-justify-content-flex-end">
<div class="column is-narrow">
<!-- Stop button -->
<template v-for="object in objects" :key="object.id">
<tr :data-testid="`department-lanes-row-${object.id}`">
<td v-if="advancedView" class="is-narrow department-lanes-table__toggle-cell">
<button
type="button"
class="button is-small is-light department-lanes-table__details-toggle"
:data-testid="`department-lanes-row-toggle-${object.id}`"
:aria-label="isExpanded(object.id) ? $t('global.hide') : $t('global.details')"
:aria-expanded="isExpanded(object.id) ? 'true' : 'false'"
@click="toggleExpanded(object.id)"
>
<span class="icon is-small">
<i :class="isExpanded(object.id) ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</span>
</button>
</td>
<EditableTableColumn
class="department-lanes-table__cell department-lanes-table__cell--name"
:object="object"
:loadList="loadList"
column="name"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
/>
<EditableTableColumn
class="department-lanes-table__cell department-lanes-table__cell--department"
:object="object"
:loadList="loadList"
column="department"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
:parse-function="SessionUser.objects.economic_departments.functions.getDepartmentName"
/>
<template v-if="advancedView">
<td class="department-lanes-table__cell department-lanes-table__cell--status">
<span class="icon">
<i :class="getStatusInfo(object).iconClass"></i>
</span>
<span>{{ getStatusInfo(object).label }}</span>
</td>
<td class="department-lanes-table__cell department-lanes-table__cell--id">
{{ object.id }}
</td>
<EditableTableColumn
class="department-lanes-table__cell department-lanes-table__cell--machine-type"
:object="object"
:loadList="loadList"
column="machine_type_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
:parse-function="(machineTypeId) => getMachineTypeDisplayValue(machineTypeId)"
/>
</template>
<td class="has-text-right department-lanes-table__actions-cell">
<div class="department-lanes-table__actions">
<button
class="button is-small is-light"
:class="SessionUser.objects.department_lanes.functions.isWashing(object) ? 'is-danger' : 'is-grey'"
@click="
SessionUser.objects.department_lanes.functions.isWashing(object)
? SessionUser.objects.department_lanes.functions.stopWashing(object).then(() => loadList())
: void 0
"
>
<span class="icon is-small">
<i :class="SessionUser.objects.department_lanes.functions.isWashing(object) ? 'fas fa-stop-circle' : 'fas fa-check-circle'"></i>
</span>
<span>
{{ SessionUser.objects.department_lanes.functions.isWashing(object) ? $t('global.stop') : $t('global.ready_to_wash') }}
</span>
</button>
</div>
<div class="column is-narrow">
<button
class="button is-small is-light"
type="button"
@click="openDepartmentWorkspace(object.department, 'lanes')"
>
Workspace
</button>
</div>
<div class="column is-narrow">
<ActionSettingsWheelButton :department_lane_id="object.id" :department_id="object.department" :loadList="loadList">
<template #actions>
</template>
</ActionSettingsWheelButton>
</div>
</div>
</td>
</tr>
>
<span class="icon is-small">
<i
:class="
SessionUser.objects.department_lanes.functions.isWashing(object)
? 'fas fa-stop-circle'
: 'fas fa-check-circle'
"
></i>
</span>
<span>
{{
SessionUser.objects.department_lanes.functions.isWashing(object)
? $t("global.stop")
: $t("global.ready_to_wash")
}}
</span>
</button>
<ActionSettingsWheelButton
:department_lane_id="object.id"
:department_id="object.department"
:loadList="loadList"
>
<template #actions></template>
</ActionSettingsWheelButton>
</div>
</td>
</tr>
<tr
v-if="advancedView && isExpanded(object.id)"
class="department-lanes-table__details-row"
:data-testid="`department-lanes-row-details-${object.id}`"
>
<td :colspan="visibleColumnCount" class="department-lanes-table__details-cell">
<div class="department-lanes-table__details-grid">
<div class="department-lanes-table__detail-card">
<span class="department-lanes-table__detail-label">
{{ SessionUser.objects.department_lanes.columns.relay_in_id.label }}
</span>
<EditableTableColumn
componentWrapper="div"
theme="divided"
class="department-lanes-table__detail-value"
:object="object"
:loadList="loadList"
column="relay_in_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
:parse-function="(value) => getRelayDisplayValue(value)"
:cell-test-id="`department-lanes-row-detail-relay-in-id-${object.id}`"
/>
</div>
<div class="department-lanes-table__detail-card">
<span class="department-lanes-table__detail-label">
{{ SessionUser.objects.department_lanes.columns.relay_out_id.label }}
</span>
<EditableTableColumn
componentWrapper="div"
theme="divided"
class="department-lanes-table__detail-value"
:object="object"
:loadList="loadList"
column="relay_out_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
:parse-function="(value) => getRelayDisplayValue(value)"
/>
</div>
<div class="department-lanes-table__detail-card">
<span class="department-lanes-table__detail-label">
{{ SessionUser.objects.department_lanes.columns.relay_machine_id.label }}
</span>
<EditableTableColumn
componentWrapper="div"
theme="divided"
class="department-lanes-table__detail-value"
:object="object"
:loadList="loadList"
column="relay_machine_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
:parse-function="(value) => getRelayDisplayValue(value)"
/>
</div>
<div class="department-lanes-table__detail-card">
<span class="department-lanes-table__detail-label">
{{ SessionUser.objects.department_lanes.columns.relay_machine_program_picker_id.label }}
</span>
<EditableTableColumn
componentWrapper="div"
theme="divided"
class="department-lanes-table__detail-value"
:object="object"
:loadList="loadList"
column="relay_machine_program_picker_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
:parse-function="(value) => getRelayDisplayValue(value)"
/>
</div>
<div class="department-lanes-table__detail-card">
<span class="department-lanes-table__detail-label">
{{ SessionUser.objects.department_lanes.columns.relay_machine_cleaner_id.label }}
</span>
<EditableTableColumn
componentWrapper="div"
theme="divided"
class="department-lanes-table__detail-value"
:object="object"
:loadList="loadList"
column="relay_machine_cleaner_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
:parse-function="(value) => getRelayDisplayValue(value)"
/>
</div>
<div class="department-lanes-table__detail-card">
<span class="department-lanes-table__detail-label">
{{ SessionUser.objects.department_lanes.columns.dynamic_image_id.label }}
</span>
<EditableTableColumn
componentWrapper="div"
theme="divided"
class="department-lanes-table__detail-value"
:object="object"
:loadList="loadList"
column="dynamic_image_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
:parse-function="(value) => getOptionalNumberDisplayValue(value)"
/>
</div>
<div class="department-lanes-table__detail-card department-lanes-table__detail-card--actions">
<span class="department-lanes-table__detail-label">Workspace</span>
<div class="department-lanes-table__detail-actions">
<button
class="button is-small is-light"
type="button"
:data-testid="`department-lanes-row-workspace-${object.id}`"
@click="openDepartmentWorkspace(object.department, 'lanes')"
>
Open workspace
</button>
</div>
</div>
</div>
</td>
</tr>
</template>
<tr v-if="objects.length === 0">
<td :colspan="visibleColumnCount">{{ t("global.no_data") }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.department-lanes-table__wrapper {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.department-lanes-table {
width: 100%;
min-width: 760px;
}
.department-lanes-table th,
.department-lanes-table td {
vertical-align: top;
}
.department-lanes-table__toggle-header,
.department-lanes-table__toggle-cell,
.department-lanes-table__actions-cell,
.department-lanes-table__cell--id {
white-space: nowrap;
}
.department-lanes-table__details-toggle {
min-width: 2.25rem;
}
.department-lanes-table__cell {
overflow-wrap: anywhere;
}
.department-lanes-table__cell--name {
min-width: 8rem;
}
.department-lanes-table__cell--department {
min-width: 9rem;
}
.department-lanes-table__cell--status {
min-width: 10rem;
}
.department-lanes-table__cell--machine-type {
min-width: 10rem;
}
.department-lanes-table__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.department-lanes-table__details-row td {
background: #f8fafc;
}
.department-lanes-table__details-cell {
padding: 0.9rem 1rem 1rem;
}
.department-lanes-table__details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.9rem;
}
.department-lanes-table__detail-card {
display: flex;
flex-direction: column;
gap: 0.45rem;
min-width: 0;
padding: 0.85rem 0.9rem;
border: 1px solid #d8e0ea;
border-radius: 0.75rem;
background: #ffffff;
}
.department-lanes-table__detail-label {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.02em;
color: #4a5568;
text-transform: uppercase;
}
.department-lanes-table__detail-value {
min-width: 0;
}
.department-lanes-table__detail-value :deep(.level) {
align-items: flex-start;
}
.department-lanes-table__detail-value :deep(.level-left) {
max-width: calc(100% - 2.5rem);
}
.department-lanes-table__detail-value :deep(.level-left .level-item) {
white-space: normal;
overflow-wrap: anywhere;
}
.department-lanes-table__detail-value :deep(.tooltip-trigger) {
display: block;
white-space: normal;
overflow-wrap: anywhere;
}
.department-lanes-table__detail-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
@media screen and (max-width: 1023px) {
.department-lanes-table {
min-width: 700px;
}
}
</style>
@@ -1,252 +1,463 @@
<script setup>
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import { useI18n } from 'vue-i18n';
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const props = defineProps({
objects: {
type: Array,
default: () => [],
},
});
const { t } = useI18n();
defineProps(['objects']);
import { ref } from 'vue';
import { departments, getDepartments, isLoading, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
import { showEditProductForm } from "@/components/forms/superUser/editProductForm.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
const { loadList } = usePaginatedListInstance();
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
// Get the departments (If the departments are not already loaded)
if (departments.value.length === 0) {
getDepartments();
}
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
const expandedProductIds = ref([]);
const categoriesCache = ref(null);
const productOptionsCache = ref(null);
const productCache = ref(null);
const mainTableColumnCount = 9;
const getBooleanLabel = (value) => (value ? t("global.yes") : t("global.no"));
const isExpanded = (productId) => expandedProductIds.value.includes(productId);
const toggleExpanded = (productId) => {
if (isExpanded(productId)) {
expandedProductIds.value = expandedProductIds.value.filter((id) => id !== productId);
return;
}
expandedProductIds.value = [...expandedProductIds.value, productId];
};
const redirect = (path) => {
window.location = path;
}
const categories_cache = ref(null);
};
const redirectToProduct = (productId) => {
redirect(`/superuser/products/${productId}`);
return Promise.resolve();
};
const getCategoryName = (id) => {
if (categories_cache.value === null) {
categories_cache.value = [];
if (categoriesCache.value === null) {
categoriesCache.value = [];
SessionUser.objects.categories.get.all().then((categories) => {
categories_cache.value = categories;
categoriesCache.value = categories;
});
}
const category = categories_cache.value.find((category) => category.id === id);
return category ? category.name : t('global.no_data');
}
const getProductOptionNames = (id) => {
const product_options = getProductOptions(id);
// Get the product names from the product option_ids
for (let i = 0; i < product_options.length; i++) {
const product_option = product_options[i];
product_option.name = getProductName(product_option);
}
return product_options.map((product_option) => product_option.name).join(", ");
//return product_options.map((product_option) => product_option.option_id).join(", ");
}
const category = categoriesCache.value.find((entry) => entry.id === Number.parseInt(id, 10));
return category ? category.name : t("global.no_data");
};
const product_options_cache = ref(null);
const getProductOptions = (product) => {
if (!product) {
return [];
}
if (product_options_cache.value === null) {
product_options_cache.value = [];
SessionUser.objects.product_options.get.all().then((product_options) => {
product_options_cache.value = product_options;
if (productOptionsCache.value === null) {
productOptionsCache.value = [];
SessionUser.objects.product_options.get.all().then((productOptions) => {
productOptionsCache.value = productOptions;
});
}
// Find all the product options that match the product id, and return them
return product_options_cache.value.filter((product_option) => product_option.product_id === product.id);
}
return productOptionsCache.value.filter((productOption) => productOption.product_id === product.id);
};
const product_cache = ref(null);
const getProductName = (product_option) => {
if (product_cache.value === null) {
product_cache.value = [];
const getProductName = (productOption) => {
if (productCache.value === null) {
productCache.value = [];
SessionUser.objects.products.get.all().then((products) => {
product_cache.value = products;
productCache.value = products;
});
}
const product = product_cache.value.find((product) => product.id === product_option.option_id);
return product ? product.name : t('global.no_data');
}
const product = productCache.value.find((entry) => entry.id === productOption.option_id);
return product ? product.name : t("global.no_data");
};
const getDisplayProductAddons = (product) => {
const product_options = getProductOptions(product);
let html = "";
// Get the product names from the product option_ids
for (let i = 0; i < product_options.length; i++) {
const product_option = product_options[i];
product_option.name = getProductName(product_option);
}
if (product_options.length > 0) {
for (let i = 0; i < product_options.length; i++) {
const product_option = product_options[i];
html += product_option.name;
if (i < product_options.length - 1) {
html += ", ";
}
}
} else {
html = t('global.no_data');
}
return html;
}
const productOptions = getProductOptions(product).map((productOption) => ({
...productOption,
name: getProductName(productOption),
}));
if (productOptions.length === 0) {
return t("global.no_data");
}
return productOptions.map((productOption) => productOption.name).join(", ");
};
const getEconomicProductLabel = (value) => {
if (value === 0 || value === null || value === undefined || value === "") {
return t("global.no_data");
}
return SessionUser.objects.economic_products.functions.getEconomicProductName(value);
};
</script>
<template>
<table class="table is-fullwidth is-hoverable is-striped" style="table-layout: fixed;">
<thead>
<tr>
<th>{{ SessionUser.objects.products.columns.order_priority.label }}</th>
<th>{{ SessionUser.objects.products.columns.id.label }}</th>
<th>{{ SessionUser.objects.products.columns.name.label }}</th>
<th>{{ SessionUser.objects.products.columns.description.label }}</th>
<th>{{ SessionUser.objects.products.columns.price.label }}</th>
<th>{{ SessionUser.objects.products.columns.subscription_allowed.label }}</th>
<th>{{ SessionUser.objects.products.columns.category.label }}</th>
<th>{{ SessionUser.objects.products.columns.piktogram.label }}</th>
<th>{{ SessionUser.objects.products.columns.economic_product_id.label }}</th>
<th>{{ SessionUser.objects.products.columns.apply_category_discount.label }}</th>
<th>{{ SessionUser.objects.products.columns.requires_note.label }}</th>
<th>{{ SessionUser.objects.products.columns.is_wash.label }}</th>
<th>{{ SessionUser.objects.products.columns.display_in_booking_form.label}}</th>
<th>{{ SessionUser.objects.product_options.meta.title }}</th>
<th>{{ $t('tables.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="product in objects" :key="product.id">
<!-- Order priority -->
<EditableTableColumn
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="order_priority"
/>
<td>{{ product.id }}</td>
<!-- Name -->
<EditableTableColumn
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="name"
/>
<!-- Description -->
<EditableTableColumn
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="description"
/>
<!-- Price -->
<EditableTableColumn
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="price"
/>
<!-- Subscription allowed -->
<EditableTableColumn
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="subscription_allowed"
:parseFunction="value => value ? t('global.yes') : t('global.no')"
/>
<!-- Category -->
<EditableTableColumn
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="category"
:parse-function="value => getCategoryName(value)"
/>
<!-- Piktogram -->
<EditableTableColumn
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="piktogram"
/>
<!-- Economic product id -->
<EditableTableColumn
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="economic_product_id"
:parse-function="value => {
if (value === 0) {
return t('global.no_data');
}
return SessionUser.objects.economic_products.functions.getEconomicProductName(value)
}"
/>
<!-- Apply category discount -->
<EditableTableColumn
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="apply_category_discount"
:parseFunction="value => value ? t('global.yes') : t('global.no')"
/>
<!-- Requires note -->
<EditableTableColumn
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="requires_note"
:parseFunction="value => value ? t('global.yes') : t('global.no')"
/>
<!-- Is wash -->
<EditableTableColumn
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="is_wash"
:parseFunction="value => value ? t('global.yes') : t('global.no')"
/>
<!-- Display in booking form -->
<EditableTableColumn
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="display_in_booking_form"
:parseFunction="value => value ? t('global.yes') : t('global.no')"
/>
<!-- Tilvalg -->
<EditableTableColumn
:object="product"
:loadList="loadList"
:editFunction="() => SessionUser.functions.redirectTo.superUser('/products/' + product.id)"
:virtualColumn="true"
:parse-function="value => getDisplayProductAddons(value)"
/>
<!-- Actions -->
<td class="is-narrow has-text-right">
<ActionSettingsWheelButton>
<template #actions>
<ActionSettingsWheelItem
label=" til produkt"
icon="fas fa-eye"
@click="redirect(`/superuser/products/${product.id}`)"
template="default"
<div class="product-table__wrapper" data-testid="superuser-products-table-wrapper">
<table class="table is-fullwidth is-hoverable is-striped product-table" data-testid="superuser-products-table">
<thead>
<tr>
<th class="is-narrow product-table__toggle-header"></th>
<th class="is-narrow">{{ SessionUser.objects.products.columns.order_priority.label }}</th>
<th class="is-narrow">{{ SessionUser.objects.products.columns.id.label }}</th>
<th class="product-table__column-header product-table__column-header--name">
{{ SessionUser.objects.products.columns.name.label }}
</th>
<th class="is-narrow has-text-right">{{ SessionUser.objects.products.columns.price.label }}</th>
<th class="product-table__column-header product-table__column-header--category">
{{ SessionUser.objects.products.columns.category.label }}
</th>
<th class="is-narrow">{{ SessionUser.objects.products.columns.is_wash.label }}</th>
<th class="is-narrow">{{ SessionUser.objects.products.columns.display_in_booking_form.label }}</th>
<th class="is-narrow has-text-right">{{ $t("tables.actions") }}</th>
</tr>
</thead>
<tbody>
<template v-for="product in props.objects" :key="product.id">
<tr :data-testid="`products-table-row-${product.id}`">
<td class="is-narrow product-table__toggle-cell">
<button
type="button"
class="button is-small is-light product-table__details-toggle"
:data-testid="`products-table-row-toggle-${product.id}`"
:aria-label="isExpanded(product.id) ? $t('global.hide') : $t('global.details')"
:aria-expanded="isExpanded(product.id) ? 'true' : 'false'"
@click="toggleExpanded(product.id)"
>
<span class="icon is-small">
<i :class="isExpanded(product.id) ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</span>
</button>
</td>
<EditableTableColumn
class="product-table__cell product-table__cell--priority"
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="order_priority"
/>
</template>
</ActionSettingsWheelButton>
</td>
</tr>
</tbody>
</table>
<td class="product-table__cell product-table__cell--id">{{ product.id }}</td>
<EditableTableColumn
class="product-table__cell product-table__cell--name"
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="name"
:hover-text="product.name || t('global.no_data')"
/>
<EditableTableColumn
class="product-table__cell product-table__cell--price has-text-right"
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="price"
/>
<EditableTableColumn
class="product-table__cell product-table__cell--category"
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="category"
:parse-function="(value) => getCategoryName(value)"
:hover-text="getCategoryName(product.category)"
/>
<EditableTableColumn
class="product-table__cell product-table__cell--boolean"
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="is_wash"
:parseFunction="(value) => getBooleanLabel(value)"
/>
<EditableTableColumn
class="product-table__cell product-table__cell--boolean"
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="display_in_booking_form"
:parseFunction="(value) => getBooleanLabel(value)"
/>
<td class="is-narrow has-text-right product-table__actions-cell">
<ActionSettingsWheelButton>
<template #actions>
<ActionSettingsWheelItem
label=" til produkt"
icon="fas fa-eye"
@click="redirect(`/superuser/products/${product.id}`)"
template="default"
/>
</template>
</ActionSettingsWheelButton>
</td>
</tr>
<tr
v-if="isExpanded(product.id)"
class="product-table__details-row"
:data-testid="`products-table-row-details-${product.id}`"
>
<td :colspan="mainTableColumnCount" class="product-table__details-cell">
<div class="product-table__details-grid">
<div class="product-table__detail-card">
<span class="product-table__detail-label">
{{ SessionUser.objects.products.columns.description.label }}
</span>
<EditableTableColumn
componentWrapper="div"
theme="divided"
class="product-table__detail-value"
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="description"
:hover-text="product.description || t('global.no_data')"
/>
</div>
<div class="product-table__detail-card">
<span class="product-table__detail-label">
{{ SessionUser.objects.products.columns.subscription_allowed.label }}
</span>
<EditableTableColumn
componentWrapper="div"
theme="divided"
class="product-table__detail-value"
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="subscription_allowed"
:parseFunction="(value) => getBooleanLabel(value)"
/>
</div>
<div class="product-table__detail-card">
<span class="product-table__detail-label">
{{ SessionUser.objects.products.columns.piktogram.label }}
</span>
<EditableTableColumn
componentWrapper="div"
theme="divided"
class="product-table__detail-value"
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="piktogram"
/>
</div>
<div class="product-table__detail-card">
<span class="product-table__detail-label">
{{ SessionUser.objects.products.columns.economic_product_id.label }}
</span>
<EditableTableColumn
componentWrapper="div"
theme="divided"
class="product-table__detail-value"
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="economic_product_id"
:parse-function="(value) => getEconomicProductLabel(value)"
:hover-text="getEconomicProductLabel(product.economic_product_id)"
/>
</div>
<div class="product-table__detail-card">
<span class="product-table__detail-label">
{{ SessionUser.objects.products.columns.apply_category_discount.label }}
</span>
<EditableTableColumn
componentWrapper="div"
theme="divided"
class="product-table__detail-value"
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="apply_category_discount"
:parseFunction="(value) => getBooleanLabel(value)"
/>
</div>
<div class="product-table__detail-card">
<span class="product-table__detail-label">
{{ SessionUser.objects.products.columns.requires_note.label }}
</span>
<EditableTableColumn
componentWrapper="div"
theme="divided"
class="product-table__detail-value"
:object="product"
:loadList="loadList"
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
column="requires_note"
:parseFunction="(value) => getBooleanLabel(value)"
/>
</div>
<div class="product-table__detail-card product-table__detail-card--wide">
<span class="product-table__detail-label">{{ SessionUser.objects.product_options.meta.title }}</span>
<EditableTableColumn
componentWrapper="div"
theme="divided"
class="product-table__detail-value"
:object="product"
:loadList="loadList"
:editFunction="redirectToProduct"
:virtualColumn="true"
:parse-function="(value) => getDisplayProductAddons(value)"
:hover-text="getDisplayProductAddons(product)"
/>
</div>
</div>
</td>
</tr>
</template>
<tr v-if="props.objects.length === 0">
<td :colspan="mainTableColumnCount">{{ t("global.no_data") }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.product-table__wrapper {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
</style>
.product-table {
width: 100%;
min-width: 860px;
}
.product-table th,
.product-table td {
vertical-align: top;
}
.product-table__toggle-header,
.product-table__toggle-cell,
.product-table__actions-cell {
white-space: nowrap;
}
.product-table__details-toggle {
min-width: 2.25rem;
}
.product-table__column-header {
white-space: normal;
}
.product-table__cell {
overflow-wrap: anywhere;
}
.product-table__cell--priority,
.product-table__cell--id,
.product-table__cell--price,
.product-table__cell--boolean {
white-space: nowrap;
}
.product-table__cell--name {
min-width: 14rem;
}
.product-table__cell--category {
min-width: 10rem;
}
.product-table__details-row td {
background: #f8fafc;
}
.product-table__details-cell {
padding: 0.9rem 1rem 1rem;
}
.product-table__details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.9rem;
}
.product-table__detail-card {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding: 0.85rem 0.9rem;
background: #ffffff;
border: 1px solid #d8e0ea;
border-radius: 0.75rem;
min-width: 0;
}
.product-table__detail-card--wide {
grid-column: 1 / -1;
}
.product-table__detail-label {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.02em;
color: #4a5568;
text-transform: uppercase;
}
.product-table__detail-value {
min-width: 0;
}
.product-table__detail-value :deep(.level) {
align-items: flex-start;
}
.product-table__detail-value :deep(.level-left) {
max-width: calc(100% - 2.5rem);
}
.product-table__detail-value :deep(.level-left .level-item) {
white-space: normal;
overflow-wrap: anywhere;
}
.product-table__detail-value :deep(.tooltip-trigger) {
display: block;
white-space: normal;
overflow-wrap: anywhere;
}
.product-table__detail-value :deep(.button.is-transparent.is-text) {
min-width: 2rem;
}
@media screen and (max-width: 1023px) {
.product-table {
min-width: 760px;
}
.product-table__cell--name {
min-width: 11rem;
}
.product-table__cell--category {
min-width: 8rem;
}
}
</style>
+8 -36
View File
@@ -1,45 +1,25 @@
<script>
import { ref } from 'vue'
import axios from 'axios'
import {API_URL} from "@/config.js";
import {
createCustomerAttribute,
deleteCustomerAttribute,
listCustomerAttributes,
} from "@/features/customer/customerAttributeService.js";
export const getAttributes = (customer_number) => {
const token = localStorage.getItem('token');
if (!token) {
return null;
}
// If the customer number is empty, return null
if (!customer_number) {
return null;
}
return axios.get(API_URL + '/customer/attributes?customer_number=' + customer_number, {
headers: {
Authorization: `Bearer ${token}`
}
});
return listCustomerAttributes({ customerNumber: customer_number });
};
const isAttributesLoading = ref(false);
export const createAttribute = (customer_number, attribute) => {
const token = localStorage.getItem('token');
if (!token) {
return null;
}
// If the loading state is true, return null
if (isAttributesLoading.value) {
return null;
}
// Set the loading state to true
isAttributesLoading.value = true;
return axios.post(API_URL + '/customer/attributes', {
customer_number,
attribute
}, {
headers: {
Authorization: `Bearer ${token}`
}
}).then(
return createCustomerAttribute({ customerNumber: customer_number }, attribute).then(
() => {
isAttributesLoading.value = false;
}
@@ -51,21 +31,13 @@ export const createAttribute = (customer_number, attribute) => {
};
export const deleteAttribute = (customer_number, attribute) => {
const token = localStorage.getItem('token');
if (!token) {
return null;
}
// If the loading state is true, return null
if (isAttributesLoading.value) {
return null;
}
// Set the loading state to true
isAttributesLoading.value = true;
return axios.delete(API_URL + '/customer/attributes?customer_number=' + customer_number + '&attribute=' + attribute, {
headers: {
Authorization: `Bearer ${token}`
}
}).then(
return deleteCustomerAttribute({ customerNumber: customer_number }, attribute).then(
() => {
isAttributesLoading.value = false;
}
@@ -0,0 +1,111 @@
import axios from "axios";
import { API_URL } from "@/config.js";
const getAuthHeaders = () => {
const token = window.localStorage.getItem("token");
if (!token) {
return null;
}
return {
Authorization: `Bearer ${token}`,
};
};
const normalizePositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
export const normalizeCustomerAttributeTarget = (target = {}) => {
const userId = normalizePositiveInteger(target.userId ?? target.user_id);
const customerNumber = normalizePositiveInteger(target.customerNumber ?? target.customer_number);
return {
userId,
customerNumber,
};
};
export const hasCustomerAttributeTarget = (target = {}) => {
const normalizedTarget = normalizeCustomerAttributeTarget(target);
return Boolean(normalizedTarget.userId || normalizedTarget.customerNumber);
};
export const buildCustomerAttributeTargetPayload = (target = {}) => {
const normalizedTarget = normalizeCustomerAttributeTarget(target);
if (normalizedTarget.userId) {
return {
user_id: normalizedTarget.userId,
};
}
if (normalizedTarget.customerNumber) {
return {
customer_number: normalizedTarget.customerNumber,
};
}
return {};
};
export const extractCustomerAttributesData = (response) => {
if (Array.isArray(response?.data?.data)) {
return response.data.data;
}
if (Array.isArray(response?.data)) {
return response.data;
}
if (Array.isArray(response)) {
return response;
}
return [];
};
export const listCustomerAttributes = (target = {}) => {
const headers = getAuthHeaders();
if (!headers || !hasCustomerAttributeTarget(target)) {
return null;
}
return axios.get(`${API_URL}/customer/attributes`, {
params: buildCustomerAttributeTargetPayload(target),
headers,
});
};
export const createCustomerAttribute = (target = {}, attribute) => {
const headers = getAuthHeaders();
if (!headers || !hasCustomerAttributeTarget(target) || !attribute) {
return null;
}
return axios.post(
`${API_URL}/customer/attributes`,
{
...buildCustomerAttributeTargetPayload(target),
attribute,
},
{
headers,
}
);
};
export const deleteCustomerAttribute = (target = {}, attribute) => {
const headers = getAuthHeaders();
if (!headers || !hasCustomerAttributeTarget(target) || !attribute) {
return null;
}
return axios.delete(`${API_URL}/customer/attributes`, {
params: {
...buildCustomerAttributeTargetPayload(target),
attribute,
},
headers,
});
};
@@ -0,0 +1,127 @@
export const CUSTOMER_RULE_DEFINITIONS = Object.freeze([
{
attribute: "restrictAdditionalServices",
group: "category",
sortOrder: 10,
icon: "fas fa-cogs",
labelKey: "customer_rules.attributes.restrictAdditionalServices.label",
descriptionKey: "customer_rules.attributes.restrictAdditionalServices.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
},
{
attribute: "restrictTankCleaning",
group: "category",
sortOrder: 20,
icon: "fas fa-cogs",
labelKey: "customer_rules.attributes.restrictTankCleaning.label",
descriptionKey: "customer_rules.attributes.restrictTankCleaning.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
},
{
attribute: "restrictSpotFree",
group: "category",
sortOrder: 30,
icon: "fas fa-cogs",
labelKey: "customer_rules.attributes.restrictSpotFree.label",
descriptionKey: "customer_rules.attributes.restrictSpotFree.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
},
{
attribute: "restrictInteriorCleaning",
group: "category",
sortOrder: 40,
icon: "fas fa-cogs",
labelKey: "customer_rules.attributes.restrictInteriorCleaning.label",
descriptionKey: "customer_rules.attributes.restrictInteriorCleaning.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
},
{
attribute: "onlyTankCleaning",
group: "category",
sortOrder: 50,
icon: "fas fa-cogs",
labelKey: "customer_rules.attributes.onlyTankCleaning.label",
descriptionKey: "customer_rules.attributes.onlyTankCleaning.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
},
{
attribute: "requiresReferenceNumber",
group: "workflow",
sortOrder: 60,
icon: "fas fa-cogs",
labelKey: "customer_rules.attributes.requiresReferenceNumber.label",
descriptionKey: "customer_rules.attributes.requiresReferenceNumber.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
},
{
attribute: "requiresRegistrationNumbersInvoice",
group: "workflow",
sortOrder: 70,
icon: "fas fa-cogs",
labelKey: "customer_rules.attributes.requiresRegistrationNumbersInvoice.label",
descriptionKey: "customer_rules.attributes.requiresRegistrationNumbersInvoice.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
},
{
attribute: "invoiceAllOrdersIndividually",
group: "workflow",
sortOrder: 80,
icon: "fas fa-cogs",
labelKey: "customer_rules.attributes.invoiceAllOrdersIndividually.label",
descriptionKey: "customer_rules.attributes.invoiceAllOrdersIndividually.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
},
{
attribute: "invoiceWithStripe",
group: "workflow",
sortOrder: 90,
icon: "fas fa-cogs",
labelKey: "customer_rules.attributes.invoiceWithStripe.label",
descriptionKey: "customer_rules.attributes.invoiceWithStripe.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
},
{
attribute: "showPricesOnBookingPage",
group: "workflow",
sortOrder: 100,
icon: "fas fa-cogs",
labelKey: "customer_rules.attributes.showPricesOnBookingPage.label",
descriptionKey: "customer_rules.attributes.showPricesOnBookingPage.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
},
{
attribute: "usePONumbers",
group: "workflow",
sortOrder: 110,
icon: "fas fa-cogs",
labelKey: "customer_rules.attributes.usePONumbers.label",
descriptionKey: "customer_rules.attributes.usePONumbers.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
},
{
attribute: "exemptFromAdministrationFee",
group: "workflow",
sortOrder: 120,
icon: "fas fa-cogs",
labelKey: "customer_rules.attributes.exemptFromAdministrationFee.label",
descriptionKey: "customer_rules.attributes.exemptFromAdministrationFee.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
},
]);
export const getCustomerRuleDefinitions = () => CUSTOMER_RULE_DEFINITIONS;
export const getCustomerRuleDefinition = (attribute) =>
CUSTOMER_RULE_DEFINITIONS.find((rule) => rule.attribute === attribute) ?? null;
@@ -43,6 +43,7 @@ const tabs = [
{ id: "overview", label: "Overview" },
{ id: "lanes", label: "Lanes & Self-Serve" },
{ id: "gates", label: "Gates" },
{ id: "relays", label: "Relays" },
{ id: "scanners", label: "Scanners" },
{ id: "gateways", label: "Gateways" },
{ id: "issues", label: "Issues" },
@@ -58,6 +59,7 @@ const summary = computed(() => state.workspace?.summary || {});
const lanes = computed(() => state.workspace?.lanes || []);
const selfServe = computed(() => state.workspace?.self_serve || {});
const gates = computed(() => state.workspace?.gates || []);
const relays = computed(() => state.workspace?.relays || []);
const scanners = computed(() => state.workspace?.scanners || []);
const issues = computed(() => state.workspace?.issues || []);
const overviewIssues = computed(() => issues.value.slice(0, 5));
@@ -312,7 +314,7 @@ watch(
watch(
activeTab,
(tabId) => {
if (tabId === "lanes") {
if (tabId === "lanes" || tabId === "relays") {
void ensureRelayOptionsLoaded();
}
},
@@ -479,6 +481,97 @@ const deleteGate = async (gate) => {
void handleGateMutationSuccess(`Gate deleted: ${gate.name}.`);
});
};
const handleRelayMutationSuccess = async (message) => {
state.notice = {
kind: "success",
message,
};
state.error = null;
await loadWorkspace({ resetTransient: false });
await setTab("relays");
};
const createRelay = async () => {
await SessionUser.objects.department_relays.showCreateObjectForm(
async () => {
await handleRelayMutationSuccess("Relay created.");
},
{
department: props.departmentId,
}
);
};
const editRelayField = async (relay, column) => {
await SessionUser.objects.department_relays.showEditObjectFieldForm(
relay.id,
column,
relay?.[column] ?? relay?.config ?? null,
async () => {
await handleRelayMutationSuccess(`Relay updated: ${relay.name}.`);
}
);
};
const deleteRelay = async (relay) => {
await SessionUser.objects.department_relays.functions.showDeleteObjectForm(relay.id, () => {
void handleRelayMutationSuccess(`Relay deleted: ${relay.name}.`);
});
};
const getRelayConfigSummary = (relay) => {
const config = relay?.config;
if (!config || typeof config !== "object" || Array.isArray(config) || Object.keys(config).length === 0) {
return "No config";
}
return Object.entries(config)
.slice(0, 3)
.map(([key, value]) => `${key}: ${value === null || value === undefined || value === "" ? "n/a" : value}`)
.join(" | ");
};
const getRelayBindingSummary = (relay) => {
const bindings = Array.isArray(relay?.coverage?.bindings) ? relay.coverage.bindings : [];
if (bindings.length === 0) {
return "No gateway bindings";
}
return bindings
.map((binding) => {
const parts = [binding?.gateway_label || `Gateway ${binding?.gateway_id || "?"}`];
if (binding?.device_id) {
parts.push(String(binding.device_id));
}
if (binding?.channel !== undefined && binding?.channel !== null && binding?.channel !== "") {
parts.push(`ch ${binding.channel}`);
}
return parts.join(" / ");
})
.join(", ");
};
const getRelayConsumerContextLabel = (context) => {
const type = String(context?.type || "consumer");
const label = String(context?.label || context?.id || "Unknown");
const slot = String(context?.slot || "").trim();
if (type === "lane") {
return slot ? `Lane ${label} (${slot})` : `Lane ${label}`;
}
if (type === "gate") {
return slot ? `Gate ${label} (${slot})` : `Gate ${label}`;
}
return slot ? `${type} ${label} (${slot})` : `${type} ${label}`;
};
</script>
<template>
@@ -487,7 +580,7 @@ const deleteGate = async (gate) => {
<div>
<p class="department-hardware-workspace__eyebrow">Integrated hardware workspace</p>
<h2>{{ department?.name || `Department ${departmentId}` }}</h2>
<p>Gateways, lanes, self-serve readiness, gates, scanners, and setup gaps in one department view.</p>
<p>Gateways, lanes, self-serve readiness, gates, relays, scanners, and setup gaps in one department view.</p>
</div>
<div class="department-hardware-workspace__header-actions">
<button
@@ -638,6 +731,7 @@ const deleteGate = async (gate) => {
:key="action.code"
class="button is-light"
type="button"
:data-testid="`department-hardware-action-${action.code}`"
@click="openPath(action.path)"
>
{{ action.label }}
@@ -675,7 +769,15 @@ const deleteGate = async (gate) => {
data-testid="department-hardware-open-selfserve-studio"
@click="openPath(selfServe.links?.studio)"
>
Open Self-Serve Studio
Open Studio
</button>
<button
class="button is-light"
type="button"
data-testid="department-hardware-open-legacy-selfserve"
@click="openPath(selfServe.links?.legacy)"
>
Open Legacy Self-Serve
</button>
<button
class="button is-light"
@@ -836,6 +938,123 @@ const deleteGate = async (gate) => {
</article>
</section>
<section
v-else-if="activeTab === 'relays'"
class="department-hardware-workspace__panel"
data-testid="department-hardware-panel-relays"
>
<div class="department-hardware-workspace__panel-header">
<div>
<h3>Relays</h3>
<p>Department relays bridge lanes and gates onto gateway bindings. Use them to verify ownership and coverage.</p>
</div>
<div class="department-hardware-workspace__button-list">
<button
class="button is-dark"
type="button"
data-testid="department-hardware-add-relay"
@click="createRelay"
>
Add Relay
</button>
<button
class="button is-light"
type="button"
data-testid="department-hardware-open-legacy-relays"
@click="openPath('/superuser/department/relays')"
>
Open Legacy Relays
</button>
</div>
</div>
<div
v-if="relays.length === 0"
class="department-hardware-workspace__empty"
data-testid="department-hardware-empty-relays"
>
No relays configured for this department yet.
</div>
<article
v-for="relay in relays"
:key="relay.id"
class="department-hardware-workspace__row"
:data-testid="`department-relay-${relay.id}`"
>
<div class="department-hardware-workspace__row-header">
<div>
<strong>{{ relay.name }}</strong>
<p>{{ relay.relay_id }} - {{ relay.type || "Unknown type" }}</p>
</div>
<span class="department-hardware-workspace__badge" :data-state="relay.coverage?.covered ? 'READY' : 'MISSING'">
{{ relay.coverage?.covered ? "Bound" : "Unbound" }}
</span>
</div>
<div class="department-hardware-workspace__list-grid">
<div class="department-hardware-workspace__detail">
<strong>Config</strong>
<span>{{ getRelayConfigSummary(relay) }}</span>
</div>
<div class="department-hardware-workspace__detail">
<strong>Gateway bindings</strong>
<span>{{ getRelayBindingSummary(relay) }}</span>
</div>
<div class="department-hardware-workspace__detail">
<strong>Consumers</strong>
<span v-if="relay.consumer_contexts?.length">
{{ relay.consumer_contexts.map(getRelayConsumerContextLabel).join(", ") }}
</span>
<span v-else>Not assigned</span>
</div>
</div>
<div class="department-hardware-workspace__button-list">
<button
class="button is-light is-small"
type="button"
:data-testid="`department-relay-edit-relay-id-${relay.id}`"
@click="editRelayField(relay, 'relay_id')"
>
Edit Relay ID
</button>
<button
class="button is-light is-small"
type="button"
:data-testid="`department-relay-edit-name-${relay.id}`"
@click="editRelayField(relay, 'name')"
>
Edit Name
</button>
<button
class="button is-light is-small"
type="button"
:data-testid="`department-relay-edit-type-${relay.id}`"
@click="editRelayField(relay, 'type')"
>
Edit Type
</button>
<button
class="button is-light is-small"
type="button"
:data-testid="`department-relay-edit-config-${relay.id}`"
@click="editRelayField(relay, 'config')"
>
Edit Config
</button>
<button
class="button is-danger is-light is-small"
type="button"
:data-testid="`department-relay-delete-${relay.id}`"
@click="deleteRelay(relay)"
>
Delete Relay
</button>
</div>
</article>
</section>
<section
v-else-if="activeTab === 'scanners'"
class="department-hardware-workspace__panel"
+123 -11
View File
@@ -575,6 +575,30 @@
"login_as_user_qr": "Log ind som bruger (QR)",
"mark_as_completed": "Marker som fuldført",
"no_actions_defined": "Ingen handlinger defineret",
"customer_section": "Kunde",
"rules_section": "Regler",
"shortcuts_section": "Genveje",
"shortcut_overview": "Oversigt",
"shortcut_orders": "Ordrer",
"shortcut_pricing": "Priser",
"shortcut_other": "Andet",
"shortcut_vehicles": "K\u00f8ret\u00f8jer",
"self_serve_studio_section": "Selvvask Studio",
"gates_section": "Porte",
"relays_section": "Rel\u00e6er",
"gateways_section": "Gateways",
"open_studio": "\u00c5bn Studio",
"open_legacy_self_serve": "\u00c5bn \u00e6ldre selvvask",
"open_hardware_workspace_lanes": "\u00c5bn hardware-arbejdsomr\u00e5de (baner)",
"open_gates_tab": "\u00c5bn porte-fane",
"open_legacy_gates": "\u00c5bn \u00e6ldre porte",
"add_gate": "Tilf\u00f8j port",
"open_relays_tab": "\u00c5bn rel\u00e6-fane",
"open_legacy_relays": "\u00c5bn \u00e6ldre rel\u00e6er",
"add_relay": "Tilf\u00f8j rel\u00e6",
"open_gateways_tab": "\u00c5bn gateway-fane",
"open_fleet_landing": "\u00c5bn gateway-oversigt",
"open_primary_gateway": "\u00c5bn prim\u00e6r gateway",
"open_attached_file": "Åbn vedhæftet fil",
"password_changed": "Adgangskode ændret",
"password_changed_text": "Din adgangskode er blevet ændret",
@@ -4654,20 +4678,56 @@
"stale": "Dette snapshot er ældre end forventet. Automatisk opdatering kan være forsinket.",
"no_sessions": "Ingen nyere sessioner fundet."
},
"sections": {
"infrastructure": "Infrastruktur",
"modules": "Moduler",
"sessions": "Seneste sessioner"
"sections": {
"infrastructure": "Infrastruktur",
"gateways": "Edge gateways",
"modules": "Moduler",
"sessions": "Seneste sessioner"
},
"cards": {
"database": "Database",
"redis": "Redis",
"minio": "MinIO",
"cpu": "CPU",
"memory": "RAM",
"disk": "Disk"
},
"gateways": {
"description": "Skrivebeskyttet fleet-status for installerede edge gateways.",
"loading": "Indlæser gatewaystatus...",
"empty": "Der er ingen registrerede edge gateways.",
"error": "Gatewaystatus kunne ikke indlæses lige nu.",
"department_fallback": "Afdeling {id}",
"actions": {
"open_fleet": "Åbn fleet",
"open_gateway": "Åbn gateway"
},
"cards": {
"database": "Database",
"redis": "Redis",
"minio": "MinIO",
"cpu": "CPU",
"memory": "RAM",
"disk": "Disk"
"summary": {
"total": "Gateways",
"online": "Online",
"degraded": "Kræver opmærksomhed",
"offline": "Offline"
},
"labels": {
"discovery": "Discovery",
"last_heartbeat": "Sidste heartbeat",
"active_operation": "Aktiv opgave"
},
"status": {
"online": "Online",
"degraded": "Kræver opmærksomhed",
"offline": "Offline",
"unknown": "Ukendt"
},
"discovery_status": {
"ready": "Klar",
"stale": "Forældet",
"pending": "Afventer",
"failed": "Fejlet",
"unknown": "Ukendt"
}
},
"labels": {
"database_index": "Databaseindeks",
"runtime_source": "Runtime-kilde",
"buckets_available": "{available} af {total} buckets tilgængelige",
@@ -4783,5 +4843,57 @@
"no": "Nej",
"unknown": "Ukendt"
}
},
"customer_rules": {
"attributes": {
"restrictAdditionalServices": {
"label": "Begr\u00e6ns till\u00e6gsydelser",
"description": "Bloker till\u00e6gsydelser og add-ons for denne kunde."
},
"restrictTankCleaning": {
"label": "Begr\u00e6ns tankrens",
"description": "Bloker tankrens for denne kunde."
},
"restrictSpotFree": {
"label": "Begr\u00e6ns pletfri skyl",
"description": "Bloker pletfri skyl for denne kunde."
},
"restrictInteriorCleaning": {
"label": "Begr\u00e6ns indvendig vask",
"description": "Bloker indvendig vask for denne kunde."
},
"onlyTankCleaning": {
"label": "Kun tankrens",
"description": "Tillad kun tankrens for denne kunde."
},
"requiresReferenceNumber": {
"label": "Kr\u00e6v referencenummer",
"description": "Kr\u00e6v et referencenummer p\u00e5 bookinger og ordrer for denne kunde."
},
"requiresRegistrationNumbersInvoice": {
"label": "Kr\u00e6v registreringsnumre p\u00e5 faktura",
"description": "Kr\u00e6v registreringsnumre p\u00e5 fakturaer for denne kunde."
},
"invoiceAllOrdersIndividually": {
"label": "Fakturer alle ordrer enkeltvis",
"description": "Opret separate fakturaer i stedet for at samle kundens ordrer."
},
"invoiceWithStripe": {
"label": "Fakturer med Stripe",
"description": "Brug Stripe-fakturering for denne kunde."
},
"showPricesOnBookingPage": {
"label": "Vis priser p\u00e5 bookingsiden",
"description": "Vis kundens priser p\u00e5 bookingsiden."
},
"usePONumbers": {
"label": "Brug PO-numre",
"description": "Aktiv\u00e9r PO-numre for denne kunde."
},
"exemptFromAdministrationFee": {
"label": "Fritag for administrationsgebyr",
"description": "Opkr\u00e6v ikke administrationsgebyr for denne kunde."
}
}
}
}
+114
View File
@@ -541,6 +541,30 @@
"login_as_user_qr": "Als Benutzer anmelden (QR-Code)",
"mark_as_completed": "Als abgeschlossen markieren",
"no_actions_defined": "Keine Aktionen definiert",
"customer_section": "Kunde",
"rules_section": "Regeln",
"shortcuts_section": "Verkn\u00fcpfungen",
"shortcut_overview": "\u00dcbersicht",
"shortcut_orders": "Auftr\u00e4ge",
"shortcut_pricing": "Preise",
"shortcut_other": "Sonstiges",
"shortcut_vehicles": "Fahrzeuge",
"self_serve_studio_section": "Self-serve Studio",
"gates_section": "Tore",
"relays_section": "Relais",
"gateways_section": "Gateways",
"open_studio": "Studio \u00f6ffnen",
"open_legacy_self_serve": "Legacy Self-Serve \u00f6ffnen",
"open_hardware_workspace_lanes": "Hardware-Arbeitsbereich (Spuren) \u00f6ffnen",
"open_gates_tab": "Tore-Tab \u00f6ffnen",
"open_legacy_gates": "Legacy Gates \u00f6ffnen",
"add_gate": "Tor hinzuf\u00fcgen",
"open_relays_tab": "Relais-Tab \u00f6ffnen",
"open_legacy_relays": "Legacy Relays \u00f6ffnen",
"add_relay": "Relais hinzuf\u00fcgen",
"open_gateways_tab": "Gateways-Tab \u00f6ffnen",
"open_fleet_landing": "Fleet Landing \u00f6ffnen",
"open_primary_gateway": "Prim\u00e4res Gateway \u00f6ffnen",
"open_attached_file": "Angeh?ngte Datei #{id} ?ffnen",
"password_changed": "Passwort ge?ndert",
"password_changed_text": "Das Passwort wurde ge?ndert.",
@@ -4437,6 +4461,44 @@
"title": "Systemstatus",
"cards": {
"database": "Datenbank"
},
"sections": {
"gateways": "Edge-Gateways"
},
"gateways": {
"description": "Schreibgeschützter Flottenstatus für installierte Edge-Gateways.",
"loading": "Gateway-Status wird geladen...",
"empty": "Es sind keine Edge-Gateways registriert.",
"error": "Der Gateway-Status konnte derzeit nicht geladen werden.",
"department_fallback": "Abteilung {id}",
"actions": {
"open_fleet": "Flotte öffnen",
"open_gateway": "Gateway öffnen"
},
"summary": {
"total": "Gateways",
"online": "Online",
"degraded": "Benötigt Aufmerksamkeit",
"offline": "Offline"
},
"labels": {
"discovery": "Discovery",
"last_heartbeat": "Letzter Heartbeat",
"active_operation": "Aktive Operation"
},
"status": {
"online": "Online",
"degraded": "Benötigt Aufmerksamkeit",
"offline": "Offline",
"unknown": "Unbekannt"
},
"discovery_status": {
"ready": "Bereit",
"stale": "Veraltet",
"pending": "Ausstehend",
"failed": "Fehlgeschlagen",
"unknown": "Unbekannt"
}
}
},
"superuser_invoice_distribution": {
@@ -4592,5 +4654,57 @@
"customer_table_caption": "Kundenbezogene Zuordnungszeilen f?r den ausgew?hlten Monat",
"compare_table_caption": "Rechnungsvergleichsergebnisse nach Abweichungen und Warnungen sortiert"
}
},
"customer_rules": {
"attributes": {
"restrictAdditionalServices": {
"label": "Restrict additional services",
"description": "Block additional services and add-ons for this customer."
},
"restrictTankCleaning": {
"label": "Restrict tank cleaning",
"description": "Block tank cleaning services for this customer."
},
"restrictSpotFree": {
"label": "Restrict spot-free rinse",
"description": "Block spot-free rinse services for this customer."
},
"restrictInteriorCleaning": {
"label": "Restrict interior cleaning",
"description": "Block interior cleaning services for this customer."
},
"onlyTankCleaning": {
"label": "Only tank cleaning",
"description": "Allow only tank cleaning services for this customer."
},
"requiresReferenceNumber": {
"label": "Require reference number",
"description": "Require a reference number on bookings and orders for this customer."
},
"requiresRegistrationNumbersInvoice": {
"label": "Require registration numbers on invoice",
"description": "Require registration numbers to appear on invoices for this customer."
},
"invoiceAllOrdersIndividually": {
"label": "Invoice all orders individually",
"description": "Create separate invoices instead of grouping this customer's orders."
},
"invoiceWithStripe": {
"label": "Invoice with Stripe",
"description": "Use Stripe invoicing for this customer."
},
"showPricesOnBookingPage": {
"label": "Show prices on booking page",
"description": "Display customer prices on the booking page."
},
"usePONumbers": {
"label": "Use PO numbers",
"description": "Enable PO number handling for this customer."
},
"exemptFromAdministrationFee": {
"label": "Exempt from administration fee",
"description": "Do not apply administration fees to this customer."
}
}
}
}
+124 -12
View File
@@ -575,6 +575,30 @@
"login_as_user_qr": "Log in as user (QR code)",
"mark_as_completed": "Mark as completed",
"no_actions_defined": "No actions defined",
"customer_section": "Customer",
"rules_section": "Rules",
"shortcuts_section": "Shortcuts",
"shortcut_overview": "Overview",
"shortcut_orders": "Orders",
"shortcut_pricing": "Pricing",
"shortcut_other": "Other",
"shortcut_vehicles": "Vehicles",
"self_serve_studio_section": "Self-serve Studio",
"gates_section": "Gates",
"relays_section": "Relays",
"gateways_section": "Gateways",
"open_studio": "Open Studio",
"open_legacy_self_serve": "Open Legacy Self-Serve",
"open_hardware_workspace_lanes": "Open Hardware Workspace (Lanes)",
"open_gates_tab": "Open Gates Tab",
"open_legacy_gates": "Open Legacy Gates",
"add_gate": "Add Gate",
"open_relays_tab": "Open Relays Tab",
"open_legacy_relays": "Open Legacy Relays",
"add_relay": "Add Relay",
"open_gateways_tab": "Open Gateways Tab",
"open_fleet_landing": "Open Fleet Landing",
"open_primary_gateway": "Open Primary Gateway",
"open_attached_file": "Open attached file #{id}",
"password_changed": "Password changed",
"password_changed_text": "The password has been changed.",
@@ -4653,21 +4677,57 @@
"stale": "This snapshot is older than expected. Automatic refresh may be delayed.",
"no_sessions": "No recent sessions found."
},
"sections": {
"infrastructure": "Infrastructure",
"modules": "Modules",
"sessions": "Recent sessions"
"sections": {
"infrastructure": "Infrastructure",
"gateways": "Edge gateways",
"modules": "Modules",
"sessions": "Recent sessions"
},
"cards": {
"database": "Database",
"redis": "Redis",
"minio": "MinIO",
"cpu": "CPU",
"memory": "Memory",
"disk": "Disk"
},
"gateways": {
"description": "Read-only fleet health for installed edge gateways.",
"loading": "Loading gateway health...",
"empty": "No edge gateways are registered.",
"error": "Gateway health could not be loaded right now.",
"department_fallback": "Department {id}",
"actions": {
"open_fleet": "Open fleet",
"open_gateway": "Open gateway"
},
"cards": {
"database": "Database",
"redis": "Redis",
"minio": "MinIO",
"cpu": "CPU",
"memory": "Memory",
"disk": "Disk"
"summary": {
"total": "Gateways",
"online": "Online",
"degraded": "Needs attention",
"offline": "Offline"
},
"labels": {
"database_index": "Database index",
"discovery": "Discovery",
"last_heartbeat": "Last heartbeat",
"active_operation": "Active operation"
},
"status": {
"online": "Online",
"degraded": "Needs attention",
"offline": "Offline",
"unknown": "Unknown"
},
"discovery_status": {
"ready": "Ready",
"stale": "Stale",
"pending": "Pending",
"failed": "Failed",
"unknown": "Unknown"
}
},
"labels": {
"database_index": "Database index",
"runtime_source": "Runtime source",
"buckets_available": "{available} of {total} buckets available",
"warnings": "Warnings",
@@ -4782,5 +4842,57 @@
"no": "No",
"unknown": "Unknown"
}
},
"customer_rules": {
"attributes": {
"restrictAdditionalServices": {
"label": "Restrict additional services",
"description": "Block additional services and add-ons for this customer."
},
"restrictTankCleaning": {
"label": "Restrict tank cleaning",
"description": "Block tank cleaning services for this customer."
},
"restrictSpotFree": {
"label": "Restrict spot-free rinse",
"description": "Block spot-free rinse services for this customer."
},
"restrictInteriorCleaning": {
"label": "Restrict interior cleaning",
"description": "Block interior cleaning services for this customer."
},
"onlyTankCleaning": {
"label": "Only tank cleaning",
"description": "Allow only tank cleaning services for this customer."
},
"requiresReferenceNumber": {
"label": "Require reference number",
"description": "Require a reference number on bookings and orders for this customer."
},
"requiresRegistrationNumbersInvoice": {
"label": "Require registration numbers on invoice",
"description": "Require registration numbers to appear on invoices for this customer."
},
"invoiceAllOrdersIndividually": {
"label": "Invoice all orders individually",
"description": "Create separate invoices instead of grouping this customer's orders."
},
"invoiceWithStripe": {
"label": "Invoice with Stripe",
"description": "Use Stripe invoicing for this customer."
},
"showPricesOnBookingPage": {
"label": "Show prices on booking page",
"description": "Display customer prices on the booking page."
},
"usePONumbers": {
"label": "Use PO numbers",
"description": "Enable PO number handling for this customer."
},
"exemptFromAdministrationFee": {
"label": "Exempt from administration fee",
"description": "Do not apply administration fees to this customer."
}
}
}
}
+114
View File
@@ -541,6 +541,30 @@
"login_as_user_qr": "Logg inn som bruker (QR-kode)",
"mark_as_completed": "Merk som fullført",
"no_actions_defined": "Ingen handlinger definert",
"customer_section": "Kunde",
"rules_section": "Regler",
"shortcuts_section": "Snarveier",
"shortcut_overview": "Oversikt",
"shortcut_orders": "Ordre",
"shortcut_pricing": "Priser",
"shortcut_other": "Annet",
"shortcut_vehicles": "Kj\u00f8ret\u00f8y",
"self_serve_studio_section": "Self-serve Studio",
"gates_section": "Porter",
"relays_section": "Rel\u00e9er",
"gateways_section": "Gateways",
"open_studio": "\u00c5pne Studio",
"open_legacy_self_serve": "\u00c5pne eldre selvvask",
"open_hardware_workspace_lanes": "\u00c5pne hardware-arbeidsomr\u00e5de (felt)",
"open_gates_tab": "\u00c5pne porter-fane",
"open_legacy_gates": "\u00c5pne eldre porter",
"add_gate": "Legg til port",
"open_relays_tab": "\u00c5pne rel\u00e9-fane",
"open_legacy_relays": "\u00c5pne eldre rel\u00e9er",
"add_relay": "Legg til rel\u00e9",
"open_gateways_tab": "\u00c5pne gateways-fane",
"open_fleet_landing": "\u00c5pne gateway-oversikt",
"open_primary_gateway": "\u00c5pne prim\u00e6r gateway",
"open_attached_file": "Åpne vedlagt fil #{id}",
"password_changed": "Passord endret",
"password_changed_text": "Passordet er endret.",
@@ -4392,6 +4416,44 @@
"title": "Systemstatus",
"cards": {
"database": "Database"
},
"sections": {
"gateways": "Edge gateways"
},
"gateways": {
"description": "Skrivebeskyttet flåtestatus for installerte edge gateways.",
"loading": "Laster gatewaystatus...",
"empty": "Det er ingen registrerte edge gateways.",
"error": "Gatewaystatus kunne ikke lastes inn akkurat nå.",
"department_fallback": "Avdeling {id}",
"actions": {
"open_fleet": "Åpne flåte",
"open_gateway": "Åpne gateway"
},
"summary": {
"total": "Gateways",
"online": "Online",
"degraded": "Trenger oppmerksomhet",
"offline": "Offline"
},
"labels": {
"discovery": "Discovery",
"last_heartbeat": "Siste heartbeat",
"active_operation": "Aktiv operasjon"
},
"status": {
"online": "Online",
"degraded": "Trenger oppmerksomhet",
"offline": "Offline",
"unknown": "Ukjent"
},
"discovery_status": {
"ready": "Klar",
"stale": "Foreldet",
"pending": "Venter",
"failed": "Feilet",
"unknown": "Ukjent"
}
}
},
"superuser_invoice_distribution": {
@@ -4547,5 +4609,57 @@
"customer_table_caption": "Tildelingsrader på kundenivå for den valgte måneden",
"compare_table_caption": "Fakturasammenligningsresultater sortert etter uoverensstemmelser og advarsler"
}
},
"customer_rules": {
"attributes": {
"restrictAdditionalServices": {
"label": "Restrict additional services",
"description": "Block additional services and add-ons for this customer."
},
"restrictTankCleaning": {
"label": "Restrict tank cleaning",
"description": "Block tank cleaning services for this customer."
},
"restrictSpotFree": {
"label": "Restrict spot-free rinse",
"description": "Block spot-free rinse services for this customer."
},
"restrictInteriorCleaning": {
"label": "Restrict interior cleaning",
"description": "Block interior cleaning services for this customer."
},
"onlyTankCleaning": {
"label": "Only tank cleaning",
"description": "Allow only tank cleaning services for this customer."
},
"requiresReferenceNumber": {
"label": "Require reference number",
"description": "Require a reference number on bookings and orders for this customer."
},
"requiresRegistrationNumbersInvoice": {
"label": "Require registration numbers on invoice",
"description": "Require registration numbers to appear on invoices for this customer."
},
"invoiceAllOrdersIndividually": {
"label": "Invoice all orders individually",
"description": "Create separate invoices instead of grouping this customer's orders."
},
"invoiceWithStripe": {
"label": "Invoice with Stripe",
"description": "Use Stripe invoicing for this customer."
},
"showPricesOnBookingPage": {
"label": "Show prices on booking page",
"description": "Display customer prices on the booking page."
},
"usePONumbers": {
"label": "Use PO numbers",
"description": "Enable PO number handling for this customer."
},
"exemptFromAdministrationFee": {
"label": "Exempt from administration fee",
"description": "Do not apply administration fees to this customer."
}
}
}
}
+114
View File
@@ -541,6 +541,30 @@
"login_as_user_qr": "Log in as user (QR code)",
"mark_as_completed": "Markera som slutförd",
"no_actions_defined": "Inga åtgärder definierade",
"customer_section": "Kund",
"rules_section": "Regler",
"shortcuts_section": "Genv\u00e4gar",
"shortcut_overview": "\u00d6versikt",
"shortcut_orders": "Order",
"shortcut_pricing": "Priser",
"shortcut_other": "Annat",
"shortcut_vehicles": "Fordon",
"self_serve_studio_section": "Self-serve Studio",
"gates_section": "Grindar",
"relays_section": "Rel\u00e4er",
"gateways_section": "Gateways",
"open_studio": "\u00d6ppna Studio",
"open_legacy_self_serve": "\u00d6ppna \u00e4ldre self-serve",
"open_hardware_workspace_lanes": "\u00d6ppna h\u00e5rdvaruarbetsyta (banor)",
"open_gates_tab": "\u00d6ppna grindflik",
"open_legacy_gates": "\u00d6ppna \u00e4ldre grindar",
"add_gate": "L\u00e4gg till grind",
"open_relays_tab": "\u00d6ppna rel\u00e4flik",
"open_legacy_relays": "\u00d6ppna \u00e4ldre rel\u00e4er",
"add_relay": "L\u00e4gg till rel\u00e4",
"open_gateways_tab": "\u00d6ppna gateways-flik",
"open_fleet_landing": "\u00d6ppna gateway-\u00f6versikt",
"open_primary_gateway": "\u00d6ppna prim\u00e4r gateway",
"open_attached_file": "Open attached file #{id}",
"password_changed": "Password changed",
"password_changed_text": "Lösenordet har ändrats.",
@@ -4392,6 +4416,44 @@
"title": "Systemstatus",
"cards": {
"database": "Databas"
},
"sections": {
"gateways": "Edge gateways"
},
"gateways": {
"description": "Skrivskyddad flottstatus för installerade edge gateways.",
"loading": "Laddar gatewaystatus...",
"empty": "Det finns inga registrerade edge gateways.",
"error": "Gatewaystatus kunde inte laddas just nu.",
"department_fallback": "Avdelning {id}",
"actions": {
"open_fleet": "Öppna flotta",
"open_gateway": "Öppna gateway"
},
"summary": {
"total": "Gateways",
"online": "Online",
"degraded": "Kräver uppmärksamhet",
"offline": "Offline"
},
"labels": {
"discovery": "Discovery",
"last_heartbeat": "Senaste heartbeat",
"active_operation": "Aktiv operation"
},
"status": {
"online": "Online",
"degraded": "Kräver uppmärksamhet",
"offline": "Offline",
"unknown": "Okänd"
},
"discovery_status": {
"ready": "Klar",
"stale": "Föråldrad",
"pending": "Väntar",
"failed": "Misslyckad",
"unknown": "Okänd"
}
}
},
"superuser_invoice_distribution": {
@@ -4547,5 +4609,57 @@
"customer_table_caption": "Kundniväns färdelningsrader för vald månad",
"compare_table_caption": "Fakturajämfärelser sorterade efter avvikelser och varningar"
}
},
"customer_rules": {
"attributes": {
"restrictAdditionalServices": {
"label": "Restrict additional services",
"description": "Block additional services and add-ons for this customer."
},
"restrictTankCleaning": {
"label": "Restrict tank cleaning",
"description": "Block tank cleaning services for this customer."
},
"restrictSpotFree": {
"label": "Restrict spot-free rinse",
"description": "Block spot-free rinse services for this customer."
},
"restrictInteriorCleaning": {
"label": "Restrict interior cleaning",
"description": "Block interior cleaning services for this customer."
},
"onlyTankCleaning": {
"label": "Only tank cleaning",
"description": "Allow only tank cleaning services for this customer."
},
"requiresReferenceNumber": {
"label": "Require reference number",
"description": "Require a reference number on bookings and orders for this customer."
},
"requiresRegistrationNumbersInvoice": {
"label": "Require registration numbers on invoice",
"description": "Require registration numbers to appear on invoices for this customer."
},
"invoiceAllOrdersIndividually": {
"label": "Invoice all orders individually",
"description": "Create separate invoices instead of grouping this customer's orders."
},
"invoiceWithStripe": {
"label": "Invoice with Stripe",
"description": "Use Stripe invoicing for this customer."
},
"showPricesOnBookingPage": {
"label": "Show prices on booking page",
"description": "Display customer prices on the booking page."
},
"usePONumbers": {
"label": "Use PO numbers",
"description": "Enable PO number handling for this customer."
},
"exemptFromAdministrationFee": {
"label": "Exempt from administration fee",
"description": "Do not apply administration fees to this customer."
}
}
}
}
+16
View File
@@ -361,6 +361,22 @@
"login_as_user_qr": "Log in as user (QR code)",
"mark_as_completed": "Mark as completed",
"no_actions_defined": "No actions defined",
"self_serve_studio_section": "Self-serve Studio",
"gates_section": "Gates",
"relays_section": "Relays",
"gateways_section": "Gateways",
"open_studio": "Open Studio",
"open_legacy_self_serve": "Open Legacy Self-Serve",
"open_hardware_workspace_lanes": "Open Hardware Workspace (Lanes)",
"open_gates_tab": "Open Gates Tab",
"open_legacy_gates": "Open Legacy Gates",
"add_gate": "Add Gate",
"open_relays_tab": "Open Relays Tab",
"open_legacy_relays": "Open Legacy Relays",
"add_relay": "Add Relay",
"open_gateways_tab": "Open Gateways Tab",
"open_fleet_landing": "Open Fleet Landing",
"open_primary_gateway": "Open Primary Gateway",
"open_attached_file": "Open attached file #{id}",
"password_changed": "Password changed",
"password_changed_text": "The password has been changed.",
+56
View File
@@ -3,7 +3,9 @@ import { createPosFixture, mockApi, seedAuthenticatedState } from "./support/net
const POS_PERMISSIONS = [
"admin",
"add_customer_attribute",
"department_access_12",
"delete_customer_attribute",
"delete_order",
"edit_order",
"edit_order_items",
@@ -2296,14 +2298,19 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
await expect(page.locator("table")).toBeVisible();
const dropdownRoot = await openLowestVisibleOrderActionDropdown(page);
const dropdownMenu = dropdownRoot.locator(".dropdown-menu").first();
const dropdownContent = dropdownRoot.locator(".dropdown-content").first();
const flyout = dropdownContent.getByTestId("action-settings-wheel-flyout");
const sectionsRail = dropdownContent.getByTestId("action-settings-wheel-sections");
const customerSection = dropdownContent.getByTestId("action-settings-wheel-section-customer");
const rulesSection = dropdownContent.getByTestId("action-settings-wheel-section-rules");
const shortcutsSection = dropdownContent.getByTestId("action-settings-wheel-section-shortcuts");
const vehicleSection = dropdownContent.getByTestId("action-settings-wheel-section-vehicle");
const attachmentsSection = dropdownContent.getByTestId("action-settings-wheel-section-attachments");
await expect(flyout).toBeVisible();
await expect(dropdownMenu).toHaveCSS("z-index", "4001");
await expect(dropdownContent).toHaveCSS("z-index", "4002");
await expect(customerSection.locator(".fa-chevron-left")).toBeVisible();
await customerSection.hover();
@@ -2324,6 +2331,55 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
(sectionsRailBox?.x ?? 0) - 4
);
await rulesSection.hover();
const rulesSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-rules");
const invoiceAllOrdersIndividuallyToggle = dropdownContent.getByTestId(
"action-settings-wheel-toggle-customer-rule-invoiceAllOrdersIndividually"
);
const requiresRegistrationNumbersInvoiceLabel = dropdownContent.locator(
'[data-testid="action-settings-wheel-toggle-customer-rule-requiresRegistrationNumbersInvoice"] .action-settings-wheel-toggle-item__label'
);
await expect(rulesSubmenu).toBeVisible();
await expect
.poll(
() =>
requiresRegistrationNumbersInvoiceLabel.evaluate((node) => {
return node.scrollWidth <= node.clientWidth + 1;
}),
{ timeout: 5000 }
)
.toBe(true);
const initialInvoiceRuleState = await invoiceAllOrdersIndividuallyToggle.getAttribute("aria-pressed");
expect(["true", "false"]).toContain(initialInvoiceRuleState);
const expectedToggleMethod = initialInvoiceRuleState === "true" ? "DELETE" : "POST";
const toggleRequestPromise = page.waitForRequest((request) => {
return request.method() === expectedToggleMethod && request.url().includes("/customer/attributes");
});
const toggleResponsePromise = page.waitForResponse((response) => {
return response.request().method() === expectedToggleMethod && response.url().includes("/customer/attributes");
});
await invoiceAllOrdersIndividuallyToggle.click();
const [toggleRequest] = await Promise.all([toggleRequestPromise, toggleResponsePromise]);
if (expectedToggleMethod === "POST") {
const body = toggleRequest.postDataJSON();
expect(body.attribute).toBe("invoiceAllOrdersIndividually");
expect(body.customer_number || body.user_id).toBeTruthy();
} else {
const requestUrl = new URL(toggleRequest.url());
expect(requestUrl.searchParams.get("attribute")).toBe("invoiceAllOrdersIndividually");
expect(requestUrl.searchParams.get("customer_number") || requestUrl.searchParams.get("user_id")).toBeTruthy();
}
await expect(flyout).toBeVisible();
await shortcutsSection.hover();
const shortcutsSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-shortcuts");
await expect(shortcutsSubmenu).toBeVisible();
await expect(shortcutsSubmenu.locator("button.dropdown-item-action")).toHaveCount(5);
await vehicleSection.hover();
const vehicleSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-vehicle");
+25
View File
@@ -132,3 +132,28 @@ test("shows colored issues with suggest-fix actions in the department workspace"
"Suggest fix: Assign scanners to lanes"
);
});
test("keeps overview action shortcuts aligned to the workspace tabs", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await seedAuthenticatedState(page, "superuser-edge-gateway-routes-token");
await page.goto("/superuser/departments/1/gateways");
await expect(page.getByTestId("department-hardware-panel-overview")).toBeVisible();
await page.getByTestId("department-hardware-action-REVIEW_LANE_BINDINGS").click();
await expect(page).toHaveURL(/\/superuser\/departments\/1\/gateways\?tab=lanes$/);
await expect(page.getByTestId("department-hardware-panel-lanes")).toBeVisible();
await page.getByTestId("department-hardware-tab-overview").click();
await page.getByTestId("department-hardware-action-ASSIGN_SCANNERS").click();
await expect(page).toHaveURL(/\/superuser\/departments\/1\/gateways\?tab=scanners$/);
await expect(page.getByTestId("department-hardware-panel-scanners")).toBeVisible();
await page.getByTestId("department-hardware-tab-overview").click();
await page.getByTestId("department-hardware-action-OPEN_GATEWAY_TAB").click();
await expect(page).toHaveURL(/\/superuser\/departments\/1\/gateways\?tab=gateways$/);
await expect(page.getByTestId("department-hardware-panel-gateways")).toBeVisible();
});
+128
View File
@@ -432,6 +432,134 @@ test.describe("Edge gateway management smoke", () => {
await expect(page.getByTestId("department-scanner-key-2")).toContainText("rotated-scanner-key-2");
});
test("@smoke shows all five hardware categories on department lane actions", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
await page.goto("/superuser/department/lanes", { waitUntil: "domcontentloaded" });
const laneRow = page.locator("tr", { hasText: "Lane 7" }).first();
await expect(laneRow).toBeVisible();
await laneRow.locator(".action-settings-wheel-trigger").click();
const sectionLabels = await page
.locator('[data-testid="action-settings-wheel-sections"] .action-settings-wheel-section-trigger__label')
.allTextContents();
expect(sectionLabels).toEqual(["Lane", "Self-serve Studio", "Gates", "Relays", "Gateways"]);
await page.getByTestId("action-settings-wheel-section-department-self-serve-studio").hover();
await expect(page.getByTestId("action-settings-wheel-submenu-department-self-serve-studio")).toContainText(
"Open Studio"
);
await expect(page.getByTestId("action-settings-wheel-submenu-department-self-serve-studio")).toContainText(
"Open Legacy Self-Serve"
);
await expect(page.getByTestId("action-settings-wheel-submenu-department-self-serve-studio")).toContainText(
"Open Hardware Workspace (Lanes)"
);
await page.getByTestId("action-settings-wheel-section-department-gates").hover();
await expect(page.getByTestId("action-settings-wheel-submenu-department-gates")).toContainText("Open Gates Tab");
await expect(page.getByTestId("action-settings-wheel-submenu-department-gates")).toContainText("Open Legacy Gates");
await expect(page.getByTestId("action-settings-wheel-submenu-department-gates")).toContainText("Add Gate");
await page.getByTestId("action-settings-wheel-section-department-relays").hover();
await expect(page.getByTestId("action-settings-wheel-submenu-department-relays")).toContainText("Open Relays Tab");
await expect(page.getByTestId("action-settings-wheel-submenu-department-relays")).toContainText(
"Open Legacy Relays"
);
await expect(page.getByTestId("action-settings-wheel-submenu-department-relays")).toContainText("Add Relay");
await page.getByTestId("action-settings-wheel-section-department-gateways").hover();
await expect(page.getByTestId("action-settings-wheel-submenu-department-gateways")).toContainText(
"Open Gateways Tab"
);
await expect(page.getByTestId("action-settings-wheel-submenu-department-gateways")).toContainText(
"Open Fleet Landing"
);
await expect(page.getByTestId("action-settings-wheel-submenu-department-gateways")).toContainText(
"Open Primary Gateway"
);
});
test("@smoke renders Danish hardware action labels without mojibake", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await page.addInitScript(() => {
window.localStorage.setItem("locale", "da");
});
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
await page.goto("/superuser/department/lanes", { waitUntil: "domcontentloaded" });
const laneRow = page.locator("tr", { hasText: "Lane 7" }).first();
await expect(laneRow).toBeVisible();
await laneRow.locator(".action-settings-wheel-trigger").click();
await expect(page.getByTestId("action-settings-wheel-section-department-self-serve-studio")).toContainText(
"Selvvask Studio"
);
await expect(page.getByTestId("action-settings-wheel-section-department-gates")).toContainText("Porte");
await expect(page.getByTestId("action-settings-wheel-section-department-relays")).toContainText("Relæer");
await page.getByTestId("action-settings-wheel-section-department-self-serve-studio").hover();
await expect(page.getByTestId("action-settings-wheel-submenu-department-self-serve-studio")).toContainText(
"Åbn Studio"
);
await expect(page.getByTestId("action-settings-wheel-submenu-department-self-serve-studio")).toContainText(
"Åbn ældre selvvask"
);
await expect(page.getByTestId("action-settings-wheel-submenu-department-self-serve-studio")).toContainText(
"Åbn hardware-arbejdsområde (baner)"
);
await page.getByTestId("action-settings-wheel-section-department-relays").hover();
await expect(page.getByTestId("action-settings-wheel-submenu-department-relays")).toContainText("Åbn relæ-fane");
await expect(page.getByTestId("action-settings-wheel-submenu-department-relays")).toContainText("Åbn ældre relæer");
await expect(page.getByTestId("action-settings-wheel-submenu-department-relays")).toContainText("Tilføj relæ");
await page.getByTestId("action-settings-wheel-section-department-gateways").hover();
await expect(page.getByTestId("action-settings-wheel-submenu-department-gateways")).toContainText(
"Åbn gateway-fane"
);
await expect(page.getByTestId("action-settings-wheel-submenu-department-gateways")).toContainText(
"Åbn gateway-oversigt"
);
await expect(page.getByTestId("action-settings-wheel-submenu-department-gateways")).toContainText(
"Åbn primær gateway"
);
});
test("@smoke surfaces relay contexts and self-serve CTAs in the integrated workspace", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
await page.goto("/superuser/departments/1/gateways?tab=lanes", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("department-hardware-open-selfserve-studio")).toContainText("Open Studio");
await expect(page.getByTestId("department-hardware-open-legacy-selfserve")).toContainText("Open Legacy Self-Serve");
await expect(page.getByTestId("department-hardware-open-binding-inventory")).toBeVisible();
await page.getByTestId("department-hardware-tab-relays").click();
await expect(page.getByTestId("department-hardware-panel-relays")).toBeVisible();
await expect(page.getByTestId("department-hardware-add-relay")).toBeVisible();
await expect(page.getByTestId("department-hardware-open-legacy-relays")).toBeVisible();
await expect(page.getByTestId("department-relay-52")).toContainText("North machine relay");
await expect(page.getByTestId("department-relay-52")).toContainText("Lane 7");
await expect(page.getByTestId("department-relay-52")).toContainText("North Entrance");
await expect(page.getByTestId("department-relay-52")).toContainText("CPH Edge 01");
});
test("@smoke saves Shelly relay selections from the integrated workspace", async ({ page }) => {
await mockApi(page, {
authenticated: true,
+141 -6
View File
@@ -1,5 +1,7 @@
import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
import { isDesktopProject } from "./support/projects";
const json = (body: unknown, status = 200) => ({
status,
@@ -7,8 +9,38 @@ const json = (body: unknown, status = 200) => ({
body: JSON.stringify(body),
});
const suppressVersionCheck = async (page) => {
await page.addInitScript(() => {
window.localStorage.setItem("lastVersionCheck", String(Date.now()));
});
};
const mockAppShellRequests = async (page) => {
await page.route(/\/ping(\?.*)?$/i, async (route) => {
await route.fulfill(
json({
data: {
ok: true,
},
})
);
});
await page.route(/\/worker\/version(\?.*)?$/i, async (route) => {
await route.fulfill(
json({
data: {
version: "test-build",
},
})
);
});
};
test.describe("Superuser department lanes", () => {
test("edits Shelly relay bindings through a fetched select list", async ({ page }) => {
test("edits Shelly relay bindings through a fetched select list", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
const lanes = [
{
id: 7,
@@ -28,10 +60,12 @@ test.describe("Superuser department lanes", () => {
let lastUpdatePayload: Record<string, any> | null = null;
await seedAuthenticatedState(page, "superuser-department-lanes-token");
await suppressVersionCheck(page);
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "list_department_lanes", "edit_department_lane"],
});
await mockAppShellRequests(page);
await page.route(
/https?:\/\/(?:api\.truckwash\.io(?::\d+)?)\/department\/selfserve\/machine-types(\?.*)?$/i,
@@ -86,11 +120,17 @@ test.describe("Superuser department lanes", () => {
await page.goto("/superuser/department/lanes", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/superuser\/department\/lanes$/);
await expect(page.locator("tbody tr")).toHaveCount(1, { timeout: 15_000 });
const row = page.locator("tbody tr").first();
await expect(row).toContainText("OUT-7", { timeout: 15_000 });
const row = page.getByTestId("department-lanes-row-7");
const relayInField = page.getByTestId("department-lanes-row-detail-relay-in-id-7");
await row.locator("td").nth(4).click();
await expect(row).toContainText("7", { timeout: 15_000 });
await expect(row).toContainText("Aktiv", { timeout: 15_000 });
await page.getByTestId("department-lanes-row-toggle-7").click();
await expect(page.getByTestId("department-lanes-row-details-7")).toBeVisible();
await expect(relayInField).toContainText("IN-7");
await relayInField.click();
const modal = page.locator(".swal2-popup");
await expect(modal).toBeVisible();
@@ -108,6 +148,101 @@ test.describe("Superuser department lanes", () => {
id: 7,
relay_in_id: "shelly-plus-01",
});
await expect(row).toContainText("shelly-plus-01", { timeout: 15_000 });
await expect(relayInField).toContainText("shelly-plus-01", { timeout: 15_000 });
});
test("keeps the advanced lanes table within a normal desktop viewport", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
const lanes = [
{
id: 1,
department: 1,
name: "1",
status: "MAINTENANCE",
relay_in_id: "e4b323243f90",
relay_out_id: "e4b32327a610",
relay_machine_id: null,
relay_machine_program_picker_id: null,
relay_machine_cleaner_id: null,
dynamic_image_id: null,
machine_type_id: null,
},
{
id: 3,
department: 2,
name: "Roskilde primærbane",
status: "AVAILABLE",
relay_in_id: "e4b3231437d0",
relay_out_id: "e4b3231f6410",
relay_machine_id: "e4b3231cce40",
relay_machine_program_picker_id: "e4b063feb380",
relay_machine_cleaner_id: "dcb4d9cc3798",
dynamic_image_id: 1,
machine_type_id: 4,
},
];
await page.setViewportSize({ width: 1280, height: 720 });
await seedAuthenticatedState(page, "superuser-department-lanes-layout-token");
await suppressVersionCheck(page);
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "list_department_lanes", "edit_department_lane"],
});
await mockAppShellRequests(page);
await page.route(
/https?:\/\/(?:api\.truckwash\.io(?::\d+)?)\/department\/selfserve\/machine-types(\?.*)?$/i,
async (route) => {
await route.fulfill(
json({
data: [{ id: 4, name: "Klar til vask" }],
})
);
}
);
await page.route(/https?:\/\/(?:api\.truckwash\.io(?::\d+)?)\/department\/lanes(\?.*)?$/i, async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill(json({ data: lanes }));
return;
}
await route.fulfill(json({ data: lanes[0] }));
});
await page.goto("/superuser/department/lanes", { waitUntil: "domcontentloaded" });
const table = page.getByTestId("department-lanes-table");
const row = page.getByTestId("department-lanes-row-3");
const details = page.getByTestId("department-lanes-row-details-3");
await expect(table).toBeVisible();
await expect(row).toContainText("Roskilde primærbane");
await expect(row).toContainText("Aktiv");
await expect(row).toContainText("Klar til vask");
const pageOverflow = await page.evaluate(() => {
const doc = document.documentElement;
return {
documentScrollWidth: doc.scrollWidth,
documentClientWidth: doc.clientWidth,
bodyScrollWidth: document.body.scrollWidth,
};
});
expect(pageOverflow.documentScrollWidth).toBeLessThanOrEqual(pageOverflow.documentClientWidth + 1);
expect(pageOverflow.bodyScrollWidth).toBeLessThanOrEqual(pageOverflow.documentClientWidth + 1);
await page.getByTestId("department-lanes-row-toggle-3").click();
await expect(details).toBeVisible();
await expect(details).toContainText("e4b3231437d0");
await expect(details).toContainText("e4b3231cce40");
await expect(details).toContainText("e4b063feb380");
await expect(page.getByTestId("department-lanes-row-workspace-3")).toBeVisible();
await expect(row.locator("button.action-settings-wheel-trigger").first()).toBeVisible();
});
});
+291
View File
@@ -0,0 +1,291 @@
import { expect, test } from "@playwright/test";
import { seedAuthenticatedState } from "./support/network.js";
import { isDesktopProject } from "./support/projects";
const json = (body: unknown, status = 200) => ({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
test.describe("Superuser products layout", () => {
test("fits a normal desktop viewport and exposes secondary product fields behind details rows", async ({
page,
}, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on("pageerror", (error) => {
pageErrors.push(error.stack || error.message);
});
page.on("console", (message) => {
if (message.type() === "error" && !message.text().includes("Invalid version check response")) {
consoleErrors.push(message.text());
}
});
const products = [
{
id: 1,
order_priority: 40,
name: "Trækker",
description:
"Trækkende køretøj med en lang beskrivelse der tidligere skubbede hele products-tabellen ud over normale desktopskærme.",
price: 579,
subscription_allowed: true,
category: 8,
piktogram: "01",
economic_product_id: 101,
apply_category_discount: true,
requires_note: false,
is_wash: true,
display_in_booking_form: true,
},
{
id: 2,
order_priority: 50,
name: "Trailer",
description: "Standard trailer-vask.",
price: 599,
subscription_allowed: true,
category: 8,
piktogram: "02",
economic_product_id: 102,
apply_category_discount: true,
requires_note: false,
is_wash: true,
display_in_booking_form: true,
},
{
id: 3,
order_priority: 60,
name: "Undervognsskyl pr. enhed",
description: "Tilvalg til undervognsskyl.",
price: 89,
subscription_allowed: false,
category: 8,
piktogram: "03",
economic_product_id: 103,
apply_category_discount: false,
requires_note: false,
is_wash: true,
display_in_booking_form: false,
},
{
id: 4,
order_priority: 70,
name: "Fælg Flex pr. enhed",
description: "Tilvalg til fælgvask.",
price: 129,
subscription_allowed: false,
category: 8,
piktogram: "04",
economic_product_id: 104,
apply_category_discount: false,
requires_note: true,
is_wash: true,
display_in_booking_form: false,
},
];
const categories = [{ id: 8, name: "Udvendig" }];
const productOptions = [
{
id: 9001,
product_id: 1,
option_id: 3,
name: "Undervognsskyl pr. enhed",
min: 0,
max: 1,
},
{
id: 9002,
product_id: 1,
option_id: 4,
name: "Fælg Flex pr. enhed",
min: 0,
max: 1,
},
];
const economicProducts = [
{ id: 101, name: "Trækker (01)" },
{ id: 102, name: "Trailer (02)" },
{ id: 103, name: "Undervognsskyl (03)" },
{ id: 104, name: "Fælg Flex (04)" },
];
await page.setViewportSize({ width: 1280, height: 720 });
await seedAuthenticatedState(page, "superuser-products-layout-token");
await page.route(/https?:\/\/(?:api\.truckwash\.io(?::\d+)?)\/.*/i, async (route) => {
const request = route.request();
const url = new URL(request.url());
const pathname = url.pathname;
const method = request.method();
if (pathname.endsWith("/auth/session") && method === "GET") {
await route.fulfill(
json({
data: {
id: 1,
customer_number: 12345,
group_id: 1,
email: "e2e@example.com",
phone: {
number: "12345678",
country_code: 45,
},
notifications: {
wash_certificate_email: null,
email_notifications_enabled: true,
sms_notifications_enabled: false,
},
display_name: "E2E User",
permissions: ["superuser", "user"],
economic_customer: [],
runtime_config: {
economic: {
transaction_draft_customer_number: null,
},
},
},
})
);
return;
}
if (
(pathname.endsWith("/auth/recaptcha/pre-check") || pathname.endsWith("/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: "cc0b238d",
},
})
);
return;
}
if (pathname.endsWith("/departments") && method === "GET") {
await route.fulfill(
json({
data: [{ id: 1, name: "Hvidovre", visible: true }],
})
);
return;
}
if (pathname.endsWith("/categories") && method === "GET") {
await route.fulfill(json({ data: categories }));
return;
}
if (pathname.endsWith("/product/options") && method === "GET") {
await route.fulfill(json({ data: productOptions }));
return;
}
if (pathname.endsWith("/economic/products") && method === "GET") {
await route.fulfill(json({ data: economicProducts }));
return;
}
if (pathname.endsWith("/products") && method === "GET") {
const perPage = Number(url.searchParams.get("limit") || "100");
await route.fulfill(
json({
data: products,
meta: {
pagination: {
page: 1,
per_page: perPage,
total: products.length,
},
},
})
);
return;
}
await route.fulfill(json({ data: [] }));
});
await page.goto("/superuser/products");
const table = page.getByTestId("superuser-products-table");
const row = page.getByTestId("products-table-row-1");
const detailsToggle = page.getByTestId("products-table-row-toggle-1");
const detailsRow = page.getByTestId("products-table-row-details-1");
await expect(table).toBeVisible();
await expect(row).toContainText("Trækker");
await expect(row).toContainText("579");
await expect(row).toContainText("Udvendig");
await expect(row).toContainText("Ja");
const pageOverflow = await page.evaluate(() => {
const doc = document.documentElement;
return {
documentScrollWidth: doc.scrollWidth,
documentClientWidth: doc.clientWidth,
bodyScrollWidth: document.body.scrollWidth,
};
});
expect(pageOverflow.documentScrollWidth).toBeLessThanOrEqual(pageOverflow.documentClientWidth + 1);
expect(pageOverflow.bodyScrollWidth).toBeLessThanOrEqual(pageOverflow.documentClientWidth + 1);
await detailsToggle.click();
await expect(detailsRow).toBeVisible();
await expect(detailsRow).toContainText("Trækkende køretøj");
await expect(detailsRow).toContainText("Trækker (01)");
await expect(detailsRow).toContainText("Undervognsskyl pr. enhed");
await expect(detailsRow).toContainText("Fælg Flex pr. enhed");
const actionTrigger = row.locator("button.action-settings-wheel-trigger").first();
await expect(actionTrigger).toBeVisible();
await actionTrigger.click();
await expect(page.locator(".dropdown.is-active .dropdown-content")).toContainText(/produkt/i);
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});
});
+184 -12
View File
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
import { mockApi, primeMockSession } from "./support/network.js";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
function json(body, status = 200) {
return {
@@ -11,7 +11,16 @@ function json(body, status = 200) {
async function primeSuperuserSession(page) {
const token = "superuser-system-status-token";
await primeMockSession(page, { token });
await seedAuthenticatedState(page, token);
}
async function bootSuperuser(page, { permissions = ["superuser", "user"], edgeGateways = false } = {}) {
await mockApi(page, {
authenticated: true,
permissions,
edgeGateways,
});
await primeSuperuserSession(page);
}
async function installSystemStatusMock(page, snapshot) {
@@ -27,6 +36,59 @@ async function installSystemStatusMock(page, snapshot) {
});
}
function createGateway(id, overrides = {}) {
return {
id,
label: `Gateway ${id}`,
hostname: `gateway-${id}.truckwash.test`,
department_id: 11,
status: "ONLINE",
discovery_status: "READY",
last_heartbeat_at: "2026-04-08T09:00:00.000Z",
active_operation: null,
diagnostics: [],
error_state: null,
...overrides,
};
}
function createGatewayFleetUsage(rows) {
return {
gateways: {
total: rows.length,
online: rows.filter((gateway) => gateway.status === "ONLINE").length,
degraded: rows.filter((gateway) => gateway.status === "DEGRADED").length,
offline: rows.filter((gateway) => gateway.status === "OFFLINE").length,
},
};
}
async function installGatewayFleetMock(page, { rows, departments }) {
await page.route(/\/edge-gateways(?:\?.*)?$/, async (route) => {
await route.fulfill(
json({
success: true,
data: rows,
meta: {
fleet_usage: createGatewayFleetUsage(rows),
},
includes: {},
})
);
});
await page.route(/\/departments(?:\?.*)?$/, async (route) => {
await route.fulfill(
json({
success: true,
data: departments,
meta: {},
includes: {},
})
);
});
}
const degradedSnapshot = {
overall_status: "degraded",
generated_at: "2026-04-08T08:45:00.000Z",
@@ -215,24 +277,17 @@ const longTitleSnapshot = {
};
test.describe("Superuser system status smoke", () => {
test.beforeEach(async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
});
test("@smoke superuser status dashboard renders Danish translations for degraded infrastructure and recent sessions", async ({
page,
}) => {
await bootSuperuser(page);
await installSystemStatusMock(page, degradedSnapshot);
await page.goto("/superuser");
await expect(page).toHaveURL(/\/superuser$/);
await expect(page.getByTestId("system-status-dashboard")).toBeVisible();
await expect(page.getByTestId("status-card-database")).toContainText("truckwash");
await expect(page.getByTestId("system-status-dashboard")).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("status-card-database")).toContainText("truckwash", { timeout: 20_000 });
await expect(page.locator("body")).toContainText("Acme Logistics");
await expect(page.locator("body")).toContainText("Kunde 1001");
await expect(page.locator("body")).toContainText("Bruger");
@@ -247,6 +302,7 @@ test.describe("Superuser system status smoke", () => {
});
test("@smoke superuser database compatibility page renders shared database status snapshot", async ({ page }) => {
await bootSuperuser(page);
await installSystemStatusMock(page, databaseCompatibilitySnapshot);
await page.goto("/superuser/system/database");
@@ -258,6 +314,7 @@ test.describe("Superuser system status smoke", () => {
});
test("@smoke superuser module cards keep long Danish status reasons below the header row", async ({ page }) => {
await bootSuperuser(page);
await installSystemStatusMock(page, longReasonSnapshot);
await page.goto("/superuser");
@@ -286,6 +343,7 @@ test.describe("Superuser system status smoke", () => {
});
test("@smoke superuser module headers keep long titles and status pills inside their own cards", async ({ page }) => {
await bootSuperuser(page);
await installSystemStatusMock(page, longTitleSnapshot);
await page.goto("/superuser");
@@ -323,4 +381,118 @@ test.describe("Superuser system status smoke", () => {
expect(overlapWidth * overlapHeight).toBeLessThanOrEqual(1);
});
test("@smoke superuser status dashboard renders gateway health cards and gateway links", async ({ page }) => {
const gatewayRows = [
createGateway(301, {
label: "Gateway Atlas",
department_id: 11,
status: "OFFLINE",
discovery_status: "FAILED",
last_heartbeat_at: "2026-04-08T08:00:00.000Z",
error_state: { message: "MQTT disconnected" },
}),
createGateway(302, {
label: "Gateway Bering",
department_id: 12,
status: "OFFLINE",
discovery_status: "FAILED",
last_heartbeat_at: "2026-04-08T08:05:00.000Z",
}),
createGateway(303, {
label: "Gateway Carls",
department_id: 11,
status: "OFFLINE",
discovery_status: "STALE",
last_heartbeat_at: "2026-04-08T08:10:00.000Z",
}),
createGateway(304, {
label: "Gateway Delta",
department_id: 12,
status: "DEGRADED",
discovery_status: "READY",
last_heartbeat_at: "2026-04-08T08:15:00.000Z",
active_operation: {
summary: { label: "Udruller opdatering" },
},
}),
createGateway(305, {
label: "Gateway Echo",
department_id: 11,
status: "DEGRADED",
discovery_status: "STALE",
last_heartbeat_at: "2026-04-08T08:20:00.000Z",
diagnostics: [{ message: "Discovery har ikke rapporteret i 30 minutter." }],
}),
createGateway(306, {
label: "Gateway Fjord",
department_id: 12,
status: "DEGRADED",
discovery_status: "PENDING",
last_heartbeat_at: "2026-04-08T08:25:00.000Z",
}),
createGateway(307, {
label: "",
hostname: "gw-307.truckwash.test",
department_id: 11,
status: "ONLINE",
discovery_status: "READY",
last_heartbeat_at: "2026-04-08T08:30:00.000Z",
}),
createGateway(308, {
label: "Gateway Haven",
department_id: 12,
status: "ONLINE",
discovery_status: "READY",
last_heartbeat_at: "2026-04-08T08:35:00.000Z",
}),
createGateway(309, {
label: "Gateway Ist",
department_id: 11,
status: "ONLINE",
discovery_status: "READY",
last_heartbeat_at: "2026-04-08T08:40:00.000Z",
}),
createGateway(310, {
label: "Gateway Jutland",
department_id: 12,
status: "ONLINE",
discovery_status: "READY",
last_heartbeat_at: "2026-04-08T08:45:00.000Z",
}),
];
await bootSuperuser(page, {
permissions: ["superuser", "user", "modules_shelly_config"],
edgeGateways: false,
});
await installSystemStatusMock(page, degradedSnapshot);
await installGatewayFleetMock(page, {
rows: gatewayRows,
departments: [
{ id: 11, name: "Odense" },
{ id: 12, name: "Aarhus" },
],
});
await page.goto("/superuser");
await expect(page.getByTestId("system-status-gateways")).toBeVisible();
await expect(page.getByTestId("gateway-summary-card-total")).toContainText("10");
await expect(page.getByTestId("gateway-summary-card-online")).toContainText("4");
await expect(page.getByTestId("gateway-summary-card-degraded")).toContainText("3");
await expect(page.getByTestId("gateway-summary-card-offline")).toContainText("3");
await expect(page.locator('[data-testid="system-status-gateways"] .section-link')).toBeVisible();
const gatewayCards = page.locator('[data-testid^="gateway-card-"]');
await expect(gatewayCards).toHaveCount(8);
await expect(gatewayCards.nth(0)).toContainText("Gateway Atlas");
await expect(gatewayCards.nth(0)).toContainText("Odense");
await expect(gatewayCards.nth(0)).toContainText("MQTT disconnected");
await expect(gatewayCards.nth(3)).toContainText("Udruller opdatering");
await expect(gatewayCards.nth(4)).toContainText("Discovery har ikke rapporteret i 30 minutter.");
await expect(gatewayCards.nth(6)).toContainText("gw-307.truckwash.test");
await expect(page.locator('a[href="/superuser/configuration/edgegateway/301/overview"]')).toBeVisible();
await expect(page.locator('[data-testid="gateway-card-309"]')).toHaveCount(0);
});
});
+275 -8
View File
@@ -132,6 +132,43 @@ function normalizePositiveIntegerValue(value) {
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function resolveCustomerNumberFromAttributeTarget(posFixture, target = {}) {
const customerNumber = normalizePositiveIntegerValue(target.customer_number ?? target.customerNumber);
if (customerNumber) {
return customerNumber;
}
const userId = normalizePositiveIntegerValue(target.user_id ?? target.userId);
if (!userId) {
return null;
}
const matchedCustomer = Object.values(posFixture.customersByNumber || {}).find((customer) => {
return Number(customer?.id ?? customer?.user_id ?? 0) === userId;
});
return normalizePositiveIntegerValue(
matchedCustomer?.customerNumber ?? matchedCustomer?.customer_number ?? matchedCustomer?.economic_customer
);
}
function ensureCustomerAttributeBucket(posFixture, customerNumber) {
const normalizedCustomerNumber = normalizePositiveIntegerValue(customerNumber);
if (!normalizedCustomerNumber) {
return [];
}
if (!posFixture.customerAttributesByNumber) {
posFixture.customerAttributesByNumber = {};
}
if (!Array.isArray(posFixture.customerAttributesByNumber[normalizedCustomerNumber])) {
posFixture.customerAttributesByNumber[normalizedCustomerNumber] = [];
}
return posFixture.customerAttributesByNumber[normalizedCustomerNumber];
}
function isWashCertificateProduct(product) {
const productId = Number(product?.id ?? product?.product_id ?? product?.product?.id ?? 0);
if (productId === 41) {
@@ -1953,6 +1990,7 @@ export function createPosFixture(overrides = {}) {
{ id: 2, customer_number: cardCustomer.customerNumber, attribute: "invoiceWithStripe" },
],
},
nextCustomerAttributeId: 3,
customerNotesByNumber: {},
products,
departmentCategories: [
@@ -2580,8 +2618,64 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
}
if (pathname.endsWith("/customer/attributes") && method === "GET") {
const customerNumber = Number(parsedUrl.searchParams.get("customer_number") || 0);
await route.fulfill(json({ success: true, data: posFixture.customerAttributesByNumber[customerNumber] || [] }));
const customerNumber = resolveCustomerNumberFromAttributeTarget(posFixture, {
customer_number: parsedUrl.searchParams.get("customer_number"),
user_id: parsedUrl.searchParams.get("user_id"),
});
await route.fulfill(json({ success: true, data: ensureCustomerAttributeBucket(posFixture, customerNumber) }));
return true;
}
if (pathname.endsWith("/customer/attributes") && method === "POST") {
const body = request.postDataJSON?.() || {};
const customerNumber = resolveCustomerNumberFromAttributeTarget(posFixture, body);
const attribute = String(body.attribute || "").trim();
if (!customerNumber || !attribute) {
await route.fulfill(json({ success: false, data: null }, 422));
return true;
}
const bucket = ensureCustomerAttributeBucket(posFixture, customerNumber);
const existingAttribute = bucket.find((entry) => String(entry?.attribute || "") === attribute) || null;
if (existingAttribute) {
await route.fulfill(json({ success: true, data: existingAttribute }));
return true;
}
const createdAttribute = {
id: Number(posFixture.nextCustomerAttributeId || 1),
customer_number: customerNumber,
attribute,
};
posFixture.nextCustomerAttributeId = createdAttribute.id + 1;
bucket.push(createdAttribute);
await route.fulfill(json({ success: true, data: createdAttribute }));
return true;
}
if (pathname.endsWith("/customer/attributes") && method === "DELETE") {
const customerNumber = resolveCustomerNumberFromAttributeTarget(posFixture, {
customer_number: parsedUrl.searchParams.get("customer_number"),
user_id: parsedUrl.searchParams.get("user_id"),
});
const attribute = String(parsedUrl.searchParams.get("attribute") || "").trim();
if (!customerNumber || !attribute) {
await route.fulfill(json({ success: false, data: null }, 422));
return true;
}
const bucket = ensureCustomerAttributeBucket(posFixture, customerNumber);
const existingAttribute = bucket.find((entry) => String(entry?.attribute || "") === attribute) || null;
posFixture.customerAttributesByNumber[customerNumber] = bucket.filter(
(entry) => String(entry?.attribute || "") !== attribute
);
await route.fulfill(json({ success: true, data: existingAttribute }));
return true;
}
@@ -3185,6 +3279,7 @@ function ensureEdgeGatewayHardwareFixture(edgeGatewayFixture) {
if (!edgeGatewayFixture.hardwareWorkspace) {
edgeGatewayFixture.hardwareWorkspace = {
nextScannerId: 3,
nextRelayId: 55,
relayOptions: [
{ id: "ENTRY-8", name: "Roskilde entry (Shelly Plus 1PM)", status_color: "Green" },
{ id: "EXIT-8", name: "Roskilde exit (Shelly 1)", status_color: "Red" },
@@ -3294,6 +3389,52 @@ function ensureEdgeGatewayHardwareFixture(edgeGatewayFixture) {
},
},
],
relays: [
{
id: 51,
department: 1,
relay_id: "ENTRY-8",
name: "Lane 8 entry relay",
type: "SHELLY",
config: {
device_id: "shelly-plus-02",
channel: 0,
},
},
{
id: 52,
department: 1,
relay_id: "M-7",
name: "North machine relay",
type: "SHELLY",
config: {
device_id: "shelly-plus-01",
channel: 0,
},
},
{
id: 53,
department: 1,
relay_id: "M-8",
name: "South machine relay",
type: "SHELLY",
config: {
device_id: "shelly-plus-01",
channel: 1,
},
},
{
id: 54,
department: 2,
relay_id: "M-9",
name: "Odense machine relay",
type: "SHELLY",
config: {
device_id: "shelly-plus-03",
channel: 0,
},
},
],
scanners: [
{
id: 1,
@@ -3410,6 +3551,10 @@ function findEdgeGatewayHardwareLane(hardware, laneId) {
return null;
}
function findEdgeGatewayHardwareRelay(hardware, relayId) {
return (hardware?.relays || []).find((relay) => Number(relay?.id) === Number(relayId)) || null;
}
function buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departmentId, includeGateways = true) {
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
const department = (hardware?.departments || []).find((entry) => Number(entry.id) === Number(departmentId)) || null;
@@ -3421,6 +3566,25 @@ function buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departm
const gatewayPayloads = gateways.map((gateway) =>
buildHttpEdgeGatewayGateway(edgeGatewayFixture, settleEdgeGatewayWork(edgeGatewayFixture, gateway.id), true)
);
const relayCatalogById = Object.fromEntries(
(hardware?.relays || [])
.filter((relay) => Number(relay?.department) === Number(departmentId))
.map((relay) => [String(relay?.relay_id || "").trim(), relay])
);
const consumersByRelayId = {};
const registerRelayConsumer = (relayId, consumer) => {
const normalizedRelayId = String(relayId || "").trim();
if (!normalizedRelayId) {
return;
}
if (!consumersByRelayId[normalizedRelayId]) {
consumersByRelayId[normalizedRelayId] = [];
}
consumersByRelayId[normalizedRelayId].push(consumer);
};
const lanes = (department.lanes || []).map((lane) => {
const relaySlots = [
@@ -3431,12 +3595,25 @@ function buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departm
["CLEANER", lane.relay_machine_cleaner_id],
]
.filter(([, relayId]) => Boolean(relayId))
.map(([slot, relayId]) => ({
slot,
relay_id: relayId,
catalog: relayId ? { relay_id: relayId } : null,
coverage: buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relayId),
}));
.map(([slot, relayId]) => {
registerRelayConsumer(relayId, {
type: "lane",
id: lane.id,
slot,
label: lane.name,
});
return {
slot,
relay_id: relayId,
catalog: relayCatalogById[String(relayId || "").trim()]
? cloneJson(relayCatalogById[String(relayId || "").trim()])
: relayId
? { relay_id: relayId }
: null,
coverage: buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relayId),
};
});
const requiredRelayCount = relaySlots.length;
const boundRelayCount = relaySlots.filter((slot) => slot.coverage.covered).length;
@@ -3495,6 +3672,15 @@ function buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departm
const coverage =
transportType === "RELAY" && relayId ? buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relayId) : null;
if (transportType === "RELAY" && relayId) {
registerRelayConsumer(relayId, {
type: "gate",
id: gate.id,
slot: gate.is_entrance ? "ENTRANCE" : gate.is_exit ? "EXIT" : "GENERAL",
label: gate.name,
});
}
return {
id: gate.id,
department: gate.department,
@@ -3512,6 +3698,19 @@ function buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departm
};
});
const relays = (hardware?.relays || [])
.filter((relay) => Number(relay?.department) === Number(departmentId))
.map((relay) => ({
id: relay.id,
department: relay.department,
relay_id: relay.relay_id,
name: relay.name,
type: relay.type,
config: cloneJson(relay.config || {}),
coverage: buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relay.relay_id),
consumer_contexts: cloneJson(consumersByRelayId[String(relay?.relay_id || "").trim()] || []),
}));
const laneIndex = Object.fromEntries(lanes.map((lane) => [Number(lane.id), lane]));
const scansByScannerId = {};
(hardware.scans || [])
@@ -3768,6 +3967,7 @@ function buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departm
lanes,
self_serve: selfServe,
gates,
relays,
scanners,
issues,
actions,
@@ -3918,6 +4118,73 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
return true;
}
if (pathname.endsWith("/department/relays") && method === "GET") {
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
const relayId = Number(parsedUrl.searchParams.get("id") || 0);
if (relayId > 0) {
const relay = findEdgeGatewayHardwareRelay(hardware, relayId);
await route.fulfill(relay ? json({ data: cloneJson(relay) }) : json({ message: "Relay not found" }, 404));
return true;
}
await route.fulfill(json({ data: cloneJson(hardware?.relays || []) }));
return true;
}
if (pathname.endsWith("/department/relays") && method === "POST") {
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
const body = request.postDataJSON?.() || {};
const relay = {
id: hardware.nextRelayId++,
department: Number(body.department || 0),
relay_id: String(body.relay_id || ""),
name: String(body.name || ""),
type: String(body.type || ""),
config:
body.config && typeof body.config === "object" && !Array.isArray(body.config) ? cloneJson(body.config) : {},
};
hardware.relays.push(relay);
await route.fulfill(json({ success: true, data: cloneJson(relay) }, 201));
return true;
}
if (pathname.endsWith("/department/relays") && method === "PUT") {
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
const body = request.postDataJSON?.() || {};
const relay = findEdgeGatewayHardwareRelay(hardware, Number(body.id || 0));
if (!relay) {
await route.fulfill(json({ message: "Relay not found" }, 404));
return true;
}
["department", "relay_id", "name", "type", "config"].forEach((field) => {
if (!Object.prototype.hasOwnProperty.call(body, field)) {
return;
}
relay[field] =
field === "department"
? Number(body[field] || 0)
: field === "config" && body[field] && typeof body[field] === "object" && !Array.isArray(body[field])
? cloneJson(body[field])
: body[field];
});
await route.fulfill(json({ success: true, data: cloneJson(relay) }));
return true;
}
if (pathname.endsWith("/department/relays") && method === "DELETE") {
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
const relayId = Number(parsedUrl.searchParams.get("id") || 0);
hardware.relays = (hardware.relays || []).filter((relay) => Number(relay?.id) !== relayId);
await route.fulfill(json({ success: true, data: true }));
return true;
}
if (numberPlateScannersPattern.test(pathname) && method === "GET") {
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
const rows = cloneJson(hardware?.scanners || []);
+386 -3
View File
@@ -3,9 +3,33 @@ import { mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { CUSTOMER_RULE_DEFINITIONS } from "@/features/customer/customerRuleRegistry.js";
import { createTestI18n } from "./helpers/mountWithApp.js";
const getUserIdMock = vi.hoisted(() => vi.fn(() => Promise.resolve({ data: { data: { user_id: 777 } } })));
const listCustomerAttributesMock = vi.hoisted(() => vi.fn(() => Promise.resolve({ data: { data: [] } })));
const createCustomerAttributeMock = vi.hoisted(() => vi.fn(() => Promise.resolve({ data: { data: null } })));
const deleteCustomerAttributeMock = vi.hoisted(() => vi.fn(() => Promise.resolve({ data: { data: null } })));
vi.mock("@/features/customer/customerAttributeService.js", () => ({
buildCustomerAttributeTargetPayload: (target = {}) => {
const userId = Number.parseInt(String(target.userId ?? target.user_id ?? ""), 10);
if (Number.isInteger(userId) && userId > 0) {
return { user_id: userId };
}
const customerNumber = Number.parseInt(String(target.customerNumber ?? target.customer_number ?? ""), 10);
if (Number.isInteger(customerNumber) && customerNumber > 0) {
return { customer_number: customerNumber };
}
return {};
},
createCustomerAttribute: createCustomerAttributeMock,
deleteCustomerAttribute: deleteCustomerAttributeMock,
extractCustomerAttributesData: (response) => response?.data?.data ?? response?.data ?? response ?? [],
listCustomerAttributes: listCustomerAttributesMock,
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
@@ -20,6 +44,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
canAccessAdmin: vi.fn(() => false),
canAccessSuperUser: vi.fn(() => true),
canAccessUser: vi.fn(() => false),
hasPermission: vi.fn(() => true),
functions: {
ucFirst: (value) => value,
parseErrorMessage: () => "error",
@@ -64,6 +89,12 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
forceDisableMachine: vi.fn(() => Promise.resolve()),
},
},
department_gates: {
showCreateObjectForm: vi.fn(() => Promise.resolve()),
},
department_relays: {
showCreateObjectForm: vi.fn(() => Promise.resolve()),
},
orders: {
meta: {
labels: {
@@ -112,6 +143,11 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
},
}));
const CUSTOMER_RULE_TRANSLATION_KEYS = CUSTOMER_RULE_DEFINITIONS.flatMap((rule) => [
rule.labelKey,
rule.descriptionKey,
]);
const SETTINGS_WHEEL_TRANSLATION_KEYS = [
"admin.pos.settings_wheel.associate_order",
"admin.pos.settings_wheel.attach_wash_certificate",
@@ -139,13 +175,37 @@ const SETTINGS_WHEEL_TRANSLATION_KEYS = [
"admin.pos.settings_wheel.login_as_user_qr",
"admin.pos.settings_wheel.mark_as_completed",
"admin.pos.settings_wheel.no_actions_defined",
"admin.pos.settings_wheel.customer_section",
"admin.pos.settings_wheel.gates_section",
"admin.pos.settings_wheel.open_attached_file",
"admin.pos.settings_wheel.open_fleet_landing",
"admin.pos.settings_wheel.open_gateways_tab",
"admin.pos.settings_wheel.open_gates_tab",
"admin.pos.settings_wheel.open_hardware_workspace_lanes",
"admin.pos.settings_wheel.open_legacy_gates",
"admin.pos.settings_wheel.open_legacy_relays",
"admin.pos.settings_wheel.open_legacy_self_serve",
"admin.pos.settings_wheel.open_primary_gateway",
"admin.pos.settings_wheel.rules_section",
"admin.pos.settings_wheel.open_relays_tab",
"admin.pos.settings_wheel.open_studio",
"admin.pos.settings_wheel.password_changed",
"admin.pos.settings_wheel.password_changed_text",
"admin.pos.settings_wheel.please_enter_password",
"admin.pos.settings_wheel.relays_section",
"admin.pos.settings_wheel.scan_qr_to_login",
"admin.pos.settings_wheel.self_serve_studio_section",
"admin.pos.settings_wheel.shortcuts_section",
"admin.pos.settings_wheel.shortcut_orders",
"admin.pos.settings_wheel.shortcut_other",
"admin.pos.settings_wheel.shortcut_overview",
"admin.pos.settings_wheel.shortcut_pricing",
"admin.pos.settings_wheel.shortcut_vehicles",
"admin.pos.settings_wheel.show_customer",
"admin.pos.settings_wheel.show_qr_code",
"admin.pos.settings_wheel.add_gate",
"admin.pos.settings_wheel.add_relay",
"admin.pos.settings_wheel.gateways_section",
"admin.pos.settings_wheel.view_booking_new_tab",
"admin.pos.settings_wheel.view_customer_new_tab",
"admin.pos.settings_wheel.view_invoice_collection_new_tab",
@@ -162,6 +222,7 @@ const SETTINGS_WHEEL_TRANSLATION_KEYS = [
"global.print",
"superuser.department_lane.force_disable_machine",
"superuser.department_lane.force_enable_machine",
...CUSTOMER_RULE_TRANSLATION_KEYS,
];
const createKeyValueMessages = (keys) => {
@@ -217,6 +278,29 @@ const setDesktopFlyoutViewport = () => {
}));
};
const setCompactViewport = () => {
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: 1280,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
writable: true,
value: 900,
});
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
};
const mockMenuGeometry = () =>
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function getBoundingClientRectMock() {
if (this.classList?.contains("dropdown-trigger")) {
@@ -271,6 +355,27 @@ const mountDesktopFlyoutButton = (props = {}) => {
});
};
const mountFlatDropdownButton = (props = {}) => {
setCompactViewport();
return mount(ActionSettingsWheelButton, {
props: {
order_id: 42,
refreshFunction: vi.fn(() => Promise.resolve()),
...props,
},
slots: {
actions: "",
},
global: {
plugins: [i18n],
stubs: {
CustomerModal: { template: "<div />" },
},
},
});
};
const originalFetch = global.fetch;
const originalCreateObjectURL = global.URL.createObjectURL;
const originalRevokeObjectURL = global.URL.revokeObjectURL;
@@ -279,6 +384,24 @@ const originalMatchMedia = window.matchMedia;
describe("ActionSettingsWheelButton", () => {
beforeEach(() => {
getUserIdMock.mockClear();
listCustomerAttributesMock.mockReset();
listCustomerAttributesMock.mockResolvedValue({
data: {
data: [{ id: 1, customer_number: 123456, attribute: "invoiceAllOrdersIndividually" }],
},
});
createCustomerAttributeMock.mockReset();
createCustomerAttributeMock.mockResolvedValue({ data: { data: null } });
deleteCustomerAttributeMock.mockReset();
deleteCustomerAttributeMock.mockResolvedValue({ data: { data: null } });
SessionUser.request.mockReset();
SessionUser.request.mockResolvedValue({ data: { data: { summary: { primary_gateway: { id: 701 } } } } });
SessionUser.hasPermission.mockReset();
SessionUser.hasPermission.mockImplementation((permission) =>
["add_customer_attribute", "delete_customer_attribute", "list_customer_attributes", "superuser"].includes(
permission
)
);
SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm.mockClear();
SessionUser.objects.orders.functions.fetchAttachments.mockReset();
SessionUser.objects.orders.functions.fetchAttachments.mockResolvedValue([]);
@@ -286,6 +409,10 @@ describe("ActionSettingsWheelButton", () => {
SessionUser.objects.orders.functions.downloadAttachment.mockResolvedValue("https://cdn.example.test/attachment");
SessionUser.objects.orders.functions.removeAttachment.mockReset();
SessionUser.objects.orders.functions.removeAttachment.mockResolvedValue({ success: true });
SessionUser.objects.department_gates.showCreateObjectForm.mockClear();
SessionUser.objects.department_relays.showCreateObjectForm.mockClear();
SessionUser.functions.redirectTo.department.mockClear();
SessionUser.functions.redirectTo.superUser.mockClear();
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
@@ -304,7 +431,7 @@ describe("ActionSettingsWheelButton", () => {
vi.restoreAllMocks();
});
it("resolves customer user id once on mount and not on unrelated rerenders", async () => {
it("resolves customer user id and reloads customer rules only when the target changes in direct mode", async () => {
const wrapper = mount(ActionSettingsWheelButton, {
props: {
displayActionsDirectly: true,
@@ -324,18 +451,23 @@ describe("ActionSettingsWheelButton", () => {
await flushMicrotasks();
expect(getUserIdMock).toHaveBeenCalledTimes(1);
expect(getUserIdMock).toHaveBeenLastCalledWith(123456);
expect(listCustomerAttributesMock).toHaveBeenCalledTimes(1);
expect(listCustomerAttributesMock).toHaveBeenLastCalledWith({ customer_number: 123456 });
await wrapper.setProps({ icon: "fas fa-ellipsis-v" });
await flushMicrotasks();
expect(getUserIdMock).toHaveBeenCalledTimes(1);
expect(listCustomerAttributesMock).toHaveBeenCalledTimes(1);
await wrapper.setProps({ customer_number: 654321 });
await flushMicrotasks();
expect(getUserIdMock).toHaveBeenCalledTimes(2);
expect(getUserIdMock).toHaveBeenLastCalledWith(654321);
expect(listCustomerAttributesMock).toHaveBeenCalledTimes(2);
expect(listCustomerAttributesMock).toHaveBeenLastCalledWith({ customer_number: 654321 });
});
it("does not perform customer lookup when explicit user id is provided", async () => {
it("does not perform customer lookup when explicit user id is provided and uses the user target for rules", async () => {
mount(ActionSettingsWheelButton, {
props: {
displayActionsDirectly: true,
@@ -354,6 +486,136 @@ describe("ActionSettingsWheelButton", () => {
await flushMicrotasks();
expect(getUserIdMock).not.toHaveBeenCalled();
expect(listCustomerAttributesMock).toHaveBeenCalledTimes(1);
expect(listCustomerAttributesMock).toHaveBeenCalledWith({ user_id: 77 });
});
it("lazy loads customer rules when a flat dropdown opens and toggles them by customer number", async () => {
const wrapper = mountFlatDropdownButton({
order_id: null,
customer_number: 123456,
reg_1: null,
reg_2: null,
});
await flushMicrotasks();
expect(getUserIdMock).toHaveBeenCalledTimes(1);
expect(listCustomerAttributesMock).not.toHaveBeenCalled();
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
await flushMicrotasks();
expect(listCustomerAttributesMock).toHaveBeenCalledTimes(1);
expect(listCustomerAttributesMock).toHaveBeenCalledWith({ customer_number: 123456 });
expect(wrapper.text()).toContain("admin.pos.settings_wheel.customer_section");
expect(wrapper.text()).toContain("admin.pos.settings_wheel.rules_section");
expect(wrapper.text()).toContain("admin.pos.settings_wheel.shortcuts_section");
const ruleToggle = wrapper.get(
'[data-testid="action-settings-wheel-toggle-customer-rule-invoiceAllOrdersIndividually"]'
);
expect(ruleToggle.attributes("aria-pressed")).toBe("true");
await ruleToggle.trigger("click");
await flushMicrotasks();
expect(deleteCustomerAttributeMock).toHaveBeenCalledTimes(1);
expect(deleteCustomerAttributeMock).toHaveBeenCalledWith(
{ customer_number: 123456 },
"invoiceAllOrdersIndividually"
);
expect(
wrapper
.get('[data-testid="action-settings-wheel-toggle-customer-rule-invoiceAllOrdersIndividually"]')
.attributes("aria-pressed")
).toBe("false");
});
it("renders customer, rules, and shortcuts sections in direct mode and enables disabled rules", async () => {
listCustomerAttributesMock.mockResolvedValueOnce({ data: { data: [] } });
const wrapper = mount(ActionSettingsWheelButton, {
props: {
displayActionsDirectly: true,
order_id: null,
customer_number: 123456,
reg_1: null,
reg_2: null,
},
global: {
plugins: [i18n],
stubs: {
CustomerModal: { template: "<div />" },
},
},
});
await flushMicrotasks();
await flushMicrotasks();
expect(wrapper.text()).toContain("admin.pos.settings_wheel.customer_section");
expect(wrapper.text()).toContain("admin.pos.settings_wheel.rules_section");
expect(wrapper.text()).toContain("admin.pos.settings_wheel.shortcuts_section");
expect(wrapper.text()).toContain("admin.pos.settings_wheel.shortcut_overview");
expect(wrapper.text()).toContain("admin.pos.settings_wheel.shortcut_orders");
const ruleToggle = wrapper.get(
'[data-testid="action-settings-wheel-toggle-customer-rule-invoiceAllOrdersIndividually"]'
);
expect(ruleToggle.attributes("aria-pressed")).toBe("false");
await ruleToggle.trigger("click");
await flushMicrotasks();
expect(createCustomerAttributeMock).toHaveBeenCalledTimes(1);
expect(createCustomerAttributeMock).toHaveBeenCalledWith(
{ customer_number: 123456 },
"invoiceAllOrdersIndividually"
);
expect(
wrapper
.get('[data-testid="action-settings-wheel-toggle-customer-rule-invoiceAllOrdersIndividually"]')
.attributes("aria-pressed")
).toBe("true");
});
it("renders customer, rules, and shortcuts sections in desktop flyout mode", async () => {
const geometrySpy = mockMenuGeometry();
const wrapper = mountDesktopFlyoutButton({
order_id: null,
customer_number: 123456,
reg_1: null,
reg_2: null,
});
await flushMicrotasks();
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
await flushMicrotasks();
expect(wrapper.find('[data-testid="action-settings-wheel-section-customer"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="action-settings-wheel-section-rules"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="action-settings-wheel-section-shortcuts"]').exists()).toBe(true);
await wrapper.get('[data-testid="action-settings-wheel-section-rules"]').trigger("mouseenter");
await flushMicrotasks();
const ruleToggle = wrapper.get(
'[data-testid="action-settings-wheel-toggle-customer-rule-invoiceAllOrdersIndividually"]'
);
expect(ruleToggle.attributes("aria-pressed")).toBe("true");
await ruleToggle.trigger("click");
await flushMicrotasks();
expect(deleteCustomerAttributeMock).toHaveBeenCalledWith(
{ customer_number: 123456 },
"invoiceAllOrdersIndividually"
);
geometrySpy.mockRestore();
});
it("routes 'change invoice collection' action without opening a new tab", async () => {
@@ -435,7 +697,7 @@ describe("ActionSettingsWheelButton", () => {
windowOpenSpy.mockRestore();
});
it("builds attachment flyout rows and switches the preview panel on hover and focus", async () => {
it("builds attachment flyout rows, auto-renders previews on hover, and opens the full preview action explicitly", async () => {
SessionUser.objects.orders.functions.fetchAttachments.mockResolvedValue([
{
id: 301,
@@ -468,6 +730,7 @@ describe("ActionSettingsWheelButton", () => {
);
const geometrySpy = mockMenuGeometry();
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
const wrapper = mountDesktopFlyoutButton();
await flushMicrotasks();
@@ -490,11 +753,19 @@ describe("ActionSettingsWheelButton", () => {
const previewPanel = wrapper.get('[data-testid="action-settings-wheel-attachment-panel"]');
expect(previewPanel.find("iframe").exists()).toBe(true);
expect(previewPanel.find("img").exists()).toBe(false);
expect(SessionUser.objects.orders.functions.downloadAttachment).toHaveBeenCalledWith(42, 301, false);
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(wrapper.find('[data-testid="action-settings-wheel-attachment-action-preview-301"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="action-settings-wheel-attachment-action-download-301"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="action-settings-wheel-attachment-action-print-301"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="action-settings-wheel-attachment-action-delete-301"]').exists()).toBe(true);
await wrapper.get('[data-testid="action-settings-wheel-attachment-action-preview-301"]').trigger("click");
await flushMicrotasks();
expect(windowOpenSpy).toHaveBeenCalledTimes(1);
await imageRow.trigger("focus");
await flushMicrotasks();
await flushMicrotasks();
@@ -502,6 +773,7 @@ describe("ActionSettingsWheelButton", () => {
expect(wrapper.get('[data-testid="action-settings-wheel-attachment-panel"]').find("img").exists()).toBe(true);
expect(global.fetch).toHaveBeenCalledTimes(2);
windowOpenSpy.mockRestore();
geometrySpy.mockRestore();
});
@@ -533,6 +805,7 @@ describe("ActionSettingsWheelButton", () => {
await flushMicrotasks();
await wrapper.get('[data-testid="action-settings-wheel-attachment-row-301"]').trigger("mouseenter");
await flushMicrotasks();
await flushMicrotasks();
expect(global.URL.createObjectURL).toHaveBeenCalledTimes(1);
@@ -553,4 +826,114 @@ describe("ActionSettingsWheelButton", () => {
wrapper.unmount();
geometrySpy.mockRestore();
});
it("prints document attachments from the resolved preview source instead of a blank wrapper window", async () => {
SessionUser.objects.orders.functions.fetchAttachments.mockResolvedValue([
{
id: 301,
content: {
document: "safety-seal.pdf",
other: "safety-seal.pdf",
},
},
]);
SessionUser.objects.orders.functions.downloadAttachment.mockResolvedValue(
"https://cdn.example.test/orders/42/attachments/301"
);
const printWindow = {
focus: vi.fn(),
print: vi.fn(),
addEventListener: vi.fn(),
};
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => printWindow);
const geometrySpy = mockMenuGeometry();
const wrapper = mountDesktopFlyoutButton();
await flushMicrotasks();
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
await wrapper.get('[data-testid="action-settings-wheel-section-attachments"]').trigger("mouseenter");
await flushMicrotasks();
await wrapper.get('[data-testid="action-settings-wheel-attachment-row-301"]').trigger("mouseenter");
await flushMicrotasks();
await flushMicrotasks();
await wrapper.get('[data-testid="action-settings-wheel-attachment-action-print-301"]').trigger("click");
await flushMicrotasks();
expect(windowOpenSpy).toHaveBeenCalledWith(expect.stringMatching(/^blob:/), "_blank", "noopener,noreferrer");
expect(printWindow.addEventListener).toHaveBeenCalledWith("load", expect.any(Function), { once: true });
windowOpenSpy.mockRestore();
geometrySpy.mockRestore();
});
it("adds department hardware sections for a lane and routes the new actions correctly", async () => {
const geometrySpy = mockMenuGeometry();
const refreshFunction = vi.fn(() => Promise.resolve());
const wrapper = mountDesktopFlyoutButton({
order_id: null,
department_id: 1,
department_lane_id: 8,
refreshFunction,
});
await flushMicrotasks();
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
const sectionLabels = wrapper
.findAll(".action-settings-wheel-section-trigger__label")
.map((section) => section.text())
.filter(Boolean);
expect(sectionLabels).toEqual([
"Lane",
"admin.pos.settings_wheel.self_serve_studio_section",
"admin.pos.settings_wheel.gates_section",
"admin.pos.settings_wheel.relays_section",
"admin.pos.settings_wheel.gateways_section",
]);
const reopenDropdown = async () => {
if (!wrapper.find('[data-testid="action-settings-wheel-sections"]').exists()) {
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
}
};
const getActionButton = async (label) => {
await reopenDropdown();
const button = wrapper
.findAll("button.dropdown-item-action")
.find((buttonWrapper) => buttonWrapper.text().includes(label));
expect(button).toBeTruthy();
return button;
};
await (await getActionButton("admin.pos.settings_wheel.open_studio")).trigger("click");
expect(SessionUser.functions.redirectTo.department).toHaveBeenCalledWith(1, "modules/self-serve/studio", true);
await (await getActionButton("admin.pos.settings_wheel.add_gate")).trigger("click");
expect(SessionUser.objects.department_gates.showCreateObjectForm).toHaveBeenCalledWith(expect.any(Function), {
department: 1,
});
await (await getActionButton("admin.pos.settings_wheel.add_relay")).trigger("click");
expect(SessionUser.objects.department_relays.showCreateObjectForm).toHaveBeenCalledWith(expect.any(Function), {
department: 1,
});
await (await getActionButton("admin.pos.settings_wheel.open_primary_gateway")).trigger("click");
await flushMicrotasks();
expect(SessionUser.request).toHaveBeenCalledWith("/modules/edge-gateways/workspace/departments/1", "GET");
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenCalledWith(
"/configuration/edgegateway/701/overview",
true
);
wrapper.unmount();
geometrySpy.mockRestore();
});
});
@@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { CUSTOMER_RULE_DEFINITIONS } from "@/features/customer/customerRuleRegistry.js";
import { readJsonFile } from "./helpers/readJsonFile";
const root = process.cwd();
@@ -78,4 +79,80 @@ describe("ActionSettingsWheelButton i18n coverage", () => {
);
}
});
it("keeps the department hardware menu labels free of encoding placeholders", () => {
const departmentHardwareKeys = [
"admin.pos.settings_wheel.self_serve_studio_section",
"admin.pos.settings_wheel.gates_section",
"admin.pos.settings_wheel.relays_section",
"admin.pos.settings_wheel.gateways_section",
"admin.pos.settings_wheel.open_studio",
"admin.pos.settings_wheel.open_legacy_self_serve",
"admin.pos.settings_wheel.open_hardware_workspace_lanes",
"admin.pos.settings_wheel.open_gates_tab",
"admin.pos.settings_wheel.open_legacy_gates",
"admin.pos.settings_wheel.add_gate",
"admin.pos.settings_wheel.open_relays_tab",
"admin.pos.settings_wheel.open_legacy_relays",
"admin.pos.settings_wheel.add_relay",
"admin.pos.settings_wheel.open_gateways_tab",
"admin.pos.settings_wheel.open_fleet_landing",
"admin.pos.settings_wheel.open_primary_gateway",
];
const getKeyPathValue = (messages, keyPath) => {
let current = messages;
for (const segment of keyPath.split(".")) {
if (!current || typeof current !== "object" || Array.isArray(current)) {
return undefined;
}
current = current[segment];
}
return current;
};
for (const locale of activeLocales) {
const messages = readLocale(locale);
for (const key of departmentHardwareKeys) {
const value = getKeyPathValue(messages, key);
expect(typeof value).toBe("string");
expect(value).not.toMatch(/[?]/);
}
}
});
it("contains all shared customer rule translation keys in all active locales", () => {
const sharedCustomerRuleKeys = CUSTOMER_RULE_DEFINITIONS.flatMap((rule) => [rule.labelKey, rule.descriptionKey]);
const missingByLocale = {};
for (const locale of activeLocales) {
const messages = readLocale(locale);
const missingKeys = sharedCustomerRuleKeys.filter((key) => !hasKeyPath(messages, key));
if (missingKeys.length > 0) {
missingByLocale[locale] = missingKeys;
}
}
if (Object.keys(missingByLocale).length > 0) {
const details = Object.entries(missingByLocale)
.map(([locale, keys]) => `${locale} (${keys.length}):\n- ${keys.join("\n- ")}`)
.join("\n\n");
throw new Error(
[
"Missing translation keys detected for the shared customer rule registry.",
"Each key below is used by ActionSettingsWheelButton and PosSelectedCustomer",
"but absent from the locale JSON.",
"",
details,
].join("\n")
);
}
});
});
+1 -49
View File
@@ -1,49 +1 @@
const storage = new Map();
const localStorageMock = {
getItem(key) {
return storage.has(key) ? storage.get(key) : null;
},
setItem(key, value) {
storage.set(key, String(value));
},
removeItem(key) {
storage.delete(key);
},
clear() {
storage.clear();
},
};
if (!globalThis.localStorage) {
globalThis.localStorage = localStorageMock;
}
if (!globalThis.window) {
globalThis.window = globalThis;
}
if (!globalThis.self) {
globalThis.self = globalThis;
}
if (!globalThis.window.alert) {
globalThis.window.alert = () => {};
}
if (!globalThis.window.open) {
globalThis.window.open = () => {};
}
if (!globalThis.window.matchMedia) {
globalThis.window.matchMedia = () => ({
matches: false,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return false;
},
});
}
// Intentionally empty. The shared Vitest config expects this file to exist.
@@ -17,6 +17,7 @@ vi.mock("@/components/session/authenticatedRequest.vue", () => ({
import SystemStatusDashboard from "@/components/displays/superuser/system/SystemStatusDashboard.vue";
import DatabaseDisplay from "@/components/displays/superuser/system/DatabaseDisplay.vue";
import { SuperUserSystemStatusObject } from "@/components/session/token/superUser/systemStatus.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const root = process.cwd();
const routerSource = readFileSync(join(root, "src/router.js"), "utf8");
@@ -119,12 +120,96 @@ const createSnapshot = (overrides = {}) => ({
...overrides,
});
const createGateway = (id, overrides = {}) => ({
id,
label: `Gateway ${id}`,
hostname: `gateway-${id}.truckwash.test`,
department_id: 11,
status: "ONLINE",
discovery_status: "READY",
last_heartbeat_at: "2026-04-08T09:00:00.000Z",
active_operation: null,
diagnostics: [],
error_state: null,
...overrides,
});
const createGatewayFleetMeta = (gateways = []) => ({
fleet_usage: {
gateways: {
total: gateways.length,
online: gateways.filter((gateway) => gateway.status === "ONLINE").length,
degraded: gateways.filter((gateway) => gateway.status === "DEGRADED").length,
offline: gateways.filter((gateway) => gateway.status === "OFFLINE").length,
},
},
});
const createHttpError = (status, message) =>
Object.assign(new Error(message), {
response: { status },
});
const installDashboardMocks = ({
snapshot = createSnapshot(),
gateways = [],
departments = [
{ id: 11, name: "Odense" },
{ id: 12, name: "Aarhus" },
],
gatewayError = null,
departmentError = null,
} = {}) => {
authenticatedRequestMock.mockImplementation((path, method, params = {}) => {
if (path === "/superuser/system/status" && method === "GET") {
return Promise.resolve({
data: {
data: snapshot,
},
});
}
if (path === "/edge-gateways" && method === "GET") {
if (gatewayError) {
return Promise.reject(gatewayError);
}
return Promise.resolve({
data: {
data: gateways,
meta: createGatewayFleetMeta(gateways),
},
});
}
if (path === "/departments" && method === "GET") {
if (departmentError) {
return Promise.reject(departmentError);
}
return Promise.resolve({
data: {
data: departments,
},
});
}
return Promise.reject(new Error(`Unexpected request: ${path} ${method} ${JSON.stringify(params)}`));
});
};
const flushRendering = async () => {
await Promise.resolve();
await Promise.resolve();
await nextTick();
};
const flushDashboardLoad = async () => {
for (let attempt = 0; attempt < 5; attempt += 1) {
await flushRendering();
}
};
const resetStatusStore = () => {
SuperUserSystemStatusObject.snapshot.value = null;
SuperUserSystemStatusObject.loading.value = false;
@@ -132,6 +217,11 @@ const resetStatusStore = () => {
SuperUserSystemStatusObject.lastLoadedAt.value = null;
};
const resetSessionUser = () => {
SessionUser.permissions.value = [];
SessionUser.isSubuser.value = false;
};
describe("superuser system status route contract", () => {
it("keeps /superuser routed to the main dashboard view and mounts the system dashboard", () => {
expect(routerSource).toContain("path: '/superuser'");
@@ -154,6 +244,8 @@ describe("superuser system status dashboard", () => {
vi.useFakeTimers();
authenticatedRequestMock.mockReset();
resetStatusStore();
resetSessionUser();
SessionUser.permissions.value = ["superuser", "user"];
Object.defineProperty(document, "hidden", {
configurable: true,
value: false,
@@ -164,22 +256,20 @@ describe("superuser system status dashboard", () => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
resetStatusStore();
resetSessionUser();
});
it("renders infrastructure, modules, warnings, and recent sessions from the shared snapshot", async () => {
authenticatedRequestMock.mockResolvedValue({
data: {
data: createSnapshot(),
},
});
installDashboardMocks();
const wrapper = mountWithApp(SystemStatusDashboard, {
messages: { en: enMessages },
});
await flushRendering();
await flushDashboardLoad();
expect(authenticatedRequestMock).toHaveBeenCalledWith("/superuser/system/status", "GET", {});
expect(authenticatedRequestMock).toHaveBeenCalledWith("/edge-gateways", "GET", { view: "summary" });
expect(wrapper.get('[data-testid="status-card-database"]').text()).toContain("truckwash");
expect(wrapper.text()).toContain("Acme Logistics");
expect(wrapper.text()).toContain("/superuser/vehicles");
@@ -190,20 +280,18 @@ describe("superuser system status dashboard", () => {
});
it("shows stale state when the snapshot ages past the polling threshold", async () => {
authenticatedRequestMock.mockResolvedValue({
data: {
data: createSnapshot({
refresh_after_seconds: 1,
warnings: [],
}),
},
installDashboardMocks({
snapshot: createSnapshot({
refresh_after_seconds: 1,
warnings: [],
}),
});
const wrapper = mountWithApp(SystemStatusDashboard, {
messages: { en: enMessages },
});
await flushRendering();
await flushDashboardLoad();
SuperUserSystemStatusObject.lastLoadedAt.value = new Date(Date.now() - 3000);
await nextTick();
@@ -212,6 +300,184 @@ describe("superuser system status dashboard", () => {
wrapper.unmount();
});
it("does not request or render gateways without modules_shelly_config access", async () => {
SessionUser.permissions.value = ["superuser_system_status_view", "user"];
installDashboardMocks();
const wrapper = mountWithApp(SystemStatusDashboard, {
messages: { en: enMessages },
});
await flushDashboardLoad();
expect(authenticatedRequestMock).toHaveBeenCalledWith("/superuser/system/status", "GET", {});
expect(authenticatedRequestMock.mock.calls.some(([path]) => path === "/edge-gateways")).toBe(false);
expect(wrapper.find('[data-testid="system-status-gateways"]').exists()).toBe(false);
wrapper.unmount();
});
it("renders gateway summary cards, gateway cards, and caps the list at eight sorted rows", async () => {
const gateways = [
createGateway(201, {
label: "Gateway Atlas",
department_id: 11,
status: "OFFLINE",
discovery_status: "FAILED",
last_heartbeat_at: "2026-04-08T08:00:00.000Z",
error_state: { message: "MQTT disconnected" },
}),
createGateway(202, {
label: "Gateway Bering",
department_id: 12,
status: "OFFLINE",
discovery_status: "FAILED",
last_heartbeat_at: "2026-04-08T08:05:00.000Z",
}),
createGateway(203, {
label: "Gateway Carls",
department_id: 11,
status: "OFFLINE",
discovery_status: "STALE",
last_heartbeat_at: "2026-04-08T08:10:00.000Z",
diagnostics: [{ message: "Last heartbeat is older than expected." }],
}),
createGateway(204, {
label: "Gateway Delta",
department_id: 12,
status: "DEGRADED",
discovery_status: "READY",
last_heartbeat_at: "2026-04-08T08:15:00.000Z",
active_operation: {
summary: { label: "Applying update" },
},
}),
createGateway(205, {
label: "Gateway Echo",
department_id: 11,
status: "DEGRADED",
discovery_status: "STALE",
last_heartbeat_at: "2026-04-08T08:20:00.000Z",
diagnostics: [{ message: "Discovery has not reported in 30 minutes." }],
}),
createGateway(206, {
label: "Gateway Fjord",
department_id: 12,
status: "DEGRADED",
discovery_status: "PENDING",
last_heartbeat_at: "2026-04-08T08:25:00.000Z",
}),
createGateway(207, {
label: "",
hostname: "gw-207.truckwash.test",
department_id: 11,
status: "ONLINE",
discovery_status: "READY",
last_heartbeat_at: "2026-04-08T08:30:00.000Z",
}),
createGateway(208, {
label: "Gateway Haven",
department_id: 12,
status: "ONLINE",
discovery_status: "READY",
last_heartbeat_at: "2026-04-08T08:35:00.000Z",
}),
createGateway(209, {
label: "Gateway Ist",
department_id: 11,
status: "ONLINE",
discovery_status: "READY",
last_heartbeat_at: "2026-04-08T08:40:00.000Z",
}),
createGateway(210, {
label: "Gateway Jutland",
department_id: 12,
status: "ONLINE",
discovery_status: "READY",
last_heartbeat_at: "2026-04-08T08:45:00.000Z",
}),
];
installDashboardMocks({ gateways });
const wrapper = mountWithApp(SystemStatusDashboard, {
messages: { en: enMessages },
});
await flushDashboardLoad();
expect(wrapper.get('[data-testid="gateway-summary-card-total"]').text()).toContain("10");
expect(wrapper.get('[data-testid="gateway-summary-card-online"]').text()).toContain("4");
expect(wrapper.get('[data-testid="gateway-summary-card-degraded"]').text()).toContain("3");
expect(wrapper.get('[data-testid="gateway-summary-card-offline"]').text()).toContain("3");
expect(wrapper.get('a[href="/superuser/configuration/edgegateway"]').text()).toContain("Open fleet");
const gatewayCards = wrapper.findAll('[data-testid^="gateway-card-"]');
expect(gatewayCards).toHaveLength(8);
expect(gatewayCards.map((card) => card.attributes("data-testid"))).toEqual([
"gateway-card-201",
"gateway-card-202",
"gateway-card-203",
"gateway-card-204",
"gateway-card-205",
"gateway-card-206",
"gateway-card-207",
"gateway-card-208",
]);
expect(wrapper.get('[data-testid="gateway-card-201"]').text()).toContain("Gateway Atlas");
expect(wrapper.get('[data-testid="gateway-card-201"]').text()).toContain("Odense");
expect(wrapper.get('[data-testid="gateway-card-201"]').text()).toContain("MQTT disconnected");
expect(wrapper.get('[data-testid="gateway-card-204"]').text()).toContain("Applying update");
expect(wrapper.get('[data-testid="gateway-card-205"]').text()).toContain(
"Discovery has not reported in 30 minutes."
);
expect(wrapper.get('[data-testid="gateway-card-207"]').text()).toContain("gw-207.truckwash.test");
expect(wrapper.find('a[href="/superuser/configuration/edgegateway/201/overview"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="gateway-card-209"]').exists()).toBe(false);
wrapper.unmount();
});
it("suppresses the gateway section on a 403 gateway response without breaking the dashboard", async () => {
installDashboardMocks({
gatewayError: createHttpError(403, "Forbidden"),
});
const wrapper = mountWithApp(SystemStatusDashboard, {
messages: { en: enMessages },
});
await flushDashboardLoad();
expect(authenticatedRequestMock.mock.calls.some(([path]) => path === "/edge-gateways")).toBe(true);
expect(wrapper.find('[data-testid="system-status-gateways"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="status-card-database"]').text()).toContain("truckwash");
wrapper.unmount();
});
it("shows a local gateway warning when the gateway request fails for non-403 errors", async () => {
installDashboardMocks({
gatewayError: createHttpError(500, "Gateway service unavailable"),
});
const wrapper = mountWithApp(SystemStatusDashboard, {
messages: { en: enMessages },
});
await flushDashboardLoad();
expect(wrapper.get('[data-testid="system-status-gateways"]').exists()).toBe(true);
expect(wrapper.get('[data-testid="gateway-section-error"]').text()).toContain(
"Gateway health could not be loaded right now."
);
expect(wrapper.get('[data-testid="gateway-summary-card-total"]').text()).toContain("0");
expect(wrapper.find('[data-testid="gateway-empty-state"]').exists()).toBe(false);
wrapper.unmount();
});
it("renders the compatibility database panel from the shared snapshot and supports forced refresh", async () => {
authenticatedRequestMock.mockResolvedValue({
data: {
+1
View File
@@ -0,0 +1 @@
42380
-1
View File
@@ -13,7 +13,6 @@ export default defineConfig({
test: {
environment: "node",
include: ["tests/unit/**/*.spec.js"],
setupFiles: ["tests/unit/setup.js"],
coverage: {
enabled: false,
},