- Add tabindex attributes to input fields for better keyboard navigation - Introduce ESLint configuration for improved code quality and consistency - Update package.json to include linting scripts and dependencies - Add tests for desktop tab navigation in POS flow
1230 lines
36 KiB
Vue
1230 lines
36 KiB
Vue
<script setup>
|
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
|
import { ref, watch, onMounted } from "vue";
|
|
import { useI18n } from "vue-i18n";
|
|
import {
|
|
reg_1,
|
|
getCustomerName,
|
|
searchAndSelectCustomer,
|
|
customer_id,
|
|
customer_name,
|
|
clearCustomerSelection,
|
|
department_id,
|
|
pendingBookings,
|
|
hasLoadedPendingBookings,
|
|
loadPendingBookings,
|
|
ensureVehiclePlateBookingsLoaded,
|
|
doesVehiclePlateHaveBooking,
|
|
getVehiclePlateBookings,
|
|
getPreferredVehiclePlateBooking,
|
|
} from "@/components/shop/POSDepartmentProcess.vue";
|
|
|
|
// Define the props for the component
|
|
const props = defineProps({
|
|
isRawInputField: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
customerSelectionSource: {
|
|
type: String,
|
|
default: "none",
|
|
},
|
|
suppressedCustomerConflictKey: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits([
|
|
"update:vehicleObject",
|
|
"update:focus",
|
|
"update:bookingObject",
|
|
"update:bookingMatches",
|
|
"update:customerConflict",
|
|
"commit:selection",
|
|
]);
|
|
const { locale } = useI18n();
|
|
|
|
// Load the pending bookings when the component is mounted
|
|
onMounted(() => {
|
|
loadPendingBookings();
|
|
});
|
|
// Watch for changes in the department_id
|
|
watch(department_id, (newValue) => {
|
|
if (newValue) {
|
|
loadPendingBookings();
|
|
}
|
|
});
|
|
|
|
const resolveBookingPlate = (vehicle = null, plateOverride = null) => {
|
|
return String(vehicle?.reg ?? plateOverride ?? reg_1.value ?? "")
|
|
.trim()
|
|
.toUpperCase();
|
|
};
|
|
|
|
const getOrderBookingSortDateValue = (booking) => {
|
|
return (
|
|
booking?.datetime ??
|
|
booking?.created_at ??
|
|
booking?.date ??
|
|
booking?.booking_datetime ??
|
|
booking?.booking_date ??
|
|
null
|
|
);
|
|
};
|
|
|
|
const hasOrderBookingSortDateValue = (booking) => {
|
|
return Boolean(getOrderBookingSortDateValue(booking));
|
|
};
|
|
|
|
const getOrderBookingSortTimestamp = (booking) => {
|
|
const dateValue = getOrderBookingSortDateValue(booking);
|
|
const parsedValue = dateValue ? Date.parse(dateValue) : Number.NaN;
|
|
if (!Number.isNaN(parsedValue)) {
|
|
return parsedValue;
|
|
}
|
|
|
|
const fallbackId = Number.parseInt(String(booking?.id ?? 0), 10);
|
|
return Number.isFinite(fallbackId) ? fallbackId : Number.MAX_SAFE_INTEGER;
|
|
};
|
|
|
|
const sortBookingMatches = (bookings = []) => {
|
|
return [...bookings].sort((left, right) => {
|
|
const leftHasDate = hasOrderBookingSortDateValue(left);
|
|
const rightHasDate = hasOrderBookingSortDateValue(right);
|
|
if (leftHasDate !== rightHasDate) {
|
|
return rightHasDate ? 1 : -1;
|
|
}
|
|
|
|
const timeDifference = getOrderBookingSortTimestamp(left) - getOrderBookingSortTimestamp(right);
|
|
if (timeDifference !== 0) {
|
|
return timeDifference;
|
|
}
|
|
|
|
return Number.parseInt(String(left?.id ?? 0), 10) - Number.parseInt(String(right?.id ?? 0), 10);
|
|
});
|
|
};
|
|
|
|
const toPositiveInteger = (value) => {
|
|
const parsedValue = Number.parseInt(String(value ?? ""), 10);
|
|
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
|
};
|
|
|
|
const getCurrentDepartmentId = () => {
|
|
return toPositiveInteger(department_id.value);
|
|
};
|
|
|
|
const doesBookingMatchCurrentDepartment = (booking = null) => {
|
|
const currentDepartmentId = getCurrentDepartmentId();
|
|
const bookingDepartmentId = toPositiveInteger(booking?.department ?? booking?.department_id);
|
|
|
|
if (!currentDepartmentId) {
|
|
return true;
|
|
}
|
|
|
|
if (!bookingDepartmentId) {
|
|
return false;
|
|
}
|
|
|
|
return bookingDepartmentId === currentDepartmentId;
|
|
};
|
|
|
|
const getVehicleBookingHintDepartmentIds = (vehicle = null) => {
|
|
const hintedDepartmentIds = [
|
|
vehicle?.booking_department_id,
|
|
vehicle?.booking_department,
|
|
vehicle?.bookingDepartmentId,
|
|
vehicle?.bookingDepartment,
|
|
]
|
|
.map((value) => toPositiveInteger(value))
|
|
.filter((value) => value !== null);
|
|
|
|
const embeddedBookingMatches = []
|
|
.concat(Array.isArray(vehicle?.booking_matches) ? vehicle.booking_matches : [])
|
|
.concat(Array.isArray(vehicle?.bookingMatches) ? vehicle.bookingMatches : []);
|
|
|
|
embeddedBookingMatches
|
|
.map((booking) => toPositiveInteger(booking?.department ?? booking?.department_id))
|
|
.filter((value) => value !== null)
|
|
.forEach((value) => {
|
|
hintedDepartmentIds.push(value);
|
|
});
|
|
|
|
return [...new Set(hintedDepartmentIds)];
|
|
};
|
|
|
|
const getVehicleEmbeddedBookingMatches = (vehicle = null) => {
|
|
if (Array.isArray(vehicle?.booking_matches)) {
|
|
return vehicle.booking_matches.filter((booking) => Boolean(booking) && doesBookingMatchCurrentDepartment(booking));
|
|
}
|
|
|
|
if (Array.isArray(vehicle?.bookingMatches)) {
|
|
return vehicle.bookingMatches.filter((booking) => Boolean(booking) && doesBookingMatchCurrentDepartment(booking));
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
const mergeBookingMatches = (...collections) => {
|
|
const bookingMatchesByKey = new Map();
|
|
|
|
collections
|
|
.flat()
|
|
.filter(Boolean)
|
|
.forEach((booking) => {
|
|
const bookingKey =
|
|
booking?.id ??
|
|
[booking?.reg_1, booking?.reg_2, booking?.datetime, booking?.reference, booking?.reference_number]
|
|
.filter(Boolean)
|
|
.join("|");
|
|
|
|
if (!bookingKey) {
|
|
return;
|
|
}
|
|
|
|
if (!bookingMatchesByKey.has(bookingKey)) {
|
|
bookingMatchesByKey.set(bookingKey, booking);
|
|
}
|
|
});
|
|
|
|
return sortBookingMatches(Array.from(bookingMatchesByKey.values()));
|
|
};
|
|
|
|
const vehicleIndicatesBooking = (vehicle = null) => {
|
|
return !!vehicle?.booking_id || vehicle?.status === "booked" || getVehicleEmbeddedBookingMatches(vehicle).length > 0;
|
|
};
|
|
|
|
const hasVehiclePayloadBookingHint = (vehicle = null) => {
|
|
if (!vehicleIndicatesBooking(vehicle)) {
|
|
return false;
|
|
}
|
|
|
|
const currentDepartmentId = getCurrentDepartmentId();
|
|
if (!currentDepartmentId) {
|
|
return true;
|
|
}
|
|
|
|
const hintedDepartmentIds = getVehicleBookingHintDepartmentIds(vehicle);
|
|
if (hintedDepartmentIds.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
return hintedDepartmentIds.includes(currentDepartmentId);
|
|
};
|
|
|
|
const getBookingMatchesForSelection = (vehicle = null, plateOverride = null) => {
|
|
const bookingPlate = resolveBookingPlate(vehicle, plateOverride);
|
|
const plateMatches = bookingPlate ? getVehiclePlateBookings(bookingPlate) : [];
|
|
const embeddedMatches = getVehicleEmbeddedBookingMatches(vehicle);
|
|
return mergeBookingMatches(plateMatches, embeddedMatches);
|
|
};
|
|
|
|
const hasBookingMatchesForSelection = (vehicle = null, plateOverride = null) => {
|
|
return getBookingMatchesForSelection(vehicle, plateOverride).length > 0;
|
|
};
|
|
|
|
const ensureBookingMatchesForSelection = async (vehicle = null, plateOverride = null) => {
|
|
const bookingPlate = resolveBookingPlate(vehicle, plateOverride);
|
|
let bookingMatches = getBookingMatchesForSelection(vehicle, bookingPlate);
|
|
|
|
if (!hasLoadedPendingBookings.value || (bookingMatches.length === 0 && vehicleIndicatesBooking(vehicle))) {
|
|
await loadPendingBookings();
|
|
bookingMatches = getBookingMatchesForSelection(vehicle, bookingPlate);
|
|
}
|
|
|
|
if (bookingPlate && vehicleIndicatesBooking(vehicle)) {
|
|
await ensureVehiclePlateBookingsLoaded(bookingPlate, {
|
|
force: bookingMatches.length <= 1,
|
|
});
|
|
bookingMatches = getBookingMatchesForSelection(vehicle, bookingPlate);
|
|
}
|
|
|
|
return bookingMatches;
|
|
};
|
|
|
|
const prefetchVisibleBookedVehicleBookings = (vehicles = []) => {
|
|
vehicles
|
|
.filter((vehicle) => vehicleIndicatesBooking(vehicle))
|
|
.forEach((vehicle) => {
|
|
const bookingPlate = resolveBookingPlate(vehicle, vehicle?.reg);
|
|
if (!bookingPlate) {
|
|
return;
|
|
}
|
|
|
|
void ensureVehiclePlateBookingsLoaded(bookingPlate, {
|
|
force: getBookingMatchesForSelection(vehicle, bookingPlate).length <= 1,
|
|
});
|
|
});
|
|
};
|
|
|
|
// Function to emit the booking object to the parent component
|
|
const emitBookingObject = (vehicle, plateOverride = null) => {
|
|
const bookingMatches = getBookingMatchesForSelection(vehicle, plateOverride);
|
|
emit("update:bookingMatches", bookingMatches);
|
|
|
|
if (bookingMatches.length === 1) {
|
|
emit(
|
|
"update:bookingObject",
|
|
bookingMatches[0] || getPreferredVehiclePlateBooking(resolveBookingPlate(vehicle, plateOverride))
|
|
);
|
|
return;
|
|
}
|
|
|
|
emit("update:bookingObject", null);
|
|
};
|
|
|
|
// Define the vehicle response object (Example)
|
|
const vehicle_response_object = {
|
|
id: 43, // Vehicle ID
|
|
user_id: 1820, // User ID
|
|
customer_id: 25266211, // Economic customer ID
|
|
customer_name: "", // Customer name (From E-conomic)
|
|
type: 5, // Vehicle primary product id
|
|
reg: "EC25573", // License plate
|
|
wash_subscription: true, // Subscription status
|
|
addons: {
|
|
enabled: 1, // How many addons are enabled
|
|
available: 2, // How many addons are available
|
|
list: [
|
|
// List of enabled addons
|
|
{
|
|
id: 289, // Vehicle addon ID (Unique, different from the product ID or option ID)
|
|
vehicle_id: 43, // Vehicle ID
|
|
addon_id: 64, // Product option ID (Unique, different from the product ID or vehicle ID)
|
|
amount: 1, // Amount of this addon
|
|
product: {
|
|
id: 24, // Product ID (Unique, different from the vehicle ID or option ID)
|
|
name: "Spot Free- Lastbil", // Product name
|
|
description: " ", // Product description
|
|
price: 39, // Product (default) price
|
|
subscription_allowed: 1, // True if subscription is allowed (This should be true for all products listed here.)
|
|
category: "4", // Product category ID
|
|
piktogram: "", // Product image URL can be empty
|
|
economic_product_id: "33", // Economic product ID
|
|
apply_category_discount: false, // True if the product allows category, or E-conomic customer discount rules.
|
|
requires_note: false, // True if the product requires a note when added to a vehicle
|
|
created_at: "2024-12-09 14:31:49", // Product created date
|
|
updated_at: "2025-04-08 14:07:41", // Product updated date
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const get_unregistered_vehicle_object = (reg) => {
|
|
// Generate an id for the unregistered vehicle, this is only used for the dropdown
|
|
let id_prefix = 999999999999;
|
|
let id = id_prefix + Math.floor(Math.random() * 1000000);
|
|
return {
|
|
id: id,
|
|
user_id: null,
|
|
customer_id: null,
|
|
customer_name: "",
|
|
type: null,
|
|
barred: false,
|
|
reg: reg,
|
|
wash_subscription: false,
|
|
addons: {
|
|
enabled: 0,
|
|
available: 0,
|
|
list: [],
|
|
},
|
|
};
|
|
};
|
|
|
|
// To prevent the searches from overriding more recent searches
|
|
const current_search_id = ref(null);
|
|
const register_new_search = () => {
|
|
let search_id = Date.now();
|
|
current_search_id.value = search_id;
|
|
return search_id;
|
|
};
|
|
|
|
const is_latest_search = (search_id) => {
|
|
return search_id === current_search_id.value;
|
|
};
|
|
|
|
const normalizeCustomerNumber = (value) => {
|
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
};
|
|
|
|
const getCustomerConflictKey = (conflict) => {
|
|
if (!conflict) {
|
|
return "";
|
|
}
|
|
|
|
return [
|
|
resolveBookingPlate(null, conflict.plate ?? conflict.vehicleReg),
|
|
normalizeCustomerNumber(conflict.currentCustomerNumber) ?? "",
|
|
normalizeCustomerNumber(conflict.matchedCustomerNumber) ?? "",
|
|
].join("|");
|
|
};
|
|
|
|
const hasManualCustomerSelection = () => {
|
|
return props.customerSelectionSource === "manual-customer" && normalizeCustomerNumber(customer_id.value) !== null;
|
|
};
|
|
|
|
const clearCustomerConflict = () => {
|
|
emit("update:customerConflict", null);
|
|
};
|
|
|
|
const buildCustomerConflictPayload = (vehicle, plateOverride = null) => {
|
|
const currentCustomerNumber = normalizeCustomerNumber(customer_id.value);
|
|
const matchedCustomerNumber = normalizeCustomerNumber(vehicle?.customer_id);
|
|
if (!currentCustomerNumber || !matchedCustomerNumber || currentCustomerNumber === matchedCustomerNumber) {
|
|
return null;
|
|
}
|
|
|
|
const conflict = {
|
|
plate: resolveBookingPlate(vehicle, plateOverride),
|
|
currentCustomerNumber,
|
|
currentCustomerName: String(customer_name.value ?? "").trim(),
|
|
matchedCustomerNumber,
|
|
matchedCustomerName: String(vehicle?.customer_name ?? "").trim(),
|
|
vehicleId: vehicle?.id ?? null,
|
|
vehicleReg: resolveBookingPlate(vehicle, plateOverride),
|
|
vehicle,
|
|
bookingMatches: getBookingMatchesForSelection(vehicle, plateOverride),
|
|
};
|
|
|
|
return {
|
|
...conflict,
|
|
key: getCustomerConflictKey(conflict),
|
|
};
|
|
};
|
|
|
|
const isVehicleCustomerAlreadySelected = (vehicle) => {
|
|
const vehicleCustomerNumber = normalizeCustomerNumber(vehicle?.customer_id);
|
|
return vehicleCustomerNumber !== null && vehicleCustomerNumber === normalizeCustomerNumber(customer_id.value);
|
|
};
|
|
|
|
const shouldPreserveCustomerSelection = (plateValue) => {
|
|
if (hasManualCustomerSelection()) {
|
|
return true;
|
|
}
|
|
|
|
const normalizedPlate = String(plateValue ?? "")
|
|
.trim()
|
|
.toUpperCase();
|
|
if (!normalizedPlate) {
|
|
return false;
|
|
}
|
|
|
|
const matchedVehicle = vehicles_matching.value.find((vehicle) => vehicle.reg === normalizedPlate);
|
|
return !!matchedVehicle && isVehicleCustomerAlreadySelected(matchedVehicle);
|
|
};
|
|
|
|
let pendingVehicleCustomerLookup = null;
|
|
const lastAutoSyncedVehicleKey = ref("");
|
|
|
|
const getVehicleAutoSyncKey = (vehicle, plateOverride = null) => {
|
|
if (!vehicle) {
|
|
return "";
|
|
}
|
|
|
|
return [
|
|
resolveBookingPlate(vehicle, plateOverride),
|
|
vehicle?.id ?? "",
|
|
normalizeCustomerNumber(vehicle?.customer_id) ?? "",
|
|
normalizeCustomerNumber(customer_id.value) ?? "",
|
|
].join("|");
|
|
};
|
|
|
|
const syncMatchedVehicleSelection = (vehicle, options = {}) => {
|
|
const normalizedOptions = {
|
|
plateOverride: null,
|
|
allowCustomerConflict: true,
|
|
...options,
|
|
};
|
|
|
|
if (!vehicle) {
|
|
clearCustomerConflict();
|
|
emitVehicleObject(null, normalizedOptions.plateOverride);
|
|
return {
|
|
status: "cleared",
|
|
vehicle: null,
|
|
};
|
|
}
|
|
|
|
const resolvedPlate = resolveBookingPlate(vehicle, normalizedOptions.plateOverride);
|
|
if (reg_1.value !== resolvedPlate) {
|
|
reg_1.value = resolvedPlate;
|
|
}
|
|
|
|
if (normalizedOptions.allowCustomerConflict && hasManualCustomerSelection()) {
|
|
const customerConflict = buildCustomerConflictPayload(vehicle, resolvedPlate);
|
|
if (customerConflict) {
|
|
if (props.suppressedCustomerConflictKey === customerConflict.key) {
|
|
clearCustomerConflict();
|
|
emitVehicleObject(null, resolvedPlate);
|
|
return {
|
|
status: "suppressed_conflict",
|
|
conflict: customerConflict,
|
|
};
|
|
}
|
|
|
|
emit("update:customerConflict", customerConflict);
|
|
emitVehicleObject(null, resolvedPlate);
|
|
return {
|
|
status: "conflict",
|
|
conflict: customerConflict,
|
|
};
|
|
}
|
|
}
|
|
|
|
clearCustomerConflict();
|
|
getCustomerName(vehicle.customer_id);
|
|
|
|
const vehicleCustomerNumber = normalizeCustomerNumber(vehicle.customer_id);
|
|
if (
|
|
vehicleCustomerNumber &&
|
|
!isVehicleCustomerAlreadySelected(vehicle) &&
|
|
pendingVehicleCustomerLookup !== vehicleCustomerNumber
|
|
) {
|
|
pendingVehicleCustomerLookup = vehicleCustomerNumber;
|
|
searchAndSelectCustomer(vehicleCustomerNumber).finally(() => {
|
|
if (pendingVehicleCustomerLookup === vehicleCustomerNumber) {
|
|
pendingVehicleCustomerLookup = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
emitVehicleObject(vehicle, resolvedPlate);
|
|
return {
|
|
status: "selected",
|
|
vehicle,
|
|
};
|
|
};
|
|
|
|
// Define reactive properties
|
|
const vehicles_matching = ref([]);
|
|
|
|
const onInputChange = (event) => {
|
|
const inputValue = event.target.value;
|
|
// Perform any necessary validation or processing on the input value
|
|
console.log("Input value changed:", inputValue);
|
|
};
|
|
|
|
// Function to search for vehicles
|
|
const searchVehicle = async (inputValue, search_id) => {
|
|
// Check if the search ID is the latest
|
|
if (!is_latest_search(search_id)) {
|
|
console.log("Search ID is not the latest. Ignoring this search.");
|
|
return;
|
|
}
|
|
// Set the isSearching flag to true
|
|
isSearching.value = true;
|
|
// Perform the search
|
|
SessionUser.request(SessionUser.objects.vehicles.meta.endpoint + "/search", "GET", {
|
|
search: inputValue,
|
|
...(department_id.value ? { department: department_id.value } : {}),
|
|
page: 1,
|
|
limit: 10,
|
|
})
|
|
.then((response) => {
|
|
if (!is_latest_search(search_id)) {
|
|
return;
|
|
}
|
|
let result = response?.data?.data;
|
|
if (result && result.length > 0) {
|
|
// If there are results, set the vehicles_matching array
|
|
vehicles_matching.value = result;
|
|
} else {
|
|
// If no results, clear the vehicles_matching array
|
|
vehicles_matching.value = [];
|
|
}
|
|
getUnregisteredVehicleObjects(inputValue, search_id);
|
|
})
|
|
.catch((error) => {
|
|
console.error("Error:", error, search_id);
|
|
})
|
|
.finally(() => {
|
|
console.log("Finish search", search_id);
|
|
if (is_latest_search(search_id)) {
|
|
// Set the isSearching flag to false
|
|
isSearching.value = false;
|
|
}
|
|
});
|
|
};
|
|
|
|
const getUnregisteredVehicleObjects = (inputValue, search_id) => {
|
|
// Check if the search ID is the latest
|
|
if (!is_latest_search(search_id)) {
|
|
console.log("Search ID is not the latest. Ignoring this search.");
|
|
return;
|
|
}
|
|
// If the input value is empty, clear the vehicles_matching array
|
|
if (!inputValue) {
|
|
vehicles_matching.value = [];
|
|
return;
|
|
}
|
|
SessionUser.request("/department/vehicles/unknown-customer", "GET", {
|
|
search: inputValue,
|
|
page: 1,
|
|
limit: 10,
|
|
})
|
|
.then((response) => {
|
|
console.log("Unregistered vehicles:", response.data.data);
|
|
if (!is_latest_search(search_id)) {
|
|
return;
|
|
}
|
|
let result = response?.data?.data;
|
|
let unregistered_vehicles = [];
|
|
if (result && result.length > 0) {
|
|
// If there are results, parse them into unregistered_vehicles
|
|
for (let i = 0; i < result.length; i++) {
|
|
// Check if the unregistered vehicle already exists in the vehicles_matching array
|
|
let existingVehicle = vehicles_matching.value.find((vehicle) => vehicle.reg === result[i].reg_1);
|
|
if (existingVehicle) {
|
|
// If it exists, skip adding it again
|
|
continue;
|
|
}
|
|
unregistered_vehicles.push(get_unregistered_vehicle_object(result[i].reg_1));
|
|
}
|
|
// Set the vehicles_matching array to include both registered and unregistered vehicles
|
|
vehicles_matching.value = [...vehicles_matching.value, ...unregistered_vehicles];
|
|
} else {
|
|
// Do nothing
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("Error:", error, search_id);
|
|
})
|
|
.finally(() => {
|
|
console.log("Finish search", search_id);
|
|
if (is_latest_search(search_id)) {
|
|
// Set the isSearching flag to false
|
|
isSearching.value = false;
|
|
}
|
|
});
|
|
};
|
|
|
|
// Watch for changes in reg_1
|
|
watch(reg_1, (newValue) => {
|
|
// Define the search ID for this input change
|
|
const search_id = register_new_search();
|
|
console.log("reg_1 changed:", newValue);
|
|
lastAutoSyncedVehicleKey.value = "";
|
|
const shouldKeepCustomerSelection = shouldPreserveCustomerSelection(newValue);
|
|
selectedDropdownItem.value = -1;
|
|
// Check if the new value is empty, if so, clear the vehicles_matching array
|
|
if (!newValue) {
|
|
clearCustomerConflict();
|
|
unselectCustomerOnChange();
|
|
emitVehicleObject(null);
|
|
vehicles_matching.value = [];
|
|
// Set the isSearching flag to false, as no search is performed
|
|
if (is_latest_search(search_id)) {
|
|
// Set the isSearching flag to false
|
|
isSearching.value = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!shouldKeepCustomerSelection) {
|
|
clearCustomerConflict();
|
|
unselectCustomerOnChange();
|
|
emitVehicleObject(null);
|
|
}
|
|
// Perform the search
|
|
searchVehicle(newValue, search_id);
|
|
});
|
|
|
|
const unselectCustomerOnChange = () => {
|
|
if (hasManualCustomerSelection()) {
|
|
return;
|
|
}
|
|
clearCustomerSelection();
|
|
};
|
|
|
|
const selectVehicle = (vehicle, options = {}) => {
|
|
console.log("Select using click", vehicle?.id ?? vehicle?.reg ?? null);
|
|
return syncMatchedVehicleSelection(vehicle, options);
|
|
};
|
|
|
|
const getDropdownItemKey = (vehicle, index) => {
|
|
const normalizedVehicleId = vehicle?.id;
|
|
if (normalizedVehicleId !== null && normalizedVehicleId !== undefined && normalizedVehicleId !== "") {
|
|
return `vehicle-${normalizedVehicleId}-${index}`;
|
|
}
|
|
|
|
const normalizedPlate = String(vehicle?.reg ?? vehicle?.reg_1 ?? "")
|
|
.trim()
|
|
.toUpperCase();
|
|
|
|
return `vehicle-${normalizedPlate || "unknown"}-${index}`;
|
|
};
|
|
|
|
const isDropdownItemActive = (index) => {
|
|
return index === selectedDropdownItem.value;
|
|
};
|
|
|
|
const getDropdownSelectionContext = (index) => {
|
|
const vehicle = vehicles_matching.value[index] || null;
|
|
const plateOverride = vehicle?.reg ?? reg_1.value;
|
|
|
|
return {
|
|
vehicle,
|
|
plateOverride,
|
|
};
|
|
};
|
|
|
|
const clearTextSelection = () => {
|
|
if (typeof document !== "undefined") {
|
|
const activeElement = document.activeElement;
|
|
if (
|
|
activeElement &&
|
|
typeof activeElement.setSelectionRange === "function" &&
|
|
typeof activeElement.selectionEnd === "number"
|
|
) {
|
|
activeElement.setSelectionRange(activeElement.selectionEnd, activeElement.selectionEnd);
|
|
}
|
|
}
|
|
|
|
if (typeof window === "undefined" || typeof window.getSelection !== "function") {
|
|
return;
|
|
}
|
|
|
|
const selection = window.getSelection();
|
|
if (selection && selection.rangeCount > 0) {
|
|
selection.removeAllRanges();
|
|
}
|
|
};
|
|
|
|
const commitSelection = async (options = {}) => {
|
|
const normalizedOptions = {
|
|
vehicle: null,
|
|
plateOverride: reg_1.value,
|
|
source: "manual",
|
|
focusNextField: true,
|
|
clearDropdownResultsWithoutVehicle: true,
|
|
...options,
|
|
};
|
|
const currentPlate = String(normalizedOptions.plateOverride ?? reg_1.value ?? "")
|
|
.trim()
|
|
.toUpperCase();
|
|
const plateOverride = currentPlate;
|
|
const vehicle = normalizedOptions.vehicle;
|
|
|
|
clearTextSelection();
|
|
|
|
if (!currentPlate) {
|
|
showSelector.value = false;
|
|
vehicles_matching.value = [];
|
|
emitVehicleObject(null, currentPlate);
|
|
emit("commit:selection", {
|
|
source: normalizedOptions.source,
|
|
plate: currentPlate,
|
|
bookingMatchCount: 0,
|
|
});
|
|
return {
|
|
vehicle: null,
|
|
bookingMatches: [],
|
|
plate: currentPlate,
|
|
};
|
|
}
|
|
|
|
const bookingMatches = await ensureBookingMatchesForSelection(vehicle, plateOverride);
|
|
let selectionOutcome = null;
|
|
|
|
showSelector.value = false;
|
|
|
|
if (vehicle) {
|
|
selectionOutcome = selectVehicle(vehicle, {
|
|
plateOverride,
|
|
});
|
|
} else {
|
|
if (normalizedOptions.clearDropdownResultsWithoutVehicle) {
|
|
vehicles_matching.value = [];
|
|
}
|
|
const hasSelectedCustomer = normalizeCustomerNumber(customer_id.value) !== null;
|
|
if (bookingMatches.length === 0 && !hasSelectedCustomer) {
|
|
clearCustomerSelection();
|
|
}
|
|
clearCustomerConflict();
|
|
emitVehicleObject(null, plateOverride);
|
|
}
|
|
|
|
if (
|
|
normalizedOptions.focusNextField &&
|
|
bookingMatches.length <= 1 &&
|
|
selectionOutcome?.status !== "conflict" &&
|
|
selectionOutcome?.status !== "suppressed_conflict"
|
|
) {
|
|
if (bookingMatches.length === 1 || !hasVehiclePayloadBookingHint(vehicle)) {
|
|
focusNextField();
|
|
}
|
|
}
|
|
|
|
emit("commit:selection", {
|
|
source: normalizedOptions.source,
|
|
plate: currentPlate,
|
|
bookingMatchCount: bookingMatches.length,
|
|
});
|
|
|
|
return {
|
|
vehicle,
|
|
bookingMatches,
|
|
plate: currentPlate,
|
|
};
|
|
};
|
|
|
|
const selectDropdownItem = async (index, options = {}) => {
|
|
const normalizedOptions = {
|
|
source: "dropdown",
|
|
focusNextField: true,
|
|
...options,
|
|
};
|
|
const { vehicle, plateOverride } = getDropdownSelectionContext(index);
|
|
|
|
return await commitSelection({
|
|
vehicle,
|
|
plateOverride,
|
|
source: normalizedOptions.source,
|
|
focusNextField: normalizedOptions.focusNextField,
|
|
clearDropdownResultsWithoutVehicle: false,
|
|
});
|
|
};
|
|
|
|
const handleDropdownItemMouseDown = (event, index) => {
|
|
event.preventDefault();
|
|
clearTextSelection();
|
|
selectedDropdownItem.value = index;
|
|
void selectDropdownItem(index);
|
|
};
|
|
|
|
const keyDownNextTabIndexButton = (event, tabIndex) => {
|
|
// Handle keydown event to navigate to the next button
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
const button = document.querySelector(`button[tabindex="${tabIndex}"]`);
|
|
if (button) {
|
|
button.click();
|
|
}
|
|
}
|
|
};
|
|
const arrowKeyHandler = async (event) => {
|
|
// Handle arrow key navigation in the dropdown
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
clearTextSelection();
|
|
if (selectedDropdownItem.value < vehicles_matching.value.length - 1) {
|
|
selectedDropdownItem.value++;
|
|
}
|
|
}
|
|
if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
clearTextSelection();
|
|
if (selectedDropdownItem.value > -1) {
|
|
selectedDropdownItem.value--;
|
|
}
|
|
}
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
clearTextSelection();
|
|
await selectDropdownItem(selectedDropdownItem.value, { source: "keyboard" });
|
|
selectedDropdownItem.value = -1;
|
|
}
|
|
if (event.key === "Tab") {
|
|
event.preventDefault();
|
|
clearTextSelection();
|
|
await selectDropdownItem(selectedDropdownItem.value, { source: "keyboard" });
|
|
selectedDropdownItem.value = -1;
|
|
}
|
|
};
|
|
const selectedDropdownItem = ref(-1);
|
|
const showSelector = ref(false);
|
|
const lostfocus = async () => {
|
|
emitFocus(false);
|
|
// Delay to prevent the dropdown from closing immediately
|
|
setTimeout(() => {
|
|
showSelector.value = false;
|
|
}, 200);
|
|
const currentValue = String(reg_1.value ?? "")
|
|
.trim()
|
|
.toUpperCase();
|
|
const vehicle = vehicles_matching.value.find((matchingVehicle) => matchingVehicle.reg === currentValue) || null;
|
|
|
|
await commitSelection({
|
|
vehicle,
|
|
plateOverride: currentValue,
|
|
source: "blur",
|
|
focusNextField: false,
|
|
clearDropdownResultsWithoutVehicle: true,
|
|
});
|
|
};
|
|
const isSearching = ref(false);
|
|
|
|
// Watch for changes in the vehicles_matching array, to check if the current index is valid
|
|
watch(vehicles_matching, (newValue) => {
|
|
prefetchVisibleBookedVehicleBookings(newValue);
|
|
// Check if the input directly matches an item in the vehicles_matching array (Then we can automatically select it)
|
|
const currentValue = reg_1.value;
|
|
const vehicle = newValue.find((vehicle) => vehicle.reg === currentValue);
|
|
if (vehicle) {
|
|
const nextAutoSyncKey = getVehicleAutoSyncKey(vehicle, currentValue);
|
|
if (lastAutoSyncedVehicleKey.value !== nextAutoSyncKey) {
|
|
lastAutoSyncedVehicleKey.value = nextAutoSyncKey;
|
|
syncMatchedVehicleSelection(vehicle);
|
|
}
|
|
} else if (getBookingMatchesForSelection(null, currentValue).length > 0) {
|
|
lastAutoSyncedVehicleKey.value = "";
|
|
clearCustomerConflict();
|
|
emitVehicleObject(null, currentValue);
|
|
} else {
|
|
lastAutoSyncedVehicleKey.value = "";
|
|
clearCustomerConflict();
|
|
}
|
|
// Check if the selectedDropdownItem index is valid
|
|
if (selectedDropdownItem.value >= newValue.length) {
|
|
// Set the selectedDropdownItem to the last index if it exceeds the new length
|
|
selectedDropdownItem.value = newValue.length - 1;
|
|
}
|
|
});
|
|
|
|
watch(pendingBookings, () => {
|
|
const currentValue = reg_1.value;
|
|
if (!currentValue) {
|
|
return;
|
|
}
|
|
|
|
const vehicle = vehicles_matching.value.find((matchingVehicle) => matchingVehicle.reg === currentValue) || null;
|
|
emitBookingObject(vehicle, currentValue);
|
|
});
|
|
|
|
const focusNextField = () => {
|
|
// Focus on the next input field
|
|
const nextField = document.querySelector('input[tabindex="2"]');
|
|
if (nextField) {
|
|
nextField.focus();
|
|
}
|
|
};
|
|
|
|
// Function to emit the vehicle object to the parent component
|
|
const emitVehicleObject = (vehicle, plateOverride = null) => {
|
|
// Emit the vehicle object
|
|
emit("update:vehicleObject", vehicle);
|
|
// Emit the booking object if the vehicle has a booking
|
|
emitBookingObject(vehicle, plateOverride);
|
|
};
|
|
|
|
const emitFocus = (isFocused) => {
|
|
// Emit the focus event
|
|
emit("update:focus", isFocused);
|
|
};
|
|
|
|
const finalizeSelection = async (options = {}) => {
|
|
const normalizedOptions = {
|
|
source: "next",
|
|
focusNextField: false,
|
|
...options,
|
|
};
|
|
const currentValue = String(reg_1.value ?? "")
|
|
.trim()
|
|
.toUpperCase();
|
|
const vehicle = vehicles_matching.value.find((matchingVehicle) => matchingVehicle.reg === currentValue) || null;
|
|
|
|
return await commitSelection({
|
|
vehicle,
|
|
plateOverride: currentValue,
|
|
source: normalizedOptions.source,
|
|
focusNextField: normalizedOptions.focusNextField,
|
|
clearDropdownResultsWithoutVehicle: true,
|
|
});
|
|
};
|
|
|
|
defineExpose({
|
|
finalizeSelection,
|
|
});
|
|
|
|
const isVehicleBooked = (vehicle) => {
|
|
return hasBookingMatchesForSelection(vehicle, vehicle?.reg) || hasVehiclePayloadBookingHint(vehicle);
|
|
};
|
|
|
|
const getVehicleBookingDateLabel = (vehicle) => {
|
|
if (!isVehicleBooked(vehicle)) {
|
|
return "";
|
|
}
|
|
|
|
const bookingMatches = getBookingMatchesForSelection(vehicle, vehicle?.reg);
|
|
const preferredBooking =
|
|
bookingMatches.find((booking) => getOrderBookingSortDateValue(booking)) || bookingMatches[0] || null;
|
|
const rawValue =
|
|
getOrderBookingSortDateValue(preferredBooking) ??
|
|
getOrderBookingSortDateValue(vehicle) ??
|
|
vehicle?.datetime ??
|
|
vehicle?.created_at ??
|
|
null;
|
|
|
|
if (!rawValue) {
|
|
return "";
|
|
}
|
|
|
|
const parsedValue = new Date(rawValue);
|
|
if (Number.isNaN(parsedValue.getTime())) {
|
|
return String(rawValue);
|
|
}
|
|
|
|
return new Intl.DateTimeFormat(locale.value || undefined, {
|
|
dateStyle: "short",
|
|
}).format(parsedValue);
|
|
};
|
|
|
|
const getDropdownIconColor = (vehicle) => {
|
|
// Determine the icon color based on the vehicle's status
|
|
const colors = {
|
|
verified: "has-text-success", // Used when the vehicles owner is verified
|
|
unverified: "has-text-warning", // Used when the vehicles owner is unverified
|
|
barred: "has-text-danger", // Used when the vehicles owner is barred
|
|
hasBooking: "has-text-link", // Used when the vehicle has a booking
|
|
};
|
|
let iconColor = colors.unverified; // Default color
|
|
|
|
// Check if the vehicle is verified
|
|
function isVerified(vehicle) {
|
|
return vehicle.customer_id && vehicle.customer_id > 0;
|
|
}
|
|
// Check if the vehicle is barred
|
|
function isBarred(vehicle) {
|
|
return !!vehicle.barred;
|
|
}
|
|
// Check if the vehicle has a booking
|
|
function hasBooking(vehicle) {
|
|
return isVehicleBooked(vehicle);
|
|
}
|
|
|
|
if (isVerified(vehicle)) {
|
|
iconColor = colors.verified;
|
|
}
|
|
if (hasBooking(vehicle)) {
|
|
iconColor = colors.hasBooking;
|
|
}
|
|
if (isBarred(vehicle)) {
|
|
iconColor = colors.barred;
|
|
}
|
|
|
|
return iconColor;
|
|
};
|
|
|
|
const getCurrentIconColor = () => {
|
|
// Check if the current input value is in the vehicles_matching array
|
|
const currentValue = reg_1.value;
|
|
const vehicle = vehicles_matching.value.find((vehicle) => vehicle.reg === currentValue);
|
|
let tmp_color = "has-text-grey";
|
|
if (vehicle) {
|
|
// If the vehicle is found, return the icon color
|
|
tmp_color = getDropdownIconColor(vehicle);
|
|
}
|
|
// Check if there's a booking for the current vehicle
|
|
if (doesVehiclePlateHaveBooking(currentValue)) {
|
|
tmp_color = "has-text-link";
|
|
}
|
|
// If not found, return the default color
|
|
return tmp_color;
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<template v-if="isRawInputField">
|
|
<input
|
|
type="text"
|
|
v-model="reg_1"
|
|
@focus="
|
|
showSelector = true;
|
|
emitFocus(true);
|
|
"
|
|
@blur="lostfocus"
|
|
@keydown="arrowKeyHandler"
|
|
id="reg_1"
|
|
tabindex="1"
|
|
autocomplete="off"
|
|
/>
|
|
</template>
|
|
<div v-if="!isRawInputField">
|
|
<!-- License Plate Registration 1 Input -->
|
|
<div class="field">
|
|
<div class="control has-icons-left" :class="{ 'is-loading': isSearching }">
|
|
<span class="icon is-small is-left">
|
|
<!-- Circle icon -->
|
|
<i class="fas fa-circle" :class="getCurrentIconColor()"></i>
|
|
</span>
|
|
<input
|
|
type="text"
|
|
class="input has-sharp-edges is-loading"
|
|
v-model="reg_1"
|
|
@focus="
|
|
showSelector = true;
|
|
emitFocus(true);
|
|
"
|
|
@blur="lostfocus"
|
|
@keydown="arrowKeyHandler"
|
|
id="reg_1"
|
|
tabindex="1"
|
|
autocomplete="off"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<!-- Dropdown for license plate suggestions -->
|
|
<div class="dropdown" :class="{ 'is-active': showSelector }" v-show="showSelector && vehicles_matching.length > 0">
|
|
<div class="dropdown-menu">
|
|
<div class="dropdown-content license-plate-dropdown-content" @selectstart.prevent @dragstart.prevent>
|
|
<a
|
|
class="dropdown-item is-clickable license-plate-dropdown-item"
|
|
v-for="(result, index) in vehicles_matching"
|
|
:key="getDropdownItemKey(result, index)"
|
|
@mousedown="handleDropdownItemMouseDown($event, index)"
|
|
@selectstart.prevent
|
|
@dragstart.prevent
|
|
:class="{
|
|
'license-plate-dropdown-item--active': isDropdownItemActive(index),
|
|
}"
|
|
:aria-selected="isDropdownItemActive(index)"
|
|
>
|
|
<div class="license-plate-result">
|
|
<div class="license-plate-result__text">
|
|
<span class="icon">
|
|
<!-- Circle icon -->
|
|
<i class="fas fa-circle" :class="getDropdownIconColor(result)"></i>
|
|
</span>
|
|
<span>
|
|
{{ result.reg }}{{ result.customer_name ? " - " + result.customer_name : ""
|
|
}}{{ result.barred ? " (" + SessionUser.objects.global.language.barred + ")" : "" }}
|
|
</span>
|
|
</div>
|
|
<span v-if="isVehicleBooked(result)" class="license-plate-result__booking">
|
|
<span
|
|
class="icon has-text-link license-plate-result__booking-icon"
|
|
:data-testid="`desktop-booked-icon-${result.id}`"
|
|
>
|
|
<i class="fas fa-calendar-check"></i>
|
|
</span>
|
|
<span
|
|
v-if="getVehicleBookingDateLabel(result)"
|
|
class="license-plate-result__booking-date"
|
|
:data-testid="`desktop-booked-date-${result.id}`"
|
|
>
|
|
{{ getVehicleBookingDateLabel(result) }}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.dropdown {
|
|
display: block;
|
|
position: relative;
|
|
width: 100%;
|
|
z-index: 40;
|
|
}
|
|
|
|
.dropdown-menu {
|
|
width: 100%;
|
|
max-width: none;
|
|
min-width: 100%;
|
|
z-index: 50;
|
|
}
|
|
|
|
.dropdown-content {
|
|
position: relative;
|
|
z-index: 55;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.license-plate-dropdown-item,
|
|
.license-plate-dropdown-item * {
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
|
|
.license-plate-dropdown-item::selection,
|
|
.license-plate-dropdown-item *::selection {
|
|
background: transparent;
|
|
color: inherit;
|
|
}
|
|
|
|
.license-plate-dropdown-content {
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
|
|
.license-plate-dropdown-item {
|
|
cursor: pointer;
|
|
transition: background-color 120ms ease, color 120ms ease;
|
|
}
|
|
|
|
.license-plate-dropdown-item:hover {
|
|
background-color: #f3f7fb;
|
|
}
|
|
|
|
.license-plate-dropdown-item--active {
|
|
background-color: #102a63;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.license-plate-dropdown-item--active .license-plate-result__booking-icon,
|
|
.license-plate-dropdown-item--active .license-plate-result__booking-date {
|
|
color: rgba(255, 255, 255, 0.92) !important;
|
|
}
|
|
|
|
.license-plate-dropdown-item--active .license-plate-result__text > span:last-child {
|
|
color: #ffffff;
|
|
}
|
|
|
|
.license-plate-result {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
min-width: 0;
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
|
|
.license-plate-result__text {
|
|
display: inline-flex;
|
|
flex: 1 1 auto;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
min-width: 0;
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
|
|
.license-plate-result__text > span:last-child {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.license-plate-result__booking-icon {
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.license-plate-result__booking {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
flex: 0 0 auto;
|
|
justify-content: flex-end;
|
|
margin-left: auto;
|
|
min-width: max-content;
|
|
padding-left: 0.5rem;
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
|
|
.license-plate-result__booking-date {
|
|
color: #58697f;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
}
|
|
</style>
|