diff --git a/src/components/displays/buttons/ActionSettingsWheelButton.vue b/src/components/displays/buttons/ActionSettingsWheelButton.vue
index 6126a2aa..bd6592b0 100644
--- a/src/components/displays/buttons/ActionSettingsWheelButton.vue
+++ b/src/components/displays/buttons/ActionSettingsWheelButton.vue
@@ -3,11 +3,21 @@ import { onMounted, onBeforeUnmount, computed, nextTick, watch } from "vue";
import { useSlots } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
+import ActionSettingsWheelToggleItem from "@/components/displays/buttons/ActionSettingsWheelToggleItem.vue";
import Swal from "sweetalert2";
import { ref } from "vue";
import CustomerModal from "@/components/displays/modals/CustomerModal.vue";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
import { useI18n } from "vue-i18n";
+import { API_URL } from "@/config.js";
+import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
+import {
+ buildCustomerAttributeTargetPayload,
+ createCustomerAttribute,
+ deleteCustomerAttribute,
+ extractCustomerAttributesData,
+ listCustomerAttributes,
+} from "@/features/customer/customerAttributeService.js";
const { t } = useI18n();
const slots = useSlots();
@@ -89,6 +99,10 @@ const props = defineProps({
const customerModalVisible = ref(false);
const userIdFromCustomerNumber = ref(null);
const userLookupRequestId = ref(0);
+const isResolvingUserId = ref(false);
+const customerRuleAttributes = ref([]);
+const customerRuleAttributesLoaded = ref(false);
+const customerRuleAttributesLoading = ref(false);
const dropdownRoot = ref(null);
const dropdownContent = ref(null);
const isDropdownOpen = ref(false);
@@ -284,17 +298,20 @@ const onViewportChange = () => {
const resolveUserId = async () => {
if (props.user_id) {
+ isResolvingUserId.value = false;
userIdFromCustomerNumber.value = props.user_id;
return;
}
if (!props.customer_number) {
+ isResolvingUserId.value = false;
userIdFromCustomerNumber.value = null;
return;
}
const currentLookupId = userLookupRequestId.value + 1;
userLookupRequestId.value = currentLookupId;
+ isResolvingUserId.value = true;
try {
const response = await SessionUser.adminUser.customers.fromCustomerNumber.getUserId(props.customer_number);
@@ -310,34 +327,67 @@ const resolveUserId = async () => {
userIdFromCustomerNumber.value = null;
console.error(error);
+ } finally {
+ if (currentLookupId === userLookupRequestId.value) {
+ isResolvingUserId.value = false;
+ }
}
};
-watch(
- () => [props.user_id, props.customer_number],
- () => {
- void resolveUserId();
- },
- { immediate: true }
-);
+const resetCustomerRuleState = () => {
+ customerRuleAttributes.value = [];
+ customerRuleAttributesLoaded.value = false;
+ customerRuleAttributesLoading.value = false;
+};
-watch(isDropdownOpen, async (isOpen) => {
- if (!isOpen) {
- isDesktopFlyoutLayout.value = false;
- activeDesktopFlyoutSectionKey.value = null;
- clearAttachmentPreviewState();
- resetDropdownLayout();
+const loadCustomerRuleAttributes = async ({ force = false } = {}) => {
+ if (!canViewCustomerRules.value || !customerAttributeTarget.value) {
+ return customerRuleAttributes.value;
+ }
+
+ if (!force && (customerRuleAttributesLoaded.value || customerRuleAttributesLoading.value)) {
+ return customerRuleAttributes.value;
+ }
+
+ customerRuleAttributesLoading.value = true;
+
+ try {
+ const response = await listCustomerAttributes(customerAttributeTarget.value);
+ customerRuleAttributes.value = extractCustomerAttributesData(response);
+ customerRuleAttributesLoaded.value = true;
+ } catch (error) {
+ console.warn("Unable to load customer attributes for action wheel", error);
+ } finally {
+ customerRuleAttributesLoading.value = false;
+ }
+
+ return customerRuleAttributes.value;
+};
+
+const toggleCustomerRuleAttribute = async (attribute, enabled) => {
+ if (!customerAttributeTarget.value || !attribute) {
return;
}
- await nextTick();
+ if (enabled) {
+ await createCustomerAttribute(customerAttributeTarget.value, attribute);
- if (dropdownContent.value) {
- dropdownContent.value.scrollTop = 0;
+ if (!activeCustomerRuleAttributes.value.has(attribute)) {
+ customerRuleAttributes.value = [
+ ...customerRuleAttributes.value,
+ {
+ attribute,
+ ...customerAttributeTarget.value,
+ },
+ ];
+ }
+
+ return;
}
- void updateDropdownLayout();
-});
+ await deleteCustomerAttribute(customerAttributeTarget.value, attribute);
+ customerRuleAttributes.value = customerRuleAttributes.value.filter((entry) => entry?.attribute !== attribute);
+};
const showSetCustomerPassword = (userId) => {
Swal.fire({
@@ -398,6 +448,132 @@ const redirectDepartmentOrderPage = async (orderId, newTab = false) => {
};
const hasUser = computed(() => Boolean(props.user_id || props.customer_number || userIdFromCustomerNumber.value));
+const customerAttributeTarget = computed(() => {
+ if (!hasUser.value) {
+ return null;
+ }
+
+ const payload = buildCustomerAttributeTargetPayload({
+ userId: props.user_id,
+ customerNumber: props.customer_number,
+ });
+
+ return Object.keys(payload).length > 0 ? payload : null;
+});
+const canViewCustomerRules = computed(
+ () => hasUser.value && SessionUser.canAccessSuperUser() && SessionUser.hasPermission("list_customer_attributes")
+);
+const customerRuleDefinitions = computed(() => getCustomerRuleDefinitions());
+const activeCustomerRuleAttributes = computed(() => {
+ const enabledAttributes = new Set();
+
+ customerRuleAttributes.value.forEach((entry) => {
+ const attributeKey = String(entry?.attribute || "").trim();
+ if (attributeKey) {
+ enabledAttributes.add(attributeKey);
+ }
+ });
+
+ return enabledAttributes;
+});
+const customerRuleItems = computed(() =>
+ customerRuleDefinitions.value.map((rule) => {
+ const isEnabled = activeCustomerRuleAttributes.value.has(rule.attribute);
+ const requiredPermission = isEnabled ? rule.deletePermission : rule.addPermission;
+
+ return {
+ attribute: rule.attribute,
+ description: t(rule.descriptionKey),
+ disabled: !SessionUser.hasPermission(requiredPermission),
+ key: `customer-rule-${rule.attribute}`,
+ label: t(rule.labelKey),
+ testId: `action-settings-wheel-toggle-customer-rule-${rule.attribute}`,
+ type: "toggle",
+ value: isEnabled,
+ };
+ })
+);
+const customerShortcutItems = computed(() => {
+ if (!SessionUser.canAccessSuperUser() || !userIdFromCustomerNumber.value) {
+ return [];
+ }
+
+ const userId = userIdFromCustomerNumber.value;
+ return [
+ {
+ key: "customer-shortcut-overview",
+ icon: "fas fa-user",
+ label: t("admin.pos.settings_wheel.shortcut_overview"),
+ clickAction: () => SessionUser.functions.redirectTo.superUser(`/users/${userId}`, true),
+ },
+ {
+ key: "customer-shortcut-orders",
+ icon: "fas fa-file-alt",
+ label: t("admin.pos.settings_wheel.shortcut_orders"),
+ clickAction: () => SessionUser.functions.redirectTo.superUser(`/users/${userId}/orders`, true),
+ },
+ {
+ key: "customer-shortcut-pricing",
+ icon: "fas fa-tags",
+ label: t("admin.pos.settings_wheel.shortcut_pricing"),
+ clickAction: () => SessionUser.functions.redirectTo.superUser(`/users/${userId}/pricing`, true),
+ },
+ {
+ key: "customer-shortcut-other",
+ icon: "fas fa-sliders-h",
+ label: t("admin.pos.settings_wheel.shortcut_other"),
+ clickAction: () => SessionUser.functions.redirectTo.superUser(`/users/${userId}/other`, true),
+ },
+ {
+ key: "customer-shortcut-vehicles",
+ icon: "fas fa-car",
+ label: t("admin.pos.settings_wheel.shortcut_vehicles"),
+ clickAction: () => SessionUser.functions.redirectTo.superUser(`/users/${userId}/vehicles`, true),
+ },
+ ];
+});
+
+watch(
+ () => [props.user_id, props.customer_number],
+ () => {
+ resetCustomerRuleState();
+ void resolveUserId();
+
+ if (props.displayActionsDirectly || isDropdownOpen.value) {
+ void loadCustomerRuleAttributes({ force: true });
+ }
+ },
+ { immediate: true }
+);
+
+watch(
+ () => props.displayActionsDirectly,
+ (displayActionsDirectly) => {
+ if (displayActionsDirectly) {
+ void loadCustomerRuleAttributes();
+ }
+ },
+ { immediate: true }
+);
+
+watch(isDropdownOpen, async (isOpen) => {
+ if (!isOpen) {
+ isDesktopFlyoutLayout.value = false;
+ activeDesktopFlyoutSectionKey.value = null;
+ clearAttachmentPreviewState();
+ resetDropdownLayout();
+ return;
+ }
+
+ await nextTick();
+
+ if (dropdownContent.value) {
+ dropdownContent.value.scrollTop = 0;
+ }
+
+ void loadCustomerRuleAttributes();
+ void updateDropdownLayout();
+});
const attachmentsFromOrder = ref([]);
const attachmentsFromOrderError = ref(null);
@@ -467,6 +643,46 @@ const getAttachmentPreviewKind = (attachment) => {
return "none";
};
+const getAttachmentPreviewPlaceholderIcon = (attachment) => {
+ const previewKind = getAttachmentPreviewKind(attachment);
+
+ if (previewKind === "image") {
+ return "fas fa-image";
+ }
+
+ if (previewKind === "document" || previewKind === "office") {
+ return "fas fa-file-alt";
+ }
+
+ if (previewKind === "link") {
+ return "fas fa-link";
+ }
+
+ if (previewKind === "text") {
+ return "fas fa-align-left";
+ }
+
+ return "fas fa-paperclip";
+};
+
+const getAttachmentPreviewPlaceholderLabel = (attachment) => {
+ const extension = getAttachmentExtension(attachment).replace(".", "").toUpperCase();
+ if (extension) {
+ return extension;
+ }
+
+ const previewKind = getAttachmentPreviewKind(attachment);
+ if (previewKind === "link") {
+ return "LINK";
+ }
+
+ if (previewKind === "text") {
+ return "TEXT";
+ }
+
+ return "FILE";
+};
+
const activeAttachment = computed(() => {
return attachmentsFromOrder.value.find((attachment) => attachment.id === activeAttachmentId.value) || null;
});
@@ -491,18 +707,69 @@ const hasCachedPreviewSource = (attachmentId) => {
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
};
+const buildAttachmentPreviewHeaders = () => {
+ const headers = {};
+ const token = window.localStorage.getItem("token");
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+
+ const isSubuser = window.localStorage.getItem("is_subuser") === "true";
+ const selectedCustomerNumber = window.localStorage.getItem("selected_customer_number");
+ if (isSubuser && selectedCustomerNumber) {
+ headers["X-Customer-Number"] = selectedCustomerNumber;
+ }
+
+ return headers;
+};
+
+const shouldRetryPreviewRequestWithAuth = (downloadLink) => {
+ if (!downloadLink) {
+ return false;
+ }
+
+ try {
+ const previewUrl = new URL(downloadLink, window.location.origin);
+ const apiOrigin = new URL(API_URL, window.location.origin).origin;
+ return previewUrl.origin === apiOrigin || previewUrl.origin === window.location.origin;
+ } catch {
+ return false;
+ }
+};
+
+const fetchAttachmentPreviewBlob = async (downloadLink, withAuthHeaders = false) => {
+ const requestConfig = {
+ method: "GET",
+ };
+
+ if (withAuthHeaders) {
+ requestConfig.headers = buildAttachmentPreviewHeaders();
+ }
+
+ const response = await fetch(downloadLink, requestConfig);
+ if (!response.ok) {
+ return null;
+ }
+
+ const fileBlob = await response.blob();
+ if (!fileBlob || fileBlob.size === 0) {
+ return null;
+ }
+
+ return fileBlob;
+};
+
const createEmbeddablePreviewUrl = async (downloadLink) => {
if (!downloadLink) {
return null;
}
try {
- const response = await fetch(downloadLink, { method: "GET" });
- if (!response.ok) {
- return null;
+ let fileBlob = await fetchAttachmentPreviewBlob(downloadLink, false);
+ if (!fileBlob && shouldRetryPreviewRequestWithAuth(downloadLink)) {
+ fileBlob = await fetchAttachmentPreviewBlob(downloadLink, true);
}
- const fileBlob = await response.blob();
if (!fileBlob || fileBlob.size === 0) {
return null;
}
@@ -688,20 +955,15 @@ const printAttachment = async (attachment) => {
}
const printableSource = (await ensurePreviewSource(attachment)) || downloadLink;
- const printWindow = window.open("", "_blank", "noopener,noreferrer,width=960,height=720");
+ if (previewKind === "image") {
+ const printWindow = window.open("", "_blank", "noopener,noreferrer,width=960,height=720");
+ if (!printWindow) {
+ openAttachmentInNewTab(printableSource);
+ return;
+ }
- if (!printWindow) {
- openAttachmentInNewTab(printableSource);
- return;
- }
-
- const escapedTitle = getAttachmentLabel(attachment).replace(/"/g, """);
- const contentMarkup =
- previewKind === "image"
- ? `
`
- : ``;
-
- printWindow.document.write(`
+ const escapedTitle = getAttachmentLabel(attachment).replace(/"/g, """);
+ printWindow.document.write(`
${escapedTitle}
@@ -716,25 +978,61 @@ const printAttachment = async (attachment) => {
align-items: center;
justify-content: center;
}
- iframe {
+ img {
display: block;
+ max-width: 100%;
+ max-height: 100vh;
+ object-fit: contain;
}
- ${contentMarkup}
+
+
+
`);
- printWindow.document.close();
+ printWindow.document.close();
- printWindow.addEventListener(
- "load",
- () => {
- printWindow.focus();
- window.setTimeout(() => {
- printWindow.print();
- }, 150);
- },
- { once: true }
- );
+ const printableImage = printWindow.document.getElementById("attachment-print-image");
+ if (!printableImage) {
+ openAttachmentInNewTab(printableSource);
+ return;
+ }
+
+ printableImage.addEventListener(
+ "load",
+ () => {
+ printWindow.focus();
+ window.setTimeout(() => {
+ printWindow.print();
+ }, 150);
+ },
+ { once: true }
+ );
+ printableImage.addEventListener(
+ "error",
+ () => {
+ openAttachmentInNewTab(printableSource);
+ },
+ { once: true }
+ );
+ return;
+ }
+
+ const printWindow = window.open(printableSource, "_blank", "noopener,noreferrer");
+ if (!printWindow) {
+ openAttachmentInNewTab(printableSource);
+ return;
+ }
+
+ const triggerPrint = () => {
+ printWindow.focus();
+ window.setTimeout(() => {
+ printWindow.print();
+ }, 250);
+ };
+
+ printWindow.addEventListener("load", triggerPrint, { once: true });
+ window.setTimeout(triggerPrint, 900);
} catch (error) {
console.warn("Unable to print attachment", attachment?.id, error);
Swal.fire({
@@ -838,350 +1136,21 @@ const onClickShowImpersonationQRCode = async (userId) => {
onShowImpersonationQRCode(response.value, link);
};
-const defaultActions = computed(() => {
- return [
- /**
- * Example action
- {
- icon: 'fas fa-eye',
- label: 'View Order',
- clickAction: () => {
- console.warn('View Order clicked');
- },
- showFunction: () => {
- return true; // Logic to determine if action should be shown
- },
- disabled: false,
- template: 'success' // Optional: Set the template to 'success' or 'danger'
- isLabel: false // Optional: Set to true to render the label as a button
- }
- */
- /** Bookings actions */
- {
- label: t("admin.pos.settings_wheel.booking"),
- isLabel: true,
- showFunction: () => {
- return !!props.order_booking_id;
- },
- },
- {
- icon: "fas fa-check-circle",
- label: t("admin.pos.settings_wheel.mark_as_completed"),
- template: "success",
- clickAction: () => {
- return SessionUser.objects.order_bookings.functions.showCompleteConfirmationModal(
- props.order_booking_id,
- () => {
- props.refreshFunction();
- }
- );
- },
- showFunction: () => {
- return !!props.order_booking_id && SessionUser.canAccessAdmin() && !props.order_id;
- },
- disabled: false,
- },
- {
- icon: "fas fa-external-link-alt",
- label: t("admin.pos.settings_wheel.view_booking_new_tab"),
- clickAction: () => {
- return SessionUser.functions.redirectTo.department(
- props.department_id,
- "modules/bookings/order/" + props.order_booking_id,
- true
- );
- },
- showFunction: () => {
- return !!props.order_booking_id;
- },
- },
- {
- icon: "fas fa-edit",
- label: t("admin.pos.settings_wheel.change_association"),
- clickAction: () => {
- return SessionUser.objects.order_bookings.showEditObjectFieldForm(
- props.order_booking_id,
- "order_id",
- props.order_id,
- () => {
- props.refreshFunction();
- },
- {
- filters: {
- ...(props.customer_number ? { customer_id: props.customer_number } : {}),
- ...(props.department_id ? { department_id: props.department_id } : {}),
- },
- pagination: {
- page: 1,
- limit: 100,
- },
- }
- );
- },
- showFunction: () => {
- return !!props.order_booking_id && SessionUser.canAccessAdmin();
- },
- disabled: false,
- },
- {
- icon: "fas fa-trash-alt",
- label: t("admin.pos.settings_wheel.delete_booking"),
- clickAction: () => {
- return SessionUser.objects.order_bookings.functions.showDeleteConfirmationModal(props.order_booking_id, () => {
- props.refreshFunction();
- });
- },
- showFunction: () => {
- return !!props.order_booking_id && SessionUser.canAccessAdmin() && !props.order_id;
- },
- disabled: false,
- template: "danger",
- },
- /** Department lane actions */
- {
- label: SessionUser.objects.department_lanes.meta.labels.single,
- isLabel: true,
- showFunction: () => {
- return !!props.department_lane_id;
- },
- },
- {
- icon: `fas fa-external-link-alt`,
- label: t("admin.pos.settings_wheel.view_lane_new_tab"),
- clickAction: () => {
- return SessionUser.functions.redirectTo.superUser(
- "/department/lanes/" + props.department_lane_id,
- !SessionUser.functions.device.isMobile()
- );
- },
- showFunction: () => {
- return !!props.department_lane_id && SessionUser.canAccessSuperUser();
- },
- },
- {
- icon: "fas fa-play",
- label: t("superuser.department_lane.force_enable_machine"),
- template: "success",
- clickAction: () => {
- return SessionUser.objects.department_lanes.functions.forceEnableMachine(props.department_lane_id);
- },
- showFunction: () => {
- return !!props.department_lane_id && SessionUser.canAccessSuperUser();
- },
- },
- {
- icon: "fas fa-stop",
- label: t("superuser.department_lane.force_disable_machine"),
- template: "danger",
- clickAction: () => {
- return SessionUser.objects.department_lanes.functions.forceDisableMachine(props.department_lane_id);
- },
- showFunction: () => {
- return !!props.department_lane_id && SessionUser.canAccessSuperUser();
- },
- },
- /** Orders actions */
- {
- label: SessionUser.functions.ucFirst(SessionUser.objects.orders.meta.labels.single),
- isLabel: true,
- showFunction: () => {
- return !!props.order_id;
- },
- },
- {
- icon: "fas fa-external-link-alt",
- label: t("admin.pos.settings_wheel.view_order_new_tab"),
- clickAction: async () => {
- return redirectDepartmentOrderPage(props.order_id, true);
- },
- showFunction: () => {
- return !!props.order_id && (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser());
- },
- },
- {
- icon: "fas fa-paperclip",
- label: t("admin.pos.settings_wheel.attach_wash_certificate"),
- clickAction: () => {
- return SessionUser.objects.orders.functions.showAttachWashCertificateForm(props.order_id, () => {
- props.refreshFunction();
- });
- },
- showFunction: () => {
- return !!props.order_id && (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser());
- },
- disabled: false,
- },
- {
- icon: "fas fa-user-edit",
- label: t("admin.pos.settings_wheel.change_customer"),
- clickAction: () => {
- return SessionUser.objects.orders.functions.showChangeCustomerForm(props.order_id);
- },
- showFunction: () => {
- return !!props.order_id && SessionUser.canAccessSuperUser();
- },
- disabled: false,
- },
- {
- icon: "fas fa-file-invoice-dollar",
- label: t("admin.pos.settings_wheel.change_invoice_collection"),
- clickAction: () => {
- return SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm(
- props.order_id,
- props.refreshFunction
- );
- },
- showFunction: () => {
- return !!props.order_id && SessionUser.canAccessSuperUser();
- },
- disabled: false,
- },
- {
- icon: "fas fa-download",
- label: t("admin.pos.settings_wheel.download_invoice"),
- clickAction: () => {
- return SessionUser.objects.collectedOrderInvoices.functions.download(props.invoice_collection_id);
- },
- showFunction: () => {
- return true;
- },
- disabled: false,
- },
- {
- icon: "fas fa-trash-alt",
- label: t("admin.pos.settings_wheel.delete_order"),
- clickAction: () => {
- return SessionUser.objects.orders.functions.showDeleteConfirmationModal(props.order_id, () => {
- emitDeleted();
- });
- },
- showFunction: () => {
- return !!props.order_id && (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser());
- },
- disabled: false,
- template: "danger",
- },
- {
- icon: "fas fa-file-invoice-dollar",
- label: t("admin.pos.settings_wheel.view_invoice_collection_new_tab"),
- clickAction: () => {
- return redirectSuperUserInvoiceCollectionPage(props.invoice_collection_id);
- },
- showFunction: () => {
- return !!props.invoice_collection_id && SessionUser.canAccessSuperUser();
- },
- },
- /** Vehicle actions */
- {
- label:
- props.reg_1 && props.reg_2
- ? `${SessionUser.objects.vehicles.meta.labels.multiple}`
- : `${SessionUser.objects.vehicles.meta.labels.single}`,
- isLabel: true,
- showFunction: () => {
- return (!!props.reg_1 || !!props.reg_2) && SessionUser.canAccessSuperUser();
- },
- },
- {
- icon: "fas fa-car",
- label: t("admin.pos.settings_wheel.view_vehicle_new_tab", { reg: props.reg_1 ? props.reg_1 : "-" }),
- clickAction: () => {
- return SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_1, true);
- },
- showFunction: () => {
- return !!props.reg_1 && SessionUser.canAccessSuperUser();
- },
- },
- {
- icon: "fas fa-car",
- label: t("admin.pos.settings_wheel.view_vehicle_new_tab", { reg: props.reg_2 ? props.reg_2 : "-" }),
- clickAction: () => {
- return SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_2, true);
- },
- showFunction: () => {
- return !!props.reg_2 && SessionUser.canAccessSuperUser();
- },
- },
- /** User actions */
- {
- label: SessionUser.objects.global.language.customer,
- isLabel: true,
- showFunction: () => {
- return hasUser.value && SessionUser.canAccessSuperUser();
- },
- },
- {
- icon: "fas fa-user-edit",
- label: t("admin.pos.settings_wheel.view_customer_new_tab"),
- clickAction: () => {
- return SessionUser.functions.redirectTo.superUser("/users/" + userIdFromCustomerNumber.value, true);
- },
- showFunction: () => {
- return hasUser.value && SessionUser.canAccessSuperUser() && userIdFromCustomerNumber.value;
- },
- },
- {
- icon: "fas fa-user-shield",
- label: t("admin.pos.settings_wheel.login_as_customer"),
- clickAction: () => {
- return SessionUser.superUser.intimidate.intimidateUser(userIdFromCustomerNumber.value);
- },
- showFunction: () => {
- return hasUser.value && SessionUser.canAccessSuperUser();
- },
- },
- {
- icon: "fas fa-qrcode",
- label: t("admin.pos.settings_wheel.show_qr_code"),
- clickAction: () => {
- return onClickShowImpersonationQRCode(userIdFromCustomerNumber.value);
- },
- showFunction: () => {
- return hasUser.value && SessionUser.canAccessSuperUser();
- },
- },
- {
- icon: "fas fa-key",
- label: t("admin.pos.settings_wheel.change_password"),
- clickAction: () => {
- return showSetCustomerPassword(userIdFromCustomerNumber.value);
- },
- showFunction: () => {
- return hasUser.value && SessionUser.canAccessSuperUser();
- },
- },
- {
- icon: "fas fa-user",
- label: t("admin.pos.settings_wheel.show_customer"),
- clickAction: () => {
- customerModalVisible.value = true;
- },
- showFunction: () => {
- return hasUser.value && SessionUser.canAccessSuperUser();
- },
- },
- /** Subuser grant actions */
- {
- icon: "fas fa-edit",
- label: t("admin.pos.settings_wheel.edit_permissions"),
- clickAction: () => {
- return SessionUser.objects.subuser_grants.functions.showPermissionEditForm(props.subuserGrant, () => {
- props.refreshFunction();
- });
- },
- showFunction: () => {
- return !!props.subuserGrant && SessionUser.canAccessUser();
- },
- },
- ];
-});
-
const hasCustomActionsSlot = computed(() => Boolean(slots.actions));
const buildMenuAction = (key, config) => ({
key,
disabled: false,
template: "default",
+ type: "action",
+ ...config,
+});
+
+const buildMenuToggleAction = (key, config) => ({
+ key,
+ disabled: false,
+ type: "toggle",
+ value: false,
...config,
});
@@ -1198,6 +1167,8 @@ const buildMenuSection = (key, label, items) => {
};
};
+const isToggleMenuItem = (item) => item?.type === "toggle";
+
const downloadOrderAttachment = (attachment) =>
downloadAttachment(attachment).catch(() => {
Swal.fire({
@@ -1207,6 +1178,39 @@ const downloadOrderAttachment = (attachment) =>
});
});
+const openDepartmentWorkspaceTab = (tabId) =>
+ SessionUser.functions.redirectTo.superUser(
+ `/departments/${props.department_id}/gateways?tab=${encodeURIComponent(String(tabId))}`,
+ true
+ );
+
+const openPrimaryGatewayPage = async () => {
+ if (!props.department_id) {
+ SessionUser.functions.redirectTo.superUser("/configuration/edgegateway", true);
+ return;
+ }
+
+ try {
+ const response = await SessionUser.request(
+ `/modules/edge-gateways/workspace/departments/${props.department_id}`,
+ "GET"
+ );
+ const primaryGatewayId = response?.data?.data?.summary?.primary_gateway?.id ?? null;
+
+ if (primaryGatewayId) {
+ SessionUser.functions.redirectTo.superUser(
+ `/configuration/edgegateway/${encodeURIComponent(String(primaryGatewayId))}/overview`,
+ true
+ );
+ return;
+ }
+ } catch (error) {
+ console.warn("Unable to resolve primary gateway from department workspace", error);
+ }
+
+ SessionUser.functions.redirectTo.superUser("/configuration/edgegateway", true);
+};
+
const flatBuiltInMenuSections = computed(() => {
const sections = [];
@@ -1409,47 +1413,238 @@ const flatBuiltInMenuSections = computed(() => {
}
}
+ if (props.department_lane_id && props.department_id) {
+ const selfServeStudioSection = buildMenuSection(
+ "department-self-serve-studio",
+ t("admin.pos.settings_wheel.self_serve_studio_section"),
+ [
+ (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()) && props.department_id
+ ? buildMenuAction("department-self-serve-studio-open", {
+ icon: "fas fa-external-link-alt",
+ label: t("admin.pos.settings_wheel.open_studio"),
+ clickAction: () =>
+ SessionUser.functions.redirectTo.department(
+ props.department_id,
+ "modules/self-serve/studio",
+ !SessionUser.functions.device.isMobile()
+ ),
+ })
+ : null,
+ SessionUser.canAccessSuperUser()
+ ? buildMenuAction("department-self-serve-studio-open-legacy", {
+ icon: "fas fa-external-link-alt",
+ label: t("admin.pos.settings_wheel.open_legacy_self_serve"),
+ clickAction: () => SessionUser.functions.redirectTo.superUser("/selfserve", true),
+ })
+ : null,
+ SessionUser.canAccessSuperUser()
+ ? buildMenuAction("department-self-serve-studio-open-workspace", {
+ icon: "fas fa-microchip",
+ label: t("admin.pos.settings_wheel.open_hardware_workspace_lanes"),
+ clickAction: () => openDepartmentWorkspaceTab("lanes"),
+ })
+ : null,
+ ]
+ );
+
+ if (selfServeStudioSection) {
+ sections.push(selfServeStudioSection);
+ }
+
+ const gatesSection = buildMenuSection("department-gates", t("admin.pos.settings_wheel.gates_section"), [
+ SessionUser.canAccessSuperUser()
+ ? buildMenuAction("department-gates-open-tab", {
+ icon: "fas fa-door-open",
+ label: t("admin.pos.settings_wheel.open_gates_tab"),
+ clickAction: () => openDepartmentWorkspaceTab("gates"),
+ })
+ : null,
+ SessionUser.canAccessSuperUser()
+ ? buildMenuAction("department-gates-open-legacy", {
+ icon: "fas fa-external-link-alt",
+ label: t("admin.pos.settings_wheel.open_legacy_gates"),
+ clickAction: () => SessionUser.functions.redirectTo.superUser("/department/gates", true),
+ })
+ : null,
+ SessionUser.canAccessSuperUser()
+ ? buildMenuAction("department-gates-create", {
+ icon: "fas fa-plus",
+ label: t("admin.pos.settings_wheel.add_gate"),
+ clickAction: () =>
+ SessionUser.objects.department_gates.showCreateObjectForm(() => {
+ props.refreshFunction();
+ }, { department: props.department_id }),
+ })
+ : null,
+ ]);
+
+ if (gatesSection) {
+ sections.push(gatesSection);
+ }
+
+ const relaysSection = buildMenuSection("department-relays", t("admin.pos.settings_wheel.relays_section"), [
+ SessionUser.canAccessSuperUser()
+ ? buildMenuAction("department-relays-open-tab", {
+ icon: "fas fa-bolt",
+ label: t("admin.pos.settings_wheel.open_relays_tab"),
+ clickAction: () => openDepartmentWorkspaceTab("relays"),
+ })
+ : null,
+ SessionUser.canAccessSuperUser()
+ ? buildMenuAction("department-relays-open-legacy", {
+ icon: "fas fa-external-link-alt",
+ label: t("admin.pos.settings_wheel.open_legacy_relays"),
+ clickAction: () => SessionUser.functions.redirectTo.superUser("/department/relays", true),
+ })
+ : null,
+ SessionUser.canAccessSuperUser()
+ ? buildMenuAction("department-relays-create", {
+ icon: "fas fa-plus",
+ label: t("admin.pos.settings_wheel.add_relay"),
+ clickAction: () =>
+ SessionUser.objects.department_relays.showCreateObjectForm(() => {
+ props.refreshFunction();
+ }, { department: props.department_id }),
+ })
+ : null,
+ ]);
+
+ if (relaysSection) {
+ sections.push(relaysSection);
+ }
+
+ const gatewaysSection = buildMenuSection(
+ "department-gateways",
+ t("admin.pos.settings_wheel.gateways_section"),
+ [
+ SessionUser.canAccessSuperUser()
+ ? buildMenuAction("department-gateways-open-tab", {
+ icon: "fas fa-network-wired",
+ label: t("admin.pos.settings_wheel.open_gateways_tab"),
+ clickAction: () => openDepartmentWorkspaceTab("gateways"),
+ })
+ : null,
+ SessionUser.canAccessSuperUser()
+ ? buildMenuAction("department-gateways-open-fleet", {
+ icon: "fas fa-sitemap",
+ label: t("admin.pos.settings_wheel.open_fleet_landing"),
+ clickAction: () => SessionUser.functions.redirectTo.superUser("/configuration/edgegateway", true),
+ })
+ : null,
+ SessionUser.canAccessSuperUser()
+ ? buildMenuAction("department-gateways-open-primary", {
+ icon: "fas fa-star",
+ label: t("admin.pos.settings_wheel.open_primary_gateway"),
+ clickAction: () => openPrimaryGatewayPage(),
+ })
+ : null,
+ ]
+ );
+
+ if (gatewaysSection) {
+ sections.push(gatewaysSection);
+ }
+ }
+
if (hasUser.value && SessionUser.canAccessSuperUser()) {
- const customerSection = buildMenuSection("customer", SessionUser.objects.global.language.customer, [
- userIdFromCustomerNumber.value
- ? buildMenuAction("customer-view", {
+ const customerSectionItems = userIdFromCustomerNumber.value
+ ? [
+ buildMenuAction("customer-view", {
icon: "fas fa-user-edit",
label: t("admin.pos.settings_wheel.view_customer_new_tab"),
clickAction: () =>
SessionUser.functions.redirectTo.superUser("/users/" + userIdFromCustomerNumber.value, true),
- })
- : null,
- userIdFromCustomerNumber.value
- ? buildMenuAction("customer-login", {
+ }),
+ buildMenuAction("customer-login", {
icon: "fas fa-user-shield",
label: t("admin.pos.settings_wheel.login_as_user"),
clickAction: () => SessionUser.superUser.intimidate.intimidateUser(userIdFromCustomerNumber.value),
- })
- : null,
- userIdFromCustomerNumber.value
- ? buildMenuAction("customer-login-qr", {
+ }),
+ buildMenuAction("customer-login-qr", {
icon: "fas fa-user-shield",
label: t("admin.pos.settings_wheel.login_as_user_qr"),
clickAction: () => onClickShowImpersonationQRCode(userIdFromCustomerNumber.value),
- })
- : null,
- buildMenuAction("customer-change-password", {
- icon: "fas fa-key",
- label: t("admin.pos.settings_wheel.change_password"),
- clickAction: () => showSetCustomerPassword(userIdFromCustomerNumber.value),
- }),
- buildMenuAction("customer-show", {
- icon: "fas fa-user",
- label: t("admin.pos.settings_wheel.show_customer"),
- clickAction: () => {
- customerModalVisible.value = true;
- },
- }),
- ]);
+ }),
+ buildMenuAction("customer-change-password", {
+ icon: "fas fa-key",
+ label: t("admin.pos.settings_wheel.change_password"),
+ clickAction: () => showSetCustomerPassword(userIdFromCustomerNumber.value),
+ }),
+ buildMenuAction("customer-show", {
+ icon: "fas fa-user",
+ label: t("admin.pos.settings_wheel.show_customer"),
+ clickAction: () => {
+ customerModalVisible.value = true;
+ },
+ }),
+ ]
+ : isResolvingUserId.value
+ ? [
+ buildMenuAction("customer-loading", {
+ disabled: true,
+ icon: "fas fa-spinner",
+ label: t("global.loading"),
+ }),
+ ]
+ : [];
+ const customerSection = buildMenuSection(
+ "customer",
+ t("admin.pos.settings_wheel.customer_section"),
+ customerSectionItems
+ );
if (customerSection) {
sections.push(customerSection);
}
+
+ if (canViewCustomerRules.value) {
+ const ruleSection = buildMenuSection(
+ "rules",
+ t("admin.pos.settings_wheel.rules_section"),
+ customerRuleAttributesLoading.value && !customerRuleAttributesLoaded.value
+ ? [
+ buildMenuAction("customer-rules-loading", {
+ disabled: true,
+ icon: "fas fa-spinner",
+ label: t("global.loading"),
+ }),
+ ]
+ : customerRuleItems.value.map((rule) =>
+ buildMenuToggleAction(rule.key, {
+ description: rule.description,
+ disabled: rule.disabled,
+ label: rule.label,
+ testId: rule.testId,
+ value: rule.value,
+ clickAction: () => toggleCustomerRuleAttribute(rule.attribute, !rule.value),
+ })
+ )
+ );
+
+ if (ruleSection) {
+ sections.push(ruleSection);
+ }
+ }
+
+ const shortcutsSection = buildMenuSection(
+ "shortcuts",
+ t("admin.pos.settings_wheel.shortcuts_section"),
+ userIdFromCustomerNumber.value
+ ? customerShortcutItems.value
+ : isResolvingUserId.value
+ ? [
+ buildMenuAction("customer-shortcuts-loading", {
+ disabled: true,
+ icon: "fas fa-spinner",
+ label: t("global.loading"),
+ }),
+ ]
+ : []
+ );
+
+ if (shortcutsSection) {
+ sections.push(shortcutsSection);
+ }
}
if (props.reg_1 || props.reg_2) {
@@ -1598,7 +1793,10 @@ const syncDesktopFlyoutState = (triggerRect) => {
return;
}
- const canUseHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
+ const canUseHover =
+ typeof window.matchMedia === "function"
+ ? window.matchMedia("(hover: hover) and (pointer: fine)").matches
+ : false;
const hasSections = desktopFlyoutMenuSections.value.length > 0;
const hasSpaceForFlyout =
!!triggerRect && triggerRect.right - getDesktopFlyoutEstimatedWidth() >= desktopFlyoutPanelGapPx;
@@ -1641,44 +1839,38 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -1715,6 +1907,7 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
:class="{
'is-active': activeDesktopFlyoutSection?.key === section.key,
'action-settings-wheel-flyout__submenu--attachments': section.key === 'attachments',
+ 'action-settings-wheel-flyout__submenu--rules': section.key === 'rules',
}"
:data-testid="`action-settings-wheel-submenu-${section.key}`"
>
@@ -1730,6 +1923,7 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
class="action-settings-wheel-section-trigger action-settings-wheel-section-trigger--attachment"
:class="{ 'is-active': activeAttachmentId === attachment.id }"
:data-testid="`action-settings-wheel-attachment-row-${attachment.id}`"
+ :title="getAttachmentLabel(attachment)"
@mouseenter="setActiveAttachment(attachment)"
@focus="setActiveAttachment(attachment)"
@click.stop.prevent="setActiveAttachment(attachment)"
@@ -1746,15 +1940,25 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
-
+
+
+
+
{
class="action-settings-wheel-attachment-panel"
data-testid="action-settings-wheel-attachment-panel"
>
-
+
{{ getAttachmentLabel(activeAttachment) }}
@@ -1793,6 +1997,17 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
>
{{ activeAttachment.content?.other }}
+
+
+
+
+
+ {{ getAttachmentPreviewPlaceholderLabel(activeAttachment) }}
+
+
{{ t("admin.pos.attachments_office_preview_unavailable") }}
@@ -1892,15 +2107,25 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
-
+
+
+
+
{
min-width: auto;
}
+.dropdown {
+ position: relative;
+}
+
.dropdown-menu {
min-width: 15rem;
+ z-index: 4001;
}
.dropdown-content {
+ position: relative;
background: #ffffff;
border: 1px solid #cfd8e3;
border-radius: 0.8rem;
box-shadow: 0 18px 36px rgba(19, 35, 57, 0.14);
padding: 0.35rem;
+ z-index: 4002;
}
.action-settings-wheel-dropdown-content--desktop-flyout {
@@ -2037,6 +2269,10 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
overflow: visible;
}
+.action-settings-wheel-flyout__submenu--rules {
+ min-width: 18.75rem;
+}
+
.action-settings-wheel-flyout__standalone-actions {
margin-top: 0.35rem;
padding-top: 0.35rem;
@@ -2090,9 +2326,12 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
.action-settings-wheel-section-trigger__label {
flex: 1;
+ min-width: 0;
text-align: left;
font-weight: 600;
- overflow-wrap: anywhere;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.action-settings-wheel-attachment-panel {
@@ -2117,7 +2356,9 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
font-size: 0.95rem;
font-weight: 600;
color: #132339;
- overflow-wrap: anywhere;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.action-settings-wheel-attachment-panel__preview {
@@ -2154,6 +2395,39 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
text-decoration: none;
}
+.action-settings-wheel-attachment-panel__placeholder {
+ width: 100%;
+ padding: 1.5rem 1rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ color: #5c6980;
+}
+
+.action-settings-wheel-attachment-panel__placeholder-icon {
+ width: 3rem;
+ height: 3rem;
+ border-radius: 999px;
+ background: #eaf1f8;
+ color: #42526b;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.1rem;
+}
+
+.action-settings-wheel-attachment-panel__placeholder-label {
+ padding: 0.2rem 0.55rem;
+ border-radius: 999px;
+ background: #eef3f8;
+ color: #42526b;
+ font-size: 0.74rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+}
+
.action-settings-wheel-attachment-panel__actions {
display: flex;
flex-direction: column;
diff --git a/src/components/displays/buttons/ActionSettingsWheelToggleItem.vue b/src/components/displays/buttons/ActionSettingsWheelToggleItem.vue
new file mode 100644
index 00000000..f84efbca
--- /dev/null
+++ b/src/components/displays/buttons/ActionSettingsWheelToggleItem.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
diff --git a/src/components/displays/department/pos/PosSelectedCustomer.vue b/src/components/displays/department/pos/PosSelectedCustomer.vue
index beeafdc2..230acfad 100644
--- a/src/components/displays/department/pos/PosSelectedCustomer.vue
+++ b/src/components/displays/department/pos/PosSelectedCustomer.vue
@@ -19,6 +19,7 @@ import { computed, ref, watch } from "vue";
import "bulma-switch/dist/css/bulma-switch.min.css";
import "bulma-block-list/src/block-list.scss";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
+import { useI18n } from "vue-i18n";
import {
popperBox,
popper,
@@ -29,6 +30,9 @@ import {
import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue";
import CustomerDiscountsDepartmentDisplay from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentBox.vue";
+import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
+
+const { t } = useI18n();
const panel_tabs = ref([
{
@@ -99,80 +103,14 @@ const details = ref([
},
]);
-const attributes = ref([
- {
- name: "Kræver reference nr.",
- prop: "requiresReferenceNumber",
- description: "Når denne er sat, kræves der et reference nr. på ordren før den kan oprettes",
- icon: "fas fa-cogs",
- },
- {
- name: "Må ikke ydes tillægsydelser",
- prop: "restrictAdditionalServices",
- description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "addons" til ordren',
- icon: "fas fa-cogs",
- },
- {
- name: "Registreringsnumre på faktura linjer",
- prop: "requiresRegistrationNumbersInvoice",
- description: "Når denne er sat, bliver der sendt registreringsnumre med på alle faktura linjer",
- icon: "fas fa-cogs",
- },
- {
- name: "Fakturer alle ordrer individuelt",
- prop: "invoiceAllOrdersIndividually",
- description: "Når denne er sat, faktureres alle ordrer individuelt og ikke samlet",
- icon: "fas fa-cogs",
- },
- {
- name: "Må ikke ydes tank cleaning",
- prop: "restrictTankCleaning",
- description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "tank cleaning" til ordren',
- icon: "fas fa-cogs",
- },
- {
- name: "Må ikke ydes spot free",
- prop: "restrictSpotFree",
- description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "spot free" til ordren',
- icon: "fas fa-cogs",
- },
- {
- name: "Må ikke ydes indvendig vask",
- prop: "restrictInteriorCleaning",
- description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "interior cleaning" til ordren',
- icon: "fas fa-cogs",
- },
- {
- name: "Faktureres med Stripe",
- prop: "invoiceWithStripe",
- description: "Når denne er sat, faktureres ordren med Stripe",
- icon: "fas fa-cogs",
- },
- {
- name: "Only tank cleaning",
- prop: "onlyTankCleaning",
- description: 'Når denne er sat, bliver kunden kategoriseret som "Tank cleaning" kunde.',
- icon: "fas fa-cogs",
- },
- {
- name: "Vises priser på kunde og bookingside",
- prop: "showPricesOnBookingPage",
- description: "Når denne er sat, bliver kunden vist priser på kunde og bookingsiden.",
- icon: "fas fa-cogs",
- },
- {
- name: "Bruger PO nummer",
- prop: "usePONumbers",
- description: "Når denne er sat, kan kunden angive et PO nummer på ordrer.",
- icon: "fas fa-cogs",
- },
- {
- name: "Undtaget fra månedligt administrations- og miljøgebyr",
- prop: "exemptFromAdministrationFee",
- description: "Når denne er sat, bliver kunden undtaget fra månedligt administrations- og miljøgebyr.",
- icon: "fas fa-cogs",
- },
-]);
+const attributes = computed(() =>
+ getCustomerRuleDefinitions().map((attribute) => ({
+ ...attribute,
+ name: t(attribute.labelKey),
+ prop: attribute.attribute,
+ description: t(attribute.descriptionKey),
+ }))
+);
const shortcuts = ref([
{
@@ -321,7 +259,7 @@ const customer_data_has_empty_details = () => {
class="panel-block pos-selected-customer__row"
v-if="panel_tabs[1].active"
v-for="attribute in attributes"
- :key="attribute.name"
+ :key="attribute.attribute"
>
diff --git a/src/components/displays/superuser/system/SystemStatusDashboard.vue b/src/components/displays/superuser/system/SystemStatusDashboard.vue
index 9878ee67..04f3805f 100644
--- a/src/components/displays/superuser/system/SystemStatusDashboard.vue
+++ b/src/components/displays/superuser/system/SystemStatusDashboard.vue
@@ -6,9 +6,22 @@ import {
SuperUserSystemStatusObject,
getSuperuserSystemStatus,
} from "@/components/session/token/superUser/systemStatus.vue";
+import { SessionUser } from "@/components/session/token/SessionUser.vue";
+import {
+ listEdgeGatewayDepartments,
+ listEdgeGateways,
+ unwrapEdgeGatewayMeta,
+} from "@/services/edgeGateways.js";
const { t, te, locale } = useI18n();
+const MAX_GATEWAY_CARDS = 8;
+const gatewayStatusPriority = Object.freeze({
+ OFFLINE: 0,
+ DEGRADED: 1,
+ ONLINE: 2,
+});
+
const nowTick = ref(Date.now());
let pollTimeout = null;
let clockInterval = null;
@@ -54,6 +67,63 @@ const error = computed(() => SuperUserSystemStatusObject.error.value);
const lastLoadedAt = computed(() => SuperUserSystemStatusObject.lastLoadedAt.value);
const refreshAfterSeconds = computed(() => SuperUserSystemStatusObject.refreshAfterSeconds.value || 30);
const overallStatus = computed(() => SuperUserSystemStatusObject.overallStatus.value);
+const canViewGateways = computed(() => SessionUser.hasPermission("modules_shelly_config"));
+
+const gatewayLoading = ref(false);
+const gatewayError = ref(null);
+const gatewayRows = ref([]);
+const gatewayFleetUsage = ref(createEmptyGatewayFleetUsage());
+const gatewaySectionSuppressed = ref(false);
+const gatewayDepartments = ref({});
+const gatewayDepartmentsLoaded = ref(false);
+const showGatewaySection = computed(() => canViewGateways.value && !gatewaySectionSuppressed.value);
+const gatewaySummaryCards = computed(() => [
+ {
+ id: "total",
+ title: t("system_status.gateways.summary.total"),
+ value: gatewayFleetUsage.value.total,
+ toneClass: null,
+ },
+ {
+ id: "online",
+ title: t("system_status.gateways.summary.online"),
+ value: gatewayFleetUsage.value.online,
+ toneClass: "is-ok",
+ },
+ {
+ id: "degraded",
+ title: t("system_status.gateways.summary.degraded"),
+ value: gatewayFleetUsage.value.degraded,
+ toneClass: "is-degraded",
+ },
+ {
+ id: "offline",
+ title: t("system_status.gateways.summary.offline"),
+ value: gatewayFleetUsage.value.offline,
+ toneClass: "is-down",
+ },
+]);
+const gatewayCards = computed(() => {
+ return [...gatewayRows.value]
+ .sort(compareGateways)
+ .slice(0, MAX_GATEWAY_CARDS)
+ .map((gateway) => {
+ const activeOperation = gateway?.active_operation || null;
+ return {
+ id: gateway?.id ?? null,
+ displayLabel: gatewayDisplayLabel(gateway),
+ departmentName: gatewayDepartmentName(gateway),
+ toneClass: gatewayToneClass(gateway?.status),
+ statusLabel: gatewayStatusLabel(gateway?.status),
+ discoveryLabel: gatewayDiscoveryLabel(gateway?.discovery_status),
+ lastHeartbeatLabel: formatDate(gateway?.last_heartbeat_at),
+ activeOperationLabel: gatewayOperationLabel(activeOperation),
+ message: gatewayPrimaryMessage(gateway),
+ link: `/superuser/configuration/edgegateway/${encodeURIComponent(String(gateway?.id ?? ""))}/overview`,
+ };
+ })
+ .filter((gateway) => gateway.id !== null);
+});
const statusCards = computed(() => [
{
@@ -114,7 +184,12 @@ const isStale = computed(() => {
});
const loadStatus = async ({ force = false } = {}) => {
- await getSuperuserSystemStatus({ force });
+ const [snapshotValue] = await Promise.all([
+ getSuperuserSystemStatus({ force }),
+ loadGatewayFleet({ force }),
+ ]);
+
+ return snapshotValue;
};
const clearPolling = () => {
@@ -233,6 +308,83 @@ function moduleReasonText(module) {
);
}
+function createEmptyGatewayFleetUsage() {
+ return {
+ total: 0,
+ online: 0,
+ degraded: 0,
+ offline: 0,
+ };
+}
+
+function resetGatewayState({ suppress = false } = {}) {
+ gatewayRows.value = [];
+ gatewayFleetUsage.value = createEmptyGatewayFleetUsage();
+ gatewayError.value = null;
+ gatewaySectionSuppressed.value = suppress;
+}
+
+async function loadGatewayFleet({ force = false } = {}) {
+ if (!canViewGateways.value) {
+ resetGatewayState();
+ return [];
+ }
+
+ gatewayLoading.value = true;
+ gatewayError.value = null;
+
+ try {
+ const response = await listEdgeGateways({ view: "summary", forceRefresh: force });
+ const rows = Array.isArray(response?.data?.data) ? response.data.data : [];
+ const fleetUsage = unwrapEdgeGatewayMeta(response)?.fleet_usage;
+
+ gatewayRows.value = rows;
+ gatewayFleetUsage.value = normalizeGatewayFleetUsage(rows, fleetUsage);
+ gatewaySectionSuppressed.value = false;
+
+ await ensureGatewayDepartmentsLoaded();
+
+ return rows;
+ } catch (requestError) {
+ const statusCode = Number(requestError?.response?.status ?? 0);
+ if (statusCode === 403) {
+ resetGatewayState({ suppress: true });
+ return [];
+ }
+
+ gatewayError.value = requestError;
+ gatewaySectionSuppressed.value = false;
+ return gatewayRows.value;
+ } finally {
+ gatewayLoading.value = false;
+ }
+}
+
+async function ensureGatewayDepartmentsLoaded() {
+ if (gatewayDepartmentsLoaded.value) {
+ return gatewayDepartments.value;
+ }
+
+ try {
+ const response = await listEdgeGatewayDepartments();
+ const rows = Array.isArray(response?.data?.data) ? response.data.data : [];
+
+ gatewayDepartments.value = rows.reduce((lookup, department) => {
+ const departmentId = Number(department?.id ?? 0);
+ const name = String(department?.name || department?.label || department?.title || "").trim();
+ if (departmentId > 0 && name !== "") {
+ lookup[departmentId] = name;
+ }
+ return lookup;
+ }, {});
+ gatewayDepartmentsLoaded.value = true;
+ } catch (_error) {
+ gatewayDepartmentsLoaded.value = false;
+ }
+
+ return gatewayDepartments.value;
+}
+
function warningText(warning) {
return translateSystemStatusText(
"warnings",
@@ -268,6 +420,42 @@ function formatDeviceType(deviceType) {
return translateSystemStatusText("devices", deviceType, {}, deviceType || t("system_status.status.unknown"));
}
+function normalizeGatewayStatus(status) {
+ const normalizedStatus = String(status || "").trim().toUpperCase();
+ return normalizedStatus || "UNKNOWN";
+}
+
+function gatewayToneClass(status) {
+ switch (normalizeGatewayStatus(status)) {
+ case "ONLINE":
+ return "is-ok";
+ case "DEGRADED":
+ return "is-degraded";
+ default:
+ return "is-down";
+ }
+}
+
+function gatewayStatusLabel(status) {
+ const normalizedStatus = normalizeGatewayStatus(status).toLowerCase();
+ return translateSystemStatusText(
+ "gateways.status",
+ normalizedStatus,
+ {},
+ t("system_status.gateways.status.unknown")
+ );
+}
+
+function gatewayDiscoveryLabel(status) {
+ const normalizedStatus = String(status || "unknown").trim().toLowerCase();
+ return translateSystemStatusText(
+ "gateways.discovery_status",
+ normalizedStatus,
+ {},
+ t("system_status.gateways.discovery_status.unknown")
+ );
+}
+
function formatDate(value) {
if (!value) {
return "--";
@@ -318,6 +506,110 @@ function formatMinioBuckets(buckets) {
return t("system_status.labels.buckets_available", { available, total: buckets.length });
}
+function gatewayDisplayLabel(gateway) {
+ const label = String(gateway?.label || "").trim();
+ if (label !== "") {
+ return label;
+ }
+
+ const hostname = String(gateway?.hostname || "").trim();
+ if (hostname !== "") {
+ return hostname;
+ }
+
+ return `Gateway #${gateway?.id ?? "?"}`;
+}
+
+function gatewayDepartmentName(gateway) {
+ const departmentId = Number(gateway?.department_id ?? 0);
+ if (departmentId > 0 && gatewayDepartments.value[departmentId]) {
+ return gatewayDepartments.value[departmentId];
+ }
+
+ return t("system_status.gateways.department_fallback", {
+ id: departmentId > 0 ? departmentId : "?",
+ });
+}
+
+function gatewayOperationLabel(operation) {
+ const summaryLabel = String(operation?.summary?.label || "").trim();
+ if (summaryLabel !== "") {
+ return summaryLabel;
+ }
+
+ const operationType = String(operation?.type || "").trim();
+ if (operationType === "") {
+ return null;
+ }
+
+ return operationType
+ .toLowerCase()
+ .split("_")
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
+ .join(" ");
+}
+
+function gatewayPrimaryMessage(gateway) {
+ const errorMessage = String(gateway?.error_state?.message || "").trim();
+ if (errorMessage !== "") {
+ return errorMessage;
+ }
+
+ const diagnosticMessage = String(gateway?.diagnostics?.[0]?.message || "").trim();
+ return diagnosticMessage !== "" ? diagnosticMessage : null;
+}
+
+function normalizeGatewayFleetUsage(rows, fleetUsage) {
+ const gatewayCounts = fleetUsage && typeof fleetUsage === "object" ? fleetUsage.gateways || {} : {};
+
+ return {
+ total: toFiniteNumber(gatewayCounts.total, rows.length),
+ online: toFiniteNumber(
+ gatewayCounts.online,
+ rows.filter((gateway) => normalizeGatewayStatus(gateway?.status) === "ONLINE").length
+ ),
+ degraded: toFiniteNumber(
+ gatewayCounts.degraded,
+ rows.filter((gateway) => normalizeGatewayStatus(gateway?.status) === "DEGRADED").length
+ ),
+ offline: toFiniteNumber(
+ gatewayCounts.offline,
+ rows.filter((gateway) => normalizeGatewayStatus(gateway?.status) === "OFFLINE").length
+ ),
+ };
+}
+
+function toFiniteNumber(value, fallback = 0) {
+ return Number.isFinite(Number(value)) ? Number(value) : Number(fallback || 0);
+}
+
+function gatewaySortRank(status) {
+ return gatewayStatusPriority[normalizeGatewayStatus(status)] ?? Number.MAX_SAFE_INTEGER;
+}
+
+function compareGateways(left, right) {
+ const rankDifference = gatewaySortRank(left?.status) - gatewaySortRank(right?.status);
+ if (rankDifference !== 0) {
+ return rankDifference;
+ }
+
+ const heartbeatDifference = gatewayHeartbeatTimestamp(left?.last_heartbeat_at) - gatewayHeartbeatTimestamp(right?.last_heartbeat_at);
+ if (heartbeatDifference !== 0) {
+ return heartbeatDifference;
+ }
+
+ return Number(left?.id ?? 0) - Number(right?.id ?? 0);
+}
+
+function gatewayHeartbeatTimestamp(value) {
+ if (!value) {
+ return 0;
+ }
+
+ const timestamp = new Date(value).getTime();
+ return Number.isFinite(timestamp) ? timestamp : 0;
+}
+
function modulePath(key) {
return moduleConfigPaths[key] || null;
}
@@ -400,6 +692,96 @@ function modulePath(key) {
+
+
+
+
{{ $t("system_status.sections.gateways") }}
+
{{ $t("system_status.gateways.description") }}
+
+
+ {{ $t("system_status.gateways.actions.open_fleet") }}
+
+
+
+
+
+ {{ card.title }}
+ {{ card.value }}
+
+
+
+
+ {{ $t("system_status.gateways.error") }}
+
+
+
+ {{ $t("system_status.gateways.loading") }}
+
+
+
+
+
+
+
{{ gateway.displayLabel }}
+
{{ gateway.departmentName }}
+
+
{{ gateway.statusLabel }}
+
+
+
+
+ {{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
+
+
+ {{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
+
+
+ {{ $t("system_status.gateways.labels.active_operation") }}: {{ gateway.activeOperationLabel }}
+
+
+
+
+ {{ gateway.message }}
+
+
+
+ {{ $t("system_status.gateways.actions.open_gateway") }}
+
+
+
+
+
+ {{ $t("system_status.gateways.empty") }}
+
+
+
{{ $t("system_status.sections.modules") }}
@@ -491,7 +873,8 @@ function modulePath(key) {
.summary-card,
.status-card,
-.module-card {
+.module-card,
+.gateway-card {
border: 1px solid #d7dde7;
border-radius: 18px;
padding: 1rem 1.1rem;
@@ -535,13 +918,31 @@ function modulePath(key) {
}
.status-card-grid,
-.module-grid {
+.module-grid,
+.gateway-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
align-items: stretch;
}
+.gateway-summary-grid {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ align-items: stretch;
+}
+
+.section-subtitle {
+ margin: 0.2rem 0 0;
+ color: #64748b;
+}
+
+.section-link {
+ font-weight: 600;
+ color: #0f4c81;
+}
+
.status-card__top,
.module-card__top {
display: flex;
@@ -571,6 +972,13 @@ function modulePath(key) {
align-content: start;
}
+.gateway-card {
+ display: grid;
+ gap: 0.85rem;
+ height: 100%;
+ align-content: start;
+}
+
.module-card__top {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -579,6 +987,14 @@ function modulePath(key) {
row-gap: 0.35rem;
}
+.gateway-card__top {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
+ column-gap: 0.75rem;
+ row-gap: 0.35rem;
+}
+
.module-card__title {
margin: 0;
min-width: 0;
@@ -593,6 +1009,18 @@ function modulePath(key) {
align-self: start;
}
+.gateway-card__title {
+ display: block;
+ color: #0f172a;
+ overflow-wrap: anywhere;
+}
+
+.gateway-card__subtitle {
+ margin: 0.2rem 0 0;
+ color: #475569;
+ overflow-wrap: anywhere;
+}
+
.module-card__reason {
margin: 0;
color: #475569;
@@ -606,12 +1034,37 @@ function modulePath(key) {
font-size: 0.85rem;
}
+.gateway-card__meta {
+ display: grid;
+ gap: 0.25rem;
+ font-size: 0.85rem;
+ color: #475569;
+}
+
+.gateway-card__message {
+ margin: 0;
+ color: #7c2d12;
+ line-height: 1.5;
+}
+
.module-card__link {
margin-top: auto;
font-weight: 600;
color: #0f4c81;
}
+.gateway-empty {
+ border: 1px dashed #cbd5e1;
+ border-radius: 18px;
+ padding: 1rem 1.1rem;
+ color: #475569;
+ background: rgba(248, 250, 252, 0.8);
+}
+
+.gateway-notification {
+ margin-bottom: 0;
+}
+
.status-pill {
display: inline-flex;
align-items: center;
diff --git a/src/components/displays/superuser/tables/departmentLanesTable.vue b/src/components/displays/superuser/tables/departmentLanesTable.vue
index 4d6dff2a..7227c07c 100644
--- a/src/components/displays/superuser/tables/departmentLanesTable.vue
+++ b/src/components/displays/superuser/tables/departmentLanesTable.vue
@@ -1,17 +1,15 @@
-
+
+
-
- | {{ SessionUser.objects.department_lanes.columns.name.label }} |
- {{ SessionUser.objects.department_lanes.columns.department.label }} |
-
- {{ $t('objects.columns.status') }} |
- {{ SessionUser.objects.department_lanes.columns.id.label }} |
- {{ SessionUser.objects.department_lanes.columns.relay_in_id.label }} |
- {{ SessionUser.objects.department_lanes.columns.relay_out_id.label }} |
- {{ SessionUser.objects.department_lanes.columns.relay_machine_id.label }} |
- {{ SessionUser.objects.department_lanes.columns.relay_machine_program_picker_id.label }} |
- {{ SessionUser.objects.department_lanes.columns.relay_machine_cleaner_id.label }} |
- {{ SessionUser.objects.department_lanes.columns.dynamic_image_id.label }} |
- {{ SessionUser.objects.department_lanes.columns.machine_type_id.label }} |
-
- {{ $t('tables.actions') }} |
-
+
+
+ | {{ SessionUser.objects.department_lanes.columns.name.label }} |
+ {{ SessionUser.objects.department_lanes.columns.department.label }} |
+
+ {{ $t("objects.columns.status") }} |
+ {{ SessionUser.objects.department_lanes.columns.id.label }} |
+ {{ SessionUser.objects.department_lanes.columns.machine_type_id.label }} |
+
+ {{ $t("tables.actions") }} |
+
-
-
-
-
- |
-
-
-
-
- {{
- 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')
- }}
-
- |
- {{ object.id }} |
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ |
+ |
+
+
+
+
+
+
+
+ {{ getStatusInfo(object).label }}
+ |
+
+ {{ object.id }}
+ |
+
+
+
+
+
-
-
-
-
-
-
- |
-
+ >
+
+
+
+
+ {{
+ SessionUser.objects.department_lanes.functions.isWashing(object)
+ ? $t("global.stop")
+ : $t("global.ready_to_wash")
+ }}
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+ {{ SessionUser.objects.department_lanes.columns.relay_in_id.label }}
+
+
+
+
+
+ {{ SessionUser.objects.department_lanes.columns.relay_out_id.label }}
+
+
+
+
+
+ {{ SessionUser.objects.department_lanes.columns.relay_machine_id.label }}
+
+
+
+
+
+ {{ SessionUser.objects.department_lanes.columns.relay_machine_program_picker_id.label }}
+
+
+
+
+
+ {{ SessionUser.objects.department_lanes.columns.relay_machine_cleaner_id.label }}
+
+
+
+
+
+ {{ SessionUser.objects.department_lanes.columns.dynamic_image_id.label }}
+
+
+
+
+ Workspace
+
+
+
+
+
+ |
+
+
+
+ | {{ t("global.no_data") }} |
+
+
diff --git a/src/components/displays/superuser/tables/productsTable.vue b/src/components/displays/superuser/tables/productsTable.vue
index cb53d92d..dd52a608 100644
--- a/src/components/displays/superuser/tables/productsTable.vue
+++ b/src/components/displays/superuser/tables/productsTable.vue
@@ -1,252 +1,463 @@
-
-
-
- | {{ SessionUser.objects.products.columns.order_priority.label }} |
- {{ SessionUser.objects.products.columns.id.label }} |
- {{ SessionUser.objects.products.columns.name.label }} |
- {{ SessionUser.objects.products.columns.description.label }} |
- {{ SessionUser.objects.products.columns.price.label }} |
- {{ SessionUser.objects.products.columns.subscription_allowed.label }} |
- {{ SessionUser.objects.products.columns.category.label }} |
- {{ SessionUser.objects.products.columns.piktogram.label }} |
- {{ SessionUser.objects.products.columns.economic_product_id.label }} |
- {{ SessionUser.objects.products.columns.apply_category_discount.label }} |
- {{ SessionUser.objects.products.columns.requires_note.label }} |
- {{ SessionUser.objects.products.columns.is_wash.label }} |
- {{ SessionUser.objects.products.columns.display_in_booking_form.label}} |
- {{ SessionUser.objects.product_options.meta.title }} |
- {{ $t('tables.actions') }} |
-
-
-
-
-
-
- | {{ product.id }} |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ | {{ SessionUser.objects.products.columns.order_priority.label }} |
+ {{ SessionUser.objects.products.columns.id.label }} |
+
+ {{ SessionUser.objects.products.columns.name.label }}
+ |
+ {{ SessionUser.objects.products.columns.price.label }} |
+
+ {{ SessionUser.objects.products.columns.category.label }}
+ |
+ {{ SessionUser.objects.products.columns.is_wash.label }} |
+ {{ SessionUser.objects.products.columns.display_in_booking_form.label }} |
+ {{ $t("tables.actions") }} |
+
+
+
+
+
+ |
+
+ |
+
-
-
-
-
-
-
+ {{ product.id }} |
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+
+ {{ SessionUser.objects.products.columns.description.label }}
+
+
+
+
+
+ {{ SessionUser.objects.products.columns.subscription_allowed.label }}
+
+
+
+
+
+ {{ SessionUser.objects.products.columns.piktogram.label }}
+
+
+
+
+
+ {{ SessionUser.objects.products.columns.economic_product_id.label }}
+
+
+
+
+
+ {{ SessionUser.objects.products.columns.apply_category_discount.label }}
+
+
+
+
+
+ {{ SessionUser.objects.products.columns.requires_note.label }}
+
+
+
+
+ {{ SessionUser.objects.product_options.meta.title }}
+
+
+
+ |
+
+
+
+ | {{ t("global.no_data") }} |
+
+
+
+
\ No newline at end of file
+.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;
+ }
+}
+
diff --git a/src/components/shop/CustomerAttributes.vue b/src/components/shop/CustomerAttributes.vue
index b8e8d199..6774d766 100644
--- a/src/components/shop/CustomerAttributes.vue
+++ b/src/components/shop/CustomerAttributes.vue
@@ -1,45 +1,25 @@
@@ -487,7 +580,7 @@ const deleteGate = async (gate) => {
Integrated hardware workspace
{{ department?.name || `Department ${departmentId}` }}
-
Gateways, lanes, self-serve readiness, gates, scanners, and setup gaps in one department view.
+
Gateways, lanes, self-serve readiness, gates, relays, scanners, and setup gaps in one department view.