Files
pleno-vue/src/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue
T

2268 lines
67 KiB
Vue

<script lang="ts">
import { ref, watch, computed, defineComponent } from "vue";
import type { PosVehicle } from "./PosVehicle.vue";
import type { PosProduct } from "./PosProduct.vue";
import type { PosCategory } from "./PosCategory.vue";
import type { PosPopup } from "./PosPopup.vue";
import type { PosOrder } from "./PosOrder.vue";
import type { Addon } from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
import type { PosLocation } from "./PosLocation.vue";
import type { PosSoundEffect } from "./PosSoundEffect.vue";
import type { PosActionButton } from "./PosActionButton.vue";
import {
doesTransactionContainWashCertificateProduct,
WASH_CERTIFICATE_PRODUCT_ID,
} from "@/components/displays/department/pos/utils/washCertificate.js";
import { determineStatusKey, type VehicleStatusKey } from "./PosVehicleStatus.vue";
import i18n from "@/i18n";
/** Views */
// Step 1
const manualInput = ref(false);
const attachmentView = ref(false);
// Step 2
const vehicleSelection = ref(false);
const additionalItemSelection = ref(false);
// Transaction history view
const transactionHistoryView = ref(false);
// Is any view active?
const isAnyViewActive = computed(() => {
return manualInput.value || vehicleSelection.value || additionalItemSelection.value || transactionHistoryView.value;
});
const views = {
// Step 1: Manual Input of registration numbers.
manualInput,
attachmentView,
// Step 2: Vehicle Selection from a list.
vehicleSelection, // Used to select the "Primary" product for the transaction.
additionalItemSelection, // Used to select additional items for the transaction.
transactionHistoryView, // View the last transactions made on this device.
isAnyActive: isAnyViewActive,
};
/** Sound effects */
const soundEffects = ref<PosSoundEffect[]>([]);
const soundEffectsEnabled = ref<boolean>(true);
const playSoundEffect = (soundEffect: PosSoundEffect) => {
if (!soundEffectsEnabled.value) return;
const audio = new Audio(soundEffect.src);
audio.volume = soundEffect.volume ?? 1.0;
audio.play().catch((error) => {
console.error("Error playing sound effect:", error);
});
};
const toggleSoundEffects = () => {
soundEffectsEnabled.value = !soundEffectsEnabled.value;
};
const setSoundEffects = (enabled: boolean) => {
soundEffectsEnabled.value = enabled;
};
const getSoundEffects = () => {
return soundEffects.value;
};
const addSoundEffect = (soundEffect: PosSoundEffect) => {
soundEffects.value.push(soundEffect);
};
const clearSoundEffects = () => {
soundEffects.value = [];
};
const defaultSoundEffects = ref<{ [key: string]: PosSoundEffect }>({
onAfterSuccessfulScan: {
id: "success",
src: "/sounds/success.mp3",
volume: 0.5,
pitch: 1.0,
delay: 0,
loop: false,
},
});
const listDefaultSoundEffects = computed(() => defaultSoundEffects.value);
const sounds = {
soundEffects,
enabled: soundEffectsEnabled,
play: playSoundEffect, // example: sounds.play(sounds.list.value.onAfterSuccessfulScan)
toggle: toggleSoundEffects,
set: setSoundEffects,
get: getSoundEffects,
add: addSoundEffect,
clear: clearSoundEffects,
list: listDefaultSoundEffects,
};
/** Device location */
const location = ref<PosLocation | null>(null);
const locationTimeout = ref<number>(5000); // Timeout for location retrieval in milliseconds
const setLocation = (newLocation: PosLocation | null) => {
location.value = newLocation;
};
const getLocation = () => {
return location.value;
};
const clearLocation = () => {
location.value = null;
};
type CoordinatePair = {
latitude?: number | string | null;
longitude?: number | string | null;
};
export const normalizeCoordinatePair = (
coordinates: CoordinatePair | null | undefined,
{ allowZeroPair = true }: { allowZeroPair?: boolean } = {}
): { latitude: number; longitude: number } | null => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return null;
}
if (!allowZeroPair && latitude === 0 && longitude === 0) {
return null;
}
return { latitude, longitude };
};
export const hasValidCoordinatePair = (
coordinates: CoordinatePair | null | undefined,
options: { allowZeroPair?: boolean } = {}
): boolean => normalizeCoordinatePair(coordinates, options) !== null;
// Get distance in kilometers between two locations
const getDistance = (
from: CoordinatePair,
to: CoordinatePair
): number => {
const normalizedFrom = normalizeCoordinatePair(from);
const normalizedTo = normalizeCoordinatePair(to);
if (!normalizedFrom || !normalizedTo) {
return Number.POSITIVE_INFINITY;
}
const toRad = (value: number) => (value * Math.PI) / 180;
const R = 6371; // Radius of the Earth in kilometers
const dLat = toRad(normalizedTo.latitude - normalizedFrom.latitude);
const dLon = toRad(normalizedTo.longitude - normalizedFrom.longitude);
const lat1 = toRad(normalizedFrom.latitude);
const lat2 = toRad(normalizedTo.latitude);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in kilometers
};
const locations = {
location,
set: setLocation,
get: getLocation,
clear: clearLocation,
normalizeCoordinatePair,
hasValidCoordinatePair,
getDistance,
defaultTimeout: locationTimeout,
};
/** Action buttons */
const defaultActionButtons = computed<{ [key: string]: PosActionButton }>(() => {
const t = i18n.global.t;
return {
/** General purpose action buttons (used by PosAddons)*/
confirm: {
label: t("common.confirm"),
description: t("admin.pos.action_buttons.confirm_desc"),
onClick: () => {
clearPopup(); // Close the popup
},
color: "primary",
},
cancel: {
label: t("common.cancel"),
description: t("admin.pos.action_buttons.cancel_desc"),
onClick: () => {
clearPopup(); // Close the popup
},
color: "light",
},
close: {
label: t("common.close"),
description: t("admin.pos.action_buttons.close_desc"),
onClick: () => {
clearPopup(); // Close the popup
},
color: "light",
},
/** Customer selection action buttons */
selectCustomer: {
label: t("admin.pos.action_buttons.select_customer"),
description: t("admin.pos.action_buttons.select_customer_desc"),
onClick: () => {
// Open the select customer popup
popups.select("select_customer");
},
color: "primary",
},
addCustomer: {
label: t("admin.pos.action_buttons.add_customer"),
description: t("admin.pos.action_buttons.add_customer_desc"),
onClick: () => {
// Open the add customer popup
popups.select("add_customer", { props: { error: null, canCreate: true } });
},
color: "primary",
},
editReference: {
label: t("admin.pos.action_buttons.edit_reference"),
description: t("admin.pos.action_buttons.edit_reference_desc"),
onClick: () => {
// Open the edit reference popup
//popups.select('edit_reference');
},
color: "primary",
},
addNote: {
label: t("admin.pos.action_buttons.add_note"),
description: t("admin.pos.action_buttons.add_note_desc"),
onClick: () => {
// Open the add note popup
popups.select("customer_notes", { props: { showInput: true, showNotes: false } });
},
color: "primary",
},
};
});
// Call action buttons object for use in other components
const actionButtons = {
default: defaultActionButtons,
};
/** Popups */
const popup = ref<PosPopup | null>(null);
// Function to check if a popup is currently set
const isPopupSet = computed(() => {
return popup.value !== null;
});
// Function to get the current popup
const getPopup = () => {
if (popup.value) {
return popup.value;
}
return null;
};
// Function to set the popup
const setPopup = (newPopup: PosPopup | null) => {
popup.value = newPopup;
};
// Function to clear the popup
const clearPopup = () => {
popup.value = null;
};
// Default list of popups
/** Popups list */
const popupsList = ref<{ [key: string]: { value: PosPopup } }>({});
// Function to add a new popup to the list
const addPopup = (popup: PosPopup) => {
if (!popup.id) {
console.warn("Popup must have an id to be added to the list.");
return;
}
popupsList.value[popup.id] = { value: popup };
};
// Function to add default popups to the list
const addDefaultPopups = () => {
// Select customer
addPopup({
id: "select_customer",
title: "Vælg Kunde",
message: "Bekræft venligst valget af kunde til denne transaktion",
component: "select_customer",
showHeaderClose: true,
headerCloseTestId: "pos-mobile-select-customer-header-close",
style: { maxHeight: "72dvh" },
actionButtons: [
{ ...defaultActionButtons.value.cancel },
{ ...defaultActionButtons.value.addCustomer, label: "Ny kunde" },
],
});
addPopup({
id: "select_order_booking",
title: i18n.global.t("admin.pos.order_booking_selector.title"),
message: i18n.global.t("admin.pos.order_booking_selector.help_text"),
component: "select_order_booking",
showHeaderClose: true,
headerCloseTestId: "pos-mobile-order-booking-header-close",
style: { maxHeight: "72dvh" },
actionButtons: [],
});
// Completed
addPopup({
id: "completed_transaction",
title: "Gennemført",
message: "Transaktionen er gennemført!",
component: "completed_transaction",
hideHeader: true,
});
// Completed
addPopup({
id: "complete_booking",
title: "Fuldført",
message: "Bookingen er nu fuldført!",
component: "complete_booking",
hideHeader: true,
actionButtons: [],
});
// Error
addPopup({
id: "error",
title: "Fejl",
message: "Der opstod en fejl under behandlingen af din anmodning. Prøv venligst igen.",
component: "error",
hideHeader: true,
actionButtons: [{ ...defaultActionButtons.value.close }],
});
// Add product note
addPopup({
id: "add_product_note",
title: "Tilføj note",
message: "Tilføj venligst en note for produktet",
component: "add_product_note", // This component should handle input and return the notes
hideHeader: true,
actionButtons: [{ ...defaultActionButtons.value.cancel }],
});
// Select vehicle
addPopup({
id: "select_vehicle",
title: "Vælg køretøj",
message: "Vælg venligst et køretøj til denne transaktion",
component: "select_vehicle", // This component should handle vehicle selection
showHeaderClose: true,
headerCloseTestId: "pos-mobile-select-vehicle-header-close",
style: { maxHeight: "72dvh" },
actionButtons: [{ ...defaultActionButtons.value.confirm }, { ...defaultActionButtons.value.close }],
});
// Change reference
addPopup({
id: "change_reference",
title: "Indtast reference",
message: "Indtast venligst en reference for denne transaktion",
component: "change_reference",
showHeaderClose: true,
headerCloseTestId: "pos-mobile-change-reference-header-close",
style: { maxHeight: "40vh" },
actionButtons: [{ ...defaultActionButtons.value.confirm }, { ...defaultActionButtons.value.close }],
});
// Customer notes
addPopup({
id: "customer_notes",
title: "Kundebemærkninger",
message: "Læs venligst kundens bemærkninger",
component: "customer_notes",
showHeaderClose: true,
headerCloseTestId: "pos-mobile-customer-notes-header-close",
style: { maxHeight: "50vh" },
actionButtons: [{ ...defaultActionButtons.value.addNote }, { ...defaultActionButtons.value.close }],
props: { showInput: false, showNotes: true },
});
// Image viewer
addPopup({
id: "image",
title: "Billede",
message: "",
component: "image_viewer",
showHeaderClose: true,
headerCloseTestId: "pos-mobile-image-viewer-header-close",
style: { maxHeight: "50vh" },
actionButtons: [{ ...defaultActionButtons.value.close }],
props: { base64String: null, filename: null },
});
// Add customer
addPopup({
id: "add_customer",
title: "Tilføj kunde",
message: "Indtast venligst oplysninger for den nye kunde",
component: "add_customer",
showHeaderClose: true,
headerCloseTestId: "pos-mobile-add-customer-header-close",
style: { maxHeight: "60vh" },
actionButtons: [{ ...defaultActionButtons.value.cancel }],
props: {
cvr: null,
contactEmail: null,
contactPhone: null,
companyPhone: null,
invoiceEmail: null,
searchResult: null,
canCreate: false,
error: null,
},
});
};
// Function to check if a popup is defined in the list
const isPopupDefined = (id: string): boolean => {
return !!popupsList.value[id];
};
// Function to get a popup by its key
const getPopupByKey = (key: string): PosPopup | null => {
const popupItem = popupsList.value[key];
if (popupItem) {
return popupItem.value;
}
console.warn(`Popup with key "${key}" not found.`);
return null;
};
// Function to remove a popup by its key
const removePopupByKey = (key: string): boolean => {
if (popupsList.value[key]) {
delete popupsList.value[key];
return true;
}
console.warn(`Popup with key "${key}" not found.`);
return false;
};
// Function to clear the popups list
const clearPopupsList = () => {
popupsList.value = {};
};
// Function to select a popup by its ID
const selectPopupById = (id: string, modifications?: Partial<PosPopup>): PosPopup | null => {
const popupItem = popupsList.value[id];
if (popupItem) {
setPopup({
...popupItem.value,
...modifications, // Apply any modifications if provided
});
// Optionally, you can also return the popup
return <PosPopup>{
...popupItem.value,
...modifications, // Apply any modifications if provided
};
}
console.warn(`Popup with id "${id}" not found.`);
return null;
};
// Add default popups to the list on module load
addDefaultPopups();
// Exporting the popups object for use in other components
const popups = {
// Popup-related functions
popup,
isSet: isPopupSet,
get: getPopup,
set: setPopup,
clear: clearPopup,
select: selectPopupById,
// Popups list-related functions
list: popupsList,
exists: isPopupDefined,
getByKey: getPopupByKey,
removeByKey: removePopupByKey,
clearList: clearPopupsList,
add: addPopup,
};
/** Vehicles */
// Define reactive references for vehicle registrations
const vehicle_1 = ref<PosVehicle | null>(null);
const vehicle_2 = ref<PosVehicle | null>(null);
const vehicle_3 = ref<PosVehicle | null>(null);
// Active vehicle reference to track the currently selected vehicle
const activeVehicleIndex = ref<number>(1);
// Function to set the active vehicle
const setActiveVehicleIndex = (index: number): void => {
activeVehicleIndex.value = index;
};
const normalizeVehicleStatus = (vehicle: PosVehicle): VehicleStatusKey => {
if (typeof vehicle.status === "string" && vehicle.status.length > 0) {
return vehicle.status as VehicleStatusKey;
}
return determineStatusKey({
hasBooking: Array.isArray(vehicle.booking_matches) ? vehicle.booking_matches.length > 0 : !!vehicle.booking_id,
customer_id: vehicle.customer_id,
customer_name: vehicle.customer_name,
barred: vehicle.barred,
wash_subscription: vehicle.wash_subscription,
});
};
const normalizeVehicleSelection = (vehicle: PosVehicle | null): PosVehicle | null => {
if (!vehicle) {
return null;
}
return {
...vehicle,
reg: String(vehicle.reg ?? ""),
status: normalizeVehicleStatus(vehicle),
};
};
// Function to select a vehicle
const selectVehicle = (index: number, vehicle: PosVehicle | null) => {
const normalizedVehicle = normalizeVehicleSelection(vehicle);
switch (index) {
case 1:
vehicle_1.value = normalizedVehicle;
// If the vehicle has a reference, and it is currently not set, set it
if (normalizedVehicle?.reference && !metadata.getReference()) {
metadata.setReference(normalizedVehicle.reference);
}
break;
case 2:
vehicle_2.value = normalizedVehicle;
break;
case 3:
vehicle_3.value = normalizedVehicle;
break;
default:
console.warn(`Invalid vehicle index: ${index}. Please use 1, 2, or 3.`);
return;
}
};
const getActiveVehicle = () => {
switch (activeVehicleIndex.value) {
default:
setActiveVehicleIndex(1);
return vehicle_1.value;
case 2:
return vehicle_2.value;
case 3:
return vehicle_3.value;
}
};
const getVehicleByIndex = (index: number): PosVehicle | null => {
switch (index) {
case 1:
return vehicle_1.value;
case 2:
return vehicle_2.value;
case 3:
return vehicle_3.value;
default:
console.warn(`Invalid vehicle index: ${index}. Please use 1, 2, or 3.`);
return null;
}
};
const vehicles = {
select: selectVehicle,
vehicle_1,
vehicle_2,
vehicle_3,
// Active vehicle (Used by scanner, to determine what the next response should apply to.)
activeVehicleIndex,
setActiveVehicleIndex,
getActiveVehicle,
get: getVehicleByIndex,
};
/** Transaction history */
// Store the last transactions created on this device
const lastTransactions = ref<PosOrder[]>([]);
const timeoutToClearLastTransactions = ref<number>(1000 * 60 * 60 * 24); // Default timeout to clear a transaction from history (24 hours)
const limitToLastTransactions = ref<number>(100); // Limit to the last X transactions in history, older transactions will be removed
// Function to check if a transaction is outdated based on the timeout
const isTransactionOutdated = (order: PosOrder): boolean => {
const now = Date.now();
const orderTime = new Date(order.created_at).getTime();
return now - orderTime > timeoutToClearLastTransactions.value;
};
// Function to clear outdated transactions from history
const clearOutdatedTransactions = () => {
// Check if there are more than the limit of transactions
const excessTransactions = lastTransactions.value.length - limitToLastTransactions.value;
if (excessTransactions > 0) {
lastTransactions.value.splice(0, excessTransactions);
}
// Remove transactions that are older than the timeout
lastTransactions.value = lastTransactions.value.filter((order) => !isTransactionOutdated(order));
};
// Watcher to automatically clear outdated transactions every 10 minutes
setInterval(() => {
clearOutdatedTransactions();
}, 1000 * 60 * 10); // Every 10 minutes
// Function to add a transaction to the history
const addTransactionToHistory = (order: PosOrder) => {
lastTransactions.value.push(order);
clearOutdatedTransactions();
};
// Function to get the last transactions
const getLastTransactions = () => {
return lastTransactions.value;
};
// Function to get pending transactions (not completed)
const getPendingTransactions = () => {
return lastTransactions.value.filter((order) => !order.completed_at);
};
// Function to clear the last transactions
const clearLastTransactions = () => {
lastTransactions.value = [];
};
const transactionHistory = {
list: lastTransactions,
add: addTransactionToHistory,
get: getLastTransactions,
getPending: getPendingTransactions,
clear: clearLastTransactions,
timeout: timeoutToClearLastTransactions,
limit: limitToLastTransactions,
clearOutdated: clearOutdatedTransactions,
};
/** Transaction items */
// The primary item is the main product or service being purchased
const primaryItem = ref<PosProduct | null>(null);
// Additional items are any extra products or services added to the transaction, not to be confused with the primary item addons.
// This could include things like additional products, services, or fees.
// It is a separate list to allow for flexibility in the transaction.
const additionalItems = ref<PosProduct[]>([]);
const normalizeAdditionalItem = (item: PosProduct): PosProduct => ({
...item,
quantity: Number(item?.quantity ?? 0) > 0 ? Number(item.quantity) : 1,
});
// Function to add another item to the transaction
const addAdditionalItem = (item: PosProduct) => {
// If the additional item requires a note, prompt for it before adding
if (item.requires_note) {
promptForNotesIfRequired(item, (notes: string) => {
additionalItems.value.push(
normalizeAdditionalItem({
...item,
notes,
})
);
});
return;
}
additionalItems.value.push(normalizeAdditionalItem(item));
};
// Function to remove an additional item from the transaction
const removeAdditionalItem = (item: PosProduct) => {
const index = additionalItems.value.indexOf(item);
if (index > -1) {
additionalItems.value.splice(index, 1);
}
};
// Function to clear all additional items from the transaction
const clearAdditionalItems = () => {
additionalItems.value = [];
};
// Function to set the additional items for the transaction (replaces the current list)
const setAdditionalItems = (items: PosProduct[]) => {
additionalItems.value = items;
};
// Function to get the total price of all additional items
const getAdditionalItemsTotal = () => {
let total = 0;
additionalItems.value.forEach((item) => {
total += item.price * (item.quantity || 1);
// Add the addons prices if they exist
if (item.addons && item.addons.length > 0) {
// Make sure that there is a quantity for each addon, if not, assume 0 quantity
total += item.addons.reduce((sum, addon) => sum + addon.price * (addon.quantity || 0), 0);
}
});
return total;
};
// Function to convert a product to an addon:
const convertProductToAddon = (
product: PosProduct,
options: { quantity?: number; min?: number; max?: number } = {}
): Addon => {
return {
id: product.id,
name: product.name,
price: product.price,
product: product,
quantity: options.quantity || 0,
min: options.min || -1,
max: options.max || -1,
};
};
// Function to apply wash certificate addon if present
const applyWashCertificateIfPresent = (item: PosProduct | null): PosProduct | null => {
if (!item) return null;
if (!Array.isArray(item.addons) || item.addons.length === 0) {
console.warn("The item requires a wash certificate, but the primary service does not support it.");
return item;
}
const washCertificateAddon = item.addons.find(
(addon) => addon?.product?.id === WASH_CERTIFICATE_PRODUCT_ID || addon?.id === WASH_CERTIFICATE_PRODUCT_ID
);
if (!washCertificateAddon) {
console.warn("The item requires a wash certificate, but the primary service does not support it.");
return item;
}
washCertificateAddon.quantity = 1;
if (washCertificateAddon.product) {
washCertificateAddon.product.quantity = 1;
}
return item;
};
const removeWashCertificateIfPresent = (item: PosProduct | null): PosProduct | null => {
if (!item || !Array.isArray(item.addons) || item.addons.length === 0) return item;
const washCertificateAddon = item.addons.find(
(addon) => addon?.product?.id === WASH_CERTIFICATE_PRODUCT_ID || addon?.id === WASH_CERTIFICATE_PRODUCT_ID
);
if (!washCertificateAddon) return item;
washCertificateAddon.quantity = 0;
if (washCertificateAddon.product) {
washCertificateAddon.product.quantity = 0;
}
return item;
};
// Function to set the primary item for the transaction
const setPrimaryItem = (item: PosProduct | null) => {
// Check if the attachments include a wash certificate and if the item requires it
if (hasWashCertificate()) {
item = applyWashCertificateIfPresent(item);
} else {
item = removeWashCertificateIfPresent(item);
}
primaryItem.value = item;
// Set the vehicle type to the new primary item id (This is done to prevent overriding the vehicle type when changing the primary item)
if (item && vehicles.vehicle_1.value) {
vehicles.vehicle_1.value.type = item.id;
}
};
// Function to clear the primary item from the transaction
const clearPrimaryItem = () => {
primaryItem.value = null;
};
const getPrimaryItemTotal = () => {
let total = 0;
// Add the primary item price
total += primaryItem.value ? primaryItem.value.price : 0;
// Add the addons prices
if (primaryItem.value && primaryItem.value.addons && primaryItem.value.addons.length > 0) {
total += primaryItem.value.addons.reduce((sum, addon) => sum + addon.price * (addon.quantity || 0), 0);
}
return total;
};
// Function to get the total price of the transaction, including primary and additional items
const getTransactionTotal = () => {
return getPrimaryItemTotal() + getAdditionalItemsTotal();
};
// Function to update product prices if needed (e.g., if an item is already in the cart, update its price)
const updateTransactionProduct = (updatedProduct: PosProduct) => {
// Update primary item price if it matches the updated product
if (primaryItem.value && primaryItem.value.id === updatedProduct.id) {
primaryItem.value.price = updatedProduct.price;
// Update the addons prices if they match the updated product (while maintaining their quantity)
if (primaryItem.value.addons && primaryItem.value.addons.length > 0) {
primaryItem.value.addons = primaryItem.value.addons.map((addon) => {
if (addon.id === updatedProduct.id) {
return { ...addon, price: updatedProduct.price };
}
return addon;
});
}
}
// Update additional items prices if they match the updated product
additionalItems.value = additionalItems.value.map((item) => {
if (item.id === updatedProduct.id) {
return { ...item, price: updatedProduct.price };
}
return item;
});
// Update last vehicle orders if they match the updated product
for (const vehicleKey in lastVehicleOrders.value) {
const order = lastVehicleOrders.value[vehicleKey as keyof typeof lastVehicleOrders.value];
if (order && order.items && order.items.length > 0) {
order.items = order.items.map((item) => {
if (item.product.id === updatedProduct.id) {
return {
...item,
price: updatedProduct.price, // Update the item price
product: {
...item.product, // Keep other product details (quantity, etc.)
price: updatedProduct.price, // Update the product price
},
};
}
return item;
});
}
}
};
// Function to update product prices in additional items if needed (e.g., if an item is already in the cart, update its price)
const updateTransactionPrices = (updatedProducts: PosProduct[] | null = null) => {
// If no updated products are provided, take the current product list
if (!updatedProducts) {
//console.warn('No updated products provided, using current product list.');
updatedProducts = productList.list.value;
}
// Update prices for each product in the transaction
updatedProducts.forEach((product) => {
//console.warn(`Updating transaction prices for product with id: ${product.id}`);
updateTransactionProduct(product);
});
};
// Function to prompt for notes if required (input is a PosProduct)
const promptForNotesIfRequired = (product: PosProduct, callback: (notes: string) => void) => {
if (product.requires_note) {
// Open a popup to prompt for notes
popups.select("add_product_note", {
title: "Tilføj note",
message: `Tilføj venligst en note for produktet: ${product.name}`,
component: "add_product_note", // This component should handle input and return the notes
style: { maxHeight: "40vh" },
props: { product },
actionButtons: [
{
label: "Bekræft",
description: "Bekræft noten og fortsæt",
onClick: () => {
const activePopup = popups.get();
const note = String(activePopup?.props?.product?.notes || "").trim();
if (!note) {
if (activePopup?.props) {
activePopup.props.validationMessage = "Note er påkrævet for dette produkt";
}
return;
}
callback(note);
clearPopup();
},
color: "primary",
},
{ ...defaultActionButtons.value.cancel },
],
});
} else {
// If no notes are required, call the callback with an empty string
callback("");
}
};
const containsWashCertificate = (): boolean => {
return doesTransactionContainWashCertificateProduct({
primaryItem: primaryItem.value,
additionalItems: additionalItems.value,
attachmentsHasWashCertificate: attachments.hasWashCertificate(),
});
};
const transactionItems = {
// Additional items are separate from the primary item
additionalItems,
addAdditionalItem,
removeAdditionalItem,
clearAdditionalItems,
setAdditionalItems,
// Primary item related functions
primaryItem,
setPrimaryItem,
clearPrimaryItem,
getPrimaryItemTotal,
getAdditionalItemsTotal,
// Transaction total function
getTransactionTotal,
// Update product prices if needed (e.g., if an item is already in the cart, update its price)
updateTransactionPrices,
// Convert a product to an addon
convertProductToAddon,
containsWashCertificate,
};
/** Categories */
// Categories available for the transaction
const categoriesList = ref<PosCategory[]>([]);
const categoriesLoading = ref<boolean>(false);
// Function to add a new category
const addCategory = (category: PosCategory) => {
categoriesList.value.push(category);
};
// Function to remove a category
const removeCategory = (category: PosCategory) => {
const index = categoriesList.value.indexOf(category);
if (index > -1) {
categoriesList.value.splice(index, 1);
}
};
// Function to clear all categories
const clearCategories = () => {
categoriesList.value = [];
};
// Function to get the list of categories
const getCategories = () => {
return categoriesList.value;
};
// Current category for the transaction
const currentCategory = ref<PosCategory | null>(null);
// Function to set the current category
const setCurrentCategory = (category: PosCategory | null) => {
currentCategory.value = category;
};
// Function to clear the current category
const clearCurrentCategory = () => {
currentCategory.value = null;
};
// Function to get the current category
const getCurrentCategory = () => {
return currentCategory.value;
};
// Function to check if a category is selected
const isCurrentCategorySelected = () => {
return currentCategory.value !== null;
};
// Function to determine if a category is the current category
const isCategorySelected = (category: PosCategory) => {
return currentCategory.value === category;
};
const setCategoriesLoading = (state: boolean) => {
categoriesLoading.value = state;
};
// Exporting the categories object for use in other components
const categories = {
// Categories related functions
list: categoriesList,
loading: categoriesLoading,
add: addCategory,
remove: removeCategory,
clear: clearCategories,
get: getCategories,
setLoading: setCategoriesLoading,
// Current category-related functions
selected: currentCategory,
select: setCurrentCategory,
unselect: clearCurrentCategory,
selection: getCurrentCategory,
isSelected: isCurrentCategorySelected,
isCategorySelected,
};
/** Products */
const products = ref<PosProduct[]>([]);
const productsLoading = ref<boolean>(false);
// Function to add a product
const addProduct = (product: PosProduct) => {
products.value.push(product);
};
// Function to remove a product
const removeProduct = (product: PosProduct) => {
const index = products.value.indexOf(product);
if (index > -1) {
products.value.splice(index, 1);
}
};
// Function to clear all products
const clearProducts = () => {
products.value = [];
};
// Function to get the list of products
const getProducts = () => {
return products.value;
};
// Function to check if a product is in the list
const isProductInList = (product: PosProduct) => {
return products.value.includes(product);
};
const setProductsLoading = (state: boolean) => {
productsLoading.value = state;
};
// Exporting the products object for use in other components
const productList = {
list: products,
loading: productsLoading,
add: addProduct,
remove: removeProduct,
clear: clearProducts,
get: getProducts,
setLoading: setProductsLoading,
isInList: isProductInList,
};
/** Attachments */
// Define the reactive properties
const attachments_files = ref<File[]>([]);
// Base64 representation must conform to { filename: string; base64String: string }[]
const attachments_base64 = ref<{ filename: string; base64String: string }[]>([]);
// Wash certificate?
const attachments_wash_certificate = ref<boolean>(false);
// Function to set the wash certificate flag
const setWashCertificate = (hasCertificate: boolean) => {
attachments_wash_certificate.value = hasCertificate;
};
// Function to get the wash certificate flag
const hasWashCertificate = () => {
return attachments_wash_certificate.value;
};
// Function to clear the wash certificate flag
const clearWashCertificate = () => {
attachments_wash_certificate.value = false;
};
// Function to add a wash certificate
const addWashCertificate = () => {
attachments_wash_certificate.value = true;
};
// Function to add a file attachment
const addAttachmentFile = (file: File) => {
attachments_files.value.push(file);
};
// Function to remove a file attachment
const removeAttachmentFile = (file: File) => {
const index = attachments_files.value.indexOf(file);
if (index > -1) {
attachments_files.value.splice(index, 1);
}
};
// Function to clear all file attachments
const clearAttachmentFiles = () => {
attachments_files.value = [];
};
// Function to get the list of file attachments
const getAttachmentFiles = () => {
return attachments_files.value;
};
// Function to add a base64 attachment
const addAttachmentBase64 = (attachment: { filename: string; base64String: string }) => {
attachments_base64.value.push(attachment);
};
// Function to remove a base64 attachment
const removeAttachmentBase64 = (attachment: { filename: string; base64String: string }) => {
const index = attachments_base64.value.indexOf(attachment);
if (index > -1) {
attachments_base64.value.splice(index, 1);
}
};
// Function to clear all base64 attachments
const clearAttachmentBase64 = () => {
attachments_base64.value = [];
};
// Function to get the list of base64 attachments
const getAttachmentBase64 = () => {
return attachments_base64.value;
};
// Function to clear all attachments (both files and base64)
const clearAllAttachments = () => {
clearAttachmentFiles();
clearAttachmentBase64();
clearWashCertificate();
};
// Function to get the total count of all attachments
const getTotalAttachmentsCount = () => {
return (
attachments_files.value.length + attachments_base64.value.length + (attachments_wash_certificate.value ? 1 : 0)
);
};
// Function to take a picture as a base64 attachment
const takePicture = () => {
// Save the last picture to the base64 attachments
const lastPicture = latestImage.value;
if (lastPicture) {
addAttachmentBase64({
filename: "last_picture.jpg",
base64String: lastPicture,
});
}
};
// Exporting the attachments object for use in other components
const attachments = {
// File attachments
files: attachments_files,
addFile: addAttachmentFile,
removeFile: removeAttachmentFile,
clearFiles: clearAttachmentFiles,
getFiles: getAttachmentFiles,
// Base64 attachments
base64: attachments_base64,
addBase64: addAttachmentBase64,
removeBase64: removeAttachmentBase64,
clearBase64: clearAttachmentBase64,
getBase64: getAttachmentBase64,
// Clear all attachments
clearAll: clearAllAttachments,
// Get total count of all attachments
count: getTotalAttachmentsCount,
// Wash certificate
hasWashCertificate,
setWashCertificate,
addWashCertificate,
clearWashCertificate,
// Take a picture as a base64 attachment
takePicture,
};
watch(
() => [attachments_wash_certificate.value, primaryItem.value?.id],
([hasCertificate]) => {
if (!primaryItem.value) return;
if (hasCertificate) {
applyWashCertificateIfPresent(primaryItem.value);
} else {
removeWashCertificateIfPresent(primaryItem.value);
}
},
{ immediate: true }
);
/** Camera */
// Define the reactive properties
const latestImage = ref<string | null>(null);
const isCameraMounted = ref<boolean>(false);
const cameraImageCaptureDelayInitial = ref<number>(500); // Initial delay for camera image capture in milliseconds (First capture)
const cameraImageCaptureDelaySubsequent = ref<number>(1005); // Further captures delay in milliseconds (Every capture after the first one)
const cameraImageCaptureDelayAfterSuccess = ref<number>(1005); // Delay after a successful capture in milliseconds (After a successful capture, before the next one)
const cameraImageCaptureLastSuccess = ref<number | null>(null); // Timestamp of the last successful capture
const cameraImageZoom = ref<number>(2); // Camera zoom level (1 = no zoom, 2 = 2x zoom, etc.)
// Function to get the camera zoom level
const getCameraZoom = () => {
return cameraImageZoom.value;
};
// Function to set the camera zoom level
const setCameraZoom = (zoom: number) => {
cameraImageZoom.value = zoom;
};
// Function to clear the camera zoom level (reset to default)
const clearCameraZoom = () => {
cameraImageZoom.value = 2;
};
// Function to set the timestamp of the last successful capture
const setCameraImageCaptureLastSuccess = (timestamp?: number | null) => {
cameraImageCaptureLastSuccess.value = timestamp !== undefined ? timestamp : Date.now();
};
// Function to get the timestamp of the last successful capture
const getCameraImageCaptureLastSuccess = () => {
return cameraImageCaptureLastSuccess.value;
};
// Function to get the delay after a successful capture
const getCameraImageCaptureDelayAfterSuccess = () => {
return cameraImageCaptureDelayAfterSuccess.value;
};
// Function to set the delay after a successful capture
const setCameraImageCaptureDelayAfterSuccess = (delay: number) => {
cameraImageCaptureDelayAfterSuccess.value = delay;
};
// Function to check if the delay after a successful capture has passed
const hasCameraImageCaptureDelayAfterSuccessPassed = (): boolean => {
if (cameraImageCaptureLastSuccess.value === null) return true; // No successful capture yet
const currentTime = Date.now();
return currentTime - cameraImageCaptureLastSuccess.value > cameraImageCaptureDelayAfterSuccess.value;
};
// Function to retrieve the latest image frame.
const getLatestImage = async () => {
return latestImage.value;
};
// Function to set the latest image frame.
const setLatestImage = (image: string | null) => {
latestImage.value = image;
};
// Function to set the camera mounted state.
const setCameraMounted = (mounted: boolean) => {
isCameraMounted.value = mounted;
};
// Function to clear the latest image.
const clearLatestImage = () => {
latestImage.value = null;
};
// Function to clear the camera mounted state.
const clearCameraMounted = () => {
isCameraMounted.value = false;
};
// Function to get the camera image capture delay.
const getCameraImageCaptureDelay = (isFirstCapture: boolean): number => {
return isFirstCapture ? cameraImageCaptureDelayInitial.value : cameraImageCaptureDelaySubsequent.value;
};
// Function to set the camera image capture delay.
const setCameraImageCaptureDelay = (isFirstCapture: boolean, delay: number) => {
if (isFirstCapture) {
cameraImageCaptureDelayInitial.value = delay;
} else {
cameraImageCaptureDelaySubsequent.value = delay;
}
};
const camera = {
latestImage,
get: getLatestImage,
mounted: isCameraMounted,
setMounted: setCameraMounted,
setLatestImage,
clearLatestImage,
clearMounted: clearCameraMounted,
getImageCaptureDelay: getCameraImageCaptureDelay,
setImageCaptureDelay: setCameraImageCaptureDelay,
// Capture delay after success functions
getImageCaptureDelayAfterSuccess: getCameraImageCaptureDelayAfterSuccess,
setImageCaptureDelayAfterSuccess: setCameraImageCaptureDelayAfterSuccess,
setLastSuccess: setCameraImageCaptureLastSuccess,
getLastSuccess: getCameraImageCaptureLastSuccess,
hasDelayAfterSuccessPassed: hasCameraImageCaptureDelayAfterSuccessPassed,
// Camera zoom functions
getZoom: getCameraZoom,
setZoom: setCameraZoom,
clearZoom: clearCameraZoom,
};
/** Metadata */
const customerId = ref<number | null>(null);
const notes = ref<string>("");
const reference = ref<string>("");
const safetySeal = ref<string>("");
const washId = ref<string | null>(null); // The Wash ID is used to track the related wash event
const bookingId = ref<number | null>(null); // The Booking ID is used to track the related booking event
const bookingSelectionSkippedPlate = ref<string>("");
const laneId = ref<number | null>(null); // The Lane ID is used to track the related lane event
const loadingState = ref<boolean>(false); // Loading state for the transaction
const loadingMessage = ref<string>("Indlæser..."); // Loading message for the transaction
// Function to set the loading state
const setLoadingState = (state: boolean) => {
loadingState.value = state;
};
// Function to get the loading state
const getLoadingState = () => {
return loadingState.value;
};
// Function to set the loading message
const setLoadingMessage = (message: string) => {
loadingMessage.value = message;
};
// Function to get the loading message
const getLoadingMessage = () => {
return loadingMessage.value;
};
// Function to clear the loading state
const clearLoadingState = () => {
loadingState.value = false;
loadingMessage.value = "Indlæser...";
};
// Function to set the customer ID
const setCustomerId = (id: number | null) => {
customerId.value = id;
};
// Function to get the customer ID
const getCustomerId = () => {
return customerId.value;
};
// Function to set notes
const setNotes = (newNotes: string | null | undefined) => {
notes.value = newNotes ?? "";
};
// Function to get notes
const getNotes = () => {
return notes.value;
};
// Function to set reference
const setReference = (newReference: string | null | undefined) => {
reference.value = newReference ?? "";
};
// Function to get reference
const getReference = () => {
return reference.value;
};
// Function to set safety seal
const setSafetySeal = (newSafetySeal: string | null | undefined) => {
safetySeal.value = newSafetySeal ?? "";
};
// Function to get safety seal
const getSafetySeal = () => {
return safetySeal.value;
};
// Function to set the wash ID
const setWashId = (id: string | null) => {
washId.value = id;
};
// Function to get the wash ID
const getWashId = () => {
return washId.value;
};
// Function to set the booking ID
const setBookingId = (id: number | null) => {
bookingId.value = id;
};
// Function to get the booking ID
const getBookingId = () => {
return bookingId.value;
};
const setBookingSelectionSkippedPlate = (plate: string | null | undefined) => {
bookingSelectionSkippedPlate.value = String(plate ?? "")
.replace(/\s/g, "")
.toUpperCase();
};
const getBookingSelectionSkippedPlate = () => {
return bookingSelectionSkippedPlate.value;
};
const clearBookingSelectionSkippedPlate = () => {
bookingSelectionSkippedPlate.value = "";
};
// Function to set the lane ID
const setLaneId = (id: number | null) => {
laneId.value = id;
};
// Function to get the lane ID
const getLaneId = () => {
return laneId.value;
};
// Function to clear the lane ID
const clearLaneId = () => {
laneId.value = null;
};
const metadata = {
// Customer ID
customerId,
setCustomerId,
getCustomerId,
// Notes
notes,
setNotes,
getNotes,
// Reference
reference,
setReference,
getReference,
// Safety seal
safetySeal,
setSafetySeal,
getSafetySeal,
// Wash ID
washId,
setWashId,
getWashId,
// Booking ID
bookingId,
setBookingId,
getBookingId,
bookingSelectionSkippedPlate,
setBookingSelectionSkippedPlate,
getBookingSelectionSkippedPlate,
clearBookingSelectionSkippedPlate,
// Lane ID
laneId,
setLaneId,
getLaneId,
clearLaneId,
// Additional metadata can be added here as needed
setLoadingState,
setLoadingMessage,
loadingMessage,
clearLoadingState,
loadingState,
getLoadingState,
getLoadingMessage,
};
/** Historical data */
// This section is used to store the last order for the vehicles.
const lastVehicleOrders = ref<{
vehicle_1: PosOrder | null;
vehicle_2: PosOrder | null;
vehicle_3: PosOrder | null;
}>({
vehicle_1: null,
vehicle_2: null,
vehicle_3: null,
});
/** Last vehicle order */
const setLastVehicleOrder = (vehicleIndex: number, order: PosOrder | null) => {
switch (vehicleIndex) {
case 1:
lastVehicleOrders.value.vehicle_1 = order;
break;
case 2:
lastVehicleOrders.value.vehicle_2 = order;
break;
case 3:
lastVehicleOrders.value.vehicle_3 = order;
break;
default:
console.warn(`Invalid vehicle index: ${vehicleIndex}. Please use 1, 2, or 3.`);
return;
}
};
const getLastVehicleOrder = (vehicleIndex: number): PosOrder | null => {
switch (vehicleIndex) {
case 1:
return lastVehicleOrders.value.vehicle_1;
case 2:
return lastVehicleOrders.value.vehicle_2;
case 3:
return lastVehicleOrders.value.vehicle_3;
default:
console.warn(`Invalid vehicle index: ${vehicleIndex}. Please use 1, 2, or 3.`);
return null;
}
};
const selectLastVehicleOrder = (vehicleIndex: number) => {
// This function is used to use the last vehicle order for the selected vehicle.
const lastOrder = getLastVehicleOrder(vehicleIndex);
if (lastOrder) {
if (!lastOrder.items || lastOrder.items.length === 0) {
console.warn(`Last vehicle order for vehicle ${vehicleIndex} has no items. aborting selection.`);
return;
}
const orderItems = lastOrder.items.filter(Boolean);
const primaryOrderItem =
orderItems.find((item) => item?.related_item_id === null || item?.related_item_id === undefined) || orderItems[0];
if (!primaryOrderItem) {
console.warn(`Last vehicle order for vehicle ${vehicleIndex} has no usable primary item.`);
return;
}
const normalizeOrderItemProduct = (item: any, defaultQuantity = 1): PosProduct => {
const productId = Number(item?.product?.id ?? item?.product_id ?? item?.id ?? 0);
const quantity = Number(item?.quantity ?? defaultQuantity);
return {
...(item?.product || {}),
id: productId,
name: item?.product?.name ?? item?.name ?? "",
price: Number(item?.price ?? item?.product?.price ?? 0),
quantity,
notes: item?.notes ?? item?.product?.notes ?? "",
addons: Array.isArray(item?.product?.addons) ? item.product.addons : [],
subscription_allowed: Boolean(item?.product?.subscription_allowed ?? false),
} as PosProduct;
};
const primaryOrderItemId = Number(primaryOrderItem?.id ?? 0);
const addonItems = primaryOrderItemId
? orderItems.filter((item) => Number(item?.related_item_id ?? 0) === primaryOrderItemId)
: [];
const addonIds = new Set(addonItems.map((item) => Number(item?.id ?? 0)));
const addons: Addon[] = addonItems.map((item) => {
const addonProduct = normalizeOrderItemProduct(item, 1);
return transactionItems.convertProductToAddon(addonProduct, {
quantity: Number(item?.quantity ?? 1),
min: 0,
max: -1,
});
});
const additionalItems = orderItems
.filter((item) => {
const itemId = Number(item?.id ?? 0);
if (itemId && itemId === Number(primaryOrderItem?.id ?? 0)) {
return false;
}
if (itemId && addonIds.has(itemId)) {
return false;
}
return true;
})
.map((item) => {
return {
...normalizeOrderItemProduct(item, 1),
related_item_id: null,
} as PosProduct;
});
const primaryItem = <PosProduct>{
...normalizeOrderItemProduct(primaryOrderItem, 1),
quantity: 1,
addons,
};
vehicles.select(vehicleIndex, { ...vehicles.get(vehicleIndex), type: primaryItem.id });
transactionItems.setAdditionalItems(additionalItems);
transactionItems.setPrimaryItem({ ...primaryItem });
} else {
console.warn(`No last vehicle order found for vehicle ${vehicleIndex}.`);
}
};
/** Last vehicle order functions */
const lastOrders = {
set: setLastVehicleOrder,
get: getLastVehicleOrder,
select: selectLastVehicleOrder,
lastOrders: lastVehicleOrders,
};
/** Reset function */
// Function to reset vehicles
const resetVehicles = () => {
vehicles.vehicle_1.value = null;
vehicles.vehicle_2.value = null;
vehicles.vehicle_3.value = null;
vehicles.activeVehicleIndex.value = 1;
};
// Function to reset views
const resetViews = () => {
views.manualInput.value = false;
views.vehicleSelection.value = false;
views.additionalItemSelection.value = false;
views.transactionHistoryView.value = false;
};
// Function to reset attachments
const resetAttachments = () => {
attachments.clearAll();
};
// Function to reset transaction items
const resetTransactionItems = () => {
transactionItems.primaryItem.value = null;
transactionItems.additionalItems.value = [];
};
// Function to reset categories
const resetCategories = () => {
categories.list.value = [];
categories.selected.value = null;
categories.loading.value = false;
};
// Function to reset products
const resetProducts = () => {
productList.list.value = [];
productList.loading.value = false;
};
// Function to reset camera
const resetCamera = () => {
camera.latestImage.value = null;
/** These values are not reset to the initial state as they are not used in the current implementation. */
//camera.isCameraMounted.value = false;
//camera.cameraImageCaptureDelayInitial.value = 3000; // Reset to initial delay
//camera.cameraImageCaptureDelaySubsequent.value = 10000; // Reset to subsequent delay
camera.clearLatestImage();
camera.clearMounted();
};
// Function to reset metadata
const resetMetadata = () => {
metadata.customerId.value = null;
metadata.notes.value = "";
metadata.reference.value = "";
metadata.safetySeal.value = "";
metadata.washId.value = null;
metadata.bookingId.value = null;
metadata.bookingSelectionSkippedPlate.value = "";
metadata.laneId.value = null;
metadata.clearLoadingState();
};
// Function to reset the last vehicle orders
const resetLastVehicleOrders = () => {
lastVehicleOrders.value = {
vehicle_1: null,
vehicle_2: null,
vehicle_3: null,
};
};
// Function to reset transaction history
const resetTransactionHistory = () => {
transactionHistory.clear();
};
// Function to reset the last saved time
const resetLastSavedTime = () => {
lastSavedTime.value = null; // Reset the last saved time to null
};
/** Search vehicles */
const searchVehicles = ref<PosVehicle[]>([]);
const addSearchVehicle = (vehicle: PosVehicle) => {
searchVehicles.value.push(vehicle);
};
const removeSearchVehicle = (vehicle: PosVehicle) => {
const index = searchVehicles.value.indexOf(vehicle);
if (index > -1) {
searchVehicles.value.splice(index, 1);
}
};
const isSearchVehicleInList = (vehicle: PosVehicle) => {
return searchVehicles.value.includes(vehicle);
};
const getSearchVehiclesByRegistrationNumber = (reg: string) => {
return searchVehicles.value.filter((v) => v.reg === reg);
};
const clearSearchVehicles = () => {
searchVehicles.value = [];
};
const getSearchVehicles = () => {
return searchVehicles.value;
};
const resetSearchVehicles = () => {
searchVehicles.value = [];
};
const search = {
list: searchVehicles,
add: addSearchVehicle,
remove: removeSearchVehicle,
isInList: isSearchVehicleInList,
getByRegistrationNumber: getSearchVehiclesByRegistrationNumber,
clear: clearSearchVehicles,
get: getSearchVehicles,
reset: resetSearchVehicles,
};
// Function to reset all POS data
const resetPos = (allData: boolean = false) => {
resetSearchVehicles();
resetVehicles();
resetViews();
resetTransactionItems();
resetCategories();
resetProducts();
resetCamera();
resetMetadata();
resetLastVehicleOrders(); // Reset the last vehicle orders
resetLastSavedTime(); // Reset the last saved time
savePos(); // Save the reset state
hasRetrievedPos.value = false; // Reset the retrieval flag
retrievePos(); // Re-retrieve to ensure the reset state is applied
resetAttachments();
if (allData) {
resetTransactionHistory(); // Reset the transaction history if allData is true
}
};
// Export reset functions for use in other components
const reset = {
vehicles: resetVehicles,
views: resetViews,
transactionItems: resetTransactionItems,
categories: resetCategories,
products: resetProducts,
camera: resetCamera,
metadata: resetMetadata,
lastOrders: resetLastVehicleOrders,
attachments: resetAttachments,
transactionHistory: resetTransactionHistory,
lastSavedTime: resetLastSavedTime,
all: resetPos,
search: resetSearchVehicles,
pos: resetPos,
};
/** Collection */
const pos = {
vehicles,
views,
transactionItems,
categories,
productList,
camera,
metadata,
lastOrders,
popups,
reset,
locations,
sounds,
search,
attachments,
transactionHistory,
};
/** Persistent data */
const STORAGE_KEY = "pos"; // Key for localStorage
// Expiration time for the stored data in milliseconds (Default: 10 minutes)
const STORAGE_EXPIRE_MS = ref<number>(1000 * 60 * 10); // 10 minutes
// Time the data was last saved (used for tracking expiration)
const lastSavedTime = ref<number | null>(null);
// Function to set the expiration time for the stored data
const setStorageExpireTime = (ms: number) => {
STORAGE_EXPIRE_MS.value = ms;
};
// Function to get the expiration time for the stored data
const getStorageExpireTime = () => {
return STORAGE_EXPIRE_MS.value;
};
// Function to check if the stored data has expired
const isStorageExpired = (): boolean => {
if (lastSavedTime.value === null) return true; // No data saved yet
const currentTime = Date.now();
return currentTime - lastSavedTime.value > STORAGE_EXPIRE_MS.value;
};
const hasRetrievedPos = ref<boolean>(false);
// Build a plain snapshot for storage (keeps reactivity separate from persisted data)
const buildSnapshot = () => ({
vehicles: {
vehicle_1: vehicles.vehicle_1.value,
vehicle_2: vehicles.vehicle_2.value,
vehicle_3: vehicles.vehicle_3.value,
activeVehicleIndex: vehicles.activeVehicleIndex.value,
},
views: {
manualInput: views.manualInput.value,
vehicleSelection: views.vehicleSelection.value,
additionalItemSelection: views.additionalItemSelection.value,
transactionHistoryView: views.transactionHistoryView.value,
},
transactionItems: {
primaryItem: transactionItems.primaryItem.value,
additionalItems: transactionItems.additionalItems.value,
},
categories: {
list: categories.list.value,
selected: categories.selected.value,
},
productList: {
list: productList.list.value,
},
metadata: {
customerId: metadata.customerId.value,
notes: metadata.notes.value,
reference: metadata.reference.value,
safetySeal: metadata.safetySeal.value,
washId: metadata.washId.value,
bookingId: metadata.bookingId.value,
bookingSelectionSkippedPlate: metadata.bookingSelectionSkippedPlate.value,
laneId: metadata.laneId.value,
},
attachments: {
files: attachments.files.value,
base64: attachments.base64.value,
wash_certificate: attachments.hasWashCertificate(),
},
transactionHistory: lastTransactions.value,
lastVehicleOrders: lastOrders.lastOrders.value,
timestamp: Date.now(), // Add a timestamp for expiration checks
});
// Debounce helper to avoid frequent writes
const debounce = <T extends (...args: any[]) => void>(fn: T, delay = 200) => {
let t: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (t) clearTimeout(t);
t = setTimeout(() => fn(...args), delay);
};
};
// Save to localStorage
const savePos = (snapshot?: ReturnType<typeof buildSnapshot>) => {
if (!hasRetrievedPos.value) return; // don't save until after retrieval
try {
const dataToStore = snapshot ?? buildSnapshot();
localStorage.setItem(STORAGE_KEY, JSON.stringify(dataToStore));
} catch (error) {
console.error("Error saving POS data:", error);
}
};
// Retrieve the pos "collection" from the local storage.
const retrievePos = () => {
try {
if (typeof window === "undefined" || !window.localStorage) return;
const storedData = localStorage.getItem(STORAGE_KEY);
if (!storedData) return;
const parsedData = JSON.parse(storedData);
if (!parsedData || typeof parsedData !== "object") return;
// Restore vehicles data
if (parsedData.vehicles) {
vehicles.vehicle_1.value = normalizeVehicleSelection(parsedData.vehicles.vehicle_1 ?? null);
vehicles.vehicle_2.value = normalizeVehicleSelection(parsedData.vehicles.vehicle_2 ?? null);
vehicles.vehicle_3.value = normalizeVehicleSelection(parsedData.vehicles.vehicle_3 ?? null);
vehicles.activeVehicleIndex.value = parsedData.vehicles.activeVehicleIndex ?? 1;
}
// Restore views state
if (parsedData.views) {
views.manualInput.value = !!parsedData.views.manualInput;
views.vehicleSelection.value = !!parsedData.views.vehicleSelection;
views.additionalItemSelection.value = !!parsedData.views.additionalItemSelection;
views.transactionHistoryView.value = !!parsedData.views.transactionHistoryView;
}
// Restore transaction items
if (parsedData.transactionItems) {
transactionItems.primaryItem.value = parsedData.transactionItems.primaryItem ?? null;
transactionItems.additionalItems.value = Array.isArray(parsedData.transactionItems.additionalItems)
? parsedData.transactionItems.additionalItems
: [];
}
// Restore categories
if (parsedData.categories) {
categories.list.value = Array.isArray(parsedData.categories.list) ? parsedData.categories.list : [];
categories.selected.value = parsedData.categories.selected ?? null;
}
// Restore products
if (parsedData.productList) {
productList.list.value = Array.isArray(parsedData.productList.list) ? parsedData.productList.list : [];
}
// Restore metadata
if (parsedData.metadata) {
metadata.customerId.value = parsedData.metadata.customerId ?? null;
metadata.notes.value = parsedData.metadata.notes ?? "";
metadata.reference.value = parsedData.metadata.reference ?? "";
metadata.safetySeal.value = parsedData.metadata.safetySeal ?? "";
metadata.washId.value = parsedData.metadata.washId ?? null;
metadata.bookingId.value = parsedData.metadata.bookingId ?? null;
metadata.bookingSelectionSkippedPlate.value = parsedData.metadata.bookingSelectionSkippedPlate ?? "";
metadata.laneId.value = parsedData.metadata.laneId ?? null;
}
// Restore last vehicle orders
if (parsedData.lastVehicleOrders) {
lastOrders.lastOrders.value.vehicle_1 = parsedData.lastVehicleOrders.vehicle_1 ?? null;
lastOrders.lastOrders.value.vehicle_2 = parsedData.lastVehicleOrders.vehicle_2 ?? null;
lastOrders.lastOrders.value.vehicle_3 = parsedData.lastVehicleOrders.vehicle_3 ?? null;
}
// Restore transaction history
if (parsedData.transactionHistory && Array.isArray(parsedData.transactionHistory)) {
lastTransactions.value = parsedData.transactionHistory;
}
// Restore attachments
if (parsedData.attachments) {
attachments.files.value = Array.isArray(parsedData.attachments.files) ? parsedData.attachments.files : [];
attachments.base64.value = Array.isArray(parsedData.attachments.base64) ? parsedData.attachments.base64 : [];
attachments.setWashCertificate(!!parsedData.attachments.wash_certificate);
}
// Set the last saved time (used for expiration checks)
lastSavedTime.value = parsedData.timestamp ?? null;
// Self-heal legacy snapshots so future reloads keep the normalized vehicle shape.
localStorage.setItem(STORAGE_KEY, JSON.stringify(buildSnapshot()));
} catch (error) {
console.error("Error restoring POS data:", error);
} finally {
hasRetrievedPos.value = true;
}
};
// Start persistence only after mount/retrieval
// Start persistence only after retrieval (module-level init, guarded for browser)
if (typeof window !== "undefined" && typeof localStorage !== "undefined") {
retrievePos();
}
// Watch vehicles data
watch(
() => [
vehicles.vehicle_1.value,
vehicles.vehicle_2.value,
vehicles.vehicle_3.value,
vehicles.activeVehicleIndex.value,
],
savePos
);
// Watch views state
watch(
() => [
views.manualInput.value,
views.vehicleSelection.value,
views.additionalItemSelection.value,
views.transactionHistoryView.value,
],
savePos
);
// Watch transaction items
watch(() => [transactionItems.primaryItem.value, transactionItems.additionalItems.value], savePos);
// Watch transaction history
watch(() => lastTransactions.value, savePos);
// Watch categories
watch(() => [categories.list.value, categories.selected.value], savePos);
// Watch products
watch(() => productList.list.value, savePos);
// Watch attachments
watch(() => [attachments.files.value, attachments.base64.value, attachments.hasWashCertificate()], savePos);
// Watch metadata
watch(
() => [
metadata.customerId.value,
metadata.notes.value,
metadata.reference.value,
metadata.safetySeal.value,
metadata.washId.value,
metadata.bookingId.value,
metadata.bookingSelectionSkippedPlate.value,
metadata.laneId.value,
],
savePos
);
// Watch last vehicle orders
watch(
() => [
lastOrders.lastOrders.value.vehicle_1,
lastOrders.lastOrders.value.vehicle_2,
lastOrders.lastOrders.value.vehicle_3,
],
savePos
);
// Watch for storage expiration
watch(() => [lastSavedTime.value, STORAGE_EXPIRE_MS.value], savePos);
// One deep watcher over a serializable snapshot, debounced
const serializable = computed(buildSnapshot);
const savePosDebounced = debounce(() => savePos(serializable.value), 200);
watch(
serializable,
() => {
if (hasRetrievedPos.value) savePosDebounced();
},
{ deep: true }
);
export default defineComponent({
name: "PosDepartmentStepMobileFlow",
setup() {
return {
pos,
lastSavedTime,
setStorageExpireTime,
getStorageExpireTime,
isStorageExpired,
savePos,
retrievePos,
metadata,
vehicles,
views,
transactionItems,
categories,
productList,
camera,
lastOrders,
popups,
reset,
hasRetrievedPos,
attachments,
locations,
sounds,
search,
transactionHistory,
};
},
});
// Domain namespaces (extracted constants) for improved discoverability
export const Pos = {
pos,
lastSavedTime,
setStorageExpireTime,
getStorageExpireTime,
isStorageExpired,
savePos,
retrievePos,
hasRetrievedPos,
resetPos,
resetLastSavedTime,
resetAll: reset, // clearer intent for full reset
};
export const Vehicles = {
vehicles,
activeVehicleIndex,
setActiveVehicleIndex,
getActiveVehicle,
lastOrders,
setLastVehicleOrder,
getLastVehicleOrder,
selectLastVehicleOrder,
resetVehicles,
resetLastVehicleOrders,
};
export const ViewsUI = {
views,
popups,
isPopupSet,
resetViews,
};
export const Camera = {
camera,
isCameraMounted,
setCameraMounted,
latestImage,
setLatestImage,
clearLatestImage,
getCameraImageCaptureDelay,
resetCamera,
};
export const Transactions = {
transactionItems,
primaryItem,
setPrimaryItem,
clearPrimaryItem,
getPrimaryItemTotal,
additionalItems,
addAdditionalItem,
removeAdditionalItem,
clearAdditionalItems,
getAdditionalItemsTotal,
getTransactionTotal,
resetTransactionItems,
};
export const Categories = {
categories,
setCurrentCategory,
clearCurrentCategory,
getCurrentCategory,
isCurrentCategorySelected,
isCategorySelected,
addCategory,
removeCategory,
clearCategories,
getCategories,
resetCategories,
};
export const Products = {
productList,
addProduct,
removeProduct,
clearProducts,
getProducts,
isProductInList,
resetProducts,
};
export const Customer = {
customerId,
setCustomerId,
getCustomerId,
notes,
setNotes,
getNotes,
reference,
setReference,
getReference,
safetySeal,
setSafetySeal,
getSafetySeal,
washId,
setWashId,
getWashId,
bookingId,
setBookingId,
getBookingId,
bookingSelectionSkippedPlate,
setBookingSelectionSkippedPlate,
getBookingSelectionSkippedPlate,
clearBookingSelectionSkippedPlate,
};
export const Meta = {
metadata,
resetMetadata,
};
export const UI = {
manualInput,
vehicleSelection,
additionalItemSelection,
transactionHistoryView,
};
export const Locations = {
locations,
setLocation,
getLocation,
clearLocation,
getDistance,
locationTimeout,
};
export const Sounds = {
sounds,
soundEffects,
soundEffectsEnabled,
playSoundEffect,
toggleSoundEffects,
setSoundEffects,
getSoundEffects,
addSoundEffect,
clearSoundEffects,
};
export const Search = search;
// Backward-compatible named exports (grouped and formatted)
export {
// POS core
pos,
lastSavedTime,
setStorageExpireTime,
getStorageExpireTime,
isStorageExpired,
savePos,
retrievePos,
hasRetrievedPos,
reset,
resetPos,
resetLastSavedTime,
// Sounds
sounds,
soundEffects,
soundEffectsEnabled,
playSoundEffect,
toggleSoundEffects,
setSoundEffects,
getSoundEffects,
addSoundEffect,
clearSoundEffects,
// Vehicles
vehicles,
activeVehicleIndex,
setActiveVehicleIndex,
getActiveVehicle,
lastOrders,
setLastVehicleOrder,
getLastVehicleOrder,
selectLastVehicleOrder,
resetVehicles,
resetLastVehicleOrders,
// Attachments
attachments,
// Attachment files
attachments_files,
addAttachmentFile,
removeAttachmentFile,
clearAttachmentFiles,
getAttachmentFiles,
// Attachment base64
attachments_base64,
addAttachmentBase64,
removeAttachmentBase64,
clearAttachmentBase64,
getAttachmentBase64,
// Clear all attachments
clearAllAttachments,
// Views & Popups
views,
popups,
isPopupSet,
resetViews,
// Location
locations,
setLocation,
getLocation,
clearLocation,
getDistance,
locationTimeout,
// Transactions
transactionItems,
primaryItem,
setPrimaryItem,
clearPrimaryItem,
getPrimaryItemTotal,
additionalItems,
addAdditionalItem,
removeAdditionalItem,
clearAdditionalItems,
getAdditionalItemsTotal,
getTransactionTotal,
resetTransactionItems,
// Transaction history
transactionHistory,
lastTransactions,
addTransactionToHistory,
clearLastTransactions,
getLastTransactions,
resetTransactionHistory,
// Categories
categories,
setCurrentCategory,
clearCurrentCategory,
getCurrentCategory,
isCurrentCategorySelected,
isCategorySelected,
addCategory,
removeCategory,
clearCategories,
getCategories,
resetCategories,
// Products
productList,
addProduct,
removeProduct,
clearProducts,
getProducts,
isProductInList,
resetProducts,
// Camera
camera,
isCameraMounted,
setCameraMounted,
latestImage,
setLatestImage,
clearLatestImage,
getCameraImageCaptureDelay,
resetCamera,
// Customer / Metadata
customerId,
setCustomerId,
getCustomerId,
notes,
setNotes,
getNotes,
reference,
setReference,
getReference,
safetySeal,
setSafetySeal,
getSafetySeal,
washId,
setWashId,
getWashId,
bookingId,
setBookingId,
getBookingId,
laneId,
setLaneId,
getLaneId,
clearLaneId,
// Metadata
metadata,
resetMetadata,
// UI State
manualInput,
vehicleSelection,
additionalItemSelection,
transactionHistoryView,
// Search
search,
addSearchVehicle,
removeSearchVehicle,
isSearchVehicleInList,
getSearchVehiclesByRegistrationNumber,
clearSearchVehicles,
getSearchVehicles,
resetSearchVehicles,
// Action buttons
actionButtons,
};
// Non-breaking alias exports (clearer names while keeping originals)
export { productList as products, lastOrders as vehicleOrders, getCameraImageCaptureDelay as getCameraCaptureDelay };
</script>