Resolve recommended-profile Critical and High findings, update vulnerable dependencies, restore invoice queue E2E authentication setup, and clear the remaining frontend Qodana findings.
1739 lines
58 KiB
Vue
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>
|