Add gateway health handling to unit and E2E tests, refine department lanes table logic, and enhance advanced view behavior.
This commit is contained in:
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="Gå 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="Gå 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>
|
||||
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 || []);
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
42380
|
||||
@@ -13,7 +13,6 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/unit/**/*.spec.js"],
|
||||
setupFiles: ["tests/unit/setup.js"],
|
||||
coverage: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user