Files
pleno-vue/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue
T
Jeppe B cc7d5cf4ff fix(pleno-vue): surface order-item API errors in the mobile POS Fuldfør click (#282)
PR #345's order_item_reason_policy wiring introduced a path where syncCurrentTransactionToOrder can throw inside the next-step click handler (e.g. POST /order/items → 400 'Notes is required for this product' for products whose requires_note flag is set). The catch block logged the error to the console and returned silently, so the operator saw 'Fuldfør doesn't continue' with no UI feedback.

Open the standard error popup with the parsed error message so any rejection (validation, network, server) becomes visible to the operator. Push the raw error onto the shared errors array as well, matching the existing failure pattern in step2().

Adds a regression E2E test in tests/e2e/pos-mobile-order-flow.spec.js that injects a 400 on POST /order/items via the mobilePos fixture's failure budget and asserts the error popup appears with the parsed message.

Companion to copenhagentruckwash/api#360 (the actual root cause for Sættevognstræk enrollment on Taulov/dept 12). User report: 'Problemer med indskrivning. Når man trykker fuldfør forsætter den ikke'.
2026-08-10 12:05:34 +02:00

758 lines
22 KiB
Vue

<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { onMounted, computed, ref } from "vue";
import {
reset_all_values,
customer_name,
nextStep,
searchAndSelectCustomer,
isCustomerSelected,
order_id,
customer_id,
step,
reg_1,
reg_2,
reg_3,
reference,
order_notes,
order_safety_seal,
loadCustomerAttributes,
hasAttribute,
uploadAttachment,
restoreStoredPosOrderId,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
import * as POSDepartmentProcess from "@/components/shop/POSDepartmentProcess.vue";
import { errors } from "@/components/request/HandleGlobalError.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import ContinueArrow from "@/components/viewport/elements/icons/ContinueArrow.vue";
import {
vehicles,
metadata,
getCustomerId,
popups,
resetPos,
attachments,
transactionHistory,
transactionItems,
} from "../objects/PosDepartmentStepMobileFlow.vue";
import {
finalizeCurrentMobileOrder,
getResolvedMobileSafetySeal,
syncMobileSafetySealState,
} from "../objects/mobileOrderCompletion.js";
import Swal from "sweetalert2";
import LongPressListener from "@/components/viewport/elements/wrappers/LongPressListener.vue";
import { views } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
const { t } = useI18n();
const selectCustomerString = `${
SessionUser.objects.global.language.select
} ${SessionUser.objects.global.language.customer.toLowerCase()}`;
const isCreatingOrder = computed(() => POSDepartmentProcess.isCreatingOrder?.value ?? false);
const props = defineProps({
// If true, the button will have a white background. Default is true.
isWhite: {
type: Boolean,
default: true,
},
isDark: {
type: Boolean,
default: false, // When set to true, this overrides the isWhite prop.
},
// Function that returns a Promise, to be executed before proceeding to the next step.
onBeforeStep: {
type: Function,
default: () => Promise.resolve(),
},
// Custom action will override the default onClick behavior, if provided.
customAction: {
type: Function,
default: null,
},
// Custom is the button disabled state. If provided, this will override the default disabled logic.
customDisabled: {
type: Boolean,
default: null,
},
// Custom long press
customLongPress: {
type: Function,
default: null,
},
// Button classes
buttonClasses: {
type: Array as () => string[],
default: () => [],
},
actionKey: {
type: String,
default: null,
},
copyKey: {
type: String,
default: null,
},
});
const getActiveOrderId = () => {
const parsedOrderId = Number.parseInt(String(order_id.value ?? ""), 10);
return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null;
};
const updateReference = async () => {
const activeOrderId = getActiveOrderId();
if (activeOrderId) {
await saveOrderMetadataField("reference", reference.value, activeOrderId);
}
};
const updateNotes = async () => {
const activeOrderId = getActiveOrderId();
if (activeOrderId) {
await saveOrderMetadataField("notes", order_notes.value, activeOrderId);
}
};
const updateSafetySeal = async () => {
const activeOrderId = getActiveOrderId();
if (activeOrderId) {
await saveOrderMetadataField("safety_seal", order_safety_seal.value, activeOrderId);
}
};
// Helpers: extracted for clarity and reuse
const isNonEmptyString = (val) => typeof val === "string" && val.trim().length > 0;
const resolveReferenceFromSources = () => {
// 1) Metadata reference
const metaRef = metadata.getReference?.();
if (isNonEmptyString(metaRef)) return metaRef.trim();
// 2) Local reactive reference field
if (isNonEmptyString(reference.value)) return reference.value.trim();
// 3) Vehicle #1 reference (fallback)
const vehicleRef = vehicles.get(1)?.reference;
if (isNonEmptyString(vehicleRef)) return String(vehicleRef).trim();
return null;
};
const promptForReference = async (initialValue = "") => {
const result = await Swal.fire({
title: t("admin.pos.reference_required_title"),
text: t("admin.pos.reference_required_text"),
input: "text",
inputLabel: t("common.reference"),
inputValue: initialValue,
showCancelButton: true,
confirmButtonText: t("admin.pos.confirm"),
cancelButtonText: t("admin.pos.cancel"),
preConfirm: (newReference) => {
if (!isNonEmptyString(newReference)) {
Swal.showValidationMessage(t("admin.pos.reference_cannot_be_empty"));
}
return newReference;
},
});
if (result.isConfirmed && isNonEmptyString(result.value)) {
return result.value.trim();
}
return null;
};
// Refactored main function
const validateReferenceRequirements = async () => {
await loadCustomerAttributes();
const customerRequiresReference = hasAttribute("requiresReferenceNumber");
// Try to resolve an existing reference from known sources
const resolvedRef = resolveReferenceFromSources();
if (isNonEmptyString(resolvedRef)) {
// Ensure both local ref and metadata are synchronized
if (reference.value !== resolvedRef) reference.value = resolvedRef;
if (metadata.getReference() !== resolvedRef) metadata.setReference(resolvedRef);
return true;
}
// If customer does not require a reference, we are good even if none was resolved
if (!customerRequiresReference) {
return true;
}
// Prompt user for a reference if required and none available
try {
const enteredRef = await promptForReference(reference.value ?? "");
if (isNonEmptyString(enteredRef)) {
reference.value = enteredRef;
metadata.setReference(enteredRef);
console.warn("New reference set:", enteredRef);
return true;
}
console.warn("Reference number is required but was not provided. Aborting operation.");
return false;
} catch (error) {
console.warn("An error occurred while setting the reference number:", error);
return false;
}
};
const step1 = async () => {
/** This function can be used to perform any specific actions for step 1 */
// Set registration numbers
reg_1.value = vehicles.vehicle_1.value?.reg || "";
reg_2.value = vehicles.vehicle_2.value?.reg || "";
reg_3.value = vehicles.vehicle_3.value?.reg || "";
// Reset the vehicle selection
views.vehicleSelection.value = false;
// Merge references and notes
await mergeReferences();
await mergeNotes();
await mergeSafetySeal();
// We only need to check if the customer requires reference, if the field is empty.
if (!isNonEmptyString(reference.value)) {
const isValid = await validateReferenceRequirements();
if (!isValid) {
return false;
}
}
// Reference is resolved, persist metadata and pending attachments.
return finalizeStep1();
};
const mergeReferences = () => {
if (metadata.getReference() && metadata.getReference().length >= 1) {
// If there is no reference, set it to the registration number of vehicle 1
reference.value = metadata.getReference();
return updateReference();
} else if (reference.value && reference.value.length >= 1) {
// If there is a reference in the field, set it to the metadata
metadata.setReference(reference.value);
return updateReference();
} else if (vehicles.vehicle_1.value?.reference && vehicles.vehicle_1.value?.reference.length >= 1) {
// If there is no reference, set it to the registration number of vehicle 1
reference.value = vehicles.vehicle_1.value?.reference;
metadata.setReference(reference.value);
return updateReference();
}
return Promise.resolve();
};
const mergeNotes = () => {
if (metadata.getNotes() && metadata.getNotes().length >= 1) {
order_notes.value = metadata.getNotes();
return updateNotes();
} else if (order_notes.value && order_notes.value.length >= 1) {
metadata.setNotes(order_notes.value);
return updateNotes();
}
return Promise.resolve();
};
const mergeSafetySeal = () => {
const resolvedSafetySeal = getResolvedMobileSafetySeal();
order_safety_seal.value = resolvedSafetySeal;
syncMobileSafetySealState(resolvedSafetySeal);
return updateSafetySeal();
};
const finalizeStep1 = async () => {
await mergeReferences();
await mergeNotes();
await mergeSafetySeal();
// Upload the attachments if there are any (And the order id has been created)
if (attachments.getBase64().length > 0 && order_id.value > 0) {
const uploadResults = await Promise.all(
attachments.getBase64().map(async (attachment: any) => {
try {
const didUpload = await uploadAttachment(attachment);
if (didUpload) {
console.warn("Attachment uploaded successfully");
attachments.removeBase64(attachment);
}
return didUpload;
} catch (error: any) {
errors.value.push(error);
console.warn("An error occurred while uploading the attachment:", error);
return false;
}
})
);
if (uploadResults.some((didUpload) => didUpload === false)) {
console.warn("One or more attachments could not be uploaded before completion.");
}
}
return true;
};
const addTransactionToHistory = async (orderId: number) => {
SessionUser.objects.orders.get
.single(orderId)
.then((transaction: any) => {
if (transaction) {
transactionHistory.add(transaction);
console.warn("Transaction added to history:", transaction);
} else {
console.warn("Unable to add transaction to history: Order not found.");
}
})
.catch((error: any) => {
errors.value.push(error);
console.warn("An error occurred while fetching the transaction for history:", error);
});
};
const toPositiveInteger = (value: any) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const getSelectedBookingId = () => {
return toPositiveInteger(metadata.getBookingId?.() ?? metadata.bookingId?.value ?? null);
};
const completeStep2Order = async ({
bookingSafetySeal = null,
}: {
bookingSafetySeal?: string | null;
} = {}) => {
await finalizeCurrentMobileOrder({
bookingSafetySeal,
markOrderCompleted: true,
});
popups.select("completed_transaction", {
message: `Order #${order_id.value} successfully created.`,
});
completeOrder();
};
const step2 = async () => {
/** This function can be used to perform any specific actions for step 2 */
// If the customer is paying with a card, go to the payment step
if (isCustomerSelected() && getCustomerId() === 999) {
nextStep({ isMobile: true });
return;
}
const selectedBookingId = getSelectedBookingId();
const hasWashCertificateInBasket = Boolean(transactionItems.containsWashCertificate?.());
const resolvedSafetySeal = getResolvedMobileSafetySeal();
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
try {
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
syncMobileSafetySealState(resolvedSafetySeal);
await completeStep2Order({
bookingSafetySeal: resolvedSafetySeal,
});
return;
}
if (requiresBookingCompletionPopup) {
const onCompleteBookingWithSafetySeal = async ({ safetySeal = "" } = {}) => {
syncMobileSafetySealState(safetySeal);
await completeStep2Order({
bookingSafetySeal: String(safetySeal ?? ""),
});
};
popups.select("complete_booking", {
props: {
safetySeal: getResolvedMobileSafetySeal(),
onCompleteWithCertificate: onCompleteBookingWithSafetySeal,
onCompleteWithoutCertificate: onCompleteBookingWithSafetySeal,
},
});
return;
}
await completeStep2Order();
} catch (error: any) {
errors.value.push(error);
console.warn("An error occurred while completing the mobile order:", error);
popups.select("error");
}
};
const completeOrder = () => {
addTransactionToHistory(order_id.value);
setTimeout(() => {
// If the customer is not paying with a card, reset the POS state (After 3 seconds, to make sure the popup is visible)
reset_all_values(); // Reset all values
order_id.value = 0; // Reset the order ID
popups.clear(); // Clear the popup
resetPos(); // Reset the POS state
// Start the process again
}, 3000);
};
const step3 = () => {
/** This function can be used to perform any specific actions for step 3 */
// This step is not defined, but you can add your logic here if needed
console.warn("Step 3: Card payment step is not defined.");
};
const isProcessingClick = ref(false);
const isBusy = computed(() => metadata.getLoadingState() || isCreatingOrder.value || isProcessingClick.value);
const onClick = async () => {
// Check if the click is cancelled
if (isClickCancelled.value || isProcessingClick.value) {
return;
}
// Check if a custom action is provided
if (props.customAction) {
isProcessingClick.value = true;
try {
await Promise.resolve(props.customAction());
} finally {
isProcessingClick.value = false;
}
return;
}
if (!(isCustomerSelected() && getCustomerId() > 0)) {
// Show the popup to select a customer
popups.select("select_customer");
return;
}
isProcessingClick.value = true;
try {
const beforeStepResult = await props.onBeforeStep();
if (beforeStepResult === false) {
return;
}
// Proceed to the next step
switch (step.value) {
case 1: {
if (!(await step1())) {
return;
}
const restoredOrderId = await restoreStoredPosOrderId({
validateOrder: true,
customerId: getCustomerId(),
departmentId: POSDepartmentProcess.getDepartment(),
allowCompleted: false,
syncDepartment: true,
});
if (restoredOrderId) {
console.warn("Order ID retrieved from local storage:", restoredOrderId);
nextStep({
isMobile: true,
orderCreation: false,
bookingId: metadata.getBookingId?.() ?? null,
});
return;
}
nextStep({
isMobile: true,
orderCreation: true,
bookingId: metadata.getBookingId?.() ?? null,
});
break;
}
case 2:
if (!(await step1())) {
return;
}
await step2();
break;
case 3:
// This step is not defined, but you can add your logic here if needed
step3();
break;
default:
break;
}
} catch (error) {
console.warn("Next-step action was interrupted:", error);
// Surface the failure to the operator so silent API rejections don't look
// like the button did nothing. Backend validation errors typically carry a
// human-readable message on error.response.data.data.message.
const parsedError =
SessionUser.functions.parseErrorMessage(error) || error?.message || "Unknown error";
errors.value.push(error);
popups.select("error", { message: parsedError });
} finally {
isProcessingClick.value = false;
}
};
/** Synchronize customer selection state */
const applyCustomerSelection = () => {
let shouldApplyCustomerSelection = false;
if (!isCustomerSelected() && getCustomerId() > 0) {
// Search and select the customer
shouldApplyCustomerSelection = true;
}
if (isCustomerSelected() && parseInt(customer_id.value) !== getCustomerId()) {
// The customers are out of sync
shouldApplyCustomerSelection = true;
}
// If any checks failed, apply the customer selection.
if (shouldApplyCustomerSelection) {
searchAndSelectCustomer(getCustomerId());
}
};
// Check customer selection states.
onMounted(() => {
applyCustomerSelection();
});
const isRegistrationNumberFilled = () => {
return vehicles?.vehicle_1?.value?.reg && vehicles.vehicle_1.value?.reg.length >= 4;
};
// Is the requirements for clicking the button met?
const isRequirementsForClickMet = () => {
// If a custom disabled state is provided, use that.
if (props.customDisabled !== null) {
return !props.customDisabled;
}
/**
* States, and their requirements:
* If any popup is open, the button cannot be clicked.
* If the metadata state is loading, the button cannot be clicked.
* If the customer is not selected, the button can be clicked to open the customer selection popup.
* If the customer is selected, the button can be clicked if the reg_1 is filled. (>= 4 characters)
*/
// If any popup is open, the button cannot be clicked.
if (popups.isSet.value) {
return false;
}
// Prevent duplicate click handling while one action is in-flight.
if (isBusy.value) {
return false;
}
if (!isCustomerSelected()) {
return true; // The customer is not selected, so the button can be clicked to open the customer selection popup.
} else {
// The customer is selected, so check if the reg_1 is filled.
return isRegistrationNumberFilled();
}
};
// Should the button be visible?
const isVisible = computed(() => {
/**
* No popups are open.
* The step is 1, 2 or 3.
*/
return !popups.isSet.value && (step.value === 1 || step.value === 2 || step.value === 3);
});
const isClickCancelled = ref(false);
const cancelOnClick = () => {
isClickCancelled.value = true;
setTimeout(() => {
isClickCancelled.value = false;
}, 100); // Reset after 100ms
};
const onLongPress = () => {
// Cancel the onClick event (since it's a long press)
cancelOnClick();
// Check if there's a custom long press handler
if (props.customLongPress) {
props.customLongPress();
return;
}
// If no custom long press handler is provided, proceed with the default behavior
defaultLongPressBehavior();
};
const defaultLongPressBehavior = () => {
// Select the customer
popups.select("select_customer");
};
/**
* Colors:
* Primary:
* :buttonClasses="['has-background-primary', 'has-text-black']"
* Secondary:
* :buttonClasses="['has-background-primary-dark', 'has-text-black']"
*/
</script>
<template>
<LongPressListener @long-press="onLongPress">
<GenericButton
@click="onClick"
:class="{
white: props.isWhite,
'has-background-black': props.isDark,
'is-loading': isBusy,
...props.buttonClasses.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}),
}"
data-testid="pos-mobile-next-step"
:action-key="props.actionKey"
:copy-key="props.copyKey"
class="is-size-6-mobile is-size-5-tablet is-size-4-desktop"
:disabled="!isRequirementsForClickMet()"
v-show="isVisible"
>
<!-- Default content if no slot is provided -->
<template v-if="!$slots.default">
<template v-if="isCustomerSelected()">
<span class="pos-mobile-cta-content">
<span class="pos-mobile-cta-label">{{ customer_name }}</span>
<span class="pos-mobile-cta-value">
<ContinueArrow style="width: 100%; height: 100%; padding: 6px 12px; gap: 5px" />
</span>
</span>
</template>
<template v-else>
<span>{{ selectCustomerString }}</span>
</template>
</template>
<!-- Default slot for custom content -->
<template v-else>
<!-- {{ transactionItems.primaryItem.value?.addons?.length }} -->
<slot></slot>
</template>
</GenericButton>
</LongPressListener>
</template>
<style scoped>
.pos-mobile-cta-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
min-width: 0;
}
.pos-mobile-cta-label {
min-width: 0;
flex: 1;
display: -webkit-box;
text-align: left;
line-height: 1.2;
overflow: hidden;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.pos-mobile-cta-value {
display: inline-flex;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
}
.has-text-black > :not(.icon) {
color: black !important;
}
:deep(.pos-mobile-cta-content) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
min-width: 0;
}
:deep(.pos-mobile-cta-label) {
min-width: 0;
flex: 1;
display: -webkit-box;
text-align: left;
line-height: 1.2;
overflow: hidden;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
:deep(.pos-mobile-cta-value) {
display: inline-flex;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
}
:deep(.pos-mobile-action-content) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
width: 100%;
min-width: 0;
min-height: 100%;
box-sizing: border-box;
padding-inline: 0.75rem;
}
:deep(.pos-mobile-action-label) {
flex: 1 1 auto;
min-width: 0;
text-align: left;
line-height: 1;
}
:deep(.pos-mobile-action-icon) {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
margin-left: auto;
min-width: 1.25rem;
line-height: 1;
}
:deep(.pos-mobile-action-icon i) {
display: block;
line-height: 1;
}
:deep(.generic-button__content) {
width: 100%;
min-width: 0;
}
/* Loading state */
.is-loading {
pointer-events: none;
cursor: not-allowed;
}
.is-loading > *:not(.icon) {
visibility: hidden;
opacity: 0;
transition: opacity 0.6s ease-in-out;
}
/* Loading animation for the button */
.is-loading::after {
transition: opacity 0.6s ease-in-out;
content: "";
position: absolute;
top: auto;
left: 50%;
width: 1em;
height: 1em;
margin-top: -0.5em;
margin-left: -0.5em;
/* Set the color to the original text color */
border: 0.2em solid white;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
display: inline-block;
vertical-align: top;
border-right-color: transparent;
border-bottom-color: transparent;
pointer-events: none;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>