Files
pleno-vue/src/components/displays/buttons/ActionSettingsWheelButton.vue
T

3260 lines
102 KiB
Vue

<script setup>
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 { getReleaseRuntimeApiBaseUrl } from "@/services/releaseTimeline.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();
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
const emit = defineEmits(["deleted", "flagCreated"]);
const emitDeleted = () => {
emit("deleted");
};
const emitFlagCreated = (flag) => {
emit("flagCreated", flag);
};
const props = defineProps({
icon: {
type: String,
default: "fas fa-cog",
},
label: {
type: String,
default: "",
},
triggerButtonVariant: {
type: String,
default: "dark",
},
user_id: {
type: Number,
default: null,
},
order_id: {
type: Number,
default: null,
},
invoice_collection_id: {
type: Number,
default: null,
},
reg_1: {
type: String,
default: null,
},
reg_2: {
type: String,
default: null,
},
reg_3: {
type: String,
default: null,
},
order_booking_id: {
type: Number,
default: null,
},
customer_number: {
type: Number,
default: null,
},
refreshFunction: {
type: Function,
default: () => {
console.warn("Refresh function is not defined");
},
},
department_id: {
type: Number,
default: null,
},
attachments: {
type: Array,
default: () => [],
},
displayActionsDirectly: {
type: Boolean,
default: false,
},
allowBookingCompletion: {
type: Boolean,
default: false,
},
allowBookingDeletion: {
type: Boolean,
default: false,
},
department_lane_id: {
type: Number,
default: null,
},
subuserGrant: {
type: Object,
default: null,
},
flagTarget: {
type: Object,
default: null,
},
});
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);
const dropdownInstanceId = Math.random().toString(36).substring(2, 15);
const shouldOpenDropdownUp = ref(false);
const dropdownMaxHeight = ref(null);
const isDesktopFlyoutLayout = ref(false);
const isFixedPosition = ref(false);
const fixedPositionStyles = ref({});
const activeDesktopFlyoutSectionKey = ref(null);
const activeAttachmentId = ref(null);
const previewLoadingAttachmentId = ref(null);
const deletingAttachmentId = ref(null);
const previewSourcesById = ref({});
const previewRequestsInFlight = new Set();
const generatedObjectUrls = new Set();
let previewStateGeneration = 0;
let contentResizeObserver = null;
const desktopFlyoutMinViewportWidth = 1400;
const desktopFlyoutRootPanelWidthRem = 15;
const desktopFlyoutSubmenuWidthRem = 17;
const desktopFlyoutPanelGapPx = 12;
const closeDropdown = () => {
isDropdownOpen.value = false;
};
const openDropdown = () => {
if (!isDropdownOpen.value) {
window.dispatchEvent(
new CustomEvent("action-settings-wheel:opened", {
detail: {
id: dropdownInstanceId,
},
})
);
}
isDropdownOpen.value = true;
};
const toggleDropdown = () => {
if (isDropdownOpen.value) {
closeDropdown();
return;
}
openDropdown();
};
const onActionSelected = () => {
closeDropdown();
};
const isTextTriggerButton = computed(() => props.triggerButtonVariant === "text");
const isIconOnlyTriggerButton = computed(() => props.label.length === 0);
const getTriggerButtonClass = computed(() => ({
button: true,
"is-small": true,
"is-dark": !isTextTriggerButton.value,
"action-settings-wheel-trigger": true,
"action-settings-wheel-trigger--active": isDropdownOpen.value,
"action-settings-wheel-trigger--text": isTextTriggerButton.value,
"action-settings-wheel-trigger--icon-only": isIconOnlyTriggerButton.value,
}));
const dropdownClass = computed(() => ({
"is-active": isDropdownOpen.value,
"is-up": shouldOpenDropdownUp.value,
}));
const dropdownContentClass = computed(() => ({
"action-settings-wheel-dropdown-content--desktop-flyout": isDesktopFlyoutLayout.value,
}));
const dropdownMenuStyle = computed(() => {
if (!isFixedPosition.value) {
return {};
}
return {
position: "fixed",
zIndex: 10000,
left: "auto",
bottom: "auto",
paddingTop: 0,
paddingBottom: 0,
...fixedPositionStyles.value,
};
});
const dropdownContentStyle = computed(() => {
if (isFixedPosition.value) {
return {
maxHeight: dropdownMaxHeight.value ? `${dropdownMaxHeight.value}px` : "95vh",
overflowY: "auto",
overscrollBehavior: "contain",
};
}
if (!dropdownMaxHeight.value) {
return {};
}
return {
maxHeight: `${dropdownMaxHeight.value}px`,
overflowY: "auto",
overscrollBehavior: "contain",
};
});
const resetDropdownLayout = () => {
shouldOpenDropdownUp.value = false;
dropdownMaxHeight.value = null;
isFixedPosition.value = false;
fixedPositionStyles.value = {};
};
const isVisibleFixedInsetElement = (element) => {
if (!(element instanceof HTMLElement)) {
return false;
}
const styles = window.getComputedStyle(element);
if (styles.display === "none" || styles.visibility === "hidden" || Number(styles.opacity || "1") === 0) {
return false;
}
return styles.position === "fixed" || styles.position === "sticky";
};
const getInsetElements = (selectors) =>
selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)));
const getViewportInsets = () => {
const viewportPadding = 12;
const fixedTopElements = Array.from(new Set(getInsetElements([".navbar.is-fixed-top"])));
const fixedBottomElements = Array.from(
new Set(getInsetElements([".navbar.is-fixed-bottom", ".fixed-bottom-footer", ".request-queue-progress-shell"]))
);
const topInset = fixedTopElements.reduce((maxInset, element) => {
if (!isVisibleFixedInsetElement(element)) {
return maxInset;
}
const rect = element.getBoundingClientRect();
if (rect.bottom <= 0 || rect.top > viewportPadding) {
return maxInset;
}
return Math.max(maxInset, Math.ceil(rect.bottom));
}, 0);
const bottomInset = fixedBottomElements.reduce((maxInset, element) => {
if (!isVisibleFixedInsetElement(element)) {
return maxInset;
}
const rect = element.getBoundingClientRect();
if (rect.top >= window.innerHeight || rect.bottom < window.innerHeight - viewportPadding) {
return maxInset;
}
return Math.max(maxInset, Math.ceil(window.innerHeight - rect.top));
}, 0);
return {
top: topInset + viewportPadding,
bottom: bottomInset + viewportPadding,
};
};
const updateDropdownLayout = async () => {
if (!isDropdownOpen.value || !dropdownRoot.value || !dropdownContent.value) {
resetDropdownLayout();
return;
}
await nextTick();
const triggerElement = dropdownRoot.value.querySelector(".dropdown-trigger");
const triggerRect = (triggerElement ?? dropdownRoot.value).getBoundingClientRect();
syncDesktopFlyoutState(triggerRect);
syncDesktopFlyoutPosition();
await nextTick();
if (!dropdownContent.value) {
return;
}
const viewportInsets = getViewportInsets();
const viewportTop = viewportInsets.top;
const viewportBottom = window.innerHeight - viewportInsets.bottom;
const spaceBelow = Math.max(window.innerHeight - triggerRect.bottom - viewportInsets.bottom, 0);
const spaceAbove = Math.max(triggerRect.top - viewportInsets.top, 0);
const menuHeight = Math.ceil(dropdownContent.value.scrollHeight);
const openUpward = menuHeight > spaceBelow && spaceAbove > spaceBelow;
const availableHeight = openUpward ? spaceAbove : spaceBelow;
const nextMaxHeight = availableHeight > 0 && menuHeight > availableHeight ? Math.floor(availableHeight) : null;
shouldOpenDropdownUp.value = openUpward;
dropdownMaxHeight.value = nextMaxHeight;
syncDesktopFlyoutPosition();
await nextTick();
if (!dropdownContent.value) {
return;
}
const renderedMenuRect = dropdownContent.value.getBoundingClientRect();
const overflowAbove = Math.max(viewportTop - renderedMenuRect.top, 0);
const overflowBelow = Math.max(renderedMenuRect.bottom - viewportBottom, 0);
if (overflowAbove <= 0 && overflowBelow <= 0) {
return;
}
const boundedHeight = Math.floor(renderedMenuRect.height - overflowAbove - overflowBelow);
dropdownMaxHeight.value = boundedHeight > 0 ? boundedHeight : 1;
syncDesktopFlyoutPosition();
};
const onDocumentClick = (event) => {
if (!isDropdownOpen.value) {
return;
}
if (dropdownRoot.value && !dropdownRoot.value.contains(event.target)) {
closeDropdown();
}
};
const onDocumentKeydown = (event) => {
if (event.key === "Escape") {
closeDropdown();
}
};
const onActionSettingsWheelOpened = (event) => {
if (event?.detail?.id !== dropdownInstanceId) {
closeDropdown();
}
};
const onViewportChange = () => {
if (!isDropdownOpen.value) {
return;
}
void updateDropdownLayout();
};
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);
if (currentLookupId !== userLookupRequestId.value) {
return;
}
userIdFromCustomerNumber.value = response?.data?.data?.user_id ?? null;
} catch (error) {
if (currentLookupId !== userLookupRequestId.value) {
return;
}
userIdFromCustomerNumber.value = null;
console.error(error);
} finally {
if (currentLookupId === userLookupRequestId.value) {
isResolvingUserId.value = false;
}
}
};
const resetCustomerRuleState = () => {
customerRuleAttributes.value = [];
customerRuleAttributesLoaded.value = false;
customerRuleAttributesLoading.value = false;
};
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;
}
if (enabled) {
await createCustomerAttribute(customerAttributeTarget.value, attribute);
if (!activeCustomerRuleAttributes.value.has(attribute)) {
customerRuleAttributes.value = [
...customerRuleAttributes.value,
{
attribute,
...customerAttributeTarget.value,
},
];
}
return;
}
await deleteCustomerAttribute(customerAttributeTarget.value, attribute);
customerRuleAttributes.value = customerRuleAttributes.value.filter((entry) => entry?.attribute !== attribute);
};
const showSetCustomerPassword = (userId) => {
Swal.fire({
title: t("admin.pos.settings_wheel.change_password"),
html: `<input id="swal-input1" class="swal2-input" placeholder="${t("admin.pos.settings_wheel.enter_password")}">`,
focusConfirm: false,
preConfirm: () => {
const password = document.getElementById("swal-input1").value;
if (password === "") {
Swal.showValidationMessage(t("admin.pos.settings_wheel.please_enter_password"));
}
return password;
},
}).then((result) => {
if (result.isConfirmed) {
const password = result.value;
// Call the API to set the password
SessionUser.request("/superuser/user/password", "POST", {
user_id: userId,
password: password,
})
.then(() => {
Swal.fire({
title: t("admin.pos.settings_wheel.password_changed"),
text: t("admin.pos.settings_wheel.password_changed_text"),
icon: "success",
});
})
.catch((error) => {
Swal.fire({
title: t("common.error"),
text: t("admin.pos.settings_wheel.error_changing_password"),
icon: "error",
});
});
}
});
};
const redirectSuperUserInvoiceCollectionPage = (invoiceCollectionId) => {
// Send the user to the invoice collection page (In a new tab)
window.open(`/superuser/invoices/${invoiceCollectionId}`, "_blank");
};
const redirectDepartmentOrderPage = async (orderId, newTab = false) => {
// Send the user to the order page (In a new tab)
// Get the department id from the order
await SessionUser.objects.orders.functions
.get_department_id(props.order_id)
.then((response) => {
console.warn(response);
// Get the department id from the response
SessionUser.functions.redirectTo.department(response, "modules/pos/orders/" + orderId, !!newTab);
})
.catch((error) => {
console.error(error);
});
};
const normalizeInvoicePeriodFlagTarget = (target) => {
if (!target || typeof target !== "object") {
return null;
}
const targetType = String(target.target_type || target.targetType || "").trim();
const targetId = Number.parseInt(String(target.target_id || target.targetId || ""), 10);
if (!targetType || !Number.isInteger(targetId) || targetId < 1) {
return null;
}
return {
target_type: targetType,
target_id: targetId,
field: target.field || null,
};
};
const getInvoicePeriodFlagTargetLabel = (target) => {
if (!target || typeof target !== "object") {
return t("admin.pos.settings_wheel.add_flag_target");
}
if (target.target_type === "collected_order_invoice") {
return t("admin.pos.settings_wheel.add_flag_invoice_collection");
}
if (target.target_type === "order") {
return t("admin.pos.settings_wheel.add_flag_order");
}
if (target.target_type === "order_field") {
const orderFieldLabels = {
customer: t("admin.pos.settings_wheel.add_flag_order_customer"),
reference: t("admin.pos.settings_wheel.add_flag_order_reference"),
po: t("admin.pos.settings_wheel.add_flag_order_po"),
notes: t("admin.pos.settings_wheel.add_flag_order_notes"),
};
return orderFieldLabels[target.field] ?? t("admin.pos.settings_wheel.add_flag_order");
}
if (target.target_type === "order_item") {
return t("admin.pos.settings_wheel.add_flag_order_item");
}
if (target.target_type === "order_item_field") {
const orderItemFieldLabels = {
notes: t("admin.pos.settings_wheel.add_flag_order_item_notes"),
quantity: t("admin.pos.settings_wheel.add_flag_order_item_quantity"),
reference: t("admin.pos.settings_wheel.add_flag_order_item_reference"),
price: t("admin.pos.settings_wheel.add_flag_order_item_price"),
};
return orderItemFieldLabels[target.field] ?? t("admin.pos.settings_wheel.add_flag_order_item");
}
if (target.target_type === "customer") {
return t("admin.pos.settings_wheel.add_flag_customer");
}
return t("admin.pos.settings_wheel.add_flag_target");
};
const defaultInvoicePeriodFlagTargets = computed(() => {
const targets = [];
const explicitTarget = normalizeInvoicePeriodFlagTarget(props.flagTarget);
if (explicitTarget && explicitTarget.target_type !== "order_item") {
targets.push({
key: "custom",
label: getInvoicePeriodFlagTargetLabel(explicitTarget),
target: explicitTarget,
});
}
if (props.invoice_collection_id) {
targets.push({
key: "collected-order-invoice",
label: t("admin.pos.settings_wheel.add_flag_invoice_collection"),
target: {
target_type: "collected_order_invoice",
target_id: props.invoice_collection_id,
},
});
}
if (props.order_id) {
targets.push({
key: "order",
label: t("admin.pos.settings_wheel.add_flag_order"),
target: {
target_type: "order",
target_id: props.order_id,
},
});
[
["customer", t("admin.pos.settings_wheel.add_flag_order_customer")],
["reference", t("admin.pos.settings_wheel.add_flag_order_reference")],
["po", t("admin.pos.settings_wheel.add_flag_order_po")],
["notes", t("admin.pos.settings_wheel.add_flag_order_notes")],
].forEach(([field, label]) => {
targets.push({
key: `order-${field}`,
label,
target: {
target_type: "order_field",
target_id: props.order_id,
field,
},
});
});
}
if (explicitTarget?.target_type === "order_item") {
[
[null, t("admin.pos.settings_wheel.add_flag_order_item")],
["notes", t("admin.pos.settings_wheel.add_flag_order_item_notes")],
["quantity", t("admin.pos.settings_wheel.add_flag_order_item_quantity")],
["reference", t("admin.pos.settings_wheel.add_flag_order_item_reference")],
["price", t("admin.pos.settings_wheel.add_flag_order_item_price")],
].forEach(([field, label]) => {
targets.push({
key: `order-item-${field || "all"}`,
label,
target: {
target_type: field ? "order_item_field" : "order_item",
target_id: explicitTarget.target_id,
field,
},
});
});
}
if (props.customer_number && !targets.some((item) => item.target.target_type === "customer")) {
targets.push({
key: "customer",
label: t("admin.pos.settings_wheel.add_flag_customer"),
target: {
target_type: "customer",
target_id: props.customer_number,
},
});
}
const seen = new Set();
return targets.filter((item) => {
const key = `${item.target.target_type}:${item.target.target_id}:${item.target.field || ""}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
});
const canAddInvoicePeriodFlag = computed(
() =>
SessionUser.canAccessSuperUser() &&
SessionUser.hasPermission("add_invoice_period_flag") &&
defaultInvoicePeriodFlagTargets.value.length > 0
);
const showCreateInvoicePeriodFlagForm = async (target) => {
const result = await Swal.fire({
title: t("admin.pos.settings_wheel.add_flag"),
input: "textarea",
inputPlaceholder: t("admin.pos.settings_wheel.add_flag_reason_placeholder"),
inputValidator: (value) => {
if (!value || String(value).trim() === "") {
return t("admin.pos.settings_wheel.add_flag_reason_required");
}
return null;
},
showCancelButton: true,
confirmButtonText: t("admin.pos.settings_wheel.add_flag"),
cancelButtonText: t("common.cancel"),
});
if (!result.isConfirmed) {
return;
}
const response = await SessionUser.request("/superuser/invoicing/period/flags", "POST", {
...target,
reason: String(result.value || "").trim(),
});
emitFlagCreated(response?.data?.data ?? response?.data ?? response);
};
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("common.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);
const SELF_SERVE_WASH_ATTACHMENT_TYPE = "SELF_SERVE_WASH";
const normalizePositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const getAttachmentOtherPayload = (attachment) => attachment?.content?.other ?? null;
const isSelfServeWashAttachment = (attachment) => {
const other = getAttachmentOtherPayload(attachment);
return Boolean(other && typeof other === "object" && other.type === SELF_SERVE_WASH_ATTACHMENT_TYPE);
};
const getSelfServeWashAttachment = computed(() =>
attachmentsFromOrder.value.find((attachment) => isSelfServeWashAttachment(attachment)) || null
);
const getSelfServeWashPayload = computed(() => getAttachmentOtherPayload(getSelfServeWashAttachment.value));
const getSelfServeWashCustomerNumber = computed(() =>
normalizePositiveInteger(getSelfServeWashPayload.value?.customer_number)
);
const canAcceptSelfServeWashDraft = computed(() =>
Boolean(
props.order_id
&& getSelfServeWashCustomerNumber.value
&& normalizePositiveInteger(props.customer_number) !== getSelfServeWashCustomerNumber.value
&& (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser())
)
);
const isObjectUrl = (value) => typeof value === "string" && value.startsWith("blob:");
const releaseObjectUrl = (value) => {
if (!isObjectUrl(value)) {
return;
}
URL.revokeObjectURL(value);
generatedObjectUrls.delete(value);
};
const clearAttachmentPreviewState = () => {
previewStateGeneration += 1;
previewRequestsInFlight.clear();
Object.values(previewSourcesById.value).forEach((value) => {
releaseObjectUrl(value);
});
previewSourcesById.value = {};
previewLoadingAttachmentId.value = null;
activeAttachmentId.value = null;
};
const getAttachmentLabel = (attachment) => {
if (isSelfServeWashAttachment(attachment)) {
const customerNumber = normalizePositiveInteger(attachment?.content?.other?.customer_number);
return customerNumber
? t("admin.pos.settings_wheel.self_serve_wash_attachment_for_customer", { customerNumber })
: t("admin.pos.settings_wheel.self_serve_wash_attachment");
}
const other = getAttachmentOtherPayload(attachment);
const otherLabel = typeof other === "string"
? other
: other && typeof other === "object"
? (other.label || other.type || JSON.stringify(other))
: null;
return (
attachment?.content?.document ||
attachment?.content?.image ||
otherLabel ||
`Attachment ${attachment?.id ?? ""}`.trim()
);
};
const isWashCertificateAttachment = (attachment) => {
const marker = String(getAttachmentOtherPayload(attachment) || "").trim().toUpperCase();
if (marker === "WASH_CERTIFICATE") {
return true;
}
return /(?:^|[/\\])wash[_-]?certificate.*\.pdf$/i.test(getAttachmentLabel(attachment));
};
const getAttachmentExtension = (attachment) => {
const match = String(getAttachmentLabel(attachment))
.toLowerCase()
.match(/(\.[a-z0-9]+)$/);
return match?.[1] || "";
};
const getAttachmentPreviewKind = (attachment) => {
const extension = getAttachmentExtension(attachment);
if (attachment?.content?.image || imageExtensions.includes(extension)) {
return "image";
}
if (attachment?.content?.document || extension === ".pdf") {
return officeExtensions.includes(extension) ? "office" : "document";
}
if (officeExtensions.includes(extension)) {
return "office";
}
const other = getAttachmentOtherPayload(attachment);
if (typeof other === "string" && other.startsWith("http")) {
return "link";
}
if (other) {
return "text";
}
return "none";
};
const formatAttachmentText = (attachment) => {
const other = getAttachmentOtherPayload(attachment);
if (isSelfServeWashAttachment(attachment)) {
const parts = [
t("admin.pos.settings_wheel.self_serve_wash_attachment"),
other?.customer_number
? `${t("admin.pos.settings_wheel.self_serve_customer")}: #${other.customer_number}`
: null,
other?.subuser?.name || other?.subuser?.username || other?.subuser_id
? `${t("admin.pos.settings_wheel.self_serve_driver")}: ${other?.subuser?.name || other?.subuser?.username || `#${other.subuser_id}`}`
: null,
other?.license_plate
? `${t("pos.license_plate")}: ${other.license_plate}`
: null,
other?.elapsed_wash_time_seconds
? `${t("admin.pos.settings_wheel.self_serve_elapsed")}: ${Math.ceil(Number(other.elapsed_wash_time_seconds) / 60)} min`
: null,
].filter(Boolean);
return parts.join("\n");
}
if (typeof other === "string") {
return other;
}
if (other && typeof other === "object") {
return JSON.stringify(other, null, 2);
}
return "";
};
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;
});
const activeAttachmentPreviewKind = computed(() => {
if (!activeAttachment.value) {
return "none";
}
return getAttachmentPreviewKind(activeAttachment.value);
});
const activeAttachmentPreviewSource = computed(() => {
if (!activeAttachment.value) {
return null;
}
return previewSourcesById.value[activeAttachment.value.id] ?? null;
});
const hasWashCertificateAttachment = computed(() =>
attachmentsFromOrder.value.some((attachment) => isWashCertificateAttachment(attachment))
);
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(getReleaseRuntimeApiBaseUrl(), 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 {
let fileBlob = await fetchAttachmentPreviewBlob(downloadLink, false);
if (!fileBlob && shouldRetryPreviewRequestWithAuth(downloadLink)) {
fileBlob = await fetchAttachmentPreviewBlob(downloadLink, true);
}
if (!fileBlob || fileBlob.size === 0) {
return null;
}
const objectUrl = URL.createObjectURL(fileBlob);
generatedObjectUrls.add(objectUrl);
return objectUrl;
} catch (error) {
console.warn("Unable to create embeddable attachment preview blob", error);
return null;
}
};
const loadOrderAttachments = async () => {
if (!props.order_id) {
attachmentsFromOrder.value = [];
attachmentsFromOrderError.value = null;
clearAttachmentPreviewState();
return [];
}
attachmentsFromOrderError.value = null;
try {
let response = await SessionUser.objects.orders.functions.fetchAttachments(props.order_id);
if (!Array.isArray(response)) {
response = [];
}
attachmentsFromOrder.value = response;
if (!response.some((attachment) => attachment.id === activeAttachmentId.value)) {
activeAttachmentId.value = null;
}
return response;
} catch (error) {
const parsedErrorMessage = SessionUser.functions.parseErrorMessage(error);
console.warn("Error fetching attachments from order:", error, parsedErrorMessage);
attachmentsFromOrder.value = [];
attachmentsFromOrderError.value = `ERROR: ${parsedErrorMessage || "Failed to fetch attachments"}`;
clearAttachmentPreviewState();
return [];
}
};
const resolveAttachmentDownloadLink = async (attachment) => {
if (!props.order_id || !attachment?.id) {
return null;
}
return SessionUser.objects.orders.functions.downloadAttachment(props.order_id, attachment.id, false);
};
const ensurePreviewSource = async (attachment) => {
if (!attachment) {
return null;
}
const currentPreviewStateGeneration = previewStateGeneration;
const previewKind = getAttachmentPreviewKind(attachment);
if (!["image", "document"].includes(previewKind)) {
return null;
}
if (hasCachedPreviewSource(attachment.id) || previewRequestsInFlight.has(attachment.id)) {
return previewSourcesById.value[attachment.id] ?? null;
}
previewRequestsInFlight.add(attachment.id);
previewLoadingAttachmentId.value = attachment.id;
try {
const downloadLink = await resolveAttachmentDownloadLink(attachment);
const previewSource = await createEmbeddablePreviewUrl(downloadLink);
if (currentPreviewStateGeneration !== previewStateGeneration) {
releaseObjectUrl(previewSource);
return null;
}
previewSourcesById.value = {
...previewSourcesById.value,
[attachment.id]: previewSource,
};
return previewSource;
} catch (error) {
console.warn("Unable to load attachment preview", attachment.id, error);
if (currentPreviewStateGeneration === previewStateGeneration) {
previewSourcesById.value = {
...previewSourcesById.value,
[attachment.id]: null,
};
}
return null;
} finally {
previewRequestsInFlight.delete(attachment.id);
if (previewLoadingAttachmentId.value === attachment.id) {
previewLoadingAttachmentId.value = null;
}
}
};
const setActiveAttachment = async (attachment) => {
if (!attachment) {
activeAttachmentId.value = null;
return;
}
activeAttachmentId.value = attachment.id;
await ensurePreviewSource(attachment);
};
const openAttachmentInNewTab = (url) => {
if (!url) {
return null;
}
return window.open(url, "_blank", "noopener,noreferrer");
};
const downloadAttachmentFile = (url, attachment) => {
if (!url) {
return;
}
const link = document.createElement("a");
link.href = url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.download = getAttachmentLabel(attachment);
document.body.appendChild(link);
link.click();
link.remove();
};
const previewAttachment = async (attachment) => {
try {
const previewKind = getAttachmentPreviewKind(attachment);
if (["image", "document"].includes(previewKind)) {
const previewSource = (await ensurePreviewSource(attachment)) || (await resolveAttachmentDownloadLink(attachment));
openAttachmentInNewTab(previewSource);
return;
}
const downloadLink = await resolveAttachmentDownloadLink(attachment);
openAttachmentInNewTab(downloadLink);
} catch (error) {
console.warn("Unable to preview attachment", attachment?.id, error);
Swal.fire({
title: t("common.error"),
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
icon: "error",
});
}
};
const downloadAttachment = async (attachment) => {
try {
const downloadLink = await resolveAttachmentDownloadLink(attachment);
downloadAttachmentFile(downloadLink, attachment);
} catch (error) {
Swal.fire({
title: t("common.error"),
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
icon: "error",
});
}
};
const printAttachment = async (attachment) => {
try {
const previewKind = getAttachmentPreviewKind(attachment);
const downloadLink = await resolveAttachmentDownloadLink(attachment);
if (!downloadLink) {
return;
}
if (!["image", "document"].includes(previewKind)) {
openAttachmentInNewTab(downloadLink);
return;
}
const printableSource = (await ensurePreviewSource(attachment)) || downloadLink;
if (previewKind === "image") {
const printWindow = window.open("", "_blank", "noopener,noreferrer,width=960,height=720");
if (!printWindow) {
openAttachmentInNewTab(printableSource);
return;
}
const escapedTitle = getAttachmentLabel(attachment).replace(/"/g, "&quot;");
printWindow.document.write(`<!DOCTYPE html>
<html>
<head>
<title>${escapedTitle}</title>
<style>
html, body {
margin: 0;
min-height: 100%;
background: #ffffff;
}
body {
display: flex;
align-items: center;
justify-content: center;
}
img {
display: block;
max-width: 100%;
max-height: 100vh;
object-fit: contain;
}
</style>
</head>
<body>
<img id="attachment-print-image" src="${printableSource}" alt="" />
</body>
</html>`);
printWindow.document.close();
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({
title: t("common.error"),
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
icon: "error",
});
}
};
const removeAttachment = async (attachment) => {
if (!props.order_id || !attachment?.id || deletingAttachmentId.value === attachment.id) {
return;
}
deletingAttachmentId.value = attachment.id;
try {
const response = await SessionUser.objects.orders.functions.removeAttachment(props.order_id, attachment.id);
if (!response) {
throw new Error("Attachment delete failed");
}
releaseObjectUrl(previewSourcesById.value[attachment.id]);
const nextPreviewSourcesById = { ...previewSourcesById.value };
delete nextPreviewSourcesById[attachment.id];
previewSourcesById.value = nextPreviewSourcesById;
await loadOrderAttachments();
await props.refreshFunction();
} catch (error) {
console.warn("Unable to delete attachment", attachment?.id, error);
Swal.fire({
title: t("common.error"),
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
icon: "error",
});
} finally {
deletingAttachmentId.value = null;
}
};
watch(
() => [attachmentsFromOrder.value.length, attachmentsFromOrderError.value, userIdFromCustomerNumber.value],
() => {
if (isDropdownOpen.value) {
void updateDropdownLayout();
}
}
);
watch(
() => props.order_id,
() => {
void loadOrderAttachments();
},
{ immediate: true }
);
onMounted(() => {
document.addEventListener("click", onDocumentClick);
document.addEventListener("keydown", onDocumentKeydown);
window.addEventListener("action-settings-wheel:opened", onActionSettingsWheelOpened);
window.addEventListener("resize", onViewportChange);
document.addEventListener("scroll", onViewportChange, true);
contentResizeObserver = new ResizeObserver(() => {
if (isDropdownOpen.value) {
syncDesktopFlyoutPosition();
}
});
if (dropdownContent.value) {
contentResizeObserver.observe(dropdownContent.value);
}
});
watch(dropdownContent, (newEl, oldEl) => {
if (oldEl && contentResizeObserver) {
contentResizeObserver.unobserve(oldEl);
}
if (newEl && contentResizeObserver) {
contentResizeObserver.observe(newEl);
}
});
onBeforeUnmount(() => {
document.removeEventListener("click", onDocumentClick);
document.removeEventListener("keydown", onDocumentKeydown);
window.removeEventListener("action-settings-wheel:opened", onActionSettingsWheelOpened);
window.removeEventListener("resize", onViewportChange);
document.removeEventListener("scroll", onViewportChange, true);
clearAttachmentPreviewState();
if (contentResizeObserver) {
contentResizeObserver.disconnect();
contentResizeObserver = null;
}
});
const onShowImpersonationQRCode = (src, directLink) => {
Swal.fire({
title: t("admin.pos.settings_wheel.scan_qr_to_login"),
html: `<img src="${src}" alt="QR-kode" />`,
showCloseButton: true,
showConfirmButton: !!directLink,
showCancelButton: true,
focusConfirm: false,
confirmButtonText: t("admin.pos.settings_wheel.copy_link"),
cancelButtonText: t("common.close"),
preConfirm: () => {
navigator.clipboard.writeText(directLink);
Swal.showValidationMessage(t("admin.pos.settings_wheel.link_copied"));
},
});
};
const onClickShowImpersonationQRCode = async (userId) => {
// Get the session token
const newToken = await SessionUser.superUser.intimidate.getImpersonationToken(userId);
// Create the impersonation link
const link = `${window.location.protocol}//${window.location.host}/login/qr?token=${newToken}`;
// Generate a QR code for the impersonation link
const response = await SessionUser.superUser.intimidate.showImpersonationQRCode(link);
// Show the QR code in a modal
onShowImpersonationQRCode(response.value, link);
};
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,
});
const buildMenuSection = (key, label, items) => {
const visibleItems = items.filter(Boolean);
if (visibleItems.length === 0) {
return null;
}
return {
key,
label,
items: visibleItems,
};
};
const isToggleMenuItem = (item) => item?.type === "toggle";
const downloadOrderAttachment = (attachment) =>
downloadAttachment(attachment).catch(() => {
Swal.fire({
title: t("common.error"),
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
icon: "error",
});
});
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("/selfserve/edge-agents", 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(
`/selfserve/edge-agents/${encodeURIComponent(String(primaryGatewayId))}/overview`,
true
);
return;
}
} catch (error) {
console.warn("Unable to resolve primary gateway from department workspace", error);
}
SessionUser.functions.redirectTo.superUser("/selfserve/edge-agents", true);
};
const showCompleteOrderBookingConfirmation = async () => {
const result = await Swal.fire({
title: t("bookings.complete_booking"),
text: t("pos.confirm_complete_order"),
icon: "warning",
showDenyButton: true,
showCancelButton: true,
confirmButtonText: t("admin.pos.settings_wheel.view_booking_new_tab"),
denyButtonText: t("tables.bookings.complete_wash_without_certificate"),
cancelButtonText: t("common.cancel"),
reverseButtons: true,
});
if (result.isConfirmed) {
SessionUser.functions.redirectTo.department(
props.department_id,
"modules/bookings/order/" + props.order_booking_id,
true
);
return;
}
if (result.isDenied) {
await SessionUser.objects.order_bookings.functions.complete(props.order_booking_id, null, () => {
props.refreshFunction();
});
}
};
const showEmailNotificationActionResult = async (requestAction, successKey, errorKey) => {
try {
await requestAction();
await Swal.fire({
title: t(successKey),
icon: "success",
showConfirmButton: false,
timer: 2000,
heightAuto: false,
});
} catch (error) {
console.error(error);
await Swal.fire({
title: t("common.error"),
text: [t(errorKey), SessionUser.functions.parseErrorMessage?.(error)].filter(Boolean).join(": "),
icon: "error",
heightAuto: false,
});
}
};
const resendBookingConfirmation = () =>
showEmailNotificationActionResult(
() => SessionUser.objects.order_bookings.functions.resendBookingConfirmation(props.order_booking_id),
"admin.pos.settings_wheel.resend_booking_confirmation_success",
"admin.pos.settings_wheel.resend_booking_confirmation_error"
);
const resendBookingCompletionConfirmation = () =>
showEmailNotificationActionResult(
() => SessionUser.objects.order_bookings.functions.resendBookingCompletionConfirmation(props.order_booking_id),
"admin.pos.settings_wheel.resend_booking_completion_confirmation_success",
"admin.pos.settings_wheel.resend_booking_completion_confirmation_error"
);
const resendWashCertificate = () =>
showEmailNotificationActionResult(
() => SessionUser.objects.orders.functions.resendWashCertificate(props.order_id),
"admin.pos.settings_wheel.resend_wash_certificate_success",
"admin.pos.settings_wheel.resend_wash_certificate_error"
);
const getAvailableInvoiceCollectionsForCustomer = async (customerNumber) => {
const normalizedCustomerNumber = normalizePositiveInteger(customerNumber);
if (!normalizedCustomerNumber) {
return [];
}
const response = await SessionUser.request("/collected-invoices", "GET", {
page: 1,
limit: 100,
order: "closed_at:asc",
filters: `customer_number:${normalizedCustomerNumber},booked_invoice_id:is_null`,
});
return Array.isArray(response?.data?.data) ? response.data.data : [];
};
const ensureOpenInvoiceCollectionForCustomer = async (customerNumber) => {
const collections = await getAvailableInvoiceCollectionsForCustomer(customerNumber);
const openCollection = collections.find((collection) => collection?.closed_at === null);
const openCollectionId = normalizePositiveInteger(openCollection?.id);
if (openCollectionId) {
return openCollectionId;
}
const response = await SessionUser.objects.collectedOrderInvoices.add(
customerNumber,
t("admin.pos.drafts_assignment.new_collection_name"),
t("admin.pos.drafts_assignment.new_collection_description"),
null
);
return normalizePositiveInteger(response?.data?.data?.id ?? response?.data?.id);
};
const acceptSelfServeWashDraft = async () => {
const customerNumber = getSelfServeWashCustomerNumber.value;
const orderId = normalizePositiveInteger(props.order_id);
if (!customerNumber || !orderId) {
return;
}
const result = await Swal.fire({
title: t("admin.pos.settings_wheel.accept_self_serve_wash"),
text: t("admin.pos.settings_wheel.accept_self_serve_wash_confirm", { customerNumber }),
icon: "question",
showCancelButton: true,
confirmButtonText: t("common.confirm"),
cancelButtonText: t("common.cancel"),
});
if (!result.isConfirmed) {
return;
}
try {
const invoiceCollectionId = await ensureOpenInvoiceCollectionForCustomer(customerNumber);
if (!invoiceCollectionId) {
throw new Error(t("admin.pos.drafts_assignment.invoice_collection_empty"));
}
await SessionUser.objects.orders.functions.assignDraftCustomer({
order_id: orderId,
customer_id: customerNumber,
invoice_collection_id: invoiceCollectionId,
department_id: normalizePositiveInteger(props.department_id),
recalculate_prices: true,
});
await props.refreshFunction();
await Swal.fire({
icon: "success",
title: t("admin.pos.settings_wheel.accept_self_serve_wash_success"),
timer: 1800,
showConfirmButton: false,
});
} catch (error) {
await Swal.fire({
icon: "error",
title: t("admin.pos.drafts_assignment.error"),
text: SessionUser.functions.parseErrorMessage(error) || t("admin.pos.drafts_assignment.error"),
});
}
};
const canDeleteOrderBooking = computed(() =>
!props.order_id &&
(props.allowBookingDeletion || SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser())
);
const flatBuiltInMenuSections = computed(() => {
const sections = [];
if (props.order_booking_id) {
const bookingSection = buildMenuSection("booking", t("admin.pos.settings_wheel.booking"), [
props.allowBookingCompletion
? buildMenuAction("booking-complete", {
icon: "fas fa-check",
label: t("admin.pos.settings_wheel.mark_as_completed"),
template: "success",
clickAction: showCompleteOrderBookingConfirmation,
})
: null,
buildMenuAction("booking-view", {
icon: "fas fa-external-link-alt",
label: t("admin.pos.settings_wheel.view_booking_new_tab"),
clickAction: () =>
SessionUser.functions.redirectTo.department(
props.department_id,
"modules/bookings/order/" + props.order_booking_id,
true
),
}),
SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()
? buildMenuAction("booking-change-association", {
icon: "fas fa-edit",
label: !props.order_id
? t("admin.pos.settings_wheel.associate_order", {
order: SessionUser.objects.orders.meta.labels.single.toLowerCase(),
})
: t("admin.pos.settings_wheel.change_order", {
order: SessionUser.objects.orders.meta.labels.single.toLowerCase(),
}),
clickAction: () =>
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,
},
}
),
})
: null,
canDeleteOrderBooking.value
? buildMenuAction("booking-delete", {
icon: "fas fa-trash-alt",
label: t("admin.pos.settings_wheel.delete_booking"),
template: "danger",
clickAction: () =>
SessionUser.objects.order_bookings.functions.showDeleteConfirmationModal(props.order_booking_id, () => {
props.refreshFunction();
}),
})
: null,
]);
if (bookingSection) {
sections.push(bookingSection);
}
}
if (props.order_id) {
const orderSection = buildMenuSection(
"order",
SessionUser.functions.ucFirst(SessionUser.objects.orders.meta.labels.single),
[
buildMenuAction("order-view", {
icon: "fas fa-external-link-alt",
label: t("admin.pos.settings_wheel.view_order_new_tab"),
clickAction: () =>
SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()
? redirectDepartmentOrderPage(props.order_id, true)
: SessionUser.functions.redirectTo.user("/orders/" + props.order_id, true),
}),
SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()
? canAcceptSelfServeWashDraft.value
? buildMenuAction("order-accept-self-serve-wash", {
icon: "fas fa-check-circle",
label: t("admin.pos.settings_wheel.accept_self_serve_wash"),
template: "success",
clickAction: acceptSelfServeWashDraft,
})
: null
: null,
SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()
? buildMenuAction("order-attach-wash-certificate", {
icon: "fas fa-paperclip",
label: t("admin.pos.settings_wheel.attach_wash_certificate"),
clickAction: () =>
SessionUser.objects.orders.functions.showAttachWashCertificateForm(props.order_id, () => {
props.refreshFunction();
}),
})
: null,
SessionUser.canAccessSuperUser()
? buildMenuAction("order-change-customer", {
icon: "fas fa-user-edit",
label: t("admin.pos.settings_wheel.change_customer"),
clickAction: () => SessionUser.objects.orders.functions.showChangeCustomerForm(props.order_id),
})
: null,
SessionUser.canAccessSuperUser()
? buildMenuAction("order-change-invoice-collection", {
icon: "fas fa-file-invoice-dollar",
label: t("admin.pos.settings_wheel.change_invoice_collection"),
clickAction: () =>
SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm(
props.order_id,
props.refreshFunction
),
})
: null,
SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()
? buildMenuAction("order-delete", {
icon: "fas fa-trash-alt",
label: t("admin.pos.settings_wheel.delete_order"),
template: "danger",
clickAction: () =>
SessionUser.objects.orders.functions.showDeleteConfirmationModal(props.order_id, () => {
emitDeleted();
}),
})
: null,
]
);
if (orderSection) {
sections.push(orderSection);
}
}
if (props.order_booking_id || hasWashCertificateAttachment.value) {
const emailNotificationsSection = buildMenuSection(
"email-notifications",
t("admin.pos.settings_wheel.email_notifications_section"),
[
props.order_booking_id
? buildMenuAction("email-notifications-resend-booking-confirmation", {
icon: "fas fa-envelope",
label: t("admin.pos.settings_wheel.resend_booking_confirmation"),
clickAction: resendBookingConfirmation,
})
: null,
props.order_booking_id && hasWashCertificateAttachment.value
? buildMenuAction("email-notifications-resend-booking-completion-confirmation", {
icon: "fas fa-envelope-open-text",
label: t("admin.pos.settings_wheel.resend_booking_completion_confirmation"),
clickAction: resendBookingCompletionConfirmation,
})
: null,
!props.order_booking_id && hasWashCertificateAttachment.value
? buildMenuAction("email-notifications-resend-wash-certificate", {
icon: "fas fa-file-pdf",
label: t("admin.pos.settings_wheel.resend_wash_certificate"),
clickAction: resendWashCertificate,
})
: null,
]
);
if (emailNotificationsSection) {
sections.push(emailNotificationsSection);
}
}
if (props.invoice_collection_id && SessionUser.canAccessSuperUser()) {
const invoiceCollectionLinkSection = buildMenuSection(
"invoice-collection-link",
SessionUser.objects.collectedOrderInvoices.meta.title,
[
buildMenuAction("invoice-collection-view", {
icon: "fas fa-file-invoice-dollar",
label: t("admin.pos.settings_wheel.view_invoice_collection_new_tab"),
clickAction: () => redirectSuperUserInvoiceCollectionPage(props.invoice_collection_id),
}),
]
);
if (invoiceCollectionLinkSection) {
sections.push(invoiceCollectionLinkSection);
}
}
if (props.department_lane_id) {
const departmentLaneSection = buildMenuSection(
"department-lane",
SessionUser.objects.department_lanes.meta.labels.single,
[
(SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()) && props.department_id
? buildMenuAction("department-lane-view-admin", {
icon: "fas fa-external-link-alt",
label: t("admin.pos.settings_wheel.view_lane_new_tab"),
clickAction: () =>
SessionUser.functions.redirectTo.department(
props.department_id,
"modules/wash-lanes/" + props.department_lane_id,
!SessionUser.functions.device.isMobile()
),
})
: null,
SessionUser.canAccessSuperUser()
? buildMenuAction("department-lane-view-superuser", {
icon: "fas fa-external-link-alt",
label: t("admin.pos.settings_wheel.view_lane_setup_new_tab"),
clickAction: () =>
SessionUser.functions.redirectTo.superUser("/department/lanes/" + props.department_lane_id, true),
})
: null,
SessionUser.canAccessSuperUser()
? buildMenuAction("department-lane-force-enable", {
icon: "fas fa-play",
label: t("superuser.department_lane.force_enable_machine"),
template: "success",
clickAction: () => SessionUser.objects.department_lanes.functions.forceEnableMachine(props.department_lane_id),
})
: null,
SessionUser.canAccessSuperUser()
? buildMenuAction("department-lane-force-disable", {
icon: "fas fa-stop",
label: t("superuser.department_lane.force_disable_machine"),
template: "danger",
clickAction: () => SessionUser.objects.department_lanes.functions.forceDisableMachine(props.department_lane_id),
})
: null,
]
);
if (departmentLaneSection) {
sections.push(departmentLaneSection);
}
}
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("/selfserve/edge-agents", 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 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),
}),
buildMenuAction("customer-login", {
icon: "fas fa-user-shield",
label: t("admin.pos.settings_wheel.login_as_user"),
clickAction: () => SessionUser.superUser.intimidate.intimidateUser(userIdFromCustomerNumber.value),
}),
buildMenuAction("customer-login-qr", {
icon: "fas fa-user-shield",
label: t("admin.pos.settings_wheel.login_as_user_qr"),
clickAction: () => onClickShowImpersonationQRCode(userIdFromCustomerNumber.value),
}),
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 || props.reg_3) {
const vehicleSection = buildMenuSection(
"vehicle",
[props.reg_1, props.reg_2, props.reg_3].filter(Boolean).length > 1
? SessionUser.objects.vehicles.meta.labels.multiple
: SessionUser.objects.vehicles.meta.labels.single,
[
props.reg_1 && SessionUser.canAccessSuperUser()
? buildMenuAction("vehicle-reg-1", {
icon: "fas fa-car",
label: t("admin.pos.settings_wheel.view_vehicle_new_tab", { reg: props.reg_1 }),
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_1, true),
})
: null,
props.reg_2 && SessionUser.canAccessSuperUser()
? buildMenuAction("vehicle-reg-2", {
icon: "fas fa-car",
label: t("admin.pos.settings_wheel.view_vehicle_new_tab", { reg: props.reg_2 }),
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_2, true),
})
: null,
props.reg_3 && SessionUser.canAccessSuperUser()
? buildMenuAction("vehicle-reg-3", {
icon: "fas fa-car",
label: t("admin.pos.settings_wheel.view_vehicle_new_tab", { reg: props.reg_3 }),
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_3, true),
})
: null,
]
);
if (vehicleSection) {
sections.push(vehicleSection);
}
}
if (attachmentsFromOrder.value.length > 0) {
const attachmentsSection = buildMenuSection("attachments", t("admin.pos.settings_wheel.attached_files"), [
...attachmentsFromOrder.value.map((attachment) =>
buildMenuAction(`attachment-${attachment.id}`, {
icon: "fas fa-paperclip",
label: getAttachmentLabel(attachment),
clickAction: () => downloadOrderAttachment(attachment),
})
),
]);
if (attachmentsSection) {
sections.push({
...attachmentsSection,
attachments: [...attachmentsFromOrder.value],
});
}
}
if (props.invoice_collection_id) {
const invoiceCollectionDownloadSection = buildMenuSection(
"invoice-collection-download",
SessionUser.objects.collectedOrderInvoices.meta.title,
[
buildMenuAction("invoice-collection-download-invoice", {
icon: "fas fa-download",
label: t("admin.pos.settings_wheel.download_invoice"),
clickAction: () => SessionUser.objects.collectedOrderInvoices.functions.download(props.invoice_collection_id),
}),
]
);
if (invoiceCollectionDownloadSection) {
sections.push(invoiceCollectionDownloadSection);
}
}
if (canAddInvoicePeriodFlag.value) {
const flagSection = buildMenuSection(
"invoice-period-flags",
t("admin.pos.settings_wheel.flags_section"),
defaultInvoicePeriodFlagTargets.value.map((item) =>
buildMenuAction(`add-invoice-period-flag-${item.key}`, {
icon: "fas fa-flag",
label: item.label,
template: "danger",
clickAction: () => showCreateInvoicePeriodFlagForm(item.target),
})
)
);
if (flagSection) {
sections.push(flagSection);
}
}
return sections;
});
const standaloneMenuActions = computed(() => {
const actions = [];
if (props.subuserGrant && SessionUser.canAccessUser()) {
actions.push(
buildMenuAction("subuser-grant-edit-permissions", {
icon: "fas fa-edit",
label: t("admin.pos.settings_wheel.edit_permissions"),
clickAction: () =>
SessionUser.objects.subuser_grants.functions.showPermissionEditForm(props.subuserGrant, () => {
props.refreshFunction();
}),
})
);
actions.push(
buildMenuAction("subuser-grant-delete-user", {
icon: "fas fa-trash",
label: t("admin.pos.settings_wheel.delete_user"),
clickAction: () =>
SessionUser.objects.subuser_grants.delete(props.subuserGrant.id, () => {
props.refreshFunction();
}),
})
);
}
return actions;
});
const desktopFlyoutMenuSections = computed(() => {
const mergedSections = [];
const mergedSectionsByLabel = new Map();
flatBuiltInMenuSections.value.forEach((section) => {
const labelKey = String(section.label);
const existingSection = mergedSectionsByLabel.get(labelKey);
if (existingSection) {
existingSection.items.push(...section.items);
return;
}
const mergedSection = {
...section,
items: [...section.items],
};
mergedSectionsByLabel.set(labelKey, mergedSection);
mergedSections.push(mergedSection);
});
return mergedSections;
});
watch(
[
activeDesktopFlyoutSectionKey,
activeAttachmentId,
isDesktopFlyoutLayout,
desktopFlyoutMenuSections,
activeAttachmentPreviewSource,
attachmentsFromOrder,
],
() => {
if (isDropdownOpen.value) {
void nextTick(() => {
syncDesktopFlyoutPosition();
});
}
}
);
const activeDesktopFlyoutSection = computed(() => {
return (
desktopFlyoutMenuSections.value.find((section) => section.key === activeDesktopFlyoutSectionKey.value) ??
desktopFlyoutMenuSections.value[0] ??
null
);
});
const hasDropdownContent = computed(
() => hasCustomActionsSlot.value || flatBuiltInMenuSections.value.length > 0 || standaloneMenuActions.value.length > 0
);
const getDesktopFlyoutEstimatedWidth = () => {
const rootFontSize = Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize || "16") || 16;
return (desktopFlyoutRootPanelWidthRem + desktopFlyoutSubmenuWidthRem) * rootFontSize + desktopFlyoutPanelGapPx;
};
const syncDesktopFlyoutState = (triggerRect) => {
if (typeof window === "undefined") {
isDesktopFlyoutLayout.value = false;
return;
}
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;
const nextDesktopFlyoutState =
canUseHover && hasSections && window.innerWidth >= desktopFlyoutMinViewportWidth && hasSpaceForFlyout;
isDesktopFlyoutLayout.value = nextDesktopFlyoutState;
if (!nextDesktopFlyoutState) {
activeDesktopFlyoutSectionKey.value = null;
return;
}
const currentSectionStillExists = desktopFlyoutMenuSections.value.some(
(section) => section.key === activeDesktopFlyoutSectionKey.value
);
if (!currentSectionStillExists) {
activeDesktopFlyoutSectionKey.value = desktopFlyoutMenuSections.value[0]?.key ?? null;
}
};
const setActiveDesktopFlyoutSection = (sectionKey) => {
activeDesktopFlyoutSectionKey.value = sectionKey;
if (sectionKey !== "attachments") {
activeAttachmentId.value = null;
}
};
const isOverflowClippingStyle = (style) =>
["overflow", "overflowX", "overflowY"].some((property) => {
const value = style[property];
return value && value !== "visible";
});
const isDropdownClippedByAncestor = (dropdownRect, triggerRect) => {
let el = dropdownRoot.value?.parentElement;
while (el && el !== document.body) {
const style = window.getComputedStyle(el);
if (isOverflowClippingStyle(style)) {
const ancestorRect = el.getBoundingClientRect();
const wouldClipCurrentDropdown =
dropdownRect.top < ancestorRect.top ||
dropdownRect.right > ancestorRect.right ||
dropdownRect.bottom > ancestorRect.bottom ||
dropdownRect.left < ancestorRect.left;
const wouldClipBelow = triggerRect.bottom + dropdownRect.height > ancestorRect.bottom;
const wouldClipAbove = triggerRect.top - dropdownRect.height < ancestorRect.top;
if (wouldClipCurrentDropdown || (wouldClipBelow && wouldClipAbove)) {
return true;
}
}
el = el.parentElement;
}
return false;
};
// Smart re-positioning of the flyout menu when either it's partially or fully hidden
const syncDesktopFlyoutPosition = () => {
if (typeof window === "undefined") {
return;
}
const dropdownContentEl = dropdownContent.value;
const triggerEl = dropdownRoot.value?.querySelector(".dropdown-trigger") ?? dropdownRoot.value;
if (!dropdownContentEl || !triggerEl) {
return;
}
const triggerRect = triggerEl.getBoundingClientRect();
const contentHeight = dropdownContentEl.scrollHeight;
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const contentRect = dropdownContentEl.getBoundingClientRect();
let needsFixed = isDropdownClippedByAncestor(contentRect, triggerRect);
if (isDesktopFlyoutLayout.value) {
let el = dropdownRoot.value.parentElement;
while (el && el !== document.body && el !== null) {
const style = window.getComputedStyle(el);
if (style.overflowY !== "visible") {
if (el.clientHeight < contentHeight + 10) {
needsFixed = true;
break;
}
}
el = el.parentElement;
}
}
if (isFixedPosition.value !== needsFixed) {
isFixedPosition.value = needsFixed;
}
if (needsFixed) {
if (dropdownContentEl.style.top !== "") {
dropdownContentEl.style.top = "";
}
const viewportInsets = getViewportInsets();
const viewportTop = viewportInsets.top;
const viewportBottom = viewportHeight - viewportInsets.bottom;
let top = triggerRect.bottom;
if (top + contentHeight > viewportBottom) {
top = Math.max(viewportTop, triggerRect.top - contentHeight);
}
const nextFixedStyles = {
top: `${top}px`,
right: `${viewportWidth - triggerRect.right}px`,
width: "max-content",
};
if (
fixedPositionStyles.value.top !== nextFixedStyles.top ||
fixedPositionStyles.value.right !== nextFixedStyles.right ||
fixedPositionStyles.value.width !== nextFixedStyles.width
) {
fixedPositionStyles.value = nextFixedStyles;
}
} else {
const contentRect = dropdownContentEl.getBoundingClientRect();
const contentBottom = contentRect.top + contentRect.height;
if (contentBottom > viewportHeight) {
const nextTop = `${triggerRect.top - contentRect.height}px`;
if (dropdownContentEl.style.top !== nextTop) {
dropdownContentEl.style.top = nextTop;
}
} else {
if (dropdownContentEl.style.top !== "") {
dropdownContentEl.style.top = "";
}
}
}
};
</script>
<template>
<div>
<template v-if="displayActionsDirectly">
<!-- Error -->
<template v-if="attachmentsFromOrderError">
<div class="message is-danger">
<div class="message-body">
{{ attachmentsFromOrderError }}
</div>
</div>
</template>
<slot v-if="hasCustomActionsSlot" name="actions"></slot>
<template v-for="section in flatBuiltInMenuSections" :key="section.key">
<ActionSettingsWheelItemLabel :label="section.label" />
<template v-for="item in section.items" :key="item.key">
<ActionSettingsWheelToggleItem
v-if="isToggleMenuItem(item)"
:checked="item.value"
:click-action="item.clickAction"
:description="item.description"
:disabled="item.disabled"
:label="item.label"
:test-id="item.testId"
/>
<ActionSettingsWheelItem
v-else
:icon="item.icon"
:label="item.label"
:template="item.template"
:click-action="item.clickAction"
:disabled="item.disabled"
/>
</template>
</template>
<ActionSettingsWheelItem
v-for="item in standaloneMenuActions"
:key="item.key"
:icon="item.icon"
:label="item.label"
:template="item.template"
:click-action="item.clickAction"
:disabled="item.disabled"
/>
</template>
<div class="dropdown is-right" :class="dropdownClass" v-else ref="dropdownRoot">
<div class="dropdown-trigger">
<button
type="button"
:class="getTriggerButtonClass"
class="is-inverted"
aria-haspopup="true"
aria-controls="dropdown-menu"
:aria-expanded="isDropdownOpen ? 'true' : 'false'"
@click.stop="toggleDropdown"
>
<span class="icon">
<i :class="props.icon"></i>
</span>
<span v-if="props.label.length > 0" class="ml-2">{{ props.label }}</span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu" :style="dropdownMenuStyle">
<div
ref="dropdownContent"
class="dropdown-content"
:class="dropdownContentClass"
:style="dropdownContentStyle"
@dropdown-action-selected="onActionSelected"
>
<template v-if="hasDropdownContent">
<template v-if="isDesktopFlyoutLayout && desktopFlyoutMenuSections.length > 0">
<div class="action-settings-wheel-flyout" data-testid="action-settings-wheel-flyout">
<div class="action-settings-wheel-flyout__submenu-stack">
<div
v-for="section in desktopFlyoutMenuSections"
:key="section.key"
class="action-settings-wheel-flyout__submenu"
: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}`"
>
<p class="action-settings-wheel-flyout__submenu-title">
{{ SessionUser.functions.ucFirst(section.label) }}
</p>
<div class="action-settings-wheel-flyout__submenu-items">
<template v-if="section.key === 'attachments'">
<button
v-for="attachment in section.attachments || []"
:key="`attachment-row-${attachment.id}`"
type="button"
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)"
>
<span class="action-settings-wheel-section-trigger__arrow">
<i class="fas fa-chevron-left" aria-hidden="true"></i>
</span>
<span class="action-settings-wheel-section-trigger__icon">
<i class="fas fa-paperclip" aria-hidden="true"></i>
</span>
<span class="action-settings-wheel-section-trigger__label">
{{ getAttachmentLabel(attachment) }}
</span>
</button>
</template>
<template v-else>
<template v-for="item in section.items" :key="item.key">
<ActionSettingsWheelToggleItem
v-if="isToggleMenuItem(item)"
:checked="item.value"
:click-action="item.clickAction"
:description="item.description"
:disabled="item.disabled"
:label="item.label"
:test-id="item.testId"
/>
<ActionSettingsWheelItem
v-else
:icon="item.icon"
:label="item.label"
:template="item.template"
:click-action="item.clickAction"
:disabled="item.disabled"
/>
</template>
</template>
</div>
<div
v-if="section.key === 'attachments' && activeDesktopFlyoutSection?.key === 'attachments' && activeAttachment"
class="action-settings-wheel-attachment-panel"
data-testid="action-settings-wheel-attachment-panel"
>
<p class="action-settings-wheel-attachment-panel__title" :title="getAttachmentLabel(activeAttachment)">
{{ getAttachmentLabel(activeAttachment) }}
</p>
<div class="action-settings-wheel-attachment-panel__preview" data-testid="action-settings-wheel-attachment-preview">
<span
v-if="previewLoadingAttachmentId === activeAttachment.id"
class="action-settings-wheel-attachment-panel__text"
>
{{ t("global.loading") }}
</span>
<img
v-else-if="activeAttachmentPreviewKind === 'image' && activeAttachmentPreviewSource"
:src="activeAttachmentPreviewSource"
alt=""
class="action-settings-wheel-attachment-panel__image"
/>
<iframe
v-else-if="activeAttachmentPreviewKind === 'document' && activeAttachmentPreviewSource"
:src="activeAttachmentPreviewSource"
title="Attachment preview"
class="action-settings-wheel-attachment-panel__document"
></iframe>
<a
v-else-if="activeAttachmentPreviewKind === 'link'"
:href="activeAttachment.content?.other"
target="_blank"
rel="noopener noreferrer"
class="action-settings-wheel-attachment-panel__text action-settings-wheel-attachment-panel__link"
>
{{ activeAttachment.content?.other }}
</a>
<div
v-else-if="['image', 'document'].includes(activeAttachmentPreviewKind)"
class="action-settings-wheel-attachment-panel__placeholder"
>
<span class="action-settings-wheel-attachment-panel__placeholder-icon">
<i :class="getAttachmentPreviewPlaceholderIcon(activeAttachment)" aria-hidden="true"></i>
</span>
<span class="action-settings-wheel-attachment-panel__placeholder-label">
{{ getAttachmentPreviewPlaceholderLabel(activeAttachment) }}
</span>
</div>
<span v-else-if="activeAttachmentPreviewKind === 'office'" class="action-settings-wheel-attachment-panel__text">
{{ t("admin.pos.attachments_office_preview_unavailable") }}
</span>
<span v-else-if="activeAttachmentPreviewKind === 'text'" class="action-settings-wheel-attachment-panel__text">
{{ formatAttachmentText(activeAttachment) }}
</span>
<span v-else class="action-settings-wheel-attachment-panel__text">
{{ t("admin.pos.attachments_no_preview") }}
</span>
</div>
<div class="action-settings-wheel-attachment-panel__actions">
<button
type="button"
class="action-settings-wheel-attachment-action"
:data-testid="`action-settings-wheel-attachment-action-preview-${activeAttachment.id}`"
@click.stop.prevent="previewAttachment(activeAttachment)"
>
<span class="action-settings-wheel-attachment-action__icon">
<i class="fas fa-eye" aria-hidden="true"></i>
</span>
<span>{{ t("global.preview") }}</span>
</button>
<button
type="button"
class="action-settings-wheel-attachment-action"
:data-testid="`action-settings-wheel-attachment-action-download-${activeAttachment.id}`"
@click.stop.prevent="downloadAttachment(activeAttachment)"
>
<span class="action-settings-wheel-attachment-action__icon">
<i class="fas fa-download" aria-hidden="true"></i>
</span>
<span>{{ t("common.download") }}</span>
</button>
<button
type="button"
class="action-settings-wheel-attachment-action"
:data-testid="`action-settings-wheel-attachment-action-print-${activeAttachment.id}`"
@click.stop.prevent="printAttachment(activeAttachment)"
>
<span class="action-settings-wheel-attachment-action__icon">
<i class="fas fa-print" aria-hidden="true"></i>
</span>
<span>{{ t("global.print") }}</span>
</button>
<button
v-if="SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()"
type="button"
class="action-settings-wheel-attachment-action action-settings-wheel-attachment-action--danger"
:disabled="deletingAttachmentId === activeAttachment.id"
:data-testid="`action-settings-wheel-attachment-action-delete-${activeAttachment.id}`"
@click.stop.prevent="removeAttachment(activeAttachment)"
>
<span class="action-settings-wheel-attachment-action__icon">
<i class="fas fa-trash-alt" aria-hidden="true"></i>
</span>
<span>{{ t("global.delete") }}</span>
</button>
</div>
</div>
</div>
</div>
<div class="action-settings-wheel-flyout__sections" data-testid="action-settings-wheel-sections">
<slot v-if="hasCustomActionsSlot" name="actions"></slot>
<button
v-for="section in desktopFlyoutMenuSections"
:key="section.key"
type="button"
class="action-settings-wheel-section-trigger"
:class="{ 'is-active': activeDesktopFlyoutSection?.key === section.key }"
:data-testid="`action-settings-wheel-section-${section.key}`"
@mouseenter="setActiveDesktopFlyoutSection(section.key)"
@focus="setActiveDesktopFlyoutSection(section.key)"
@click.stop.prevent="setActiveDesktopFlyoutSection(section.key)"
>
<span class="action-settings-wheel-section-trigger__arrow">
<i class="fas fa-chevron-left" aria-hidden="true"></i>
</span>
<span class="action-settings-wheel-section-trigger__label">
{{ SessionUser.functions.ucFirst(section.label) }}
</span>
</button>
<div v-if="standaloneMenuActions.length > 0" class="action-settings-wheel-flyout__standalone-actions">
<ActionSettingsWheelItem
v-for="item in standaloneMenuActions"
:key="item.key"
:icon="item.icon"
:label="item.label"
:template="item.template"
:click-action="item.clickAction"
:disabled="item.disabled"
/>
</div>
</div>
</div>
</template>
<template v-else>
<div data-testid="action-settings-wheel-sections">
<slot v-if="hasCustomActionsSlot" name="actions"></slot>
<template v-for="section in flatBuiltInMenuSections" :key="section.key">
<ActionSettingsWheelItemLabel :label="section.label" :data-testid="`action-settings-wheel-section-${section.key}`" />
<template v-for="item in section.items" :key="item.key">
<ActionSettingsWheelToggleItem
v-if="isToggleMenuItem(item)"
:checked="item.value"
:click-action="item.clickAction"
:description="item.description"
:disabled="item.disabled"
:label="item.label"
:test-id="item.testId"
/>
<ActionSettingsWheelItem
v-else
:icon="item.icon"
:label="item.label"
:template="item.template"
:click-action="item.clickAction"
:disabled="item.disabled"
/>
</template>
</template>
<ActionSettingsWheelItem
v-for="item in standaloneMenuActions"
:key="item.key"
:icon="item.icon"
:label="item.label"
:template="item.template"
:click-action="item.clickAction"
:disabled="item.disabled"
/>
</div>
</template>
</template>
<template v-else>
<div class="dropdown-item">
<p class="has-text-centered">{{ t("admin.pos.settings_wheel.no_actions_defined") }}</p>
</div>
</template>
</div>
</div>
</div>
<CustomerModal
v-if="customerModalVisible"
:user_id="userIdFromCustomerNumber"
@closeModal="customerModalVisible = false"
/>
</div>
</template>
<style scoped>
.action-settings-wheel-trigger {
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.action-settings-wheel-trigger--active:not(.action-settings-wheel-trigger--text) {
border-color: #25344d;
box-shadow: 0 0 0 3px rgba(37, 52, 77, 0.28);
}
.action-settings-wheel-trigger--text {
background: transparent;
border: 0;
box-shadow: none;
color: #25344d;
padding-inline: 0.35rem;
text-decoration: none;
}
.action-settings-wheel-trigger--text:hover,
.action-settings-wheel-trigger--text:focus,
.action-settings-wheel-trigger--text[aria-expanded="true"] {
background: rgba(37, 52, 77, 0.1);
color: #132339;
text-decoration: none;
}
.action-settings-wheel-trigger--text .icon,
.action-settings-wheel-trigger--text .icon:hover,
.action-settings-wheel-trigger--text .icon:focus {
text-decoration: none;
}
.action-settings-wheel-trigger--icon-only {
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 {
padding: 0.5rem;
}
.action-settings-wheel-flyout {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 0.75rem;
min-width: 33rem;
z-index: 4003;
}
/* When the flyout is partly out of it's parent element, it can get cut off by overflow:hidden, so we need to allow it to overflow, but attempt to keep it visible within the viewport with smart positioning logic in the component */
.action-settings-wheel-flyout {
overflow: visible;
}
.action-settings-wheel-flyout__submenu-stack {
display: grid;
min-width: 17rem;
}
.action-settings-wheel-flyout__sections,
.action-settings-wheel-flyout__submenu {
display: flex;
flex-direction: column;
min-width: 15rem;
}
.action-settings-wheel-flyout__submenu {
grid-area: 1 / 1;
position: relative;
background: linear-gradient(180deg, #f9fbfd 0%, #f3f7fb 100%);
border: 1px solid #d9e4ef;
border-radius: 0.8rem;
padding: 0.35rem;
visibility: hidden;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s ease;
}
.action-settings-wheel-flyout__submenu.is-active {
visibility: visible;
opacity: 1;
pointer-events: auto;
}
.action-settings-wheel-flyout__submenu-title {
margin: 0;
padding: 0.55rem 0.75rem 0.35rem;
color: #6a7890;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.action-settings-wheel-flyout__submenu-items,
.action-settings-wheel-flyout__standalone-actions {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.action-settings-wheel-flyout__submenu--attachments {
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;
border-top: 1px solid #e3eaf2;
}
.action-settings-wheel-section-trigger {
width: 100%;
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.8rem 0.9rem;
background: transparent;
border: 1px solid transparent;
border-radius: 0.7rem;
color: #25344d;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.action-settings-wheel-section-trigger:hover,
.action-settings-wheel-section-trigger:focus,
.action-settings-wheel-section-trigger.is-active {
background: #f2f6fb;
border-color: #d8e2ed;
color: #132339;
outline: none;
}
.action-settings-wheel-section-trigger__arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.9rem;
color: #708097;
flex-shrink: 0;
}
.action-settings-wheel-section-trigger__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.95rem;
color: #708097;
flex-shrink: 0;
}
.action-settings-wheel-section-trigger--attachment {
padding-right: 1rem;
}
.action-settings-wheel-section-trigger__label {
flex: 1;
min-width: 0;
text-align: left;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.action-settings-wheel-attachment-panel {
position: absolute;
top: 0;
right: calc(100% + 0.75rem);
width: min(20rem, 48vw);
min-height: 20rem;
background: #ffffff;
border: 1px solid #cfd8e3;
border-radius: 0.9rem;
box-shadow: 0 18px 36px rgba(19, 35, 57, 0.14);
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 2;
}
.action-settings-wheel-attachment-panel__title {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: #132339;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.action-settings-wheel-attachment-panel__preview {
min-height: 12.5rem;
border: 1px solid #e7edf5;
border-radius: 0.75rem;
background: linear-gradient(180deg, #fbfcfe 0%, #f3f6fa 100%);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.action-settings-wheel-attachment-panel__image,
.action-settings-wheel-attachment-panel__document {
width: 100%;
height: 100%;
border: 0;
background: #ffffff;
}
.action-settings-wheel-attachment-panel__image {
object-fit: contain;
}
.action-settings-wheel-attachment-panel__text {
padding: 1rem;
text-align: center;
color: #4a5568;
overflow-wrap: anywhere;
white-space: pre-line;
}
.action-settings-wheel-attachment-panel__link {
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;
gap: 0.3rem;
}
.action-settings-wheel-attachment-action {
width: 100%;
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.72rem 0.85rem;
background: #f8fafc;
border: 1px solid #dde6f0;
border-radius: 0.7rem;
color: #25344d;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.action-settings-wheel-attachment-action:hover,
.action-settings-wheel-attachment-action:focus {
background: #f2f6fb;
border-color: #d2deea;
color: #132339;
outline: none;
}
.action-settings-wheel-attachment-action:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.action-settings-wheel-attachment-action--danger {
color: #8d2637;
border-color: #f0d4db;
background: #fff7f8;
}
.action-settings-wheel-attachment-action--danger:hover,
.action-settings-wheel-attachment-action--danger:focus {
background: #fff0f2;
border-color: #e7bcc7;
color: #6f1728;
}
.action-settings-wheel-attachment-action__icon {
width: 0.95rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dropdown-content :deep(.dropdown-item-action),
.dropdown-content :deep(.dropdown-item-label) {
border-radius: 0.55rem;
}
@media (hover: none) {
.action-settings-wheel-attachment-panel {
display: none;
}
}
</style>