Files
pleno-vue/src/components/forms/department/pos/input/LicensePlateReg1Input.vue
T
Jeppe Bundgaard e126dfccf9 Enhance accessibility and linting configuration
- 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
2026-06-08 16:35:56 +02:00

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>