Files
pleno-vue/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue
T
Jeppe B 1729443cc3 Resolve frontend Qodana critical and high findings (#176)
Resolve recommended-profile Critical and High findings, update vulnerable dependencies, restore invoice queue E2E authentication setup, and clear the remaining frontend Qodana findings.
2026-07-17 06:22:51 +02:00

1739 lines
58 KiB
Vue

<script setup lang="ts">
import {
setTransparency,
setBackgroundColor,
backgroundColors,
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import { onBeforeUnmount, onMounted, ref, watch, computed } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import PosDepartmentStepMobile2Product from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Product.vue";
import PosDepartmentStepMobile2LastOrder from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2LastOrder.vue";
import PosDepartmentStepMobile2Addons from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
import ControlFieldInput from "@/components/viewport/elements/controls/fields/ControlFieldInput.vue";
import ControlFieldInputLabel from "@/components/viewport/elements/controls/fields/ControlFieldInputLabel.vue";
import ControlField from "@/components/viewport/elements/controls/fields/ControlField.vue";
import PosDepartmentStepMobile2RegistrationNumbers from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2RegistrationNumbers.vue";
import {
vehicleSelection,
transactionItems,
notes,
reference,
vehicles,
lastOrders,
metadata,
additionalItemSelection,
attachments,
popups,
} from "./objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStep2MobileVehicleSelection from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep2MobileVehicleSelection.vue";
import PosDepartmentStepMobileButtonNextStep from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import { primaryItem } from "./objects/PosDepartmentStepMobileFlow.vue";
import {
order_id,
order_notes,
order_po,
order_safety_seal,
reference as persistedReference,
reg_1,
reg_2,
reg_3,
department_id,
customer_id,
customer_attributes,
customer_attributes_status,
customer_name,
getAddonRestriction,
getProductRestriction,
retryCustomerAttributes,
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import { PosOrderItem } from "@/components/displays/department/pos/steps/mobile/objects/PosOrderItem.vue";
import PosDepartmentStepMobile2AdditionalItems from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2AdditionalItems.vue";
import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
onMounted(() => {
// Set the header to be transparent
setTransparency(false);
setBackgroundColor(backgroundColors.default); // Set the default background color
hydrateReferenceFromSources();
hydrateSafetySealFromSources();
// Set the notes to the order notes if it's not already set
if (order_notes.value && !notes.value) {
notes.value = order_notes.value;
}
// Check for potential customer number changes
const initialCustomerId = Number.parseInt(String(customer_id.value), 10);
if (metadata.customerId.value !== (Number.isNaN(initialCustomerId) ? null : initialCustomerId)) {
applyPotentialCustomerNumberChange(metadata.customerId.value, initialCustomerId);
}
/** Last order */
// This is used to fetch the last order details for the vehicles.
fetchLastOrder(1);
fetchLastOrder(2);
fetchLastOrder(3);
initializeStepTwo();
});
const applyPotentialCustomerNumberChange = (oldId, newId) => {
const parsedCustomerId = Number.parseInt(String(newId), 10);
metadata.customerId.value = Number.isNaN(parsedCustomerId) ? null : parsedCustomerId;
};
// Watch the customer id, and if it changes, update it in the metadata
watch(customer_id, (newId, oldId) => {
if (newId !== oldId) {
applyPotentialCustomerNumberChange(oldId, newId);
}
});
const lastFetchedPrimaryItemProduct = ref<PosProduct | null>(null); // This is used to make sure all the addons are fetched when the last order is selected.
// Keep track so we only apply a booking once (idempotent)
const lastAppliedBookingId = ref<number | null>(null);
const getSelectedBookingId = () => {
const metadataBookingId = metadata.getBookingId?.() ?? metadata.bookingId?.value ?? null;
if (metadataBookingId) return metadataBookingId;
const vehicleBookingId = vehicles?.vehicle_1?.value?.booking_id ?? null;
if (vehicleBookingId) {
metadata.setBookingId?.(vehicleBookingId);
return vehicleBookingId;
}
return null;
};
const getSelectedPendingBooking = () => {
const bookingId = getSelectedBookingId();
if (bookingId) {
const booking = pendingBookings.value?.find?.((b: any) => b?.id === bookingId) || null;
if (booking) return booking;
}
return null;
};
const getSelectedBooking = async () => {
const bookingId = getSelectedBookingId();
if (bookingId) {
try {
return await SessionUser.objects.order_bookings.get.single(bookingId, {
department_id: department_id.value,
});
} catch (error) {
console.error("Failed to fetch selected booking by id:", bookingId, error);
}
}
const pendingBooking = getSelectedPendingBooking();
if (!pendingBooking?.id) return null;
return pendingBooking;
};
const fetchProductById = async (id: number): Promise<PosProduct> => {
// Fetch product with final price for current department/customer. Fallback to a minimal shape.
try {
// Note: metadata.getCustomerId exists in flow; fallback to global customer_id if missing
const custId =
(metadata.getCustomerId && metadata.getCustomerId()) ||
(customer_id?.value ? parseInt(customer_id.value as any) : null);
const product = await SessionUser.objects.products.get.single(id, {
department_id: department_id.value,
customer_id: custId,
category_id: null,
final_price: true,
});
return product as PosProduct;
} catch (e) {
console.error("Failed to fetch product", id, e);
// Minimal fallback; name/price will be set by caller when available
return { id, name: "Produkt", price: 0, subscription_allowed: false } as PosProduct;
}
};
const getFirstWashProduct = async (): Promise<PosProduct | null> => {
try {
const washProds = await SessionUser.objects.products.get.all({
department_id: department_id.value,
is_wash: true,
limit: 1,
});
if (washProds.length === 0) return null;
return await fetchProductById(washProds[0].id);
} catch (e) {
console.error("Failed to get first wash product:", e);
return null;
}
};
const normalizeRegistrationValue = (value: string | null | undefined) =>
String(value ?? "")
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]/g, "");
const applyPendingBookingFromSelection = async () => {
const booking: any = await getSelectedBooking();
if (!booking) return false;
if (lastAppliedBookingId.value === booking.id) return true; // avoid duplicate application
try {
// Update metadata booking id if not set
if (!metadata.getBookingId || !metadata.getBookingId()) {
metadata.setBookingId && metadata.setBookingId(booking.id);
}
// Set PO number on the order if provided by the booking
if (booking.po && typeof booking.po === "string" && booking.po.trim() !== "") {
try {
order_po.value = await saveOrderMetadataField("po", booking.po, order_id.value);
} catch (e) {
console.error("Failed to set PO on order from booking", e);
}
}
// Fetch full booking details to get items (pending list lacks them)
let items: any[] = [];
let fullBookingData: any = booking;
try {
if (!Array.isArray(fullBookingData?.items)) {
fullBookingData = await SessionUser.objects.order_bookings.get.single(booking.id, {
department_id: department_id.value,
});
}
items = Array.isArray(fullBookingData.items) ? fullBookingData.items : [];
// Apply reference and notes from full booking
const bookingReference = String(fullBookingData.reference ?? fullBookingData.reference_number ?? "").trim();
if (bookingReference !== "") {
try {
const savedReference = await saveOrderMetadataField("reference", bookingReference, order_id.value);
persistedReference.value = savedReference;
metadata.setReference(savedReference);
hydrateReferenceFromSources();
} catch (setRefError) {
console.error("Failed to set reference on order from booking:", setRefError);
}
}
const bookingNotes = String(fullBookingData.notes ?? fullBookingData.note ?? "").trim();
if (bookingNotes !== "") {
try {
const savedNotes = await saveOrderMetadataField("notes", bookingNotes, order_id.value);
order_notes.value = savedNotes;
notes.value = savedNotes;
metadata.setNotes(savedNotes);
} catch (setNotesError) {
console.error("Failed to set notes on order from booking:", setNotesError);
}
}
const bookingReg1 = normalizeRegistrationValue(fullBookingData.reg_1 ?? booking.reg_1 ?? reg_1.value);
const bookingReg2 = normalizeRegistrationValue(fullBookingData.reg_2 ?? booking.reg_2 ?? "");
const currentReg1 = normalizeRegistrationValue(reg_1.value);
const currentReg2 = normalizeRegistrationValue(reg_2.value);
if (bookingReg1 && bookingReg1 !== currentReg1) {
reg_1.value = await saveOrderMetadataField("reg_1", bookingReg1, order_id.value);
}
if (bookingReg2 !== currentReg2) {
reg_2.value = await saveOrderMetadataField("reg_2", bookingReg2, order_id.value);
}
const bookingMatches = Array.isArray(vehicles.vehicle_1.value?.booking_matches)
? vehicles.vehicle_1.value.booking_matches
: [booking];
const bookingCustomerNumber =
Number.parseInt(
String(
fullBookingData?.customer_number ??
fullBookingData?.customer_id ??
booking?.customer_number ??
booking?.customer_id ??
vehicles.vehicle_1.value?.customer_id ??
customer_id.value ??
0
),
10
) || 0;
vehicles.select(1, {
...(vehicles.vehicle_1.value || {}),
reg: bookingReg1 || vehicles.vehicle_1.value?.reg || "",
customer_id: bookingCustomerNumber,
status: "booked",
barred: false,
booking_id: booking.id,
booking_matches: bookingMatches,
reference: bookingReference || vehicles.vehicle_1.value?.reference || null,
});
if (bookingReg2) {
vehicles.select(2, {
...(vehicles.vehicle_2.value || {}),
reg: bookingReg2,
customer_id: bookingCustomerNumber,
status: "booked",
barred: false,
booking_id: booking.id,
booking_matches: bookingMatches,
});
} else {
vehicles.select(2, null);
}
} catch (fetchError) {
console.error("Failed to fetch full booking for items:", fetchError);
lastAppliedBookingId.value = booking.id;
return false;
}
if (!items.length) {
lastAppliedBookingId.value = booking.id;
return false;
}
// Primary product is the first item
const primaryRaw = items[0];
const primaryId = parseInt(primaryRaw?.id);
if (!primaryId || isNaN(primaryId)) {
lastAppliedBookingId.value = booking.id;
return false;
}
const primaryProduct = await fetchProductById(primaryId);
// Prefer booking price if provided, otherwise keep fetched price
if (typeof primaryRaw.price === "number") primaryProduct.price = primaryRaw.price;
primaryProduct.name = primaryRaw?.name || primaryProduct.name;
primaryProduct.quantity = 1; // Primary item quantity is handled as 1 in order creation
// Remaining items become addons
const addonRawList = items.slice(1);
const addonProducts = await Promise.all(
addonRawList.map(async (raw: any) => {
const id = parseInt(raw?.id);
if (!id || isNaN(id)) return null;
const p = await fetchProductById(id);
if (typeof raw.price === "number") p.price = raw.price;
p.name = raw?.name || p.name;
p.quantity = raw?.quantity || 1;
return p;
})
);
const preparedAddons = addonProducts
.filter((p) => !!p)
.map((p) => transactionItems.convertProductToAddon(p as any, { quantity: (p as any)?.quantity || 1 }));
const bookingIncludesWashCertificate = preparedAddons.some(
(addon: any) => addon?.id === 41 || addon?.product?.id === 41
);
attachments.setWashCertificate(bookingIncludesWashCertificate);
let effectivePrimaryProduct = primaryProduct;
if (!primaryProduct.is_wash) {
const firstWash = await getFirstWashProduct();
if (firstWash) {
effectivePrimaryProduct = firstWash;
}
}
effectivePrimaryProduct.addons = preparedAddons as any;
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
if (transactionItems.primaryItem.value) {
transactionItems.primaryItem.value.addons = preparedAddons as any;
}
sanitizeRestrictedTransactionItems();
lastAppliedBookingId.value = booking.id;
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
// Set vehicle type for consistency
if (vehicles.vehicle_1.value) {
vehicles.vehicle_1.value.type = effectivePrimaryProduct.id;
}
// Hide the vehicle selection as booking has been applied
vehicleSelection.value = false;
return true;
} catch (e) {
console.error("Failed to apply pending booking to Step 2", e);
return false;
}
};
const initializeStepTwo = async () => {
const hasBookingSelection = !!getSelectedBookingId();
if (!hasBookingSelection) {
fetchPrimaryItemProduct();
return;
}
const wasApplied = await applyPendingBookingFromSelection();
if (!wasApplied) {
fetchPrimaryItemProduct();
}
};
const fetchPrimaryItemProduct = () => {
if (!vehicles.vehicle_1.value?.type && !transactionItems.primaryItem.value) {
vehicleSelection.value = true; // Force the user to select a product.
return;
}
// Load the product from the type id.
if (!vehicles.vehicle_1.value?.type) {
return; // If no vehicle type is set, do not proceed.
}
vehicleSelection.value = false;
// If the primary item is already set and matches the vehicle type, do not fetch again.
//if (transactionItems.primaryItem.value && transactionItems.primaryItem.value.id === vehicles.vehicle_1.value.type) {
// console.warn('Primary item already set and matches vehicle type, skipping fetch.');
// return;
//}
SessionUser.objects.products.get
.single(vehicles.vehicle_1.value.type, {
department_id: department_id.value,
customer_id: metadata.getCustomerId(),
category_id: null,
final_price: true,
})
.then((product) => {
// If the product is found, set it as the primary item
lastFetchedPrimaryItemProduct.value = product; // Store the last fetched primary item product
// If the primary item is already set, simply update the product
if (transactionItems.primaryItem.value) {
transactionItems.primaryItem.value = {
...transactionItems.primaryItem.value,
...product,
addons: transactionItems.primaryItem.value.addons.map((addon) => ({
...addon,
product: {
...addon.product,
price: product.price, // Update the addon price to match the new primary item price
},
})),
};
return;
}
transactionItems.setPrimaryItem(product);
})
.catch(() => (vehicleSelection.value = true)); // If there's an error, force the user to select a product.
};
const fetchLastOrder = (vehicleIndex: number) => {
// This function fetches the last order for a given vehicle ID (Provided it has a last_order_id
const tmp_order_id = vehicles[`vehicle_${vehicleIndex}`]?.value?.last_order_id || 0;
if (!tmp_order_id) {
return; // If no last order ID is found, do not proceed.
}
SessionUser.objects.orders.get
.single(tmp_order_id)
.then((order) => lastOrders.set(vehicleIndex, { ...order }))
.catch((error) => console.error("Error fetching last order:", error));
};
const fetchLastOrderItems = (vehicleIndex: number) => {
// If the lastOrder is set, fetch the order items for the last order
if (lastOrders.get(vehicleIndex)) {
getOrderItems(lastOrders.get(vehicleIndex).id)
.then((response) =>
lastOrders.set(vehicleIndex, {
...lastOrders.get(vehicleIndex),
items: updateLastOrderItemPrices(response.data.data),
})
)
.catch((error) => console.error("Error fetching last order items:", error));
}
};
const updateLastOrderItemPrices = (items: PosOrderItem[]) => {
// Update the prices of the last order items to reflect any changes in the product prices
return items.map((item) => {
SessionUser.objects.products.get
.single(item.product_id, {
category_id: null,
department_id: department_id.value,
customer_id: customer_id.value,
final_price: true,
})
.then((product) => {
transactionItems.updateTransactionPrices([product]); // Update the transaction prices with the fetched product
})
.catch((error) => {
console.error("Error fetching product for last order item:", error);
});
return {
...item, // Keep existing item properties, they will be overridden if the product fetch is successful
};
});
};
const layout = {
classes: <string[]>[],
};
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
const restrictionWarningMessageKey = ref("");
const showRestrictedItemsRemovedWarning = () => {
restrictionWarningMessageKey.value = "pos.restrictions.restricted_items_removed";
};
const getRestrictionReadinessMessageKey = () =>
customer_attributes_status.value === "error" ? "pos.restrictions.load_failed" : "pos.restrictions.loading";
const getMobileAddonRestriction = (addon: any) => getAddonRestriction(addon, { isRelatedAddon: true });
const getStandaloneAdditionalItemRestriction = (item: any) =>
getProductRestriction(item, {
includeNumericAddonCategory: true,
isStandaloneAdditionalService: true,
});
const isMobileAddonRestricted = (addon: any) => getMobileAddonRestriction(addon).restricted;
const isStandaloneAdditionalItemRestricted = (item: any) => getStandaloneAdditionalItemRestriction(item).restricted;
const decorateMobileAddonRestriction = (addon: any) => {
const restriction = getMobileAddonRestriction(addon);
const nextQuantity = restriction.restricted ? 0 : Number(addon?.quantity ?? addon?.product?.quantity ?? 0);
const decoratedAddon = {
...addon,
quantity: nextQuantity,
product: addon?.product
? {
...addon.product,
quantity: nextQuantity,
}
: addon?.product,
};
if (restriction.restricted) {
decoratedAddon.restricted = true;
decoratedAddon.restrictionRule = restriction.rule;
decoratedAddon.restrictionMessageKey = restriction.messageKey;
}
return decoratedAddon;
};
const sanitizeRestrictedTransactionItems = () => {
if (customer_attributes_status.value !== "ready") {
return false;
}
let removedRestrictedItem = false;
const primary = transactionItems.primaryItem.value;
if (primary && Array.isArray(primary.addons)) {
let changedAddons = false;
const sanitizedAddons = primary.addons.map((addon: any) => {
const restriction = getMobileAddonRestriction(addon);
if (!restriction.restricted) {
return addon;
}
if (Number(addon?.quantity ?? addon?.product?.quantity ?? 0) > 0) {
removedRestrictedItem = true;
}
if (
addon?.restricted === true &&
addon?.restrictionRule === restriction.rule &&
addon?.restrictionMessageKey === restriction.messageKey &&
Number(addon?.quantity ?? 0) === 0 &&
Number(addon?.product?.quantity ?? 0) === 0
) {
return addon;
}
changedAddons = true;
return {
...addon,
restricted: true,
restrictionRule: restriction.rule,
restrictionMessageKey: restriction.messageKey,
quantity: 0,
product: addon?.product
? {
...addon.product,
quantity: 0,
}
: addon?.product,
};
});
if (changedAddons) {
primary.addons = sanitizedAddons;
}
}
const allowedAdditionalItems = (transactionItems.additionalItems.value || []).filter((item: any) => {
const restricted = isStandaloneAdditionalItemRestricted(item);
if (restricted && Number(item?.quantity ?? 0) > 0) {
removedRestrictedItem = true;
}
return !restricted;
});
if (allowedAdditionalItems.length !== (transactionItems.additionalItems.value || []).length) {
transactionItems.setAdditionalItems(allowedAdditionalItems);
}
if (removedRestrictedItem) {
showRestrictedItemsRemovedWarning();
}
return !removedRestrictedItem;
};
const onCopyLastOrder = (vehicleIndex: number) => {
if (customer_attributes_status.value !== "ready") {
restrictionWarningMessageKey.value = getRestrictionReadinessMessageKey();
return;
}
lastOrders.select(vehicleIndex);
sanitizeRestrictedTransactionItems();
};
const openCustomerSelection = () => {
popups.select("select_customer");
};
const {
state: primaryProductHoldState,
progress: primaryProductHoldProgress,
remainingMs: primaryProductHoldRemainingMs,
onPointerDown: onPrimaryProductPointerDown,
onPointerMove: onPrimaryProductPointerMove,
onPointerUp: onPrimaryProductPointerUp,
onPointerCancel: onPrimaryProductPointerCancel,
} = useHoldToTrigger({
onTrigger: () => {
vehicleSelection.value = true;
},
});
const getNormalizedOrderId = () => {
const parsedOrderId = Number.parseInt(String(order_id.value), 10);
return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null;
};
const normalizeReferenceValue = (value: unknown) => String(value ?? "");
const hasReferenceValue = (value: unknown) => normalizeReferenceValue(value).trim() !== "";
const syncVehicleRegistrationFromOrder = (vehicleIndex: number, value: string | null | undefined) => {
const normalizedValue = normalizeRegistrationValue(value);
if (!normalizedValue) {
return;
}
const currentVehicle = vehicles.get(vehicleIndex);
if (currentVehicle?.reg === normalizedValue) {
return;
}
vehicles.select(vehicleIndex, {
...(currentVehicle ?? {}),
reg: normalizedValue,
});
};
const notesAutosave = useOrderMetadataAutosave({
source: order_notes,
saveValue: async (value) => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
return value;
}
return saveOrderMetadataField("notes", value, normalizedOrderId);
},
onSaved: async (value) => {
order_notes.value = value;
metadata.setNotes(value);
},
});
const referenceAutosave = useOrderMetadataAutosave({
source: persistedReference,
saveValue: async (value) => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
return value;
}
return saveOrderMetadataField("reference", value, normalizedOrderId);
},
onSaved: async (value) => {
persistedReference.value = value;
metadata.setReference(value);
},
});
const safetySealAutosave = useOrderMetadataAutosave({
source: order_safety_seal,
saveValue: async (value) => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
return value;
}
return saveOrderMetadataField("safety_seal", value, normalizedOrderId);
},
onSaved: async (value) => {
order_safety_seal.value = value;
metadata.setSafetySeal(value);
},
});
const stepTwoNotesInput = notesAutosave.draft;
const stepTwoReferenceInput = referenceAutosave.draft;
const stepTwoSafetySealInput = safetySealAutosave.draft;
const showSafetySealField = computed(() => transactionItems.containsWashCertificate());
const flushStepTwoMetadataFields = async () => {
await Promise.all([
notesAutosave.flushAndWait(),
referenceAutosave.flushAndWait(),
safetySealAutosave.flushAndWait(),
]);
};
const unregisterStepSaveBarrier = registerPosStepSaveBarrier(flushStepTwoMetadataFields);
onBeforeUnmount(() => {
unregisterStepSaveBarrier();
});
const resolveReferenceFromSources = () => {
const orderReference = normalizeReferenceValue(persistedReference.value);
if (hasReferenceValue(orderReference)) {
return orderReference;
}
const metadataReference = normalizeReferenceValue(metadata.getReference?.() ?? reference.value);
if (hasReferenceValue(metadataReference)) {
return metadataReference;
}
const vehicleReference = normalizeReferenceValue(vehicles?.vehicle_1?.value?.reference);
if (hasReferenceValue(vehicleReference)) {
return vehicleReference;
}
return "";
};
function syncReferenceState(value: unknown) {
const normalizedValue = normalizeReferenceValue(value);
reference.value = normalizedValue;
metadata.setReference(normalizedValue);
}
const resolveSafetySealFromSources = () => {
const orderSafetySeal = normalizeReferenceValue(order_safety_seal.value);
if (hasReferenceValue(orderSafetySeal)) {
return orderSafetySeal;
}
const metadataSafetySeal = normalizeReferenceValue(metadata.getSafetySeal?.());
if (hasReferenceValue(metadataSafetySeal)) {
return metadataSafetySeal;
}
return "";
};
function syncSafetySealState(value: unknown) {
const normalizedValue = normalizeReferenceValue(value);
order_safety_seal.value = normalizedValue;
metadata.setSafetySeal(normalizedValue);
}
function hydrateReferenceFromSources() {
const currentDraft = normalizeReferenceValue(stepTwoReferenceInput.value);
if (referenceAutosave.isDirty.value && hasReferenceValue(currentDraft)) {
syncReferenceState(currentDraft);
return currentDraft;
}
const resolvedReference = resolveReferenceFromSources();
if (!hasReferenceValue(resolvedReference)) {
if (hasReferenceValue(currentDraft)) {
syncReferenceState(currentDraft);
}
return currentDraft;
}
const lastSavedReference = normalizeReferenceValue(referenceAutosave.lastSavedValue.value);
const canHydrateDraft = !hasReferenceValue(currentDraft) || currentDraft === lastSavedReference;
if (canHydrateDraft && currentDraft !== resolvedReference) {
referenceAutosave.syncFromSource(resolvedReference);
}
syncReferenceState(canHydrateDraft ? resolvedReference : currentDraft);
return canHydrateDraft ? resolvedReference : currentDraft;
}
function hydrateSafetySealFromSources() {
const currentDraft = normalizeReferenceValue(stepTwoSafetySealInput.value);
if (safetySealAutosave.isDirty.value && hasReferenceValue(currentDraft)) {
syncSafetySealState(currentDraft);
return currentDraft;
}
const resolvedSafetySeal = resolveSafetySealFromSources();
if (!hasReferenceValue(resolvedSafetySeal)) {
if (hasReferenceValue(currentDraft)) {
syncSafetySealState(currentDraft);
}
return currentDraft;
}
const lastSavedSafetySeal = normalizeReferenceValue(safetySealAutosave.lastSavedValue.value);
const canHydrateDraft = !hasReferenceValue(currentDraft) || currentDraft === lastSavedSafetySeal;
if (canHydrateDraft && currentDraft !== resolvedSafetySeal) {
safetySealAutosave.syncFromSource(resolvedSafetySeal);
}
syncSafetySealState(canHydrateDraft ? resolvedSafetySeal : currentDraft);
return canHydrateDraft ? resolvedSafetySeal : currentDraft;
}
watch(stepTwoNotesInput, (value) => {
metadata.setNotes(value);
if (getNormalizedOrderId()) {
notesAutosave.scheduleSave();
}
});
watch(stepTwoReferenceInput, (value) => {
syncReferenceState(value);
if (getNormalizedOrderId()) {
referenceAutosave.scheduleSave();
}
});
watch(stepTwoSafetySealInput, (value) => {
syncSafetySealState(value);
if (getNormalizedOrderId()) {
safetySealAutosave.scheduleSave();
}
});
watch(persistedReference, (newValue, oldValue) => {
if (newValue !== oldValue) {
hydrateReferenceFromSources();
}
});
watch(order_safety_seal, (newValue, oldValue) => {
if (newValue !== oldValue) {
hydrateSafetySealFromSources();
}
});
watch(
() => getNormalizedOrderId(),
(normalizedOrderId) => {
if (!normalizedOrderId) {
return;
}
if (notesAutosave.isDirty.value) {
notesAutosave.scheduleSave();
}
if (referenceAutosave.isDirty.value) {
referenceAutosave.scheduleSave();
}
if (safetySealAutosave.isDirty.value) {
safetySealAutosave.scheduleSave();
}
}
);
watch(
[reg_1, reg_2, reg_3],
([nextReg1, nextReg2, nextReg3]) => {
if (!getNormalizedOrderId()) {
return;
}
syncVehicleRegistrationFromOrder(1, nextReg1);
syncVehicleRegistrationFromOrder(2, nextReg2);
syncVehicleRegistrationFromOrder(3, nextReg3);
},
{ immediate: true }
);
const normalizeOrderItemShape = (item: any) => ({
product_id: Number(item?.product_id ?? item?.product?.id ?? 0),
quantity: Number(item?.quantity ?? 0),
related_item_id:
item?.related_item_id === null || item?.related_item_id === undefined ? null : Number(item.related_item_id),
price: Number(item?.price ?? 0),
notes: String(item?.notes ?? ""),
});
const buildDesiredOrderItemShapes = () => {
if (!transactionItems.primaryItem.value) {
return [];
}
const primaryShape = {
kind: "primary",
relatedKey: "primary",
product_id: Number(transactionItems.primaryItem.value.id),
quantity: 1,
related_item_id: null,
price: Number(transactionItems.primaryItem.value.price ?? 0),
notes: String(transactionItems.primaryItem.value?.notes ?? ""),
skip_price_override: transactionItems.primaryItem.value?.skip_price_override === true,
};
const addonShapes = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => {
const addonProduct = addon?.product ?? addon;
return {
kind: "addon",
relatedKey: "primary",
product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
quantity: Number(addon?.quantity ?? 0),
related_item_id: "__PRIMARY__",
price: Number(addonProduct?.price ?? addon?.price ?? 0),
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
skip_price_override: addonProduct?.skip_price_override === true || addon?.skip_price_override === true,
};
});
const additionalShapes = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) => ({
kind: "additional",
relatedKey: null,
product_id: Number(item?.id ?? 0),
quantity: Number(item?.quantity ?? 0),
related_item_id: null,
price: Number(item?.price ?? 0),
notes: String(item?.notes ?? ""),
skip_price_override: item?.skip_price_override === true,
}));
return [primaryShape, ...addonShapes, ...additionalShapes];
};
const normalizeExistingOrderItemShapes = (items: any[]) => {
const primaryItems = items.filter(
(item: any) => item?.related_item_id === null || item?.related_item_id === undefined
);
if (primaryItems.length === 0) {
return [];
}
const additionalItems = primaryItems.filter(
(item: any) =>
Number(item?.product?.id ?? item?.product_id ?? 0) !== Number(transactionItems.primaryItem.value?.id ?? 0)
);
const primaryItemShape = normalizeOrderItemShape(primaryItems[0]);
const addonShapes = items
.filter((item: any) => item?.related_item_id === primaryItems[0]?.id)
.map(normalizeOrderItemShape);
const additionalShapes = additionalItems.map(normalizeOrderItemShape);
return [primaryItemShape, ...addonShapes, ...additionalShapes];
};
const sortComparableLastWashShapes = (shapes: Array<{ kind: string; product_id: number; quantity: number }>) =>
shapes.slice().sort((left, right) => {
if (left.kind !== right.kind) {
return left.kind.localeCompare(right.kind);
}
if (left.product_id !== right.product_id) {
return left.product_id - right.product_id;
}
return left.quantity - right.quantity;
});
const buildCurrentSelectionComparableShapes = () => {
if (!transactionItems.primaryItem.value) {
return [];
}
const primaryShape = {
kind: "primary",
product_id: Number(transactionItems.primaryItem.value.id ?? 0),
quantity: 1,
};
const addonShapes = sortComparableLastWashShapes(
(transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => ({
kind: "addon",
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
quantity: Number(addon?.quantity ?? 0),
}))
);
const additionalShapes = sortComparableLastWashShapes(
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) => ({
kind: "additional",
product_id: Number(item?.id ?? 0),
quantity: Number(item?.quantity ?? 0),
}))
);
return [primaryShape, ...addonShapes, ...additionalShapes];
};
const buildLastOrderComparableShapes = (items: any[]) => {
const normalizedItems = Array.isArray(items) ? items.filter((item) => Boolean(item)) : [];
const standaloneItems = normalizedItems.filter(
(item: any) => item?.related_item_id === null || item?.related_item_id === undefined
);
if (standaloneItems.length === 0) {
return [];
}
const primaryItem = standaloneItems[0];
const primaryItemId = Number(primaryItem?.id ?? 0);
const primaryShape = {
kind: "primary",
product_id: Number(primaryItem?.product?.id ?? primaryItem?.product_id ?? 0),
quantity: Number(primaryItem?.quantity ?? 0),
};
const addonShapes = sortComparableLastWashShapes(
normalizedItems
.filter((item: any) => primaryItemId && Number(item?.related_item_id ?? 0) === primaryItemId)
.map((item: any) => ({
kind: "addon",
product_id: Number(item?.product?.id ?? item?.product_id ?? 0),
quantity: Number(item?.quantity ?? 0),
}))
);
const additionalShapes = sortComparableLastWashShapes(
standaloneItems.slice(1).map((item: any) => ({
kind: "additional",
product_id: Number(item?.product?.id ?? item?.product_id ?? 0),
quantity: Number(item?.quantity ?? 0),
}))
);
return [primaryShape, ...addonShapes, ...additionalShapes];
};
const doesCurrentSelectionMatchLastWash = computed(() => {
const currentShapes = buildCurrentSelectionComparableShapes();
const lastOrderShapes = buildLastOrderComparableShapes(lastOrders.get(1)?.items || []);
if (currentShapes.length === 0 || lastOrderShapes.length === 0) {
return false;
}
return JSON.stringify(currentShapes) === JSON.stringify(lastOrderShapes);
});
const syncCurrentTransactionToOrder = async () => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
throw new Error("Order ID is required");
}
if (!transactionItems.primaryItem.value) {
throw new Error("No primary item selected");
}
const primaryRestriction = getProductRestriction(transactionItems.primaryItem.value);
if (primaryRestriction.restricted) {
restrictionWarningMessageKey.value = primaryRestriction.messageKey;
return false;
}
const existingItemsResponse = await getOrderItems(normalizedOrderId);
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
const desiredShapes = buildDesiredOrderItemShapes();
const currentShapes = normalizeExistingOrderItemShapes(existingItems);
const shouldForceRecreateForRepricing = desiredShapes.some((shape) => shape.skip_price_override === true);
const comparableDesiredShapes = desiredShapes.map(({
kind: _kind,
relatedKey: _relatedKey,
skip_price_override: _skipPriceOverride,
...shape
}) => shape);
if (!shouldForceRecreateForRepricing && JSON.stringify(currentShapes) === JSON.stringify(comparableDesiredShapes)) {
return true;
}
await Promise.all(existingItems.map((item: any) => removeOrderItem(item.id)));
const createdPrimaryItemResponse = await createOrderItem(
normalizedOrderId,
transactionItems.primaryItem.value.id,
1,
null,
transactionItems.primaryItem.value?.notes || "",
transactionItems.primaryItem.value.skip_price_override === true ? null : transactionItems.primaryItem.value.price
);
const createdPrimaryItemId = createdPrimaryItemResponse?.data?.data?.id;
const addonPromises = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => {
const addonProduct = addon?.product ?? addon;
return createOrderItem(
normalizedOrderId,
addonProduct.id,
Number(addon.quantity),
createdPrimaryItemId,
addonProduct?.notes || addon?.notes || "",
addonProduct?.skip_price_override === true || addon?.skip_price_override === true
? null
: addonProduct.price ?? addon.price
);
});
const additionalPromises = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) =>
createOrderItem(
normalizedOrderId,
item.id,
Number(item.quantity),
null,
item?.notes || "",
item?.skip_price_override === true ? null : item.price
)
);
await Promise.all([...addonPromises, ...additionalPromises]);
return true;
};
const normalizeText = (value: unknown) => String(value ?? "").trim();
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
const getProductId = (product: any) =>
Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
const productRequiresOrderItemNote = (product: any) => {
if (!product) {
return false;
}
return (
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
);
};
const productHasOrderItemNote = (product: any) =>
normalizeText(product?.notes ?? product?.product?.notes).length > 0;
const getSelectedProductsMissingRequiredNotes = () => {
const missingProducts: any[] = [];
const primaryProduct = transactionItems.primaryItem.value;
if (productRequiresOrderItemNote(primaryProduct) && !productHasOrderItemNote(primaryProduct)) {
missingProducts.push(primaryProduct);
}
(primaryProduct?.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.forEach((addon: any) => {
const addonProduct = addon?.product ?? addon;
if (
(productRequiresOrderItemNote(addonProduct) || productRequiresOrderItemNote(addon)) &&
!productHasOrderItemNote(addonProduct) &&
!productHasOrderItemNote(addon)
) {
missingProducts.push(addonProduct);
}
});
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.forEach((item: any) => {
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
missingProducts.push(item);
}
});
return missingProducts;
};
const promptForRequiredProductNote = (product: any) =>
new Promise<boolean>((resolve) => {
const hadOriginalNote = Object.prototype.hasOwnProperty.call(product, "notes");
const originalNote = product?.notes;
const resolveAndClose = (didConfirm: boolean) => {
if (!didConfirm) {
if (hadOriginalNote) {
product.notes = originalNote;
} else {
delete product.notes;
}
}
if (popups.get()?.id === "add_product_note") {
popups.clear();
}
resolve(didConfirm);
};
popups.select("add_product_note", {
title: `${t("common.note")}: ${getProductName(product) || `#${getProductId(product)}`}`,
message: t("objects.products.columns.requires_note"),
component: "add_product_note",
hideHeader: true,
style: { maxHeight: "40vh" },
props: {
product,
validationMessage: "",
},
actionButtons: [
{
label: t("common.confirm"),
description: t("common.confirm"),
color: "primary",
testId: "pos-mobile-product-note-confirm",
onClick: () => {
const activePopup = popups.get();
const normalizedNote = normalizeText(activePopup?.props?.product?.notes);
if (!normalizedNote) {
if (activePopup?.props) {
activePopup.props.validationMessage = t("objects.products.columns.requires_note");
}
return;
}
product.notes = normalizedNote;
resolveAndClose(true);
},
},
{
label: t("common.cancel"),
description: t("common.cancel"),
color: "light",
testId: "pos-mobile-product-note-cancel",
onClick: () => resolveAndClose(false),
},
],
});
});
const ensureRequiredOrderItemNotes = async () => {
const missingProducts = getSelectedProductsMissingRequiredNotes();
for (const product of missingProducts) {
if (productHasOrderItemNote(product)) {
continue;
}
const didConfirm = await promptForRequiredProductNote(product);
if (!didConfirm) {
return false;
}
}
return true;
};
const onBeforeComplete = async () => {
if (!transactionItems.primaryItem.value) {
throw new Error("No primary item selected");
}
const primaryRestriction = getProductRestriction(transactionItems.primaryItem.value);
if (primaryRestriction.restricted) {
restrictionWarningMessageKey.value = primaryRestriction.messageKey;
return false;
}
sanitizeRestrictedTransactionItems();
if (!(await ensureRequiredOrderItemNotes())) {
return false;
}
await syncCurrentTransactionToOrder();
return true;
};
// Re-try applying booking whenever booking id or vehicle reg changes
watch(
() => metadata.bookingId?.value,
() => {
applyPendingBookingFromSelection();
}
);
watch(
() => vehicles?.vehicle_1?.value?.reg,
() => {
applyPendingBookingFromSelection();
}
);
// Watch for changes in the primary item and update the vehicle selection if necessary
// Utility: map source addons and carry over quantities from previous addons by product.id
const getAddonProductId = (addon: any) => Number(addon?.product?.id ?? addon?.id ?? 0);
const mapAddonsWithQuantity = (sourceAddons = [], previousAddons = []) =>
sourceAddons.map((addon) => {
const sourceAddonProductId = getAddonProductId(addon);
const previousAddon = previousAddons.find((a) => getAddonProductId(a) === sourceAddonProductId);
const nextQuantity = previousAddon?.quantity ?? addon?.quantity ?? addon?.product?.quantity ?? 0;
const skipPriceOverride =
previousAddon?.skip_price_override === true ||
previousAddon?.product?.skip_price_override === true ||
addon?.skip_price_override === true ||
addon?.product?.skip_price_override === true;
return {
...addon,
quantity: nextQuantity,
skip_price_override: skipPriceOverride,
product: addon?.product
? {
...addon.product,
quantity: nextQuantity,
skip_price_override: skipPriceOverride,
}
: addon?.product,
};
});
const hasConfiguredAddonQuantities = (addons = []) =>
addons.some((addon) => Number(addon?.quantity ?? addon?.product?.quantity ?? 0) > 0);
const filterAddonsByCurrentSelection = (sourceAddons = [], selectedAddons = []) => {
const selectedAddonIds = new Set(
selectedAddons.map((addon) => getAddonProductId(addon)).filter((addonId) => addonId > 0)
);
if (selectedAddonIds.size === 0) {
return [];
}
return sourceAddons.filter((addon) => selectedAddonIds.has(getAddonProductId(addon)));
};
watch(
() => transactionItems.primaryItem.value,
(nextPrimary, prevPrimary) => {
if (!nextPrimary) {
fetchPrimaryItemProduct();
return;
}
const lastFetched = lastFetchedPrimaryItemProduct.value;
const isNewPrimary = nextPrimary.id !== lastFetched?.id;
const previousAddons = hasConfiguredAddonQuantities(nextPrimary?.addons ?? [])
? nextPrimary.addons
: prevPrimary?.addons ?? transactionItems.primaryItem.value?.addons ?? [];
if (isNewPrimary) {
lastFetchedPrimaryItemProduct.value = nextPrimary;
fetchPrimaryItemProduct();
if (Array.isArray(nextPrimary.addons) && nextPrimary.addons.length > 0) {
transactionItems.primaryItem.value = {
...transactionItems.primaryItem.value,
addons: mapAddonsWithQuantity(nextPrimary.addons, previousAddons),
};
}
return;
}
// Same product as last fetched: merge/normalize addons from last fetched into the current primary item
const currentAddons = transactionItems.primaryItem.value.addons ?? [];
const sourceAddons = hasConfiguredAddonQuantities(currentAddons)
? filterAddonsByCurrentSelection(lastFetched?.addons ?? [], currentAddons)
: lastFetched?.addons ?? [];
if (sourceAddons.length > 0) {
transactionItems.primaryItem.value.addons = mapAddonsWithQuantity(sourceAddons, currentAddons);
}
},
{ immediate: true }
);
// Watch for changes in the last orders and fetch the order items when they change
watch(
() => lastOrders.get(1),
(newValue, oldValue) => {
if (newValue && newValue.id !== oldValue?.id) {
fetchLastOrderItems(1); // Fetch the order items for the last order 1
}
},
{ immediate: true }
);
watch(
() => lastOrders.get(2),
(newValue, oldValue) => {
if (newValue && newValue.id !== oldValue?.id) {
fetchLastOrderItems(2); // Fetch the order items for the last order 2
}
},
{ immediate: true }
);
watch(
() => lastOrders.get(3),
(newValue, oldValue) => {
if (newValue && newValue.id !== oldValue?.id) {
fetchLastOrderItems(3); // Fetch the order items for the last order 3
}
},
{ immediate: true }
);
// Watch for changes in the vehicle 1 last_order_id and fetch the last order when it changes
watch(
() => vehicles.vehicle_1.value?.last_order_id,
(newValue, oldValue) => {
if (newValue && newValue !== oldValue) {
fetchLastOrder(1); // Fetch the last order for vehicle 1
} else {
lastOrders.set(1, null); // Clear the last order if the last_order_id is removed
}
}
);
watch(
() => vehicles.vehicle_1.value?.type,
(newValue, oldValue) => {
if (newValue && newValue !== oldValue && !transactionItems.primaryItem.value) {
fetchPrimaryItemProduct();
}
}
);
watch(
[
() => transactionItems.primaryItem.value?.addons,
() => transactionItems.additionalItems.value,
() => customer_attributes.value,
() => customer_attributes_status.value,
],
() => {
sanitizeRestrictedTransactionItems();
},
{ deep: true }
);
// Watch for changes in the vehicle 1 reference and update the reference field when it changes
watch(
() => vehicles.vehicle_1.value?.reference,
(newValue, oldValue) => {
if (newValue && newValue !== oldValue) {
hydrateReferenceFromSources();
}
}
);
// Computed property to annotate restricted addons based on customer attributes
const filteredAddons = computed(() => {
const addons = transactionItems.primaryItem.value?.addons || [];
return addons.map((addon: any) => decorateMobileAddonRestriction(addon));
});
</script>
<template>
<template v-if="vehicleSelection">
<div
v-if="customer_attributes_status === 'error'"
class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between py-2 px-3 mb-3"
data-testid="pos-mobile-customer-restrictions-load-error"
>
<span>{{ t("pos.restrictions.load_failed") }}</span>
<button
type="button"
class="button is-small is-danger is-light"
data-testid="pos-mobile-customer-restrictions-retry"
@click="retryCustomerAttributes"
>
{{ t("common.retry") }}
</button>
</div>
<PosDepartmentStep2MobileVehicleSelection @close="vehicleSelection = false" />
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
<!-- Buttons -->
<!-- Next button -->
<PosDepartmentStepMobileButtonNextStep
:isWhite="false"
:customAction="
() => {
vehicleSelection = false;
fetchPrimaryItemProduct();
}
"
:buttonClasses="['has-background-primary', 'has-text-black']"
v-show="vehicles.vehicle_1?.value?.type"
action-key="pos-mobile-select-primary-product"
>
<span class="pos-mobile-cta-content">
<span class="pos-mobile-cta-label">{{
primaryItem ? primaryItem.name : SessionUser.objects.global.language.next
}}</span>
<span class="pos-mobile-cta-value">
<i class="fas fa-arrow-right"></i>
</span>
</span>
</PosDepartmentStepMobileButtonNextStep>
</PosDepartmentStepMobileFixedBottomControl>
</template>
<template v-else-if="additionalItemSelection">
<PosDepartmentStepMobile2AdditionalItems
:label="SessionUser.objects.global.language.additional_items"
:subtitle="''"
@close="additionalItemSelection = false"
/>
</template>
<template v-else>
<div class="pos-mobile-step-layout" data-testid="pos-mobile-step-2">
<!-- Customer selection -->
<button
class="pos-mobile-customer-banner"
type="button"
data-testid="pos-mobile-customer-banner"
@click="openCustomerSelection"
>
<div class="pos-mobile-customer-banner__content" data-testid="pos-mobile-customer-name">
<span class="icon pos-mobile-customer-banner__icon">
<i class="fas fa-user"></i>
</span>
<div class="pos-mobile-customer-banner__text">
<span class="pos-mobile-customer-banner__label">{{ SessionUser.objects.global.language.customer }}</span>
<span class="pos-mobile-customer-banner__name">{{ customer_name }}</span>
</div>
</div>
<span class="icon pos-mobile-customer-banner__chevron" data-testid="pos-mobile-customer-banner-chevron">
<i class="fa fa-search"></i>
</span>
</button>
<!-- Registration numbers -->
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
<p
v-if="restrictionWarningMessageKey"
class="notification is-warning is-light py-2 px-3 mb-0"
data-testid="pos-mobile-restriction-warning"
>
{{ t(restrictionWarningMessageKey) }}
</p>
<div
v-if="customer_attributes_status === 'error'"
class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between py-2 px-3 mb-0"
data-testid="pos-mobile-customer-restrictions-load-error"
>
<span>{{ t("pos.restrictions.load_failed") }}</span>
<button
type="button"
class="button is-small is-danger is-light"
data-testid="pos-mobile-customer-restrictions-retry"
@click="retryCustomerAttributes"
>
{{ t("common.retry") }}
</button>
</div>
<!-- Product -->
<PosDepartmentStepMobile2Product
v-on:pointerdown="onPrimaryProductPointerDown"
v-on:pointermove="onPrimaryProductPointerMove"
v-on:pointerup="onPrimaryProductPointerUp"
v-on:pointercancel="onPrimaryProductPointerCancel"
v-on:pointerleave="onPrimaryProductPointerCancel"
:product="transactionItems.primaryItem.value"
:additional-items="transactionItems.additionalItems.value"
:additional-items-label="SessionUser.objects.global.language.additional_items"
:additional-items-empty-label="SessionUser.objects.global.language.no_additional_items"
:hold-state="primaryProductHoldState"
:hold-progress="primaryProductHoldProgress"
:hold-remaining-ms="primaryProductHoldRemainingMs"
@open-additional-items="additionalItemSelection = true"
/>
<!-- Last order details -->
<PosDepartmentStepMobile2LastOrder
v-show="lastOrders.get(1)"
@copy="onCopyLastOrder(1)"
:label="'ordered on...'"
:subtitle="'Subtitle'"
:lastOrder="lastOrders.get(1)"
:matches-current-selection="doesCurrentSelectionMatchLastWash"
/>
<!-- Addons (filtered based on customer restrictions) -->
<PosDepartmentStepMobile2Addons :addons="filteredAddons" :label="'Add-ons'" :compact="false" />
<!-- Notes -->
<ControlField>
<ControlFieldInputLabel label="Notes" :classes="layout.classes" :optional="true" />
<ControlFieldInput
testId="pos-mobile-notes-input"
type="text"
placeholder=""
:classes="layout.classes"
:vmodel="stepTwoNotesInput"
@input="stepTwoNotesInput = $event"
@change="notesAutosave.flush()"
/>
</ControlField>
<!-- Reference -->
<ControlField>
<ControlFieldInputLabel label="Reference" :classes="layout.classes" :optional="true" />
<!-- TODO: Make this required, if the customer requires it -->
<ControlFieldInput
testId="pos-mobile-reference-step-2-input"
type="text"
placeholder=""
:classes="layout.classes"
:vmodel="stepTwoReferenceInput"
@input="stepTwoReferenceInput = $event"
@change="referenceAutosave.flush()"
/>
</ControlField>
<ControlField v-if="showSafetySealField">
<ControlFieldInputLabel label="Safety seal" :classes="layout.classes" :optional="true" />
<ControlFieldInput
testId="pos-mobile-safety-seal-step-2-input"
type="text"
placeholder=""
:classes="layout.classes"
:vmodel="stepTwoSafetySealInput"
@input="stepTwoSafetySealInput = $event"
@change="safetySealAutosave.flush()"
/>
</ControlField>
<PosDepartmentStepMobileFixedBottomControl variant="pos-step" :reserveSpace="false">
<!-- Complete button -->
<PosDepartmentStepMobileButtonNextStep
:isDark="false"
:onBeforeStep="() => onBeforeComplete()"
:buttonClasses="['has-background-primary', 'has-text-white']"
action-key="pos-mobile-complete-order"
copy-key="complete"
>
<span class="pos-mobile-cta-content">
<span class="pos-mobile-cta-label">{{ SessionUser.objects.global.language.complete }}</span>
<span class="pos-mobile-cta-value">{{
SessionUser.functions.currency.toLocal(transactionItems.getTransactionTotal())
}}</span>
</span>
</PosDepartmentStepMobileButtonNextStep>
<div>
<PosDepartmentStepMobileButtonClearAll />
</div>
</PosDepartmentStepMobileFixedBottomControl>
</div>
</template>
</template>
<style scoped>
.pos-mobile-step-layout {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem 1rem calc(var(--pos-mobile-fixed-bottom-height, 0px) + 1rem);
scroll-padding-bottom: calc(var(--pos-mobile-fixed-bottom-height, 0px) + 1rem);
}
.pos-mobile-step-layout :deep(.control-field-input) {
scroll-margin-bottom: calc(var(--pos-mobile-fixed-bottom-height, 0px) + 1rem);
}
.pos-mobile-customer-banner {
align-items: center;
appearance: none;
background: radial-gradient(circle at top right, rgba(31, 91, 183, 0.12), transparent 46%),
linear-gradient(180deg, #ffffff 0%, #f5f9ff 100%);
border: 1px solid #d7e2f1;
border-radius: 1rem;
box-shadow: 0 14px 26px rgba(17, 46, 92, 0.08);
cursor: pointer;
display: flex;
gap: 0.75rem;
justify-content: space-between;
padding: 0.75rem 0.9rem;
text-align: left;
width: 100%;
}
.pos-mobile-customer-banner:focus-visible {
box-shadow: 0 0 0 3px rgba(31, 91, 183, 0.18), 0 14px 26px rgba(17, 46, 92, 0.08);
}
.pos-mobile-customer-banner__label {
color: #5a6f8f;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.1;
text-transform: uppercase;
}
.pos-mobile-customer-banner__content {
align-items: center;
color: #17325c;
display: flex;
flex: 1 1 auto;
gap: 0.7rem;
min-width: 0;
}
.pos-mobile-customer-banner__text {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0.12rem;
min-width: 0;
}
.pos-mobile-customer-banner__icon {
align-items: center;
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
border-radius: 999px;
color: #ffffff;
display: inline-flex;
flex-shrink: 0;
height: 1.95rem;
justify-content: center;
width: 1.95rem;
}
.pos-mobile-customer-banner__name {
font-size: 0.98rem;
font-weight: 700;
line-height: 1.2;
word-break: break-word;
}
.pos-mobile-customer-banner__chevron {
color: #1f5bb7;
flex-shrink: 0;
}
</style>