Files
pleno-vue/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue
T

738 lines
21 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,
} 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 updateReference = () => {
if (!!order_id.value && order_id.value > 0) SessionUser.objects.orders.set.reference(order_id.value, reference.value);
};
const updateNotes = () => {
if (!!order_id.value && order_id.value > 0) SessionUser.objects.orders.set.notes(order_id.value, order_notes.value);
};
const updateSafetySeal = () => {
if (!!order_id.value && order_id.value > 0)
SessionUser.objects.orders.set.safety_seal(order_id.value, order_safety_seal.value);
};
// 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
mergeReferences();
mergeNotes();
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();
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);
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);
updateReference();
}
};
const mergeNotes = () => {
if (metadata.getNotes() && metadata.getNotes().length >= 1) {
order_notes.value = metadata.getNotes();
updateNotes();
} else if (order_notes.value && order_notes.value.length >= 1) {
metadata.setNotes(order_notes.value);
updateNotes();
}
};
const mergeSafetySeal = () => {
const resolvedSafetySeal = getResolvedMobileSafetySeal();
order_safety_seal.value = resolvedSafetySeal;
syncMobileSafetySealState(resolvedSafetySeal);
updateSafetySeal();
};
const finalizeStep1 = async () => {
mergeReferences();
mergeNotes();
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,
markOrderCompleted = false,
}: {
bookingSafetySeal?: string | null;
markOrderCompleted?: boolean;
} = {}) => {
await finalizeCurrentMobileOrder({
bookingSafetySeal,
markOrderCompleted,
});
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);
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
try {
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
syncMobileSafetySealState(resolvedSafetySeal);
await completeStep2Order({
bookingSafetySeal: resolvedSafetySeal,
markOrderCompleted: false,
});
return;
}
if (requiresBookingCompletionPopup) {
const onCompleteBookingWithSafetySeal = async ({ safetySeal = "" } = {}) => {
syncMobileSafetySealState(safetySeal);
await completeStep2Order({
bookingSafetySeal: String(safetySeal ?? ""),
markOrderCompleted: false,
});
};
popups.select("complete_booking", {
props: {
safetySeal: getResolvedMobileSafetySeal(),
onCompleteWithCertificate: onCompleteBookingWithSafetySeal,
onCompleteWithoutCertificate: onCompleteBookingWithSafetySeal,
},
});
return;
}
await completeStep2Order({
markOrderCompleted: shouldMarkOrderAsCompleted,
});
} 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 {
await props.onBeforeStep();
// 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);
} 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>