diff --git a/src/components/displays/buttons/ActionSettingsWheelButton.vue b/src/components/displays/buttons/ActionSettingsWheelButton.vue index 9cc1abbf..0b6cd3fc 100644 --- a/src/components/displays/buttons/ActionSettingsWheelButton.vue +++ b/src/components/displays/buttons/ActionSettingsWheelButton.vue @@ -10,6 +10,7 @@ import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSe import { useI18n } from "vue-i18n"; const { t } = useI18n(); +const slots = useSlots(); const emit = defineEmits(["deleted"]); const emitDeleted = () => { emit("deleted"); @@ -91,6 +92,12 @@ const dropdownContent = ref(null); const isDropdownOpen = ref(false); const shouldOpenDropdownUp = ref(false); const dropdownMaxHeight = ref(null); +const isDesktopFlyoutLayout = ref(false); +const activeDesktopFlyoutSectionKey = ref(null); +const desktopFlyoutMinViewportWidth = 1400; +const desktopFlyoutRootPanelWidthRem = 15; +const desktopFlyoutSubmenuWidthRem = 17; +const desktopFlyoutPanelGapPx = 12; const closeDropdown = () => { isDropdownOpen.value = false; @@ -118,6 +125,9 @@ const dropdownClass = computed(() => ({ "is-active": isDropdownOpen.value, "is-up": shouldOpenDropdownUp.value, })); +const dropdownContentClass = computed(() => ({ + "action-settings-wheel-dropdown-content--desktop-flyout": isDesktopFlyoutLayout.value, +})); const dropdownContentStyle = computed(() => { if (!dropdownMaxHeight.value) { return {}; @@ -135,6 +145,61 @@ const resetDropdownLayout = () => { dropdownMaxHeight.value = null; }; +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(); @@ -145,16 +210,43 @@ const updateDropdownLayout = async () => { const triggerElement = dropdownRoot.value.querySelector(".dropdown-trigger"); const triggerRect = (triggerElement ?? dropdownRoot.value).getBoundingClientRect(); - const menuRect = dropdownContent.value.getBoundingClientRect(); - const viewportPadding = 12; - const spaceBelow = Math.max(window.innerHeight - triggerRect.bottom - viewportPadding, 0); - const spaceAbove = Math.max(triggerRect.top - viewportPadding, 0); - const menuHeight = Math.ceil(menuRect.height); + syncDesktopFlyoutState(triggerRect); + + 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 = availableHeight > 0 && menuHeight > availableHeight ? Math.floor(availableHeight) : null; + dropdownMaxHeight.value = nextMaxHeight; + + 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; }; const onDocumentClick = (event) => { @@ -220,12 +312,20 @@ watch( { immediate: true } ); -watch(isDropdownOpen, (isOpen) => { +watch(isDropdownOpen, async (isOpen) => { if (!isOpen) { + isDesktopFlyoutLayout.value = false; + activeDesktopFlyoutSectionKey.value = null; resetDropdownLayout(); return; } + await nextTick(); + + if (dropdownContent.value) { + dropdownContent.value.scrollTop = 0; + } + void updateDropdownLayout(); }); @@ -292,7 +392,7 @@ const hasUser = computed(() => Boolean(props.user_id || props.customer_number || const attachmentsFromOrder = ref([]); const attachmentsFromOrderError = ref(null); watch( - () => attachmentsFromOrder.value.length, + () => [attachmentsFromOrder.value.length, attachmentsFromOrderError.value, userIdFromCustomerNumber.value], () => { if (isDropdownOpen.value) { void updateDropdownLayout(); @@ -715,6 +815,452 @@ const defaultActions = computed(() => { }, ]; }); + +const hasCustomActionsSlot = computed(() => Boolean(slots.actions)); + +const buildMenuAction = (key, config) => ({ + key, + disabled: false, + template: "default", + ...config, +}); + +const buildMenuSection = (key, label, items) => { + const visibleItems = items.filter(Boolean); + if (visibleItems.length === 0) { + return null; + } + + return { + key, + label, + items: visibleItems, + }; +}; + +const downloadOrderAttachment = (attachmentId) => + SessionUser.objects.orders.functions.downloadAttachment(props.order_id, attachmentId, true).catch(() => { + Swal.fire({ + title: t("admin.pos.settings_wheel.error"), + text: t("admin.pos.settings_wheel.error_downloading_attachment"), + icon: "error", + }); + }); + +const flatBuiltInMenuSections = computed(() => { + const sections = []; + + if (props.order_booking_id) { + const bookingSection = buildMenuSection("booking", t("admin.pos.settings_wheel.booking"), [ + (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()) && !props.order_id + ? buildMenuAction("booking-complete", { + icon: "fas fa-check-circle", + label: t("admin.pos.settings_wheel.mark_as_completed"), + template: "success", + clickAction: () => + SessionUser.objects.order_bookings.functions.showCompleteConfirmationModal(props.order_booking_id, () => { + props.refreshFunction(); + }), + }) + : 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, + !props.order_id + ? 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() + ? 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.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 (hasUser.value && SessionUser.canAccessSuperUser()) { + const customerSection = buildMenuSection("customer", SessionUser.objects.global.language.customer, [ + 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", { + 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", { + 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; + }, + }), + ]); + + if (customerSection) { + sections.push(customerSection); + } + } + + if (props.reg_1 || props.reg_2) { + const vehicleSection = buildMenuSection( + "vehicle", + props.reg_1 && props.reg_2 + ? 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, + ] + ); + + 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: `${attachment.content?.document || attachment.content?.other}`, + clickAction: () => downloadOrderAttachment(attachment.id), + }) + ), + ]); + + if (attachmentsSection) { + sections.push(attachmentsSection); + } + } + } + + 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); + } + } + + 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; +}); + +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 = window.matchMedia("(hover: hover) and (pointer: fine)").matches; + 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; +};