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-switch/dist/css/bulma-switch.min.css";
|
||||||
import "bulma-block-list/src/block-list.scss";
|
import "bulma-block-list/src/block-list.scss";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import {
|
import {
|
||||||
popperBox,
|
popperBox,
|
||||||
popper,
|
popper,
|
||||||
@@ -29,6 +30,9 @@ import {
|
|||||||
import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue";
|
import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue";
|
||||||
import CustomerDiscountsDepartmentDisplay from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
|
import CustomerDiscountsDepartmentDisplay from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
|
||||||
import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentBox.vue";
|
import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentBox.vue";
|
||||||
|
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const panel_tabs = ref([
|
const panel_tabs = ref([
|
||||||
{
|
{
|
||||||
@@ -99,80 +103,14 @@ const details = ref([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const attributes = ref([
|
const attributes = computed(() =>
|
||||||
{
|
getCustomerRuleDefinitions().map((attribute) => ({
|
||||||
name: "Kræver reference nr.",
|
...attribute,
|
||||||
prop: "requiresReferenceNumber",
|
name: t(attribute.labelKey),
|
||||||
description: "Når denne er sat, kræves der et reference nr. på ordren før den kan oprettes",
|
prop: attribute.attribute,
|
||||||
icon: "fas fa-cogs",
|
description: t(attribute.descriptionKey),
|
||||||
},
|
}))
|
||||||
{
|
);
|
||||||
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 shortcuts = ref([
|
const shortcuts = ref([
|
||||||
{
|
{
|
||||||
@@ -321,7 +259,7 @@ const customer_data_has_empty_details = () => {
|
|||||||
class="panel-block pos-selected-customer__row"
|
class="panel-block pos-selected-customer__row"
|
||||||
v-if="panel_tabs[1].active"
|
v-if="panel_tabs[1].active"
|
||||||
v-for="attribute in attributes"
|
v-for="attribute in attributes"
|
||||||
:key="attribute.name"
|
:key="attribute.attribute"
|
||||||
>
|
>
|
||||||
<span class="panel-icon pos-selected-customer__icon">
|
<span class="panel-icon pos-selected-customer__icon">
|
||||||
<i :class="attribute.icon" aria-hidden="true"></i>
|
<i :class="attribute.icon" aria-hidden="true"></i>
|
||||||
|
|||||||
@@ -6,9 +6,22 @@ import {
|
|||||||
SuperUserSystemStatusObject,
|
SuperUserSystemStatusObject,
|
||||||
getSuperuserSystemStatus,
|
getSuperuserSystemStatus,
|
||||||
} from "@/components/session/token/superUser/systemStatus.vue";
|
} 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 { t, te, locale } = useI18n();
|
||||||
|
|
||||||
|
const MAX_GATEWAY_CARDS = 8;
|
||||||
|
const gatewayStatusPriority = Object.freeze({
|
||||||
|
OFFLINE: 0,
|
||||||
|
DEGRADED: 1,
|
||||||
|
ONLINE: 2,
|
||||||
|
});
|
||||||
|
|
||||||
const nowTick = ref(Date.now());
|
const nowTick = ref(Date.now());
|
||||||
let pollTimeout = null;
|
let pollTimeout = null;
|
||||||
let clockInterval = null;
|
let clockInterval = null;
|
||||||
@@ -54,6 +67,63 @@ const error = computed(() => SuperUserSystemStatusObject.error.value);
|
|||||||
const lastLoadedAt = computed(() => SuperUserSystemStatusObject.lastLoadedAt.value);
|
const lastLoadedAt = computed(() => SuperUserSystemStatusObject.lastLoadedAt.value);
|
||||||
const refreshAfterSeconds = computed(() => SuperUserSystemStatusObject.refreshAfterSeconds.value || 30);
|
const refreshAfterSeconds = computed(() => SuperUserSystemStatusObject.refreshAfterSeconds.value || 30);
|
||||||
const overallStatus = computed(() => SuperUserSystemStatusObject.overallStatus.value);
|
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(() => [
|
const statusCards = computed(() => [
|
||||||
{
|
{
|
||||||
@@ -114,7 +184,12 @@ const isStale = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const loadStatus = async ({ force = false } = {}) => {
|
const loadStatus = async ({ force = false } = {}) => {
|
||||||
await getSuperuserSystemStatus({ force });
|
const [snapshotValue] = await Promise.all([
|
||||||
|
getSuperuserSystemStatus({ force }),
|
||||||
|
loadGatewayFleet({ force }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return snapshotValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearPolling = () => {
|
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) {
|
function warningText(warning) {
|
||||||
return translateSystemStatusText(
|
return translateSystemStatusText(
|
||||||
"warnings",
|
"warnings",
|
||||||
@@ -268,6 +420,42 @@ function formatDeviceType(deviceType) {
|
|||||||
return translateSystemStatusText("devices", deviceType, {}, deviceType || t("system_status.status.unknown"));
|
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) {
|
function formatDate(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "--";
|
return "--";
|
||||||
@@ -318,6 +506,110 @@ function formatMinioBuckets(buckets) {
|
|||||||
return t("system_status.labels.buckets_available", { available, total: buckets.length });
|
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) {
|
function modulePath(key) {
|
||||||
return moduleConfigPaths[key] || null;
|
return moduleConfigPaths[key] || null;
|
||||||
}
|
}
|
||||||
@@ -400,6 +692,96 @@ function modulePath(key) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="system-status-section">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h3>{{ $t("system_status.sections.modules") }}</h3>
|
<h3>{{ $t("system_status.sections.modules") }}</h3>
|
||||||
@@ -491,7 +873,8 @@ function modulePath(key) {
|
|||||||
|
|
||||||
.summary-card,
|
.summary-card,
|
||||||
.status-card,
|
.status-card,
|
||||||
.module-card {
|
.module-card,
|
||||||
|
.gateway-card {
|
||||||
border: 1px solid #d7dde7;
|
border: 1px solid #d7dde7;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 1rem 1.1rem;
|
padding: 1rem 1.1rem;
|
||||||
@@ -535,13 +918,31 @@ function modulePath(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-card-grid,
|
.status-card-grid,
|
||||||
.module-grid {
|
.module-grid,
|
||||||
|
.gateway-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
align-items: stretch;
|
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,
|
.status-card__top,
|
||||||
.module-card__top {
|
.module-card__top {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -571,6 +972,13 @@ function modulePath(key) {
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gateway-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
height: 100%;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
.module-card__top {
|
.module-card__top {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -579,6 +987,14 @@ function modulePath(key) {
|
|||||||
row-gap: 0.35rem;
|
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 {
|
.module-card__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -593,6 +1009,18 @@ function modulePath(key) {
|
|||||||
align-self: start;
|
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 {
|
.module-card__reason {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
@@ -606,12 +1034,37 @@ function modulePath(key) {
|
|||||||
font-size: 0.85rem;
|
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 {
|
.module-card__link {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #0f4c81;
|
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 {
|
.status-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { computed, ref, watch } from "vue";
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
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 EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
||||||
|
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
|
|
||||||
// Define the props
|
const { t } = useI18n();
|
||||||
|
const { loadList } = usePaginatedListInstance();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
objects: {
|
objects: {
|
||||||
type: Array,
|
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) => {
|
const redirect = (path) => {
|
||||||
window.location = path;
|
window.location = path;
|
||||||
};
|
};
|
||||||
@@ -33,12 +36,17 @@ const openDepartmentWorkspace = (departmentId, tab = "lanes") => {
|
|||||||
return;
|
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 () => {
|
const loadMachineTypes = async () => {
|
||||||
|
if (!props.advancedView) {
|
||||||
|
machineTypeNames.value = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const machineTypes = await SessionUser.objects.self_serve_machine_types.get.all();
|
const machineTypes = await SessionUser.objects.self_serve_machine_types.get.all();
|
||||||
machineTypeNames.value = machineTypes.reduce((accumulator, entry) => {
|
machineTypeNames.value = machineTypes.reduce((accumulator, entry) => {
|
||||||
accumulator[entry.id] = entry.name;
|
accumulator[entry.id] = entry.name;
|
||||||
@@ -46,157 +54,425 @@ const loadMachineTypes = async () => {
|
|||||||
}, {});
|
}, {});
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(() => props.objects, () => {
|
watch(
|
||||||
loadMachineTypes();
|
() => props.objects,
|
||||||
}, { immediate: true, deep: true });
|
() => {
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ SessionUser.objects.department_lanes.columns.name.label }}</th>
|
<th v-if="advancedView" class="is-narrow department-lanes-table__toggle-header"></th>
|
||||||
<th>{{ SessionUser.objects.department_lanes.columns.department.label }}</th>
|
<th>{{ SessionUser.objects.department_lanes.columns.name.label }}</th>
|
||||||
<template v-if="advancedView">
|
<th>{{ SessionUser.objects.department_lanes.columns.department.label }}</th>
|
||||||
<th>{{ $t('objects.columns.status') }}</th>
|
<template v-if="advancedView">
|
||||||
<th>{{ SessionUser.objects.department_lanes.columns.id.label }}</th>
|
<th>{{ $t("objects.columns.status") }}</th>
|
||||||
<th>{{ SessionUser.objects.department_lanes.columns.relay_in_id.label }}</th>
|
<th class="is-narrow">{{ SessionUser.objects.department_lanes.columns.id.label }}</th>
|
||||||
<th>{{ SessionUser.objects.department_lanes.columns.relay_out_id.label }}</th>
|
<th>{{ SessionUser.objects.department_lanes.columns.machine_type_id.label }}</th>
|
||||||
<th>{{ SessionUser.objects.department_lanes.columns.relay_machine_id.label }}</th>
|
</template>
|
||||||
<th>{{ SessionUser.objects.department_lanes.columns.relay_machine_program_picker_id.label }}</th>
|
<th class="has-text-right">{{ $t("tables.actions") }}</th>
|
||||||
<th>{{ SessionUser.objects.department_lanes.columns.relay_machine_cleaner_id.label }}</th>
|
</tr>
|
||||||
<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>
|
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="object in objects" :key="object.id">
|
<template v-for="object in objects" :key="object.id">
|
||||||
<EditableTableColumn
|
<tr :data-testid="`department-lanes-row-${object.id}`">
|
||||||
:object="object"
|
<td v-if="advancedView" class="is-narrow department-lanes-table__toggle-cell">
|
||||||
: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 -->
|
|
||||||
<button
|
<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="button is-small is-light"
|
||||||
:class="SessionUser.objects.department_lanes.functions.isWashing(object) ? 'is-danger' : 'is-grey'"
|
:class="SessionUser.objects.department_lanes.functions.isWashing(object) ? 'is-danger' : 'is-grey'"
|
||||||
@click="
|
@click="
|
||||||
SessionUser.objects.department_lanes.functions.isWashing(object)
|
SessionUser.objects.department_lanes.functions.isWashing(object)
|
||||||
? SessionUser.objects.department_lanes.functions.stopWashing(object).then(() => loadList())
|
? SessionUser.objects.department_lanes.functions.stopWashing(object).then(() => loadList())
|
||||||
: void 0
|
: void 0
|
||||||
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<span class="icon is-small">
|
<span class="icon is-small">
|
||||||
<i :class="SessionUser.objects.department_lanes.functions.isWashing(object) ? 'fas fa-stop-circle' : 'fas fa-check-circle'"></i>
|
<i
|
||||||
</span>
|
:class="
|
||||||
<span>
|
SessionUser.objects.department_lanes.functions.isWashing(object)
|
||||||
{{ SessionUser.objects.department_lanes.functions.isWashing(object) ? $t('global.stop') : $t('global.ready_to_wash') }}
|
? 'fas fa-stop-circle'
|
||||||
</span>
|
: 'fas fa-check-circle'
|
||||||
</button>
|
"
|
||||||
</div>
|
></i>
|
||||||
<div class="column is-narrow">
|
</span>
|
||||||
<button
|
<span>
|
||||||
class="button is-small is-light"
|
{{
|
||||||
type="button"
|
SessionUser.objects.department_lanes.functions.isWashing(object)
|
||||||
@click="openDepartmentWorkspace(object.department, 'lanes')"
|
? $t("global.stop")
|
||||||
>
|
: $t("global.ready_to_wash")
|
||||||
Workspace
|
}}
|
||||||
</button>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
<div class="column is-narrow">
|
<ActionSettingsWheelButton
|
||||||
<ActionSettingsWheelButton :department_lane_id="object.id" :department_id="object.department" :loadList="loadList">
|
:department_lane_id="object.id"
|
||||||
<template #actions>
|
:department_id="object.department"
|
||||||
</template>
|
:loadList="loadList"
|
||||||
</ActionSettingsWheelButton>
|
>
|
||||||
</div>
|
<template #actions></template>
|
||||||
</div>
|
</ActionSettingsWheelButton>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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>
|
</style>
|
||||||
|
|||||||
@@ -1,252 +1,463 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
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();
|
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();
|
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) {
|
if (departments.value.length === 0) {
|
||||||
getDepartments();
|
getDepartments();
|
||||||
}
|
}
|
||||||
|
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
const expandedProductIds = ref([]);
|
||||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
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) => {
|
const redirect = (path) => {
|
||||||
window.location = path;
|
window.location = path;
|
||||||
}
|
};
|
||||||
const categories_cache = ref(null);
|
|
||||||
|
const redirectToProduct = (productId) => {
|
||||||
|
redirect(`/superuser/products/${productId}`);
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
const getCategoryName = (id) => {
|
const getCategoryName = (id) => {
|
||||||
if (categories_cache.value === null) {
|
if (categoriesCache.value === null) {
|
||||||
categories_cache.value = [];
|
categoriesCache.value = [];
|
||||||
SessionUser.objects.categories.get.all().then((categories) => {
|
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 category = categoriesCache.value.find((entry) => entry.id === Number.parseInt(id, 10));
|
||||||
const product_options = getProductOptions(id);
|
return category ? category.name : t("global.no_data");
|
||||||
// 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 product_options_cache = ref(null);
|
|
||||||
const getProductOptions = (product) => {
|
const getProductOptions = (product) => {
|
||||||
if (!product) {
|
if (!product) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (product_options_cache.value === null) {
|
|
||||||
product_options_cache.value = [];
|
if (productOptionsCache.value === null) {
|
||||||
SessionUser.objects.product_options.get.all().then((product_options) => {
|
productOptionsCache.value = [];
|
||||||
product_options_cache.value = product_options;
|
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 = (productOption) => {
|
||||||
const getProductName = (product_option) => {
|
if (productCache.value === null) {
|
||||||
if (product_cache.value === null) {
|
productCache.value = [];
|
||||||
product_cache.value = [];
|
|
||||||
SessionUser.objects.products.get.all().then((products) => {
|
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 getDisplayProductAddons = (product) => {
|
||||||
const product_options = getProductOptions(product);
|
const productOptions = getProductOptions(product).map((productOption) => ({
|
||||||
let html = "";
|
...productOption,
|
||||||
// Get the product names from the product option_ids
|
name: getProductName(productOption),
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<table class="table is-fullwidth is-hoverable is-striped" style="table-layout: fixed;">
|
<div class="product-table__wrapper" data-testid="superuser-products-table-wrapper">
|
||||||
<thead>
|
<table class="table is-fullwidth is-hoverable is-striped product-table" data-testid="superuser-products-table">
|
||||||
<tr>
|
<thead>
|
||||||
<th>{{ SessionUser.objects.products.columns.order_priority.label }}</th>
|
<tr>
|
||||||
<th>{{ SessionUser.objects.products.columns.id.label }}</th>
|
<th class="is-narrow product-table__toggle-header"></th>
|
||||||
<th>{{ SessionUser.objects.products.columns.name.label }}</th>
|
<th class="is-narrow">{{ SessionUser.objects.products.columns.order_priority.label }}</th>
|
||||||
<th>{{ SessionUser.objects.products.columns.description.label }}</th>
|
<th class="is-narrow">{{ SessionUser.objects.products.columns.id.label }}</th>
|
||||||
<th>{{ SessionUser.objects.products.columns.price.label }}</th>
|
<th class="product-table__column-header product-table__column-header--name">
|
||||||
<th>{{ SessionUser.objects.products.columns.subscription_allowed.label }}</th>
|
{{ SessionUser.objects.products.columns.name.label }}
|
||||||
<th>{{ SessionUser.objects.products.columns.category.label }}</th>
|
</th>
|
||||||
<th>{{ SessionUser.objects.products.columns.piktogram.label }}</th>
|
<th class="is-narrow has-text-right">{{ SessionUser.objects.products.columns.price.label }}</th>
|
||||||
<th>{{ SessionUser.objects.products.columns.economic_product_id.label }}</th>
|
<th class="product-table__column-header product-table__column-header--category">
|
||||||
<th>{{ SessionUser.objects.products.columns.apply_category_discount.label }}</th>
|
{{ SessionUser.objects.products.columns.category.label }}
|
||||||
<th>{{ SessionUser.objects.products.columns.requires_note.label }}</th>
|
</th>
|
||||||
<th>{{ SessionUser.objects.products.columns.is_wash.label }}</th>
|
<th class="is-narrow">{{ SessionUser.objects.products.columns.is_wash.label }}</th>
|
||||||
<th>{{ SessionUser.objects.products.columns.display_in_booking_form.label}}</th>
|
<th class="is-narrow">{{ SessionUser.objects.products.columns.display_in_booking_form.label }}</th>
|
||||||
<th>{{ SessionUser.objects.product_options.meta.title }}</th>
|
<th class="is-narrow has-text-right">{{ $t("tables.actions") }}</th>
|
||||||
<th>{{ $t('tables.actions') }}</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
<template v-for="product in props.objects" :key="product.id">
|
||||||
<tr v-for="product in objects" :key="product.id">
|
<tr :data-testid="`products-table-row-${product.id}`">
|
||||||
<!-- Order priority -->
|
<td class="is-narrow product-table__toggle-cell">
|
||||||
<EditableTableColumn
|
<button
|
||||||
:object="product"
|
type="button"
|
||||||
:loadList="loadList"
|
class="button is-small is-light product-table__details-toggle"
|
||||||
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
|
:data-testid="`products-table-row-toggle-${product.id}`"
|
||||||
column="order_priority"
|
:aria-label="isExpanded(product.id) ? $t('global.hide') : $t('global.details')"
|
||||||
/>
|
:aria-expanded="isExpanded(product.id) ? 'true' : 'false'"
|
||||||
<td>{{ product.id }}</td>
|
@click="toggleExpanded(product.id)"
|
||||||
<!-- Name -->
|
>
|
||||||
<EditableTableColumn
|
<span class="icon is-small">
|
||||||
:object="product"
|
<i :class="isExpanded(product.id) ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||||
:loadList="loadList"
|
</span>
|
||||||
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
|
</button>
|
||||||
column="name"
|
</td>
|
||||||
/>
|
<EditableTableColumn
|
||||||
<!-- Description -->
|
class="product-table__cell product-table__cell--priority"
|
||||||
<EditableTableColumn
|
:object="product"
|
||||||
:object="product"
|
:loadList="loadList"
|
||||||
:loadList="loadList"
|
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
|
||||||
:editFunction="SessionUser.objects.products.showEditObjectFieldForm"
|
column="order_priority"
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
<td class="product-table__cell product-table__cell--id">{{ product.id }}</td>
|
||||||
</ActionSettingsWheelButton>
|
<EditableTableColumn
|
||||||
</td>
|
class="product-table__cell product-table__cell--name"
|
||||||
</tr>
|
:object="product"
|
||||||
</tbody>
|
:loadList="loadList"
|
||||||
</table>
|
: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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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>
|
<script>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import axios from 'axios'
|
import {
|
||||||
import {API_URL} from "@/config.js";
|
createCustomerAttribute,
|
||||||
|
deleteCustomerAttribute,
|
||||||
|
listCustomerAttributes,
|
||||||
|
} from "@/features/customer/customerAttributeService.js";
|
||||||
|
|
||||||
export const getAttributes = (customer_number) => {
|
export const getAttributes = (customer_number) => {
|
||||||
const token = localStorage.getItem('token');
|
return listCustomerAttributes({ customerNumber: customer_number });
|
||||||
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}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAttributesLoading = ref(false);
|
const isAttributesLoading = ref(false);
|
||||||
|
|
||||||
export const createAttribute = (customer_number, attribute) => {
|
export const createAttribute = (customer_number, attribute) => {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (!token) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// If the loading state is true, return null
|
// If the loading state is true, return null
|
||||||
if (isAttributesLoading.value) {
|
if (isAttributesLoading.value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Set the loading state to true
|
// Set the loading state to true
|
||||||
isAttributesLoading.value = true;
|
isAttributesLoading.value = true;
|
||||||
return axios.post(API_URL + '/customer/attributes', {
|
return createCustomerAttribute({ customerNumber: customer_number }, attribute).then(
|
||||||
customer_number,
|
|
||||||
attribute
|
|
||||||
}, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
}
|
|
||||||
}).then(
|
|
||||||
() => {
|
() => {
|
||||||
isAttributesLoading.value = false;
|
isAttributesLoading.value = false;
|
||||||
}
|
}
|
||||||
@@ -51,21 +31,13 @@ export const createAttribute = (customer_number, attribute) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const deleteAttribute = (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 the loading state is true, return null
|
||||||
if (isAttributesLoading.value) {
|
if (isAttributesLoading.value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Set the loading state to true
|
// Set the loading state to true
|
||||||
isAttributesLoading.value = true;
|
isAttributesLoading.value = true;
|
||||||
return axios.delete(API_URL + '/customer/attributes?customer_number=' + customer_number + '&attribute=' + attribute, {
|
return deleteCustomerAttribute({ customerNumber: customer_number }, attribute).then(
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`
|
|
||||||
}
|
|
||||||
}).then(
|
|
||||||
() => {
|
() => {
|
||||||
isAttributesLoading.value = false;
|
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: "overview", label: "Overview" },
|
||||||
{ id: "lanes", label: "Lanes & Self-Serve" },
|
{ id: "lanes", label: "Lanes & Self-Serve" },
|
||||||
{ id: "gates", label: "Gates" },
|
{ id: "gates", label: "Gates" },
|
||||||
|
{ id: "relays", label: "Relays" },
|
||||||
{ id: "scanners", label: "Scanners" },
|
{ id: "scanners", label: "Scanners" },
|
||||||
{ id: "gateways", label: "Gateways" },
|
{ id: "gateways", label: "Gateways" },
|
||||||
{ id: "issues", label: "Issues" },
|
{ id: "issues", label: "Issues" },
|
||||||
@@ -58,6 +59,7 @@ const summary = computed(() => state.workspace?.summary || {});
|
|||||||
const lanes = computed(() => state.workspace?.lanes || []);
|
const lanes = computed(() => state.workspace?.lanes || []);
|
||||||
const selfServe = computed(() => state.workspace?.self_serve || {});
|
const selfServe = computed(() => state.workspace?.self_serve || {});
|
||||||
const gates = computed(() => state.workspace?.gates || []);
|
const gates = computed(() => state.workspace?.gates || []);
|
||||||
|
const relays = computed(() => state.workspace?.relays || []);
|
||||||
const scanners = computed(() => state.workspace?.scanners || []);
|
const scanners = computed(() => state.workspace?.scanners || []);
|
||||||
const issues = computed(() => state.workspace?.issues || []);
|
const issues = computed(() => state.workspace?.issues || []);
|
||||||
const overviewIssues = computed(() => issues.value.slice(0, 5));
|
const overviewIssues = computed(() => issues.value.slice(0, 5));
|
||||||
@@ -312,7 +314,7 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
activeTab,
|
activeTab,
|
||||||
(tabId) => {
|
(tabId) => {
|
||||||
if (tabId === "lanes") {
|
if (tabId === "lanes" || tabId === "relays") {
|
||||||
void ensureRelayOptionsLoaded();
|
void ensureRelayOptionsLoaded();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -479,6 +481,97 @@ const deleteGate = async (gate) => {
|
|||||||
void handleGateMutationSuccess(`Gate deleted: ${gate.name}.`);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -487,7 +580,7 @@ const deleteGate = async (gate) => {
|
|||||||
<div>
|
<div>
|
||||||
<p class="department-hardware-workspace__eyebrow">Integrated hardware workspace</p>
|
<p class="department-hardware-workspace__eyebrow">Integrated hardware workspace</p>
|
||||||
<h2>{{ department?.name || `Department ${departmentId}` }}</h2>
|
<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>
|
||||||
<div class="department-hardware-workspace__header-actions">
|
<div class="department-hardware-workspace__header-actions">
|
||||||
<button
|
<button
|
||||||
@@ -638,6 +731,7 @@ const deleteGate = async (gate) => {
|
|||||||
:key="action.code"
|
:key="action.code"
|
||||||
class="button is-light"
|
class="button is-light"
|
||||||
type="button"
|
type="button"
|
||||||
|
:data-testid="`department-hardware-action-${action.code}`"
|
||||||
@click="openPath(action.path)"
|
@click="openPath(action.path)"
|
||||||
>
|
>
|
||||||
{{ action.label }}
|
{{ action.label }}
|
||||||
@@ -675,7 +769,15 @@ const deleteGate = async (gate) => {
|
|||||||
data-testid="department-hardware-open-selfserve-studio"
|
data-testid="department-hardware-open-selfserve-studio"
|
||||||
@click="openPath(selfServe.links?.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>
|
||||||
<button
|
<button
|
||||||
class="button is-light"
|
class="button is-light"
|
||||||
@@ -836,6 +938,123 @@ const deleteGate = async (gate) => {
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</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
|
<section
|
||||||
v-else-if="activeTab === 'scanners'"
|
v-else-if="activeTab === 'scanners'"
|
||||||
class="department-hardware-workspace__panel"
|
class="department-hardware-workspace__panel"
|
||||||
|
|||||||
+123
-11
@@ -575,6 +575,30 @@
|
|||||||
"login_as_user_qr": "Log ind som bruger (QR)",
|
"login_as_user_qr": "Log ind som bruger (QR)",
|
||||||
"mark_as_completed": "Marker som fuldført",
|
"mark_as_completed": "Marker som fuldført",
|
||||||
"no_actions_defined": "Ingen handlinger defineret",
|
"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",
|
"open_attached_file": "Åbn vedhæftet fil",
|
||||||
"password_changed": "Adgangskode ændret",
|
"password_changed": "Adgangskode ændret",
|
||||||
"password_changed_text": "Din adgangskode er blevet æ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.",
|
"stale": "Dette snapshot er ældre end forventet. Automatisk opdatering kan være forsinket.",
|
||||||
"no_sessions": "Ingen nyere sessioner fundet."
|
"no_sessions": "Ingen nyere sessioner fundet."
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"infrastructure": "Infrastruktur",
|
"infrastructure": "Infrastruktur",
|
||||||
"modules": "Moduler",
|
"gateways": "Edge gateways",
|
||||||
"sessions": "Seneste sessioner"
|
"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": {
|
"summary": {
|
||||||
"database": "Database",
|
"total": "Gateways",
|
||||||
"redis": "Redis",
|
"online": "Online",
|
||||||
"minio": "MinIO",
|
"degraded": "Kræver opmærksomhed",
|
||||||
"cpu": "CPU",
|
"offline": "Offline"
|
||||||
"memory": "RAM",
|
|
||||||
"disk": "Disk"
|
|
||||||
},
|
},
|
||||||
"labels": {
|
"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",
|
"database_index": "Databaseindeks",
|
||||||
"runtime_source": "Runtime-kilde",
|
"runtime_source": "Runtime-kilde",
|
||||||
"buckets_available": "{available} af {total} buckets tilgængelige",
|
"buckets_available": "{available} af {total} buckets tilgængelige",
|
||||||
@@ -4783,5 +4843,57 @@
|
|||||||
"no": "Nej",
|
"no": "Nej",
|
||||||
"unknown": "Ukendt"
|
"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)",
|
"login_as_user_qr": "Als Benutzer anmelden (QR-Code)",
|
||||||
"mark_as_completed": "Als abgeschlossen markieren",
|
"mark_as_completed": "Als abgeschlossen markieren",
|
||||||
"no_actions_defined": "Keine Aktionen definiert",
|
"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",
|
"open_attached_file": "Angeh?ngte Datei #{id} ?ffnen",
|
||||||
"password_changed": "Passwort ge?ndert",
|
"password_changed": "Passwort ge?ndert",
|
||||||
"password_changed_text": "Das Passwort wurde ge?ndert.",
|
"password_changed_text": "Das Passwort wurde ge?ndert.",
|
||||||
@@ -4437,6 +4461,44 @@
|
|||||||
"title": "Systemstatus",
|
"title": "Systemstatus",
|
||||||
"cards": {
|
"cards": {
|
||||||
"database": "Datenbank"
|
"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": {
|
"superuser_invoice_distribution": {
|
||||||
@@ -4592,5 +4654,57 @@
|
|||||||
"customer_table_caption": "Kundenbezogene Zuordnungszeilen f?r den ausgew?hlten Monat",
|
"customer_table_caption": "Kundenbezogene Zuordnungszeilen f?r den ausgew?hlten Monat",
|
||||||
"compare_table_caption": "Rechnungsvergleichsergebnisse nach Abweichungen und Warnungen sortiert"
|
"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)",
|
"login_as_user_qr": "Log in as user (QR code)",
|
||||||
"mark_as_completed": "Mark as completed",
|
"mark_as_completed": "Mark as completed",
|
||||||
"no_actions_defined": "No actions defined",
|
"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}",
|
"open_attached_file": "Open attached file #{id}",
|
||||||
"password_changed": "Password changed",
|
"password_changed": "Password changed",
|
||||||
"password_changed_text": "The password has been 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.",
|
"stale": "This snapshot is older than expected. Automatic refresh may be delayed.",
|
||||||
"no_sessions": "No recent sessions found."
|
"no_sessions": "No recent sessions found."
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"infrastructure": "Infrastructure",
|
"infrastructure": "Infrastructure",
|
||||||
"modules": "Modules",
|
"gateways": "Edge gateways",
|
||||||
"sessions": "Recent sessions"
|
"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": {
|
"summary": {
|
||||||
"database": "Database",
|
"total": "Gateways",
|
||||||
"redis": "Redis",
|
"online": "Online",
|
||||||
"minio": "MinIO",
|
"degraded": "Needs attention",
|
||||||
"cpu": "CPU",
|
"offline": "Offline"
|
||||||
"memory": "Memory",
|
|
||||||
"disk": "Disk"
|
|
||||||
},
|
},
|
||||||
"labels": {
|
"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",
|
"runtime_source": "Runtime source",
|
||||||
"buckets_available": "{available} of {total} buckets available",
|
"buckets_available": "{available} of {total} buckets available",
|
||||||
"warnings": "Warnings",
|
"warnings": "Warnings",
|
||||||
@@ -4782,5 +4842,57 @@
|
|||||||
"no": "No",
|
"no": "No",
|
||||||
"unknown": "Unknown"
|
"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)",
|
"login_as_user_qr": "Logg inn som bruker (QR-kode)",
|
||||||
"mark_as_completed": "Merk som fullført",
|
"mark_as_completed": "Merk som fullført",
|
||||||
"no_actions_defined": "Ingen handlinger definert",
|
"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}",
|
"open_attached_file": "Åpne vedlagt fil #{id}",
|
||||||
"password_changed": "Passord endret",
|
"password_changed": "Passord endret",
|
||||||
"password_changed_text": "Passordet er endret.",
|
"password_changed_text": "Passordet er endret.",
|
||||||
@@ -4392,6 +4416,44 @@
|
|||||||
"title": "Systemstatus",
|
"title": "Systemstatus",
|
||||||
"cards": {
|
"cards": {
|
||||||
"database": "Database"
|
"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": {
|
"superuser_invoice_distribution": {
|
||||||
@@ -4547,5 +4609,57 @@
|
|||||||
"customer_table_caption": "Tildelingsrader på kundenivå for den valgte måneden",
|
"customer_table_caption": "Tildelingsrader på kundenivå for den valgte måneden",
|
||||||
"compare_table_caption": "Fakturasammenligningsresultater sortert etter uoverensstemmelser og advarsler"
|
"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)",
|
"login_as_user_qr": "Log in as user (QR code)",
|
||||||
"mark_as_completed": "Markera som slutförd",
|
"mark_as_completed": "Markera som slutförd",
|
||||||
"no_actions_defined": "Inga åtgärder definierade",
|
"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}",
|
"open_attached_file": "Open attached file #{id}",
|
||||||
"password_changed": "Password changed",
|
"password_changed": "Password changed",
|
||||||
"password_changed_text": "Lösenordet har ändrats.",
|
"password_changed_text": "Lösenordet har ändrats.",
|
||||||
@@ -4392,6 +4416,44 @@
|
|||||||
"title": "Systemstatus",
|
"title": "Systemstatus",
|
||||||
"cards": {
|
"cards": {
|
||||||
"database": "Databas"
|
"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": {
|
"superuser_invoice_distribution": {
|
||||||
@@ -4547,5 +4609,57 @@
|
|||||||
"customer_table_caption": "Kundniväns färdelningsrader för vald månad",
|
"customer_table_caption": "Kundniväns färdelningsrader för vald månad",
|
||||||
"compare_table_caption": "Fakturajämfärelser sorterade efter avvikelser och varningar"
|
"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)",
|
"login_as_user_qr": "Log in as user (QR code)",
|
||||||
"mark_as_completed": "Mark as completed",
|
"mark_as_completed": "Mark as completed",
|
||||||
"no_actions_defined": "No actions defined",
|
"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}",
|
"open_attached_file": "Open attached file #{id}",
|
||||||
"password_changed": "Password changed",
|
"password_changed": "Password changed",
|
||||||
"password_changed_text": "The password has been changed.",
|
"password_changed_text": "The password has been changed.",
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { createPosFixture, mockApi, seedAuthenticatedState } from "./support/net
|
|||||||
|
|
||||||
const POS_PERMISSIONS = [
|
const POS_PERMISSIONS = [
|
||||||
"admin",
|
"admin",
|
||||||
|
"add_customer_attribute",
|
||||||
"department_access_12",
|
"department_access_12",
|
||||||
|
"delete_customer_attribute",
|
||||||
"delete_order",
|
"delete_order",
|
||||||
"edit_order",
|
"edit_order",
|
||||||
"edit_order_items",
|
"edit_order_items",
|
||||||
@@ -2296,14 +2298,19 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
|
|||||||
await expect(page.locator("table")).toBeVisible();
|
await expect(page.locator("table")).toBeVisible();
|
||||||
|
|
||||||
const dropdownRoot = await openLowestVisibleOrderActionDropdown(page);
|
const dropdownRoot = await openLowestVisibleOrderActionDropdown(page);
|
||||||
|
const dropdownMenu = dropdownRoot.locator(".dropdown-menu").first();
|
||||||
const dropdownContent = dropdownRoot.locator(".dropdown-content").first();
|
const dropdownContent = dropdownRoot.locator(".dropdown-content").first();
|
||||||
const flyout = dropdownContent.getByTestId("action-settings-wheel-flyout");
|
const flyout = dropdownContent.getByTestId("action-settings-wheel-flyout");
|
||||||
const sectionsRail = dropdownContent.getByTestId("action-settings-wheel-sections");
|
const sectionsRail = dropdownContent.getByTestId("action-settings-wheel-sections");
|
||||||
const customerSection = dropdownContent.getByTestId("action-settings-wheel-section-customer");
|
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 vehicleSection = dropdownContent.getByTestId("action-settings-wheel-section-vehicle");
|
||||||
const attachmentsSection = dropdownContent.getByTestId("action-settings-wheel-section-attachments");
|
const attachmentsSection = dropdownContent.getByTestId("action-settings-wheel-section-attachments");
|
||||||
|
|
||||||
await expect(flyout).toBeVisible();
|
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 expect(customerSection.locator(".fa-chevron-left")).toBeVisible();
|
||||||
|
|
||||||
await customerSection.hover();
|
await customerSection.hover();
|
||||||
@@ -2324,6 +2331,55 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
|
|||||||
(sectionsRailBox?.x ?? 0) - 4
|
(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();
|
await vehicleSection.hover();
|
||||||
|
|
||||||
const vehicleSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-vehicle");
|
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"
|
"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");
|
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 }) => {
|
test("@smoke saves Shelly relay selections from the integrated workspace", async ({ page }) => {
|
||||||
await mockApi(page, {
|
await mockApi(page, {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||||
|
import { isDesktopProject } from "./support/projects";
|
||||||
|
|
||||||
const json = (body: unknown, status = 200) => ({
|
const json = (body: unknown, status = 200) => ({
|
||||||
status,
|
status,
|
||||||
@@ -7,8 +9,38 @@ const json = (body: unknown, status = 200) => ({
|
|||||||
body: JSON.stringify(body),
|
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.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 = [
|
const lanes = [
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 7,
|
||||||
@@ -28,10 +60,12 @@ test.describe("Superuser department lanes", () => {
|
|||||||
let lastUpdatePayload: Record<string, any> | null = null;
|
let lastUpdatePayload: Record<string, any> | null = null;
|
||||||
|
|
||||||
await seedAuthenticatedState(page, "superuser-department-lanes-token");
|
await seedAuthenticatedState(page, "superuser-department-lanes-token");
|
||||||
|
await suppressVersionCheck(page);
|
||||||
await mockApi(page, {
|
await mockApi(page, {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
permissions: ["superuser", "list_department_lanes", "edit_department_lane"],
|
permissions: ["superuser", "list_department_lanes", "edit_department_lane"],
|
||||||
});
|
});
|
||||||
|
await mockAppShellRequests(page);
|
||||||
|
|
||||||
await page.route(
|
await page.route(
|
||||||
/https?:\/\/(?:api\.truckwash\.io(?::\d+)?)\/department\/selfserve\/machine-types(\?.*)?$/i,
|
/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 page.goto("/superuser/department/lanes", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/superuser\/department\/lanes$/);
|
await expect(page).toHaveURL(/\/superuser\/department\/lanes$/);
|
||||||
await expect(page.locator("tbody tr")).toHaveCount(1, { timeout: 15_000 });
|
const row = page.getByTestId("department-lanes-row-7");
|
||||||
const row = page.locator("tbody tr").first();
|
const relayInField = page.getByTestId("department-lanes-row-detail-relay-in-id-7");
|
||||||
await expect(row).toContainText("OUT-7", { timeout: 15_000 });
|
|
||||||
|
|
||||||
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");
|
const modal = page.locator(".swal2-popup");
|
||||||
await expect(modal).toBeVisible();
|
await expect(modal).toBeVisible();
|
||||||
@@ -108,6 +148,101 @@ test.describe("Superuser department lanes", () => {
|
|||||||
id: 7,
|
id: 7,
|
||||||
relay_in_id: "shelly-plus-01",
|
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 { expect, test } from "@playwright/test";
|
||||||
import { mockApi, primeMockSession } from "./support/network.js";
|
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||||
|
|
||||||
function json(body, status = 200) {
|
function json(body, status = 200) {
|
||||||
return {
|
return {
|
||||||
@@ -11,7 +11,16 @@ function json(body, status = 200) {
|
|||||||
|
|
||||||
async function primeSuperuserSession(page) {
|
async function primeSuperuserSession(page) {
|
||||||
const token = "superuser-system-status-token";
|
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) {
|
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 = {
|
const degradedSnapshot = {
|
||||||
overall_status: "degraded",
|
overall_status: "degraded",
|
||||||
generated_at: "2026-04-08T08:45:00.000Z",
|
generated_at: "2026-04-08T08:45:00.000Z",
|
||||||
@@ -215,24 +277,17 @@ const longTitleSnapshot = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test.describe("Superuser system status smoke", () => {
|
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 ({
|
test("@smoke superuser status dashboard renders Danish translations for degraded infrastructure and recent sessions", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
await bootSuperuser(page);
|
||||||
await installSystemStatusMock(page, degradedSnapshot);
|
await installSystemStatusMock(page, degradedSnapshot);
|
||||||
|
|
||||||
await page.goto("/superuser");
|
await page.goto("/superuser");
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/superuser$/);
|
await expect(page).toHaveURL(/\/superuser$/);
|
||||||
await expect(page.getByTestId("system-status-dashboard")).toBeVisible();
|
await expect(page.getByTestId("system-status-dashboard")).toBeVisible({ timeout: 20_000 });
|
||||||
await expect(page.getByTestId("status-card-database")).toContainText("truckwash");
|
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("Acme Logistics");
|
||||||
await expect(page.locator("body")).toContainText("Kunde 1001");
|
await expect(page.locator("body")).toContainText("Kunde 1001");
|
||||||
await expect(page.locator("body")).toContainText("Bruger");
|
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 }) => {
|
test("@smoke superuser database compatibility page renders shared database status snapshot", async ({ page }) => {
|
||||||
|
await bootSuperuser(page);
|
||||||
await installSystemStatusMock(page, databaseCompatibilitySnapshot);
|
await installSystemStatusMock(page, databaseCompatibilitySnapshot);
|
||||||
|
|
||||||
await page.goto("/superuser/system/database");
|
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 }) => {
|
test("@smoke superuser module cards keep long Danish status reasons below the header row", async ({ page }) => {
|
||||||
|
await bootSuperuser(page);
|
||||||
await installSystemStatusMock(page, longReasonSnapshot);
|
await installSystemStatusMock(page, longReasonSnapshot);
|
||||||
|
|
||||||
await page.goto("/superuser");
|
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 }) => {
|
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 installSystemStatusMock(page, longTitleSnapshot);
|
||||||
|
|
||||||
await page.goto("/superuser");
|
await page.goto("/superuser");
|
||||||
@@ -323,4 +381,118 @@ test.describe("Superuser system status smoke", () => {
|
|||||||
|
|
||||||
expect(overlapWidth * overlapHeight).toBeLessThanOrEqual(1);
|
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;
|
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) {
|
function isWashCertificateProduct(product) {
|
||||||
const productId = Number(product?.id ?? product?.product_id ?? product?.product?.id ?? 0);
|
const productId = Number(product?.id ?? product?.product_id ?? product?.product?.id ?? 0);
|
||||||
if (productId === 41) {
|
if (productId === 41) {
|
||||||
@@ -1953,6 +1990,7 @@ export function createPosFixture(overrides = {}) {
|
|||||||
{ id: 2, customer_number: cardCustomer.customerNumber, attribute: "invoiceWithStripe" },
|
{ id: 2, customer_number: cardCustomer.customerNumber, attribute: "invoiceWithStripe" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
nextCustomerAttributeId: 3,
|
||||||
customerNotesByNumber: {},
|
customerNotesByNumber: {},
|
||||||
products,
|
products,
|
||||||
departmentCategories: [
|
departmentCategories: [
|
||||||
@@ -2580,8 +2618,64 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.endsWith("/customer/attributes") && method === "GET") {
|
if (pathname.endsWith("/customer/attributes") && method === "GET") {
|
||||||
const customerNumber = Number(parsedUrl.searchParams.get("customer_number") || 0);
|
const customerNumber = resolveCustomerNumberFromAttributeTarget(posFixture, {
|
||||||
await route.fulfill(json({ success: true, data: posFixture.customerAttributesByNumber[customerNumber] || [] }));
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3185,6 +3279,7 @@ function ensureEdgeGatewayHardwareFixture(edgeGatewayFixture) {
|
|||||||
if (!edgeGatewayFixture.hardwareWorkspace) {
|
if (!edgeGatewayFixture.hardwareWorkspace) {
|
||||||
edgeGatewayFixture.hardwareWorkspace = {
|
edgeGatewayFixture.hardwareWorkspace = {
|
||||||
nextScannerId: 3,
|
nextScannerId: 3,
|
||||||
|
nextRelayId: 55,
|
||||||
relayOptions: [
|
relayOptions: [
|
||||||
{ id: "ENTRY-8", name: "Roskilde entry (Shelly Plus 1PM)", status_color: "Green" },
|
{ id: "ENTRY-8", name: "Roskilde entry (Shelly Plus 1PM)", status_color: "Green" },
|
||||||
{ id: "EXIT-8", name: "Roskilde exit (Shelly 1)", status_color: "Red" },
|
{ 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: [
|
scanners: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -3410,6 +3551,10 @@ function findEdgeGatewayHardwareLane(hardware, laneId) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findEdgeGatewayHardwareRelay(hardware, relayId) {
|
||||||
|
return (hardware?.relays || []).find((relay) => Number(relay?.id) === Number(relayId)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
function buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departmentId, includeGateways = true) {
|
function buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departmentId, includeGateways = true) {
|
||||||
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
||||||
const department = (hardware?.departments || []).find((entry) => Number(entry.id) === Number(departmentId)) || null;
|
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) =>
|
const gatewayPayloads = gateways.map((gateway) =>
|
||||||
buildHttpEdgeGatewayGateway(edgeGatewayFixture, settleEdgeGatewayWork(edgeGatewayFixture, gateway.id), true)
|
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 lanes = (department.lanes || []).map((lane) => {
|
||||||
const relaySlots = [
|
const relaySlots = [
|
||||||
@@ -3431,12 +3595,25 @@ function buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departm
|
|||||||
["CLEANER", lane.relay_machine_cleaner_id],
|
["CLEANER", lane.relay_machine_cleaner_id],
|
||||||
]
|
]
|
||||||
.filter(([, relayId]) => Boolean(relayId))
|
.filter(([, relayId]) => Boolean(relayId))
|
||||||
.map(([slot, relayId]) => ({
|
.map(([slot, relayId]) => {
|
||||||
slot,
|
registerRelayConsumer(relayId, {
|
||||||
relay_id: relayId,
|
type: "lane",
|
||||||
catalog: relayId ? { relay_id: relayId } : null,
|
id: lane.id,
|
||||||
coverage: buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relayId),
|
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 requiredRelayCount = relaySlots.length;
|
||||||
const boundRelayCount = relaySlots.filter((slot) => slot.coverage.covered).length;
|
const boundRelayCount = relaySlots.filter((slot) => slot.coverage.covered).length;
|
||||||
@@ -3495,6 +3672,15 @@ function buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departm
|
|||||||
const coverage =
|
const coverage =
|
||||||
transportType === "RELAY" && relayId ? buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relayId) : null;
|
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 {
|
return {
|
||||||
id: gate.id,
|
id: gate.id,
|
||||||
department: gate.department,
|
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 laneIndex = Object.fromEntries(lanes.map((lane) => [Number(lane.id), lane]));
|
||||||
const scansByScannerId = {};
|
const scansByScannerId = {};
|
||||||
(hardware.scans || [])
|
(hardware.scans || [])
|
||||||
@@ -3768,6 +3967,7 @@ function buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departm
|
|||||||
lanes,
|
lanes,
|
||||||
self_serve: selfServe,
|
self_serve: selfServe,
|
||||||
gates,
|
gates,
|
||||||
|
relays,
|
||||||
scanners,
|
scanners,
|
||||||
issues,
|
issues,
|
||||||
actions,
|
actions,
|
||||||
@@ -3918,6 +4118,73 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
|
|||||||
return true;
|
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") {
|
if (numberPlateScannersPattern.test(pathname) && method === "GET") {
|
||||||
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
||||||
const rows = cloneJson(hardware?.scanners || []);
|
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
|
import { CUSTOMER_RULE_DEFINITIONS } from "@/features/customer/customerRuleRegistry.js";
|
||||||
import { createTestI18n } from "./helpers/mountWithApp.js";
|
import { createTestI18n } from "./helpers/mountWithApp.js";
|
||||||
|
|
||||||
const getUserIdMock = vi.hoisted(() => vi.fn(() => Promise.resolve({ data: { data: { user_id: 777 } } })));
|
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", () => ({
|
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||||
SessionUser: {
|
SessionUser: {
|
||||||
@@ -20,6 +44,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
|||||||
canAccessAdmin: vi.fn(() => false),
|
canAccessAdmin: vi.fn(() => false),
|
||||||
canAccessSuperUser: vi.fn(() => true),
|
canAccessSuperUser: vi.fn(() => true),
|
||||||
canAccessUser: vi.fn(() => false),
|
canAccessUser: vi.fn(() => false),
|
||||||
|
hasPermission: vi.fn(() => true),
|
||||||
functions: {
|
functions: {
|
||||||
ucFirst: (value) => value,
|
ucFirst: (value) => value,
|
||||||
parseErrorMessage: () => "error",
|
parseErrorMessage: () => "error",
|
||||||
@@ -64,6 +89,12 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
|||||||
forceDisableMachine: vi.fn(() => Promise.resolve()),
|
forceDisableMachine: vi.fn(() => Promise.resolve()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
department_gates: {
|
||||||
|
showCreateObjectForm: vi.fn(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
department_relays: {
|
||||||
|
showCreateObjectForm: vi.fn(() => Promise.resolve()),
|
||||||
|
},
|
||||||
orders: {
|
orders: {
|
||||||
meta: {
|
meta: {
|
||||||
labels: {
|
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 = [
|
const SETTINGS_WHEEL_TRANSLATION_KEYS = [
|
||||||
"admin.pos.settings_wheel.associate_order",
|
"admin.pos.settings_wheel.associate_order",
|
||||||
"admin.pos.settings_wheel.attach_wash_certificate",
|
"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.login_as_user_qr",
|
||||||
"admin.pos.settings_wheel.mark_as_completed",
|
"admin.pos.settings_wheel.mark_as_completed",
|
||||||
"admin.pos.settings_wheel.no_actions_defined",
|
"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_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",
|
||||||
"admin.pos.settings_wheel.password_changed_text",
|
"admin.pos.settings_wheel.password_changed_text",
|
||||||
"admin.pos.settings_wheel.please_enter_password",
|
"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.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_customer",
|
||||||
"admin.pos.settings_wheel.show_qr_code",
|
"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_booking_new_tab",
|
||||||
"admin.pos.settings_wheel.view_customer_new_tab",
|
"admin.pos.settings_wheel.view_customer_new_tab",
|
||||||
"admin.pos.settings_wheel.view_invoice_collection_new_tab",
|
"admin.pos.settings_wheel.view_invoice_collection_new_tab",
|
||||||
@@ -162,6 +222,7 @@ const SETTINGS_WHEEL_TRANSLATION_KEYS = [
|
|||||||
"global.print",
|
"global.print",
|
||||||
"superuser.department_lane.force_disable_machine",
|
"superuser.department_lane.force_disable_machine",
|
||||||
"superuser.department_lane.force_enable_machine",
|
"superuser.department_lane.force_enable_machine",
|
||||||
|
...CUSTOMER_RULE_TRANSLATION_KEYS,
|
||||||
];
|
];
|
||||||
|
|
||||||
const createKeyValueMessages = (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 = () =>
|
const mockMenuGeometry = () =>
|
||||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function getBoundingClientRectMock() {
|
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function getBoundingClientRectMock() {
|
||||||
if (this.classList?.contains("dropdown-trigger")) {
|
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 originalFetch = global.fetch;
|
||||||
const originalCreateObjectURL = global.URL.createObjectURL;
|
const originalCreateObjectURL = global.URL.createObjectURL;
|
||||||
const originalRevokeObjectURL = global.URL.revokeObjectURL;
|
const originalRevokeObjectURL = global.URL.revokeObjectURL;
|
||||||
@@ -279,6 +384,24 @@ const originalMatchMedia = window.matchMedia;
|
|||||||
describe("ActionSettingsWheelButton", () => {
|
describe("ActionSettingsWheelButton", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getUserIdMock.mockClear();
|
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.showChangeInvoiceCollectionForm.mockClear();
|
||||||
SessionUser.objects.orders.functions.fetchAttachments.mockReset();
|
SessionUser.objects.orders.functions.fetchAttachments.mockReset();
|
||||||
SessionUser.objects.orders.functions.fetchAttachments.mockResolvedValue([]);
|
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.downloadAttachment.mockResolvedValue("https://cdn.example.test/attachment");
|
||||||
SessionUser.objects.orders.functions.removeAttachment.mockReset();
|
SessionUser.objects.orders.functions.removeAttachment.mockReset();
|
||||||
SessionUser.objects.orders.functions.removeAttachment.mockResolvedValue({ success: true });
|
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(() =>
|
global.fetch = vi.fn(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -304,7 +431,7 @@ describe("ActionSettingsWheelButton", () => {
|
|||||||
vi.restoreAllMocks();
|
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, {
|
const wrapper = mount(ActionSettingsWheelButton, {
|
||||||
props: {
|
props: {
|
||||||
displayActionsDirectly: true,
|
displayActionsDirectly: true,
|
||||||
@@ -324,18 +451,23 @@ describe("ActionSettingsWheelButton", () => {
|
|||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
expect(getUserIdMock).toHaveBeenCalledTimes(1);
|
expect(getUserIdMock).toHaveBeenCalledTimes(1);
|
||||||
expect(getUserIdMock).toHaveBeenLastCalledWith(123456);
|
expect(getUserIdMock).toHaveBeenLastCalledWith(123456);
|
||||||
|
expect(listCustomerAttributesMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(listCustomerAttributesMock).toHaveBeenLastCalledWith({ customer_number: 123456 });
|
||||||
|
|
||||||
await wrapper.setProps({ icon: "fas fa-ellipsis-v" });
|
await wrapper.setProps({ icon: "fas fa-ellipsis-v" });
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
expect(getUserIdMock).toHaveBeenCalledTimes(1);
|
expect(getUserIdMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(listCustomerAttributesMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await wrapper.setProps({ customer_number: 654321 });
|
await wrapper.setProps({ customer_number: 654321 });
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
expect(getUserIdMock).toHaveBeenCalledTimes(2);
|
expect(getUserIdMock).toHaveBeenCalledTimes(2);
|
||||||
expect(getUserIdMock).toHaveBeenLastCalledWith(654321);
|
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, {
|
mount(ActionSettingsWheelButton, {
|
||||||
props: {
|
props: {
|
||||||
displayActionsDirectly: true,
|
displayActionsDirectly: true,
|
||||||
@@ -354,6 +486,136 @@ describe("ActionSettingsWheelButton", () => {
|
|||||||
|
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
expect(getUserIdMock).not.toHaveBeenCalled();
|
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 () => {
|
it("routes 'change invoice collection' action without opening a new tab", async () => {
|
||||||
@@ -435,7 +697,7 @@ describe("ActionSettingsWheelButton", () => {
|
|||||||
windowOpenSpy.mockRestore();
|
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([
|
SessionUser.objects.orders.functions.fetchAttachments.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 301,
|
id: 301,
|
||||||
@@ -468,6 +730,7 @@ describe("ActionSettingsWheelButton", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const geometrySpy = mockMenuGeometry();
|
const geometrySpy = mockMenuGeometry();
|
||||||
|
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
const wrapper = mountDesktopFlyoutButton();
|
const wrapper = mountDesktopFlyoutButton();
|
||||||
|
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
@@ -490,11 +753,19 @@ describe("ActionSettingsWheelButton", () => {
|
|||||||
|
|
||||||
const previewPanel = wrapper.get('[data-testid="action-settings-wheel-attachment-panel"]');
|
const previewPanel = wrapper.get('[data-testid="action-settings-wheel-attachment-panel"]');
|
||||||
expect(previewPanel.find("iframe").exists()).toBe(true);
|
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-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-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-print-301"]').exists()).toBe(true);
|
||||||
expect(wrapper.find('[data-testid="action-settings-wheel-attachment-action-delete-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 imageRow.trigger("focus");
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
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(wrapper.get('[data-testid="action-settings-wheel-attachment-panel"]').find("img").exists()).toBe(true);
|
||||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
windowOpenSpy.mockRestore();
|
||||||
geometrySpy.mockRestore();
|
geometrySpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -533,6 +805,7 @@ describe("ActionSettingsWheelButton", () => {
|
|||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
await wrapper.get('[data-testid="action-settings-wheel-attachment-row-301"]').trigger("mouseenter");
|
await wrapper.get('[data-testid="action-settings-wheel-attachment-row-301"]').trigger("mouseenter");
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
expect(global.URL.createObjectURL).toHaveBeenCalledTimes(1);
|
expect(global.URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
@@ -553,4 +826,114 @@ describe("ActionSettingsWheelButton", () => {
|
|||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
geometrySpy.mockRestore();
|
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 { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { CUSTOMER_RULE_DEFINITIONS } from "@/features/customer/customerRuleRegistry.js";
|
||||||
import { readJsonFile } from "./helpers/readJsonFile";
|
import { readJsonFile } from "./helpers/readJsonFile";
|
||||||
|
|
||||||
const root = process.cwd();
|
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();
|
// Intentionally empty. The shared Vitest config expects this file to exist.
|
||||||
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ vi.mock("@/components/session/authenticatedRequest.vue", () => ({
|
|||||||
import SystemStatusDashboard from "@/components/displays/superuser/system/SystemStatusDashboard.vue";
|
import SystemStatusDashboard from "@/components/displays/superuser/system/SystemStatusDashboard.vue";
|
||||||
import DatabaseDisplay from "@/components/displays/superuser/system/DatabaseDisplay.vue";
|
import DatabaseDisplay from "@/components/displays/superuser/system/DatabaseDisplay.vue";
|
||||||
import { SuperUserSystemStatusObject } from "@/components/session/token/superUser/systemStatus.vue";
|
import { SuperUserSystemStatusObject } from "@/components/session/token/superUser/systemStatus.vue";
|
||||||
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
|
|
||||||
const root = process.cwd();
|
const root = process.cwd();
|
||||||
const routerSource = readFileSync(join(root, "src/router.js"), "utf8");
|
const routerSource = readFileSync(join(root, "src/router.js"), "utf8");
|
||||||
@@ -119,12 +120,96 @@ const createSnapshot = (overrides = {}) => ({
|
|||||||
...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 () => {
|
const flushRendering = async () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const flushDashboardLoad = async () => {
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
await flushRendering();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const resetStatusStore = () => {
|
const resetStatusStore = () => {
|
||||||
SuperUserSystemStatusObject.snapshot.value = null;
|
SuperUserSystemStatusObject.snapshot.value = null;
|
||||||
SuperUserSystemStatusObject.loading.value = false;
|
SuperUserSystemStatusObject.loading.value = false;
|
||||||
@@ -132,6 +217,11 @@ const resetStatusStore = () => {
|
|||||||
SuperUserSystemStatusObject.lastLoadedAt.value = null;
|
SuperUserSystemStatusObject.lastLoadedAt.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetSessionUser = () => {
|
||||||
|
SessionUser.permissions.value = [];
|
||||||
|
SessionUser.isSubuser.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
describe("superuser system status route contract", () => {
|
describe("superuser system status route contract", () => {
|
||||||
it("keeps /superuser routed to the main dashboard view and mounts the system dashboard", () => {
|
it("keeps /superuser routed to the main dashboard view and mounts the system dashboard", () => {
|
||||||
expect(routerSource).toContain("path: '/superuser'");
|
expect(routerSource).toContain("path: '/superuser'");
|
||||||
@@ -154,6 +244,8 @@ describe("superuser system status dashboard", () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
authenticatedRequestMock.mockReset();
|
authenticatedRequestMock.mockReset();
|
||||||
resetStatusStore();
|
resetStatusStore();
|
||||||
|
resetSessionUser();
|
||||||
|
SessionUser.permissions.value = ["superuser", "user"];
|
||||||
Object.defineProperty(document, "hidden", {
|
Object.defineProperty(document, "hidden", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: false,
|
value: false,
|
||||||
@@ -164,22 +256,20 @@ describe("superuser system status dashboard", () => {
|
|||||||
vi.runOnlyPendingTimers();
|
vi.runOnlyPendingTimers();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
resetStatusStore();
|
resetStatusStore();
|
||||||
|
resetSessionUser();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders infrastructure, modules, warnings, and recent sessions from the shared snapshot", async () => {
|
it("renders infrastructure, modules, warnings, and recent sessions from the shared snapshot", async () => {
|
||||||
authenticatedRequestMock.mockResolvedValue({
|
installDashboardMocks();
|
||||||
data: {
|
|
||||||
data: createSnapshot(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const wrapper = mountWithApp(SystemStatusDashboard, {
|
const wrapper = mountWithApp(SystemStatusDashboard, {
|
||||||
messages: { en: enMessages },
|
messages: { en: enMessages },
|
||||||
});
|
});
|
||||||
|
|
||||||
await flushRendering();
|
await flushDashboardLoad();
|
||||||
|
|
||||||
expect(authenticatedRequestMock).toHaveBeenCalledWith("/superuser/system/status", "GET", {});
|
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.get('[data-testid="status-card-database"]').text()).toContain("truckwash");
|
||||||
expect(wrapper.text()).toContain("Acme Logistics");
|
expect(wrapper.text()).toContain("Acme Logistics");
|
||||||
expect(wrapper.text()).toContain("/superuser/vehicles");
|
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 () => {
|
it("shows stale state when the snapshot ages past the polling threshold", async () => {
|
||||||
authenticatedRequestMock.mockResolvedValue({
|
installDashboardMocks({
|
||||||
data: {
|
snapshot: createSnapshot({
|
||||||
data: createSnapshot({
|
refresh_after_seconds: 1,
|
||||||
refresh_after_seconds: 1,
|
warnings: [],
|
||||||
warnings: [],
|
}),
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const wrapper = mountWithApp(SystemStatusDashboard, {
|
const wrapper = mountWithApp(SystemStatusDashboard, {
|
||||||
messages: { en: enMessages },
|
messages: { en: enMessages },
|
||||||
});
|
});
|
||||||
|
|
||||||
await flushRendering();
|
await flushDashboardLoad();
|
||||||
SuperUserSystemStatusObject.lastLoadedAt.value = new Date(Date.now() - 3000);
|
SuperUserSystemStatusObject.lastLoadedAt.value = new Date(Date.now() - 3000);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
@@ -212,6 +300,184 @@ describe("superuser system status dashboard", () => {
|
|||||||
wrapper.unmount();
|
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 () => {
|
it("renders the compatibility database panel from the shared snapshot and supports forced refresh", async () => {
|
||||||
authenticatedRequestMock.mockResolvedValue({
|
authenticatedRequestMock.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
42380
|
||||||
@@ -13,7 +13,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["tests/unit/**/*.spec.js"],
|
include: ["tests/unit/**/*.spec.js"],
|
||||||
setupFiles: ["tests/unit/setup.js"],
|
|
||||||
coverage: {
|
coverage: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user