Implement POS registration field and autosave utility:
- Added `PosOrderRegistrationField.vue` for managing inline registration field inputs with autosave behavior. - Introduced `useOrderMetadataAutosave.js` composable for seamless value normalization, dirty tracking, and delayed persistence. - Refactored mobile and desktop POS components to integrate `useOrderMetadataAutosave` for improved field management (e.g., `POSOrderReference.vue`, `PosOrderLicensePlates.vue`). - Updated e2e tests to cover registration field scenarios, including input normalization and persistence (`pos-mobile-order-flow.spec.js`, `admin-pos-orders.spec.ts`). - Enhanced styling and responsiveness for registration fields in `pos.css`.
This commit is contained in:
@@ -383,6 +383,11 @@
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.pos-registration-field--editing {
|
||||
justify-content: center;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.pos-registration-field:hover,
|
||||
.pos-registration-field:focus-visible {
|
||||
border-color: #93b3d8;
|
||||
@@ -428,6 +433,25 @@
|
||||
color: #63768f;
|
||||
}
|
||||
|
||||
.pos-registration-field__input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pos-registration-field__input:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pos-order-items--order-detail .pos-order-metadata-grid {
|
||||
gap: 0.9rem;
|
||||
grid-template-columns: minmax(20rem, 1.22fr) minmax(16rem, 1fr);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, defineProps, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { computed, defineProps, nextTick, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||
|
||||
const props = defineProps({
|
||||
order_id: {
|
||||
@@ -21,26 +22,23 @@ const props = defineProps({
|
||||
const { t } = useI18n();
|
||||
|
||||
const uniqueId = Math.random().toString(36).slice(2);
|
||||
const input = ref(props.note ?? '');
|
||||
const lastSavedValue = ref(props.note ?? '');
|
||||
const isEditing = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const autosave = useOrderMetadataAutosave({
|
||||
source: () => props.note,
|
||||
saveValue: async (value) => {
|
||||
await SessionUser.objects.orders.set.notes(props.order_id, value);
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
const isDirty = computed(() => input.value !== lastSavedValue.value);
|
||||
const previewValue = computed(() => input.value);
|
||||
const rowCount = computed(() => Math.max(input.value.split('\n').length, 5));
|
||||
const previewValue = computed(() => autosave.draft.value);
|
||||
const rowCount = computed(() => Math.max((autosave.draft.value || '').split('\n').length, 5));
|
||||
|
||||
const syncFromProps = (value) => {
|
||||
const normalizedValue = value ?? '';
|
||||
const shouldHydrateInput = !isEditing.value || input.value === lastSavedValue.value;
|
||||
lastSavedValue.value = normalizedValue;
|
||||
|
||||
if (shouldHydrateInput) {
|
||||
input.value = normalizedValue;
|
||||
watch(() => autosave.draft.value, () => {
|
||||
if (isEditing.value) {
|
||||
autosave.scheduleSave();
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.note, syncFromProps, { immediate: true });
|
||||
});
|
||||
|
||||
const focusOnTextarea = () => {
|
||||
document.getElementById(`${uniqueId}-textarea`)?.focus();
|
||||
@@ -57,51 +55,20 @@ const onFieldClicked = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const saveChanges = async ({ force = false } = {}) => {
|
||||
if ((!isDirty.value && !force) || isSaving.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
await SessionUser.request('/order', 'PUT', {
|
||||
id: props.order_id,
|
||||
field: 'notes',
|
||||
value: input.value
|
||||
});
|
||||
lastSavedValue.value = input.value;
|
||||
await props.loadOrder();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const lostFocus = () => {
|
||||
const lostFocus = async () => {
|
||||
isEditing.value = false;
|
||||
void saveChanges({ force: true });
|
||||
await autosave.flush();
|
||||
};
|
||||
|
||||
const autoSaveInterval = window.setInterval(() => {
|
||||
if (isEditing.value && isDirty.value) {
|
||||
void saveChanges();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearInterval(autoSaveInterval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pos-order-field pos-order-note-editor">
|
||||
<div class="control" :class="{ 'is-loading': isSaving }">
|
||||
<div class="control" :class="{ 'is-loading': autosave.isSaving.value }">
|
||||
<div v-if="isEditing" class="pos-order-field__control pos-order-field__control--editing">
|
||||
<textarea
|
||||
:id="`${uniqueId}-textarea`"
|
||||
v-model="input"
|
||||
v-model="autosave.draft.value"
|
||||
data-testid="pos-order-note-textarea"
|
||||
class="textarea pos-order-field__input"
|
||||
:rows="rowCount"
|
||||
:placeholder="t('pos.order.order_note')"
|
||||
@@ -111,7 +78,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
v-else
|
||||
class="pos-order-field__control pos-order-field__control--interactive"
|
||||
:class="{ 'pos-order-field__control--empty': !input }"
|
||||
:class="{ 'pos-order-field__control--empty': !autosave.draft.value }"
|
||||
type="button"
|
||||
@click="onFieldClicked()"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, defineProps, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { computed, defineProps, nextTick, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||
|
||||
const props = defineProps({
|
||||
order_id: {
|
||||
@@ -21,26 +22,23 @@ const props = defineProps({
|
||||
const { t } = useI18n();
|
||||
|
||||
const uniqueId = Math.random().toString(36).slice(2);
|
||||
const input = ref(props.reference ?? '');
|
||||
const lastSavedValue = ref(props.reference ?? '');
|
||||
const isEditing = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const autosave = useOrderMetadataAutosave({
|
||||
source: () => props.reference,
|
||||
saveValue: async (value) => {
|
||||
await SessionUser.objects.orders.set.reference(props.order_id, value);
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
const isDirty = computed(() => input.value !== lastSavedValue.value);
|
||||
const previewValue = computed(() => input.value);
|
||||
const rowCount = computed(() => Math.max(input.value.split('\n').length, 1));
|
||||
const previewValue = computed(() => autosave.draft.value);
|
||||
const rowCount = computed(() => Math.max((autosave.draft.value || '').split('\n').length, 1));
|
||||
|
||||
const syncFromProps = (value) => {
|
||||
const normalizedValue = value ?? '';
|
||||
const shouldHydrateInput = !isEditing.value || input.value === lastSavedValue.value;
|
||||
lastSavedValue.value = normalizedValue;
|
||||
|
||||
if (shouldHydrateInput) {
|
||||
input.value = normalizedValue;
|
||||
watch(() => autosave.draft.value, () => {
|
||||
if (isEditing.value) {
|
||||
autosave.scheduleSave();
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.reference, syncFromProps, { immediate: true });
|
||||
});
|
||||
|
||||
const focusOnTextarea = () => {
|
||||
document.getElementById(`${uniqueId}-textarea`)?.focus();
|
||||
@@ -57,51 +55,20 @@ const onFieldClicked = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const saveChanges = async ({ force = false } = {}) => {
|
||||
if ((!isDirty.value && !force) || isSaving.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
await SessionUser.request('/order', 'PUT', {
|
||||
id: props.order_id,
|
||||
field: 'reference',
|
||||
value: input.value
|
||||
});
|
||||
lastSavedValue.value = input.value;
|
||||
await props.loadOrder();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const lostFocus = () => {
|
||||
const lostFocus = async () => {
|
||||
isEditing.value = false;
|
||||
void saveChanges({ force: true });
|
||||
await autosave.flush();
|
||||
};
|
||||
|
||||
const autoSaveInterval = window.setInterval(() => {
|
||||
if (isEditing.value && isDirty.value) {
|
||||
void saveChanges();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearInterval(autoSaveInterval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pos-order-field pos-order-reference-editor">
|
||||
<div class="control" :class="{ 'is-loading': isSaving }">
|
||||
<div class="control" :class="{ 'is-loading': autosave.isSaving.value }">
|
||||
<div v-if="isEditing" class="pos-order-field__control pos-order-field__control--editing">
|
||||
<textarea
|
||||
:id="`${uniqueId}-textarea`"
|
||||
v-model="input"
|
||||
v-model="autosave.draft.value"
|
||||
data-testid="pos-order-reference-textarea"
|
||||
class="textarea pos-order-field__input"
|
||||
:rows="rowCount"
|
||||
:placeholder="t('pos.order.reference')"
|
||||
@@ -111,7 +78,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
v-else
|
||||
class="pos-order-field__control pos-order-field__control--interactive"
|
||||
:class="{ 'pos-order-field__control--empty': !input }"
|
||||
:class="{ 'pos-order-field__control--empty': !autosave.draft.value }"
|
||||
type="button"
|
||||
@click="onFieldClicked()"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup>
|
||||
import SpanSkeleton from "@/components/displays/skeletons/SpanSkeleton.vue";
|
||||
import { computed, defineProps } from "vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import PosOrderRegistrationField from "@/components/displays/department/pos/order/PosOrderRegistrationField.vue";
|
||||
|
||||
const props = defineProps({
|
||||
registration_numbers: {
|
||||
@@ -22,8 +21,6 @@ const props = defineProps({
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const registrationEntries = computed(() => {
|
||||
return [1, 2, 3].map((index) => ({
|
||||
index,
|
||||
@@ -31,17 +28,6 @@ const registrationEntries = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
const openRegistrationEditor = (index, value) => {
|
||||
return SessionUser.editField.showEditFieldForm(
|
||||
'/order',
|
||||
props.order_id,
|
||||
`reg_${index}`,
|
||||
value,
|
||||
`Reg ${index}`,
|
||||
props.loadOrder
|
||||
).then(() => props.loadOrder());
|
||||
};
|
||||
|
||||
// Check if the required props are passed
|
||||
if (!props.registration_numbers) {
|
||||
throw new Error("Missing required prop: registration_numbers");
|
||||
@@ -65,27 +51,13 @@ if (!props.loadOrder) {
|
||||
height="3rem"
|
||||
skeleton-class="is-size-6"
|
||||
/>
|
||||
<button
|
||||
v-else-if="entry.value"
|
||||
class="pos-registration-field"
|
||||
type="button"
|
||||
:data-testid="`pos-order-registration-${entry.index}`"
|
||||
@click="openRegistrationEditor(entry.index, entry.value)"
|
||||
>
|
||||
<span class="pos-registration-field__label">{{ t('pos.order.reg') }} {{ entry.index }}</span>
|
||||
<span class="pos-registration-field__value">{{ entry.value }}</span>
|
||||
</button>
|
||||
<button
|
||||
<PosOrderRegistrationField
|
||||
v-else
|
||||
class="pos-registration-field pos-registration-field--add"
|
||||
type="button"
|
||||
:data-testid="`pos-order-registration-add-${entry.index}`"
|
||||
:aria-label="`${t('common.add')} ${t('pos.order.reg')} ${entry.index}`"
|
||||
@click="openRegistrationEditor(entry.index, '')"
|
||||
>
|
||||
<span class="pos-registration-field__label">{{ t('pos.order.reg') }} {{ entry.index }}</span>
|
||||
<span class="pos-registration-field__value pos-registration-field__value--muted">+ {{ t('common.add') }}</span>
|
||||
</button>
|
||||
:index="entry.index"
|
||||
:order_id="props.order_id"
|
||||
:modelValue="entry.value"
|
||||
:loadOrder="props.loadOrder"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { computed, defineProps, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||
|
||||
const props = defineProps({
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
order_id: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
loadOrder: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const normalizeRegistrationDraft = (value) => String(value ?? "").toUpperCase();
|
||||
|
||||
const inputId = `pos-order-registration-input-${props.index}`;
|
||||
const isEditing = ref(false);
|
||||
const fieldKey = computed(() => `reg_${props.index}`);
|
||||
let autosave;
|
||||
|
||||
autosave = useOrderMetadataAutosave({
|
||||
source: () => props.modelValue,
|
||||
normalizeValue: normalizeRegistrationDraft,
|
||||
saveValue: async (value) => {
|
||||
await SessionUser.objects.orders.set[fieldKey.value](props.order_id, value);
|
||||
return value;
|
||||
},
|
||||
onSaved: async () => {
|
||||
await props.loadOrder();
|
||||
await nextTick();
|
||||
autosave.syncFromSource(props.modelValue);
|
||||
},
|
||||
});
|
||||
|
||||
const hasValue = computed(() => autosave.draft.value.length > 0);
|
||||
const buttonTestId = computed(() => (hasValue.value ? `pos-order-registration-${props.index}` : `pos-order-registration-add-${props.index}`));
|
||||
|
||||
watch(() => autosave.draft.value, () => {
|
||||
if (isEditing.value) {
|
||||
autosave.scheduleSave();
|
||||
}
|
||||
});
|
||||
|
||||
const focusInput = () => {
|
||||
document.getElementById(inputId)?.focus();
|
||||
};
|
||||
|
||||
const openEditor = () => {
|
||||
if (isEditing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isEditing.value = true;
|
||||
nextTick(() => {
|
||||
focusInput();
|
||||
});
|
||||
};
|
||||
|
||||
const closeEditor = async () => {
|
||||
isEditing.value = false;
|
||||
await autosave.flush();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="control pos-registration-control" :class="{ 'is-loading': autosave.isSaving.value }">
|
||||
<div v-if="isEditing" class="pos-registration-field pos-registration-field--editing">
|
||||
<label class="pos-registration-field__label" :for="inputId">{{ t('pos.order.reg') }} {{ props.index }}</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-model="autosave.draft.value"
|
||||
:data-testid="`pos-order-registration-input-${props.index}`"
|
||||
class="pos-registration-field__input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@blur="closeEditor()"
|
||||
@keydown.enter.prevent="closeEditor()"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="pos-registration-field"
|
||||
:class="{ 'pos-registration-field--add': !hasValue }"
|
||||
type="button"
|
||||
:data-testid="buttonTestId"
|
||||
:aria-label="!hasValue ? `${t('common.add')} ${t('pos.order.reg')} ${props.index}` : undefined"
|
||||
@click="openEditor()"
|
||||
>
|
||||
<span class="pos-registration-field__label">{{ t('pos.order.reg') }} {{ props.index }}</span>
|
||||
<span
|
||||
class="pos-registration-field__value"
|
||||
:class="{ 'pos-registration-field__value--muted': !hasValue }"
|
||||
>
|
||||
{{ hasValue ? autosave.draft.value : `+ ${t('common.add')}` }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -19,7 +19,7 @@ import PosDepartmentStep2MobileVehicleSelection
|
||||
import PosDepartmentStepMobileButtonNextStep
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
|
||||
import { primaryItem } from "./objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { order_id, order_notes, department_id, customer_id, getCustomerEmail, customer_name, isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { order_id, order_notes, reference as persistedReference, reg_1, reg_2, reg_3, department_id, customer_id, getCustomerEmail, customer_name, isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
|
||||
import PosDepartmentStepMobileButtonClearAll
|
||||
@@ -32,6 +32,7 @@ import PosDepartmentStepMobile2AdditionalItems
|
||||
import PosDepartmentStepMobile2Customer
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Customer.vue";
|
||||
import { pendingBookings, getVehiclePlateBooking } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
@@ -39,7 +40,9 @@ onMounted(() => {
|
||||
setTransparency(false);
|
||||
setBackgroundColor(backgroundColors.default); // Set the default background color
|
||||
// Set the reference to the vehicle 1 reference if it's not already set
|
||||
if (vehicles?.vehicle_1?.value?.reference && !reference.value) {
|
||||
if (persistedReference.value && !reference.value) {
|
||||
reference.value = persistedReference.value;
|
||||
} else if (vehicles?.vehicle_1?.value?.reference && !reference.value) {
|
||||
reference.value = vehicles.vehicle_1.value.reference;
|
||||
}
|
||||
// Set the notes to the order notes if it's not already set
|
||||
@@ -426,6 +429,102 @@ const getNormalizedOrderId = () => {
|
||||
return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null;
|
||||
}
|
||||
|
||||
const normalizeRegistrationValue = (value: string | null | undefined) => String(value ?? "").trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||
|
||||
const syncVehicleRegistrationFromOrder = (vehicleIndex: number, value: string | null | undefined) => {
|
||||
const normalizedValue = normalizeRegistrationValue(value);
|
||||
if (!normalizedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentVehicle = vehicles.get(vehicleIndex);
|
||||
if (currentVehicle?.reg === normalizedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
vehicles.select(vehicleIndex, {
|
||||
...(currentVehicle ?? {}),
|
||||
reg: normalizedValue,
|
||||
});
|
||||
};
|
||||
|
||||
const notesAutosave = useOrderMetadataAutosave({
|
||||
source: order_notes,
|
||||
saveValue: async (value) => {
|
||||
const normalizedOrderId = getNormalizedOrderId();
|
||||
if (!normalizedOrderId) {
|
||||
return value;
|
||||
}
|
||||
|
||||
await SessionUser.objects.orders.set.notes(normalizedOrderId, value);
|
||||
return value;
|
||||
},
|
||||
onSaved: async (value) => {
|
||||
order_notes.value = value;
|
||||
metadata.setNotes(value);
|
||||
},
|
||||
});
|
||||
|
||||
const referenceAutosave = useOrderMetadataAutosave({
|
||||
source: persistedReference,
|
||||
saveValue: async (value) => {
|
||||
const normalizedOrderId = getNormalizedOrderId();
|
||||
if (!normalizedOrderId) {
|
||||
return value;
|
||||
}
|
||||
|
||||
await SessionUser.objects.orders.set.reference(normalizedOrderId, value);
|
||||
return value;
|
||||
},
|
||||
onSaved: async (value) => {
|
||||
persistedReference.value = value;
|
||||
metadata.setReference(value);
|
||||
},
|
||||
});
|
||||
|
||||
const stepTwoNotesInput = notesAutosave.draft;
|
||||
const stepTwoReferenceInput = referenceAutosave.draft;
|
||||
|
||||
watch(stepTwoNotesInput, (value) => {
|
||||
metadata.setNotes(value);
|
||||
|
||||
if (getNormalizedOrderId()) {
|
||||
notesAutosave.scheduleSave();
|
||||
}
|
||||
});
|
||||
|
||||
watch(stepTwoReferenceInput, (value) => {
|
||||
metadata.setReference(value);
|
||||
|
||||
if (getNormalizedOrderId()) {
|
||||
referenceAutosave.scheduleSave();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => getNormalizedOrderId(), (normalizedOrderId) => {
|
||||
if (!normalizedOrderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (notesAutosave.isDirty.value) {
|
||||
notesAutosave.scheduleSave();
|
||||
}
|
||||
|
||||
if (referenceAutosave.isDirty.value) {
|
||||
referenceAutosave.scheduleSave();
|
||||
}
|
||||
});
|
||||
|
||||
watch([reg_1, reg_2, reg_3], ([nextReg1, nextReg2, nextReg3]) => {
|
||||
if (!getNormalizedOrderId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncVehicleRegistrationFromOrder(1, nextReg1);
|
||||
syncVehicleRegistrationFromOrder(2, nextReg2);
|
||||
syncVehicleRegistrationFromOrder(3, nextReg3);
|
||||
}, { immediate: true });
|
||||
|
||||
const normalizeOrderItemShape = (item: any) => ({
|
||||
product_id: Number(item?.product_id ?? item?.product?.id ?? 0),
|
||||
quantity: Number(item?.quantity ?? 0),
|
||||
@@ -715,12 +814,28 @@ const filteredAddons = computed(() => {
|
||||
<!-- Notes -->
|
||||
<ControlField>
|
||||
<ControlFieldInputLabel label="Notes" :classes="layout.classes" :optional="true"/>
|
||||
<ControlFieldInput testId="pos-mobile-notes-input" type="text" placeholder="" :classes="layout.classes" :vmodel="order_notes" @change="metadata.setNotes($event); SessionUser.objects.orders.set.notes(order_id, $event);"/>
|
||||
<ControlFieldInput
|
||||
testId="pos-mobile-notes-input"
|
||||
type="text"
|
||||
placeholder=""
|
||||
:classes="layout.classes"
|
||||
:vmodel="stepTwoNotesInput"
|
||||
@input="stepTwoNotesInput = $event"
|
||||
@change="notesAutosave.flush()"
|
||||
/>
|
||||
</ControlField>
|
||||
<!-- Reference -->
|
||||
<ControlField>
|
||||
<ControlFieldInputLabel label="Reference" :classes="layout.classes" :optional="true"/> <!-- TODO: Make this required, if the customer requires it -->
|
||||
<ControlFieldInput testId="pos-mobile-reference-step-2-input" type="text" placeholder="" :classes="layout.classes" :vmodel="reference" @change="metadata.setReference($event); SessionUser.objects.orders.set.reference(order_id, $event);"/>
|
||||
<ControlFieldInput
|
||||
testId="pos-mobile-reference-step-2-input"
|
||||
type="text"
|
||||
placeholder=""
|
||||
:classes="layout.classes"
|
||||
:vmodel="stepTwoReferenceInput"
|
||||
@input="stepTwoReferenceInput = $event"
|
||||
@change="referenceAutosave.flush()"
|
||||
/>
|
||||
</ControlField>
|
||||
<PosDepartmentStepMobileFixedBottomControl variant="pos-step" :reserveSpace="false">
|
||||
<!-- Complete button -->
|
||||
|
||||
+176
-40
@@ -1,19 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import {ref, defineEmits, onMounted, watch, defineProps} from "vue";
|
||||
import { vehicles } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { reg_1, reg_2, reg_3, vehicles_matching } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import {ref, defineEmits, onBeforeUnmount, onMounted, watch, defineProps} from "vue";
|
||||
import { order_id, reg_1, reg_2, reg_3, getOrderDetails } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumberInputField from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumberInputField.vue";
|
||||
import BarcodeScanner from "@/components/viewport/elements/icons/BarcodeScanner.vue";
|
||||
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
|
||||
import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue";
|
||||
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
|
||||
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { PosVehicle } from "@/components/displays/department/pos/steps/mobile/objects/PosVehicle.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
import PosDepartmentStepMobileFixedBottomControl
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||
import PosDepartmentStepMobileButtonNextStep
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
|
||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||
// Define the close event to emit when the component is closed
|
||||
const emit = defineEmits(["close"]);
|
||||
// Define the props
|
||||
@@ -48,16 +47,150 @@ const inputTestIds = {
|
||||
reg3: 'pos-mobile-reg-input-3',
|
||||
};
|
||||
|
||||
const normalizeRegistrationValue = (value: string | null | undefined) => String(value ?? '').toUpperCase();
|
||||
|
||||
const getNormalizedOrderId = () => {
|
||||
const parsedOrderId = Number.parseInt(String(order_id.value), 10);
|
||||
return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null;
|
||||
};
|
||||
|
||||
const getRegistrationSourceRef = (vehicleIndex: number) => {
|
||||
switch (vehicleIndex) {
|
||||
case 1:
|
||||
return reg_1;
|
||||
case 2:
|
||||
return reg_2;
|
||||
case 3:
|
||||
return reg_3;
|
||||
default:
|
||||
return reg_1;
|
||||
}
|
||||
};
|
||||
|
||||
const setPersistedRegistrationValue = (vehicleIndex: number, value: string) => {
|
||||
const normalizedValue = normalizeRegistrationValue(value);
|
||||
getRegistrationSourceRef(vehicleIndex).value = normalizedValue;
|
||||
};
|
||||
|
||||
const syncVehicleRegistration = (vehicleIndex: number, value: string, overrides: Record<string, any> = {}) => {
|
||||
const normalizedValue = normalizeRegistrationValue(value);
|
||||
|
||||
if (normalizedValue === "") {
|
||||
pos.vehicles.select(vehicleIndex, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentVehicle = pos.vehicles.get(vehicleIndex);
|
||||
pos.vehicles.select(vehicleIndex, {
|
||||
...(currentVehicle ?? {}),
|
||||
...overrides,
|
||||
reg: normalizedValue,
|
||||
});
|
||||
};
|
||||
|
||||
// Define a reactive variable to control the expanded state of the component
|
||||
const expanded = ref(props.forceShowApplicable);
|
||||
// Define reactive variables for registration numbers and labels
|
||||
const registrationNumber1 = ref("");
|
||||
const label1 = ref("Reg 1*");
|
||||
const registrationNumber2 = ref("");
|
||||
const label2 = ref("Reg 2*");
|
||||
const registrationNumber3 = ref("");
|
||||
const label3 = ref("Reg 3");
|
||||
|
||||
const createRegistrationAutosave = (vehicleIndex: number) => useOrderMetadataAutosave({
|
||||
source: getRegistrationSourceRef(vehicleIndex),
|
||||
normalizeValue: normalizeRegistrationValue,
|
||||
saveValue: async (value) => {
|
||||
const normalizedOrderId = getNormalizedOrderId();
|
||||
if (!normalizedOrderId) {
|
||||
return value;
|
||||
}
|
||||
|
||||
await SessionUser.objects.orders.set[`reg_${vehicleIndex}`](normalizedOrderId, value);
|
||||
const refreshedOrder = await getOrderDetails(normalizedOrderId);
|
||||
return refreshedOrder?.[`reg_${vehicleIndex}`] ?? value;
|
||||
},
|
||||
onSaved: async (value) => {
|
||||
setPersistedRegistrationValue(vehicleIndex, value);
|
||||
syncVehicleRegistration(vehicleIndex, value);
|
||||
},
|
||||
});
|
||||
|
||||
const registrationAutosaves = {
|
||||
1: createRegistrationAutosave(1),
|
||||
2: createRegistrationAutosave(2),
|
||||
3: createRegistrationAutosave(3),
|
||||
};
|
||||
|
||||
const reg1Draft = registrationAutosaves[1].draft;
|
||||
const reg2Draft = registrationAutosaves[2].draft;
|
||||
const reg3Draft = registrationAutosaves[3].draft;
|
||||
|
||||
const setRegistrationDraft = (vehicleIndex: number, value: string) => {
|
||||
const normalizedValue = normalizeRegistrationValue(value);
|
||||
|
||||
switch (vehicleIndex) {
|
||||
case 1:
|
||||
reg1Draft.value = normalizedValue;
|
||||
break;
|
||||
case 2:
|
||||
reg2Draft.value = normalizedValue;
|
||||
break;
|
||||
case 3:
|
||||
reg3Draft.value = normalizedValue;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
watch(reg1Draft, (value) => {
|
||||
syncVehicleRegistration(1, value);
|
||||
if (getNormalizedOrderId()) {
|
||||
registrationAutosaves[1].scheduleSave();
|
||||
return;
|
||||
}
|
||||
|
||||
setPersistedRegistrationValue(1, value);
|
||||
});
|
||||
|
||||
watch(reg2Draft, (value) => {
|
||||
syncVehicleRegistration(2, value);
|
||||
if (getNormalizedOrderId()) {
|
||||
registrationAutosaves[2].scheduleSave();
|
||||
return;
|
||||
}
|
||||
|
||||
setPersistedRegistrationValue(2, value);
|
||||
});
|
||||
|
||||
watch(reg3Draft, (value) => {
|
||||
syncVehicleRegistration(3, value);
|
||||
if (getNormalizedOrderId()) {
|
||||
registrationAutosaves[3].scheduleSave();
|
||||
return;
|
||||
}
|
||||
|
||||
setPersistedRegistrationValue(3, value);
|
||||
});
|
||||
|
||||
watch(() => getNormalizedOrderId(), (normalizedOrderId) => {
|
||||
if (!normalizedOrderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
[1, 2, 3].forEach((vehicleIndex) => {
|
||||
if (registrationAutosaves[vehicleIndex].isDirty.value) {
|
||||
registrationAutosaves[vehicleIndex].scheduleSave();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const flushRegistration = async (vehicleIndex: number) => {
|
||||
await registrationAutosaves[vehicleIndex].flush();
|
||||
};
|
||||
|
||||
const flushAllRegistrations = async () => {
|
||||
await Promise.all([1, 2, 3].map((vehicleIndex) => registrationAutosaves[vehicleIndex].flush()));
|
||||
};
|
||||
|
||||
// Function to set a value if it is not null
|
||||
function setIfNotNull(variable: string, value: { registrationNumber: string, customerStatus: any, customerId?: number, lastOrderId?: number } | null) {
|
||||
//console.warn("setIfNotNull", variable, value);
|
||||
@@ -74,8 +207,7 @@ function setIfNotNull(variable: string, value: { registrationNumber: string, cus
|
||||
last_order_id: value.lastOrderId,
|
||||
}
|
||||
)
|
||||
registrationNumber1.value = value.registrationNumber;
|
||||
reg_1.value = value.registrationNumber;
|
||||
setRegistrationDraft(1, value.registrationNumber);
|
||||
//reg_1_status.value = value.customerStatus;
|
||||
break;
|
||||
case 'reg_2':
|
||||
@@ -87,8 +219,7 @@ function setIfNotNull(variable: string, value: { registrationNumber: string, cus
|
||||
customer_id: value.customerId,
|
||||
}
|
||||
)
|
||||
registrationNumber2.value = value.registrationNumber;
|
||||
reg_2.value = value.registrationNumber;
|
||||
setRegistrationDraft(2, value.registrationNumber);
|
||||
//reg_2_status.value = value.customerStatus;
|
||||
|
||||
break;
|
||||
@@ -101,8 +232,7 @@ function setIfNotNull(variable: string, value: { registrationNumber: string, cus
|
||||
customer_id: value.customerId,
|
||||
}
|
||||
)
|
||||
registrationNumber3.value = value.registrationNumber;
|
||||
reg_3.value = value.registrationNumber;
|
||||
setRegistrationDraft(3, value.registrationNumber);
|
||||
//reg_3_status.value = value.customerStatus;
|
||||
break;
|
||||
}
|
||||
@@ -118,31 +248,38 @@ function setIfNotNull(variable: string, value: { registrationNumber: string, cus
|
||||
// - Automatically select the first vehicle in the list, if the user input matches one of the vehicles exactly
|
||||
function setValueUserInput(vehicleIndex: number, value: string) {
|
||||
//console.log("setValueUserInput", vehicleIndex, value);
|
||||
let vehicle: PosVehicle | null = null; // Initialize vehicle as null
|
||||
const normalizedValue = normalizeRegistrationValue(value);
|
||||
/** Case: Empty input */
|
||||
// If the value is empty, clear the registration number
|
||||
if (value === "") {
|
||||
pos.vehicles.select(vehicleIndex, vehicle);
|
||||
if (normalizedValue === "") {
|
||||
pos.vehicles.select(vehicleIndex, null);
|
||||
setRegistrationDraft(vehicleIndex, '');
|
||||
void flushRegistration(vehicleIndex);
|
||||
return;
|
||||
}
|
||||
/** Case: Input matches a vehicle exactly */
|
||||
// If the value matches a result exactly, select that vehicle
|
||||
let matchingVehicle = vehicles_matching.value.filter(vehicle => vehicle.reg === value); // Filter the vehicles_matching array to find a vehicle with the same registration number
|
||||
if (matchingVehicle.length > 0) {
|
||||
//console.log("setValueUserInput: matchingVehicle", matchingVehicle);
|
||||
}
|
||||
|
||||
setRegistrationDraft(vehicleIndex, normalizedValue);
|
||||
void flushRegistration(vehicleIndex);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Initialize the registration numbers from the pos.vehicles object
|
||||
reg_1.value = pos.vehicles.get(1)?.reg ?? "";
|
||||
reg_2.value = pos.vehicles.get(2)?.reg ?? "";
|
||||
reg_3.value = pos.vehicles.get(3)?.reg ?? "";
|
||||
setRegistrationDraft(1, pos.vehicles.get(1)?.reg ?? reg_1.value ?? "");
|
||||
setRegistrationDraft(2, pos.vehicles.get(2)?.reg ?? reg_2.value ?? "");
|
||||
setRegistrationDraft(3, pos.vehicles.get(3)?.reg ?? reg_3.value ?? "");
|
||||
// If the reg_3 is not empty, set the expanded state to true
|
||||
expanded.value = reg_3.value !== "" || props.forceShowApplicable;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
void flushAllRegistrations();
|
||||
});
|
||||
|
||||
const closeManualInput = async () => {
|
||||
await flushAllRegistrations();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -150,52 +287,51 @@ onMounted(() => {
|
||||
<template v-if="props.showReg1">
|
||||
<!-- First input field with a required label -->
|
||||
<PosDepartmentStepMobile1RegistrationNumberInputField
|
||||
v-model:modelValue="reg_1"
|
||||
v-model:modelValue="reg1Draft"
|
||||
v-model:label="label1"
|
||||
:inputTestId="inputTestIds.reg1"
|
||||
@focusout="setValueUserInput(1, reg_1)"
|
||||
@onInput="reg_1 = $event"
|
||||
@focusout="setValueUserInput(1, reg1Draft)"
|
||||
>
|
||||
<template #searchResults>
|
||||
<RegistrationNumberSearchResult @select="setIfNotNull('reg_1', $event)" :searchQuery="reg_1" :automaticallySelect="true"/>
|
||||
<RegistrationNumberSearchResult @select="setIfNotNull('reg_1', $event)" :searchQuery="reg1Draft" :automaticallySelect="true"/>
|
||||
</template>
|
||||
</PosDepartmentStepMobile1RegistrationNumberInputField>
|
||||
</template>
|
||||
<template v-if="props.showReg2">
|
||||
<!-- Second input field with a custom expander button -->
|
||||
<PosDepartmentStepMobile1RegistrationNumberInputField
|
||||
v-model:modelValue="reg_2"
|
||||
v-model:modelValue="reg2Draft"
|
||||
v-model:label="label2"
|
||||
:inputTestId="inputTestIds.reg2"
|
||||
@focusout="setValueUserInput(2, reg_2)"
|
||||
@focusout="setValueUserInput(2, reg2Draft)"
|
||||
>
|
||||
<template #default>
|
||||
<div class="custom-expander" @click="expanded = !expanded" v-if="!reg_3">
|
||||
<div class="custom-expander" @click="expanded = !expanded" v-if="!reg3Draft">
|
||||
<span class="custom-text">+</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #searchResults>
|
||||
<RegistrationNumberSearchResult @select="setIfNotNull('reg_2', $event)" :searchQuery="reg_2" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
|
||||
<RegistrationNumberSearchResult @select="setIfNotNull('reg_2', $event)" :searchQuery="reg2Draft" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
|
||||
</template>
|
||||
</PosDepartmentStepMobile1RegistrationNumberInputField>
|
||||
</template>
|
||||
<template v-if="props.showReg3">
|
||||
<!-- Third input field that is conditionally displayed based on the expanded state -->
|
||||
<PosDepartmentStepMobile1RegistrationNumberInputField
|
||||
v-show="expanded || reg_3 !== ''"
|
||||
v-model:modelValue="reg_3"
|
||||
v-show="expanded || reg3Draft !== ''"
|
||||
v-model:modelValue="reg3Draft"
|
||||
v-model:label="label3"
|
||||
:inputTestId="inputTestIds.reg3"
|
||||
@focusout="setValueUserInput(3, reg_3)"
|
||||
@focusout="setValueUserInput(3, reg3Draft)"
|
||||
>
|
||||
<template #searchResults>
|
||||
<RegistrationNumberSearchResult @select="setIfNotNull('reg_3', $event)" :searchQuery="reg_3" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
|
||||
<RegistrationNumberSearchResult @select="setIfNotNull('reg_3', $event)" :searchQuery="reg3Draft" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
|
||||
</template>
|
||||
</PosDepartmentStepMobile1RegistrationNumberInputField>
|
||||
</template>
|
||||
<template v-if="props.showButtons">
|
||||
<!-- Back to scan button -->
|
||||
<button class="button p-5 is-fullwidth mt-3 is-text" data-testid="pos-mobile-manual-input-close" style="text-decoration: none" @click="$emit('close')">
|
||||
<button class="button p-5 is-fullwidth mt-3 is-text" data-testid="pos-mobile-manual-input-close" style="text-decoration: none" @click="closeManualInput()">
|
||||
<span class="m-2"><BarcodeScanner/></span>
|
||||
<span class="custom-label-button">{{ SessionUser.objects.global.language.or_scan_plates}}</span>
|
||||
</button>
|
||||
@@ -203,7 +339,7 @@ onMounted(() => {
|
||||
<!-- Buttons -->
|
||||
<PosDepartmentStepMobileFixedBottomControl v-if="props.showButtons">
|
||||
<!-- Close button -->
|
||||
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => emit('close')" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
|
||||
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => closeManualInput()" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
|
||||
<span class="pos-mobile-action-content">
|
||||
<span class="pos-mobile-action-label">{{ SessionUser.objects.global.language.close }}</span>
|
||||
<span class="pos-mobile-action-icon">
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
|
||||
export const ORDER_METADATA_AUTOSAVE_DELAY_MS = 600;
|
||||
|
||||
const defaultNormalizeValue = (value) => (value == null ? "" : String(value));
|
||||
|
||||
const resolveSourceValue = (source) => {
|
||||
if (typeof source === "function") {
|
||||
return source();
|
||||
}
|
||||
|
||||
return source?.value;
|
||||
};
|
||||
|
||||
export const useOrderMetadataAutosave = ({
|
||||
source,
|
||||
saveValue,
|
||||
normalizeValue = defaultNormalizeValue,
|
||||
delay = ORDER_METADATA_AUTOSAVE_DELAY_MS,
|
||||
onSaved = null,
|
||||
onError = null,
|
||||
}) => {
|
||||
const draft = ref(normalizeValue(resolveSourceValue(source)));
|
||||
const lastSavedValue = ref(normalizeValue(resolveSourceValue(source)));
|
||||
const isSaving = ref(false);
|
||||
const isDirty = computed(() => normalizeValue(draft.value) !== normalizeValue(lastSavedValue.value));
|
||||
|
||||
let timerId = null;
|
||||
let saveAgainAfterCurrentRequest = false;
|
||||
let nextSequence = 0;
|
||||
let lastAppliedSequence = 0;
|
||||
|
||||
const clearPendingSave = () => {
|
||||
if (timerId !== null) {
|
||||
window.clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const syncFromSource = (value) => {
|
||||
const normalizedValue = normalizeValue(value);
|
||||
const shouldHydrateDraft = normalizeValue(draft.value) === normalizeValue(lastSavedValue.value);
|
||||
|
||||
lastSavedValue.value = normalizedValue;
|
||||
|
||||
if (shouldHydrateDraft) {
|
||||
draft.value = normalizedValue;
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => resolveSourceValue(source), syncFromSource, { immediate: true });
|
||||
|
||||
const persist = async () => {
|
||||
clearPendingSave();
|
||||
|
||||
if (!isDirty.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isSaving.value) {
|
||||
saveAgainAfterCurrentRequest = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const requestedValue = normalizeValue(draft.value);
|
||||
const sequence = ++nextSequence;
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
const result = await saveValue(requestedValue);
|
||||
const savedValue = normalizeValue(result ?? requestedValue);
|
||||
|
||||
if (sequence >= lastAppliedSequence) {
|
||||
lastAppliedSequence = sequence;
|
||||
lastSavedValue.value = savedValue;
|
||||
|
||||
if (normalizeValue(draft.value) === requestedValue) {
|
||||
draft.value = savedValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof onSaved === "function") {
|
||||
await onSaved(savedValue, requestedValue);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (typeof onError === "function") {
|
||||
onError(error);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
|
||||
if (saveAgainAfterCurrentRequest || isDirty.value) {
|
||||
saveAgainAfterCurrentRequest = false;
|
||||
|
||||
if (isDirty.value) {
|
||||
void persist();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleSave = () => {
|
||||
clearPendingSave();
|
||||
|
||||
if (!isDirty.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
timerId = window.setTimeout(() => {
|
||||
void persist();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const flush = async () => {
|
||||
return persist();
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPendingSave();
|
||||
|
||||
if (isSaving.value) {
|
||||
saveAgainAfterCurrentRequest = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDirty.value) {
|
||||
void persist();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
draft,
|
||||
lastSavedValue,
|
||||
isDirty,
|
||||
isSaving,
|
||||
scheduleSave,
|
||||
flush,
|
||||
clearPendingSave,
|
||||
syncFromSource,
|
||||
};
|
||||
};
|
||||
@@ -235,6 +235,22 @@ async function waitForOrderItemMutation(page: Page, method: "POST" | "PUT" | "DE
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForOrderMutation(
|
||||
page: Page,
|
||||
method: "PUT" | "POST" | "DELETE",
|
||||
endpoint: "/order" | "/orders",
|
||||
predicate: (body: Record<string, unknown>) => boolean
|
||||
) {
|
||||
return page.waitForRequest((request) => {
|
||||
if (request.method() !== method || !request.url().includes(endpoint)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = (request.postDataJSON?.() || {}) as Record<string, unknown>;
|
||||
return predicate(body);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Admin POS Orders - desktop settings", () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only order settings coverage");
|
||||
@@ -518,6 +534,114 @@ test.describe("Admin POS Orders - desktop settings", () => {
|
||||
await expect(stepTwo.getByTestId("pos-next-step")).toBeVisible();
|
||||
});
|
||||
|
||||
test("autosaves reference and note metadata after a typing pause without blur", async ({ page }) => {
|
||||
await openOrderDetail(page);
|
||||
|
||||
const referenceValue = "AUTOSAVE-REF-2026";
|
||||
const noteValue = "Autosaved desktop note";
|
||||
|
||||
const referenceRequest = waitForOrderMutation(
|
||||
page,
|
||||
"PUT",
|
||||
"/orders",
|
||||
(body) => Number(body.id) === 54518 && body.reference === referenceValue
|
||||
);
|
||||
await page.getByTestId("pos-order-metadata-reference").locator("button").click();
|
||||
const referenceInput = page.getByTestId("pos-order-reference-textarea");
|
||||
await referenceInput.fill(referenceValue);
|
||||
|
||||
const capturedReferenceRequest = await referenceRequest;
|
||||
expect(capturedReferenceRequest.postDataJSON()).toMatchObject({
|
||||
id: 54518,
|
||||
reference: referenceValue,
|
||||
});
|
||||
await expect(referenceInput).toBeFocused();
|
||||
|
||||
const noteRequest = waitForOrderMutation(
|
||||
page,
|
||||
"PUT",
|
||||
"/orders",
|
||||
(body) => Number(body.id) === 54518 && body.notes === noteValue
|
||||
);
|
||||
await page.getByTestId("pos-order-metadata-note").locator("button").click();
|
||||
const noteInput = page.getByTestId("pos-order-note-textarea");
|
||||
await noteInput.fill(noteValue);
|
||||
|
||||
const capturedNoteRequest = await noteRequest;
|
||||
expect(capturedNoteRequest.postDataJSON()).toMatchObject({
|
||||
id: 54518,
|
||||
notes: noteValue,
|
||||
});
|
||||
await expect(noteInput).toBeFocused();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-order-metadata-reference")).toContainText(referenceValue);
|
||||
await expect(page.getByTestId("pos-order-metadata-note")).toContainText(noteValue);
|
||||
});
|
||||
|
||||
test("autosaves inline registration metadata and reloads normalized values", async ({ page }) => {
|
||||
await openOrderDetail(page);
|
||||
|
||||
const rawReg1 = "xy-12 34";
|
||||
const rawReg2 = "tr/56 78";
|
||||
const rawReg3 = "no_90 12";
|
||||
|
||||
const reg1Request = waitForOrderMutation(
|
||||
page,
|
||||
"PUT",
|
||||
"/orders",
|
||||
(body) => Number(body.id) === 54518 && body.reg_1 === "XY-12 34"
|
||||
);
|
||||
await page.getByTestId("pos-order-registration-1").click();
|
||||
const reg1Input = page.getByTestId("pos-order-registration-input-1");
|
||||
await reg1Input.fill(rawReg1);
|
||||
const capturedReg1Request = await reg1Request;
|
||||
expect(capturedReg1Request.postDataJSON()).toMatchObject({
|
||||
id: 54518,
|
||||
reg_1: "XY-12 34",
|
||||
});
|
||||
await expect(reg1Input).toHaveValue("XY1234", { timeout: 10000 });
|
||||
|
||||
const reg2Request = waitForOrderMutation(
|
||||
page,
|
||||
"PUT",
|
||||
"/orders",
|
||||
(body) => Number(body.id) === 54518 && body.reg_2 === "TR/56 78"
|
||||
);
|
||||
await page.getByTestId("pos-order-registration-add-2").click();
|
||||
const reg2Input = page.getByTestId("pos-order-registration-input-2");
|
||||
await reg2Input.fill(rawReg2);
|
||||
const capturedReg2Request = await reg2Request;
|
||||
expect(capturedReg2Request.postDataJSON()).toMatchObject({
|
||||
id: 54518,
|
||||
reg_2: "TR/56 78",
|
||||
});
|
||||
await expect(reg2Input).toHaveValue("TR5678", { timeout: 10000 });
|
||||
|
||||
const reg3Request = waitForOrderMutation(
|
||||
page,
|
||||
"PUT",
|
||||
"/orders",
|
||||
(body) => Number(body.id) === 54518 && body.reg_3 === "NO_90 12"
|
||||
);
|
||||
await page.getByTestId("pos-order-registration-add-3").click();
|
||||
const reg3Input = page.getByTestId("pos-order-registration-input-3");
|
||||
await reg3Input.fill(rawReg3);
|
||||
const capturedReg3Request = await reg3Request;
|
||||
expect(capturedReg3Request.postDataJSON()).toMatchObject({
|
||||
id: 54518,
|
||||
reg_3: "NO_90 12",
|
||||
});
|
||||
await expect(reg3Input).toHaveValue("NO9012", { timeout: 10000 });
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-order-registration-1")).toContainText("XY1234");
|
||||
await expect(page.getByTestId("pos-order-registration-2")).toContainText("TR5678");
|
||||
await expect(page.getByTestId("pos-order-registration-3")).toContainText("NO9012");
|
||||
});
|
||||
|
||||
test("edits a primary order item in the Buefy modal and persists after reload", async ({ page }) => {
|
||||
await openOrderDetail(page);
|
||||
await expectOrderTotal(page, 1372);
|
||||
|
||||
@@ -472,7 +472,7 @@ test.describe("POS mobile order flow", () => {
|
||||
expect(fixture.requestCounters.orderGet).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("step 2 query bootstrap auto-loads the primary product and persists notes/reference", async ({ page }) => {
|
||||
test("step 2 query bootstrap auto-loads the primary product and persists notes/reference after a typing pause", async ({ page }) => {
|
||||
const orderId = 9401;
|
||||
const fixture = createMobilePosFixture({
|
||||
ordersById: {
|
||||
@@ -517,15 +517,88 @@ test.describe("POS mobile order flow", () => {
|
||||
await page.getByTestId("pos-mobile-notes-input").scrollIntoViewIfNeeded();
|
||||
await expectAboveFixedActions(page, page.getByTestId("pos-mobile-notes-input"));
|
||||
await page.getByTestId("pos-mobile-notes-input").fill("mobile-pos-notes");
|
||||
await page.getByTestId("pos-mobile-notes-input").press("Tab");
|
||||
|
||||
await page.getByTestId("pos-mobile-reference-step-2-input").scrollIntoViewIfNeeded();
|
||||
await expectAboveFixedActions(page, page.getByTestId("pos-mobile-reference-step-2-input"));
|
||||
await page.getByTestId("pos-mobile-reference-step-2-input").fill("mobile-pos-reference");
|
||||
await page.getByTestId("pos-mobile-reference-step-2-input").press("Tab");
|
||||
|
||||
await expect.poll(() => fixture.ordersById[orderId]?.notes ?? "").toBe("mobile-pos-notes");
|
||||
await expect.poll(() => fixture.ordersById[orderId]?.reference ?? "").toBe("mobile-pos-reference");
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
fixture.requestLog.orderUpdates.some(
|
||||
(entry) => Number(entry?.id) === orderId && entry?.notes === "mobile-pos-notes"
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
fixture.requestLog.orderUpdates.some(
|
||||
(entry) => Number(entry?.id) === orderId && entry?.reference === "mobile-pos-reference"
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("step 2 registration popup flushes edits on close and survives reload", async ({ page }) => {
|
||||
const orderId = 9405;
|
||||
const fixture = createMobilePosFixture({
|
||||
ordersById: {
|
||||
[orderId]: buildRegularOrder(orderId, {
|
||||
reference: "REG-POPUP-REF",
|
||||
reg_1: "AB12345",
|
||||
}),
|
||||
},
|
||||
orderItemsByOrderId: {
|
||||
[orderId]: [],
|
||||
},
|
||||
});
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-step2-reg-popup-token",
|
||||
seedState: {
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
reg: "AB12345",
|
||||
reference: "REG-POPUP-REF",
|
||||
includePrimaryItem: false,
|
||||
vehicleType: 53,
|
||||
},
|
||||
route: {
|
||||
step: 2,
|
||||
orderId,
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
await page.locator('[data-testid="pos-mobile-step-2"] .custom-button-secondary').first().click();
|
||||
await expect(page.getByTestId("pos-mobile-popup")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-mobile-manual-input")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("pos-mobile-reg-input-1").fill("cd-12 34");
|
||||
await page.getByTestId("pos-mobile-popup").locator(".card-footer-item").last().click();
|
||||
await expect(page.getByTestId("pos-mobile-popup")).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
await expect.poll(() => fixture.ordersById[orderId]?.reg_1 ?? "", { timeout: 10_000 }).toBe("CD1234");
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
fixture.requestLog.orderUpdates.some(
|
||||
(entry) => Number(entry?.id) === orderId && entry?.reg_1 === "CD-12 34"
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('[data-testid="pos-mobile-step-2"] .custom-button-secondary').first()).toContainText(
|
||||
"CD1234"
|
||||
);
|
||||
});
|
||||
|
||||
test("manual step 2 selection supports addons and additional items", async ({ page }) => {
|
||||
|
||||
@@ -33,6 +33,17 @@ function toPositiveInteger(value) {
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeRegistrationValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return String(value)
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]/g, "");
|
||||
}
|
||||
|
||||
function createCustomer(customerNumber, overrides = {}) {
|
||||
return {
|
||||
id: customerNumber,
|
||||
@@ -1406,9 +1417,21 @@ export async function mockMobilePosApi(page, fixture) {
|
||||
const order = fixture.ordersById[orderId];
|
||||
if (order) {
|
||||
if (body.field) {
|
||||
order[body.field] = body.value;
|
||||
order[body.field] =
|
||||
body.field === "reg_1" || body.field === "reg_2" || body.field === "reg_3"
|
||||
? normalizeRegistrationValue(body.value)
|
||||
: body.value;
|
||||
} else {
|
||||
Object.assign(order, body);
|
||||
if (Object.prototype.hasOwnProperty.call(body, "reg_1")) {
|
||||
order.reg_1 = normalizeRegistrationValue(body.reg_1);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(body, "reg_2")) {
|
||||
order.reg_2 = normalizeRegistrationValue(body.reg_2);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(body, "reg_3")) {
|
||||
order.reg_3 = normalizeRegistrationValue(body.reg_3);
|
||||
}
|
||||
}
|
||||
}
|
||||
await route.fulfill(json({ success: true, data: clone(order || null) }));
|
||||
|
||||
Reference in New Issue
Block a user