-
+
-
-
-
+
+
+
-
{{ searchResult.name }}
+
{{ searchResult.name }}
-
-
-
-
-
- {{ popups.get().props.error}}
+ {{ popups.get().props.error }}
-
-
\ No newline at end of file
+
diff --git a/src/components/displays/department/pos/steps/mobile/elements/posAddCustomerAutofill.js b/src/components/displays/department/pos/steps/mobile/elements/posAddCustomerAutofill.js
new file mode 100644
index 00000000..4b23269d
--- /dev/null
+++ b/src/components/displays/department/pos/steps/mobile/elements/posAddCustomerAutofill.js
@@ -0,0 +1,135 @@
+export const POS_ADD_CUSTOMER_AUTOFILL_FIELDS = [
+ "companyPhone",
+ "invoiceEmail",
+ "contactEmail",
+ "contactPhone",
+];
+
+export function normalizePosAddCustomerValue(value) {
+ if (value === null || value === undefined) {
+ return "";
+ }
+
+ return String(value);
+}
+
+export function createPosAddCustomerFields(overrides = {}) {
+ return {
+ cvr: "",
+ companyPhone: "",
+ invoiceEmail: "",
+ contactEmail: "",
+ contactPhone: "",
+ ...overrides,
+ };
+}
+
+export function createPosAddCustomerManualOverrides(overrides = {}) {
+ return {
+ companyPhone: false,
+ invoiceEmail: false,
+ contactEmail: false,
+ contactPhone: false,
+ ...overrides,
+ };
+}
+
+export function createPosAddCustomerRequestState(overrides = {}) {
+ return {
+ latestRequestedId: 0,
+ latestAppliedId: 0,
+ ...overrides,
+ };
+}
+
+export function nextPosAddCustomerRequestState(currentState = createPosAddCustomerRequestState()) {
+ const requestId = Number(currentState.latestRequestedId || 0) + 1;
+
+ return {
+ requestId,
+ state: {
+ ...currentState,
+ latestRequestedId: requestId,
+ },
+ };
+}
+
+export function resolvePosAddCustomerRequestState(currentState, requestId) {
+ const normalizedRequestId = Number(requestId || 0);
+
+ if (!currentState || normalizedRequestId !== Number(currentState.latestRequestedId || 0)) {
+ return {
+ shouldApply: false,
+ state: currentState,
+ };
+ }
+
+ return {
+ shouldApply: true,
+ state: {
+ ...currentState,
+ latestAppliedId: normalizedRequestId,
+ },
+ };
+}
+
+export function isValidPosAddCustomerSearchResult(searchResult, notFoundLabel = "") {
+ if (!searchResult || typeof searchResult !== "object") {
+ return false;
+ }
+
+ const normalizedName = normalizePosAddCustomerValue(searchResult.name).trim();
+ if (normalizedName === "") {
+ return false;
+ }
+
+ return normalizedName !== normalizePosAddCustomerValue(notFoundLabel).trim();
+}
+
+export function buildPosAddCustomerAutofillValues(searchResult) {
+ return {
+ cvr: normalizePosAddCustomerValue(searchResult?.vat),
+ companyPhone: normalizePosAddCustomerValue(searchResult?.phone),
+ invoiceEmail: normalizePosAddCustomerValue(searchResult?.email),
+ contactEmail: normalizePosAddCustomerValue(searchResult?.email),
+ contactPhone: normalizePosAddCustomerValue(searchResult?.phone),
+ };
+}
+
+export function applyPosAddCustomerSearchResult({
+ fields = createPosAddCustomerFields(),
+ manualOverrides = createPosAddCustomerManualOverrides(),
+ searchResult = null,
+ notFoundLabel = "",
+} = {}) {
+ const nextFields = {
+ ...createPosAddCustomerFields(),
+ ...fields,
+ };
+
+ if (!isValidPosAddCustomerSearchResult(searchResult, notFoundLabel)) {
+ return nextFields;
+ }
+
+ const autofillValues = buildPosAddCustomerAutofillValues(searchResult);
+ nextFields.cvr = autofillValues.cvr;
+
+ for (const field of POS_ADD_CUSTOMER_AUTOFILL_FIELDS) {
+ if (!manualOverrides[field] || normalizePosAddCustomerValue(nextFields[field]) === "") {
+ nextFields[field] = autofillValues[field];
+ }
+ }
+
+ return nextFields;
+}
+
+export function setPosAddCustomerManualOverride(manualOverrides, field, nextValue) {
+ if (!manualOverrides || !Object.prototype.hasOwnProperty.call(manualOverrides, field)) {
+ return manualOverrides;
+ }
+
+ return {
+ ...manualOverrides,
+ [field]: normalizePosAddCustomerValue(nextValue).trim() !== "",
+ };
+}
diff --git a/src/components/forms/department/pos/SelectVehicleFormPOS.vue b/src/components/forms/department/pos/SelectVehicleFormPOS.vue
index e836e62f..2d75fa03 100644
--- a/src/components/forms/department/pos/SelectVehicleFormPOS.vue
+++ b/src/components/forms/department/pos/SelectVehicleFormPOS.vue
@@ -42,6 +42,10 @@ const bookingObject = ref(null);
const bookingMatches = ref([]);
const isUserCurrentlyInFocusReg1 = ref(false);
const licensePlateReg1InputRef = ref(null);
+const selectionSource = ref("none");
+const referenceSource = ref(String(reference.value ?? "").trim() === "" ? "empty" : "manual");
+const pendingCustomerConflict = ref(null);
+const suppressedCustomerConflictKey = ref("");
let bookingResolutionPromise = Promise.resolve(null);
@@ -57,6 +61,69 @@ const normalizePlateValue = (value) =>
.replace(/\s/g, "")
.toUpperCase();
+const normalizeCustomerNumber = (value) => {
+ const parsedValue = Number.parseInt(String(value ?? ""), 10);
+ return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
+};
+
+const isBlankPosValue = (value) => String(value ?? "").trim() === "";
+
+const getCustomerConflictKey = (conflict) => {
+ if (!conflict) {
+ return "";
+ }
+
+ return [
+ normalizePlateValue(conflict.plate ?? conflict.vehicleReg),
+ normalizeCustomerNumber(conflict.currentCustomerNumber) ?? "",
+ normalizeCustomerNumber(conflict.matchedCustomerNumber) ?? "",
+ ].join("|");
+};
+
+const setSelectionSource = (nextSource = "none") => {
+ const allowedSources = new Set(["none", "manual-customer", "vehicle-match", "booking-match"]);
+ selectionSource.value = allowedSources.has(nextSource) ? nextSource : "none";
+};
+
+const setReferenceSource = (nextSource = "empty") => {
+ const allowedSources = new Set(["empty", "manual", "vehicle", "booking"]);
+ referenceSource.value = allowedSources.has(nextSource) ? nextSource : "empty";
+};
+
+const setReferenceValue = (nextValue, source = "manual", options = {}) => {
+ const normalizedOptions = {
+ preserveManualReference: false,
+ ...options,
+ };
+
+ if (normalizedOptions.preserveManualReference && referenceSource.value === "manual" && source !== "manual") {
+ return reference.value;
+ }
+
+ reference.value = String(nextValue ?? "");
+
+ if (isBlankPosValue(reference.value)) {
+ setReferenceSource("empty");
+ return reference.value;
+ }
+
+ setReferenceSource(source);
+ return reference.value;
+};
+
+const clearPendingCustomerConflict = (options = {}) => {
+ const normalizedOptions = {
+ preserveSuppressedKey: true,
+ ...options,
+ };
+
+ pendingCustomerConflict.value = null;
+
+ if (!normalizedOptions.preserveSuppressedKey) {
+ suppressedCustomerConflictKey.value = "";
+ }
+};
+
const getCurrentVehiclePlate = () => {
return normalizePlateValue(reg_1.value || vehicleObject.value?.reg);
};
@@ -86,38 +153,73 @@ const getBookingCustomerNumber = (booking) => {
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
-const resetReferenceFromVehicle = () => {
+const resetReferenceFromVehicle = (options = {}) => {
if (vehicleObject.value?.reference) {
- reference.value = vehicleObject.value.reference;
+ setReferenceValue(vehicleObject.value.reference, "vehicle", options);
return;
}
- reference.value = "";
+ setReferenceValue("", "empty", options);
};
const getVehicleReferenceValue = () => {
return String(vehicleObject.value?.reference ?? "").trim();
};
-const setVehicleObject = (emittedVehicleObject) => {
+const setVehicleObject = (emittedVehicleObject, options = {}) => {
+ const normalizedOptions = {
+ preserveManualReference: true,
+ nextSelectionSource: null,
+ ...options,
+ };
+
vehicleObject.value = emittedVehicleObject;
+ const matchedVehicleCustomerNumber = normalizeCustomerNumber(vehicleObject.value?.customer_id);
+ const selectedCustomerNumber = normalizeCustomerNumber(customer_id.value);
+
+ if (normalizedOptions.nextSelectionSource) {
+ setSelectionSource(normalizedOptions.nextSelectionSource);
+ } else if (vehicleObject.value) {
+ if (
+ selectionSource.value !== "manual-customer" ||
+ (matchedVehicleCustomerNumber &&
+ selectedCustomerNumber &&
+ matchedVehicleCustomerNumber !== selectedCustomerNumber)
+ ) {
+ setSelectionSource("vehicle-match");
+ }
+ } else if (!bookingObject.value && !normalizeCustomerNumber(customer_id.value)) {
+ setSelectionSource("none");
+ }
+
if (
vehicleObject.value &&
vehicleObject.value.reference &&
normalizePlateValue(selectedOrderBookingPlate.value) !== normalizePlateValue(vehicleObject.value.reg)
) {
- reference.value = vehicleObject.value.reference;
+ setReferenceValue(vehicleObject.value.reference, "vehicle", {
+ preserveManualReference: normalizedOptions.preserveManualReference,
+ });
} else if (!selectedOrderBookingId.value) {
- reference.value = "";
+ setReferenceValue("", "empty", {
+ preserveManualReference: normalizedOptions.preserveManualReference,
+ });
}
};
-const setBookingObject = (emittedBookingObject) => {
+const setBookingObject = (emittedBookingObject, options = {}) => {
+ const normalizedOptions = {
+ preserveManualReference: true,
+ ...options,
+ };
+
bookingObject.value = emittedBookingObject;
if (emittedBookingObject && emittedBookingObject.reference_number) {
- reference.value = emittedBookingObject.reference_number;
+ setReferenceValue(emittedBookingObject.reference_number, "booking", {
+ preserveManualReference: normalizedOptions.preserveManualReference,
+ });
}
};
@@ -153,7 +255,12 @@ const syncBookingRegistrationFields = async (booking) => {
}
};
-const applyBookingMetadata = async (booking) => {
+const applyBookingMetadata = async (booking, options = {}) => {
+ const normalizedOptions = {
+ preserveManualReference: true,
+ selectionSource: "booking-match",
+ ...options,
+ };
const previousReference = String(reference.value ?? "").trim();
const previousNotes = String(order_notes.value ?? "").trim();
const previousPo = String(order_po.value ?? "").trim();
@@ -161,14 +268,21 @@ const applyBookingMetadata = async (booking) => {
if (bookingCustomerNumber && Number.parseInt(String(customer_id.value || ""), 10) !== bookingCustomerNumber) {
await searchAndSelectCustomer(bookingCustomerNumber);
}
+ if (bookingCustomerNumber) {
+ setSelectionSource(normalizedOptions.selectionSource);
+ }
const bookingReference = getBookingReferenceValue(booking);
- const resolvedReference = bookingReference !== "" ? bookingReference : getVehicleReferenceValue();
if (bookingReference !== "") {
- reference.value = bookingReference;
+ setReferenceValue(bookingReference, "booking", {
+ preserveManualReference: normalizedOptions.preserveManualReference,
+ });
} else {
- resetReferenceFromVehicle();
+ resetReferenceFromVehicle({
+ preserveManualReference: normalizedOptions.preserveManualReference,
+ });
}
+ const resolvedReference = String(reference.value ?? "").trim();
const bookingNotes = getBookingNotesValue(booking);
const bookingPo = getBookingPoValue(booking);
@@ -195,6 +309,8 @@ const applyBookingMetadata = async (booking) => {
const applySelectedOrderBooking = async (booking, options = {}) => {
const normalizedOptions = {
persistSelection: true,
+ preserveManualReference: true,
+ selectionSource: "booking-match",
...options,
};
@@ -213,7 +329,10 @@ const applySelectedOrderBooking = async (booking, options = {}) => {
);
}
- await applyBookingMetadata(booking);
+ await applyBookingMetadata(booking, {
+ preserveManualReference: normalizedOptions.preserveManualReference,
+ selectionSource: normalizedOptions.selectionSource,
+ });
if (vehicleObject.value) {
vehicleObject.value = {
@@ -228,12 +347,14 @@ const continueWithoutBooking = async () => {
const previousReference = String(reference.value ?? "").trim();
const previousNotes = String(order_notes.value ?? "").trim();
const previousPo = String(order_po.value ?? "").trim();
- const nextReference = getVehicleReferenceValue();
bookingObject.value = null;
skipSelectedOrderBookingSelection(getCurrentVehiclePlate());
order_notes.value = "";
order_po.value = "";
- resetReferenceFromVehicle();
+ resetReferenceFromVehicle({
+ preserveManualReference: true,
+ });
+ const nextReference = String(reference.value ?? "").trim();
if (vehicleObject.value) {
vehicleObject.value = {
@@ -310,6 +431,21 @@ const buildDesktopStep1Context = (options = {}) => {
skippedBooking,
requiresBookingSelection,
bookingResolution,
+ selectionSource: selectionSource.value,
+ referenceSource: referenceSource.value,
+ requiresCustomerConflict: Boolean(pendingCustomerConflict.value),
+ customerConflict: pendingCustomerConflict.value
+ ? {
+ key: pendingCustomerConflict.value.key,
+ plate: pendingCustomerConflict.value.plate,
+ currentCustomerNumber: pendingCustomerConflict.value.currentCustomerNumber,
+ currentCustomerName: pendingCustomerConflict.value.currentCustomerName,
+ matchedCustomerNumber: pendingCustomerConflict.value.matchedCustomerNumber,
+ matchedCustomerName: pendingCustomerConflict.value.matchedCustomerName,
+ vehicleId: pendingCustomerConflict.value.vehicleId,
+ vehicleReg: pendingCustomerConflict.value.vehicleReg,
+ }
+ : null,
committed: normalizedOptions.committed,
source: normalizedOptions.source,
};
@@ -329,6 +465,9 @@ const resolveBookingMatchesState = async (matches) => {
clearSelectedOrderBookingSelection();
order_notes.value = "";
order_po.value = "";
+ resetReferenceFromVehicle({
+ preserveManualReference: true,
+ });
return emitDesktopStep1Context({
committed: false,
source: "booking_state",
@@ -340,7 +479,9 @@ const resolveBookingMatchesState = async (matches) => {
clearSelectedOrderBookingSelection();
order_notes.value = "";
order_po.value = "";
- resetReferenceFromVehicle();
+ resetReferenceFromVehicle({
+ preserveManualReference: true,
+ });
return emitDesktopStep1Context({
committed: false,
source: "booking_state",
@@ -350,9 +491,14 @@ const resolveBookingMatchesState = async (matches) => {
if (matches.length === 1) {
const [singleBooking] = matches;
if (Number(selectedOrderBookingId.value) !== Number(singleBooking.id)) {
- await applySelectedOrderBooking(singleBooking);
+ await applySelectedOrderBooking(singleBooking, {
+ preserveManualReference: true,
+ });
} else {
- await applySelectedOrderBooking(singleBooking, { persistSelection: false });
+ await applySelectedOrderBooking(singleBooking, {
+ persistSelection: false,
+ preserveManualReference: true,
+ });
}
return emitDesktopStep1Context({
committed: false,
@@ -362,7 +508,10 @@ const resolveBookingMatchesState = async (matches) => {
const persistedBooking = getSelectedVehiclePlateBooking(currentPlate, matches);
if (persistedBooking) {
- await applySelectedOrderBooking(persistedBooking, { persistSelection: false });
+ await applySelectedOrderBooking(persistedBooking, {
+ persistSelection: false,
+ preserveManualReference: true,
+ });
return emitDesktopStep1Context({
committed: false,
source: "booking_restore",
@@ -389,6 +538,91 @@ watch(bookingMatches, (matches) => {
bookingResolutionPromise = resolveBookingMatchesState(matches);
});
+const handleReferenceInput = (event) => {
+ setReferenceValue(event?.target?.value ?? "", "manual");
+};
+
+const handleManualCustomerSelection = () => {
+ clearPendingCustomerConflict({
+ preserveSuppressedKey: false,
+ });
+ setSelectionSource("manual-customer");
+ emitDesktopStep1Context({
+ committed: false,
+ source: "manual_customer",
+ });
+};
+
+const handleManualCustomerSuggestionSelection = async (customerNumber) => {
+ clearPendingCustomerConflict({
+ preserveSuppressedKey: false,
+ });
+ setSelectionSource("manual-customer");
+ await searchAndSelectCustomer(customerNumber);
+ emitDesktopStep1Context({
+ committed: false,
+ source: "manual_customer_suggestion",
+ });
+};
+
+const handleCustomerConflictUpdate = (conflict) => {
+ pendingCustomerConflict.value = conflict
+ ? {
+ ...conflict,
+ key: getCustomerConflictKey(conflict),
+ }
+ : null;
+
+ emitDesktopStep1Context({
+ committed: false,
+ source: conflict ? "customer_conflict" : "customer_conflict_clear",
+ });
+};
+
+watch(
+ () => customer_id.value,
+ (nextCustomerId) => {
+ const normalizedCustomerNumber = normalizeCustomerNumber(nextCustomerId);
+ if (!normalizedCustomerNumber) {
+ if (!vehicleObject.value && !bookingObject.value) {
+ setSelectionSource("none");
+ }
+ clearPendingCustomerConflict({
+ preserveSuppressedKey: false,
+ });
+ }
+ }
+);
+
+watch(
+ [() => normalizePlateValue(reg_1.value), () => normalizeCustomerNumber(customer_id.value)],
+ ([currentPlate, currentCustomerNumber]) => {
+ if (pendingCustomerConflict.value) {
+ const currentConflictKey = getCustomerConflictKey({
+ ...pendingCustomerConflict.value,
+ plate: currentPlate,
+ currentCustomerNumber,
+ });
+
+ if (currentConflictKey !== pendingCustomerConflict.value.key) {
+ pendingCustomerConflict.value = null;
+ }
+ }
+
+ if (!suppressedCustomerConflictKey.value) {
+ return;
+ }
+
+ const [suppressedPlate, suppressedCustomerNumber] = suppressedCustomerConflictKey.value.split("|");
+ if (
+ suppressedPlate !== currentPlate ||
+ String(normalizeCustomerNumber(suppressedCustomerNumber) ?? "") !== String(currentCustomerNumber ?? "")
+ ) {
+ suppressedCustomerConflictKey.value = "";
+ }
+ }
+);
+
const ucFirst = (str) => {
return str.charAt(0).toUpperCase() + str.slice(1);
};
@@ -460,6 +694,78 @@ const handleLicensePlateCommit = async (context) => {
emit("commit:desktopStep1", committedContext);
};
+const keepCustomerConflictChoice = async (options = {}) => {
+ const normalizedOptions = {
+ source: "customer_conflict_keep",
+ committed: true,
+ ...options,
+ };
+
+ if (pendingCustomerConflict.value?.key) {
+ suppressedCustomerConflictKey.value = pendingCustomerConflict.value.key;
+ }
+
+ clearPendingCustomerConflict();
+
+ return await finalizeDesktopStep1Context({
+ source: normalizedOptions.source,
+ finalizeReg1Input: false,
+ committed: normalizedOptions.committed,
+ });
+};
+
+const cancelCustomerConflictChoice = async (options = {}) => {
+ return await keepCustomerConflictChoice({
+ source: options?.source || "customer_conflict_cancel",
+ committed: options?.committed ?? true,
+ });
+};
+
+const useVehicleCustomerConflictChoice = async (options = {}) => {
+ const normalizedOptions = {
+ source: "customer_conflict_use_vehicle",
+ committed: true,
+ ...options,
+ };
+
+ const conflict = pendingCustomerConflict.value;
+ if (!conflict) {
+ return await finalizeDesktopStep1Context({
+ source: normalizedOptions.source,
+ finalizeReg1Input: false,
+ committed: normalizedOptions.committed,
+ });
+ }
+
+ clearPendingCustomerConflict({
+ preserveSuppressedKey: false,
+ });
+
+ if (referenceSource.value === "manual") {
+ setReferenceSource(isBlankPosValue(reference.value) ? "empty" : "vehicle");
+ }
+
+ const matchedCustomerNumber = normalizeCustomerNumber(conflict.matchedCustomerNumber);
+ if (matchedCustomerNumber && normalizeCustomerNumber(customer_id.value) !== matchedCustomerNumber) {
+ await searchAndSelectCustomer(matchedCustomerNumber);
+ }
+
+ setVehicleObject(conflict.vehicle || null, {
+ preserveManualReference: false,
+ nextSelectionSource: "vehicle-match",
+ });
+ setBookingMatches(conflict.bookingMatches || []);
+ setBookingObject((conflict.bookingMatches || []).length === 1 ? conflict.bookingMatches[0] : null, {
+ preserveManualReference: false,
+ });
+
+ return await finalizeDesktopStep1Context({
+ source: normalizedOptions.source,
+ finalizeReg1Input: false,
+ committed: normalizedOptions.committed,
+ });
+};
+
const applyBookingChoice = async (booking, options = {}) => {
const normalizedOptions = {
source: "booking_selection",
@@ -481,7 +787,9 @@ const applyBookingChoice = async (booking, options = {}) => {
});
}
- await applySelectedOrderBooking(resolvedBooking);
+ await applySelectedOrderBooking(resolvedBooking, {
+ preserveManualReference: false,
+ });
return await finalizeDesktopStep1Context({
source: normalizedOptions.source,
finalizeReg1Input: false,
@@ -542,6 +850,9 @@ defineExpose({
applyBookingChoice,
skipBookingChoice,
refreshOrderBookingMatches,
+ keepCustomerConflictChoice,
+ cancelCustomerConflictChoice,
+ useVehicleCustomerConflictChoice,
});
@@ -586,10 +897,13 @@ defineExpose({
@@ -643,7 +957,8 @@ defineExpose({
type="text"
class="input has-sharp-edges"
:tabindex="4"
- v-model="reference"
+ :value="reference"
+ @input="handleReferenceInput"
@keydown="keyDownNextInput($event, 'customer_id')"
/>
{{ SessionUser.objects.global.language.select }} {{ SessionUser.objects.global.language.customer }}