diff --git a/playwright.config.ts b/playwright.config.ts index ed10bc7a..e7a31266 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,11 +6,12 @@ const isCI = !!process.env.CI; export default defineConfig({ testDir: "./tests/e2e", + testIgnore: ["**/release/**"], timeout: 60_000, fullyParallel: true, forbidOnly: isCI, retries: isCI ? 2 : 0, - workers: isCI ? 2 : 4, + workers: 2, ...(process.env.PLAYWRIGHT_BASE_URL ? {} : { diff --git a/src/assets/pos.css b/src/assets/pos.css index 1840d68e..957a11ed 100644 --- a/src/assets/pos.css +++ b/src/assets/pos.css @@ -418,6 +418,31 @@ text-transform: uppercase; } +.pos-registration-field__label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.45rem; + min-width: 0; +} + +.pos-registration-field__status-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + font-size: 0.8rem; + line-height: 1; +} + +.pos-registration-field__status-icon--warning { + color: #a46d00; +} + +.pos-registration-field__status-icon--danger { + color: #b53e3e; +} + .pos-registration-field__value { min-width: 0; font-weight: 600; @@ -430,6 +455,18 @@ background: #fbfcfe; } +.pos-registration-field--warning { + border-style: solid; + border-color: #ddc15b; + background: #fff4c4; +} + +.pos-registration-field--danger { + border-style: solid; + border-color: #e0a7a7; + background: #fff1f1; +} + .pos-registration-field--add:hover, .pos-registration-field--add:focus-visible { border-color: #93b3d8; @@ -439,6 +476,20 @@ transform: none; } +.pos-registration-field--warning:hover, +.pos-registration-field--warning:focus-visible { + border-color: #cfaf3f; + background: #ffefb2; + box-shadow: 0 0 0 3px rgba(207, 175, 63, 0.16); +} + +.pos-registration-field--danger:hover, +.pos-registration-field--danger:focus-visible { + border-color: #d38c8c; + background: #ffe7e7; + box-shadow: 0 0 0 3px rgba(211, 140, 140, 0.16); +} + .pos-registration-field__value--muted { color: #63768f; } @@ -587,6 +638,16 @@ background: #fcfdff; } +.pos-order-items--order-detail .pos-registration-field--warning:hover, +.pos-order-items--order-detail .pos-registration-field--warning:focus-visible { + background: #ffefb2; +} + +.pos-order-items--order-detail .pos-registration-field--danger:hover, +.pos-order-items--order-detail .pos-registration-field--danger:focus-visible { + background: #ffe7e7; +} + .pos-order-items--order-detail .pos-registration-field__label { color: #738397; font-size: 0.68rem; @@ -601,6 +662,16 @@ border-color: #d2dbe5; } +.pos-order-items--order-detail .pos-registration-field--warning { + border-color: #d8b748; + background: #fff3bf; +} + +.pos-order-items--order-detail .pos-registration-field--danger { + border-color: #dc9f9f; + background: #fff0f0; +} + @media screen and (max-width: 1080px) { .pos-order-items--order-detail .pos-order-metadata-grid { grid-template-columns: minmax(0, 1fr); diff --git a/src/components/displays/department/pos/PosOrderItemsCurrent.vue b/src/components/displays/department/pos/PosOrderItemsCurrent.vue index a7780220..009ae8bb 100644 --- a/src/components/displays/department/pos/PosOrderItemsCurrent.vue +++ b/src/components/displays/department/pos/PosOrderItemsCurrent.vue @@ -1,8 +1,8 @@ @@ -1221,6 +1295,4 @@ const deleteOrderItem = async (orderItemId) => { justify-content: flex-start; } } - - diff --git a/src/components/displays/department/pos/PosSelectedCustomer.vue b/src/components/displays/department/pos/PosSelectedCustomer.vue index 056c54b5..beeafdc2 100644 --- a/src/components/displays/department/pos/PosSelectedCustomer.vue +++ b/src/components/displays/department/pos/PosSelectedCustomer.vue @@ -1,194 +1,217 @@ @@ -248,7 +280,7 @@ const customer_data_has_empty_details = () => {
  • @@ -266,7 +298,10 @@ const customer_data_has_empty_details = () => { -
    +
    - {{ attribute.name }} + {{ attribute.name }} - + - - + @@ -332,12 +378,11 @@ const customer_data_has_empty_details = () => {
    - +
    -
    -
    +
    @@ -476,5 +521,4 @@ const customer_data_has_empty_details = () => { max-width: none; } } - diff --git a/src/components/displays/department/pos/order/POSOrderCustomerWishes.vue b/src/components/displays/department/pos/order/POSOrderCustomerWishes.vue index 360dda08..837bbeee 100644 --- a/src/components/displays/department/pos/order/POSOrderCustomerWishes.vue +++ b/src/components/displays/department/pos/order/POSOrderCustomerWishes.vue @@ -3,6 +3,7 @@ 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"; +import { isBlankPosMetadataValue } from "@/components/shop/POSDepartmentProcess.vue"; const props = defineProps({ order_id: { @@ -17,11 +18,28 @@ const props = defineProps({ type: String, required: true, }, + referenceRequired: { + type: Boolean, + default: false, + }, + poRequired: { + type: Boolean, + default: false, + }, }); const { t } = useI18n(); -const createCustomerWishField = ({ key, label, source, saveValue, testIdBase }) => { +const createCustomerWishField = ({ + key, + label, + source, + saveValue, + testIdBase, + isRequired, + warningStateWhenEmpty, + warningIconClass, +}) => { const inputId = `${testIdBase}-input`; const isEditing = ref(false); const autosave = useOrderMetadataAutosave({ @@ -31,14 +49,21 @@ const createCustomerWishField = ({ key, label, source, saveValue, testIdBase }) return value; }, }); - const hasValue = computed(() => autosave.draft.value.length > 0); - - watch(() => autosave.draft.value, () => { - if (isEditing.value) { - autosave.scheduleSave(); - } + const isEmpty = computed(() => isBlankPosMetadataValue(autosave.draft.value)); + const hasValue = computed(() => !isEmpty.value); + const warningState = computed(() => { + return isRequired() && isEmpty.value ? warningStateWhenEmpty : null; }); + watch( + () => autosave.draft.value, + () => { + if (isEditing.value) { + autosave.scheduleSave(); + } + } + ); + const focusInput = () => { document.getElementById(inputId)?.focus(); }; @@ -66,9 +91,11 @@ const createCustomerWishField = ({ key, label, source, saveValue, testIdBase }) isEditing, autosave, hasValue, + warningState, openEditor, closeEditor, testIdBase, + warningIconClass, }; }; @@ -78,6 +105,9 @@ const referenceField = createCustomerWishField({ source: () => props.reference, saveValue: (value) => SessionUser.objects.orders.set.reference(props.order_id, value), testIdBase: "pos-order-customer-wishes-reference", + isRequired: () => props.referenceRequired, + warningStateWhenEmpty: "danger", + warningIconClass: "fas fa-exclamation-circle", }); const poField = createCustomerWishField({ @@ -86,6 +116,9 @@ const poField = createCustomerWishField({ source: () => props.po, saveValue: (value) => SessionUser.objects.orders.set.po(props.order_id, value), testIdBase: "pos-order-customer-wishes-po", + isRequired: () => props.poRequired, + warningStateWhenEmpty: "warning", + warningIconClass: "fas fa-exclamation-triangle", }); const fields = [referenceField, poField]; @@ -95,38 +128,77 @@ const fields = [referenceField, poField];
    -
    +
    - +
    + + + + +
    @@ -135,6 +207,4 @@ const fields = [referenceField, poField];
    - + diff --git a/src/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue b/src/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue index c3ad8615..501f7657 100644 --- a/src/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue +++ b/src/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue @@ -571,7 +571,28 @@ const toggleDropdown = async () => { } .action-settings-wheel-trigger--icon-only { - min-width: auto; + width: 2rem; + min-width: 2rem; + height: 2rem; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + vertical-align: middle; +} + +.action-settings-wheel-trigger--icon-only .icon { + width: 1rem; + height: 1rem; + margin: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.action-settings-wheel-trigger--icon-only .icon i { + line-height: 1; } .order-attachments-dropdown-menu { diff --git a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue index cdb5a874..2eb25c87 100644 --- a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue +++ b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue @@ -206,6 +206,7 @@ const onAutomaticSelection = (object: PosSearchResult | null) => { last_order_id: object?.lastOrderId || null, wash_subscription: object?.washSubscription, booking_id: object?.bookingId || null, + booking_matches: object?.bookingMatches || [], } as PosVehicle; switch (vehicles.activeVehicleIndex.value) { case 1: diff --git a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue index 31b9c609..01ccd3b7 100644 --- a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue +++ b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue @@ -31,7 +31,7 @@ import PosDepartmentStepMobile2AdditionalItems from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2AdditionalItems.vue"; import PosDepartmentStepMobile2Customer from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Customer.vue"; -import { pendingBookings, getVehiclePlateBooking } from "@/components/shop/POSDepartmentProcess.vue"; +import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue"; import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js"; @@ -39,12 +39,7 @@ onMounted(() => { // Set the header to be transparent 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 (persistedReference.value && !reference.value) { - reference.value = persistedReference.value; - } else if (vehicles?.vehicle_1?.value?.reference && !reference.value) { - reference.value = vehicles.vehicle_1.value.reference; - } + hydrateReferenceFromSources(); // Set the notes to the order notes if it's not already set if (order_notes.value && !notes.value) { notes.value = order_notes.value; @@ -103,14 +98,7 @@ const getSelectedPendingBooking = () => { if (booking) return booking; } - const reg = vehicles?.vehicle_1?.value?.reg || null; - if (!reg) return null; - - try { - return getVehiclePlateBooking(reg) || null; - } catch (e) { - return null; - } + return null; } const getSelectedBooking = async () => { @@ -199,6 +187,8 @@ const applyPendingBookingFromSelection = async () => { if (fullBookingData.reference && fullBookingData.reference.trim() !== '') { try { await SessionUser.objects.orders.set.reference(order_id.value, fullBookingData.reference); + persistedReference.value = fullBookingData.reference; + hydrateReferenceFromSources(); } catch (setRefError) { console.error('Failed to set reference on order from booking:', setRefError); } @@ -285,7 +275,7 @@ const applyPendingBookingFromSelection = async () => { } const initializeStepTwo = async () => { - const hasBookingSelection = !!getSelectedBookingId() || !!getSelectedPendingBooking(); + const hasBookingSelection = !!getSelectedBookingId(); if (!hasBookingSelection) { fetchPrimaryItemProduct(); return; @@ -300,39 +290,42 @@ const initializeStepTwo = async () => { const fetchPrimaryItemProduct = () => { if (!vehicles.vehicle_1.value?.type && !transactionItems.primaryItem.value) { vehicleSelection.value = true; // Force the user to select a product. - } else { - // Load the product from the type id. - if (!vehicles.vehicle_1.value?.type) { - return; // If no vehicle type is set, do not proceed. - } - // If the primary item is already set and matches the vehicle type, do not fetch again. - //if (transactionItems.primaryItem.value && transactionItems.primaryItem.value.id === vehicles.vehicle_1.value.type) { - // console.warn('Primary item already set and matches vehicle type, skipping fetch.'); - // return; - //} - SessionUser.objects.products.get.single(vehicles.vehicle_1.value.type, {department_id: department_id.value, customer_id: metadata.getCustomerId(), category_id: null, final_price: true}) - .then(product => { - // If the product is found, set it as the primary item - lastFetchedPrimaryItemProduct.value = product; // Store the last fetched primary item product - // If the primary item is already set, simply update the product - if (transactionItems.primaryItem.value) { - transactionItems.primaryItem.value = { - ...transactionItems.primaryItem.value, - ...product, - addons: transactionItems.primaryItem.value.addons.map(addon => ({ - ...addon, - product: { - ...addon.product, - price: product.price // Update the addon price to match the new primary item price - } - })) - }; - return; - } - transactionItems.setPrimaryItem(product); - }) - .catch(error => vehicleSelection.value = true); // If there's an error, force the user to select a product. + return; } + + // Load the product from the type id. + if (!vehicles.vehicle_1.value?.type) { + return; // If no vehicle type is set, do not proceed. + } + + vehicleSelection.value = false; + // If the primary item is already set and matches the vehicle type, do not fetch again. + //if (transactionItems.primaryItem.value && transactionItems.primaryItem.value.id === vehicles.vehicle_1.value.type) { + // console.warn('Primary item already set and matches vehicle type, skipping fetch.'); + // return; + //} + SessionUser.objects.products.get.single(vehicles.vehicle_1.value.type, {department_id: department_id.value, customer_id: metadata.getCustomerId(), category_id: null, final_price: true}) + .then(product => { + // If the product is found, set it as the primary item + lastFetchedPrimaryItemProduct.value = product; // Store the last fetched primary item product + // If the primary item is already set, simply update the product + if (transactionItems.primaryItem.value) { + transactionItems.primaryItem.value = { + ...transactionItems.primaryItem.value, + ...product, + addons: transactionItems.primaryItem.value.addons.map(addon => ({ + ...addon, + product: { + ...addon.product, + price: product.price // Update the addon price to match the new primary item price + } + })) + }; + return; + } + transactionItems.setPrimaryItem(product); + }) + .catch(error => vehicleSelection.value = true); // If there's an error, force the user to select a product. } const fetchLastOrder = (vehicleIndex: number) => { @@ -429,6 +422,8 @@ const getNormalizedOrderId = () => { return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null; } +const normalizeReferenceValue = (value: unknown) => String(value ?? ""); +const hasReferenceValue = (value: unknown) => normalizeReferenceValue(value).trim() !== ""; const normalizeRegistrationValue = (value: string | null | undefined) => String(value ?? "").trim().toUpperCase().replace(/[^A-Z0-9]/g, ""); const syncVehicleRegistrationFromOrder = (vehicleIndex: number, value: string | null | undefined) => { @@ -485,6 +480,58 @@ const referenceAutosave = useOrderMetadataAutosave({ const stepTwoNotesInput = notesAutosave.draft; const stepTwoReferenceInput = referenceAutosave.draft; +const resolveReferenceFromSources = () => { + const orderReference = normalizeReferenceValue(persistedReference.value); + if (hasReferenceValue(orderReference)) { + return orderReference; + } + + const metadataReference = normalizeReferenceValue(metadata.getReference?.() ?? reference.value); + if (hasReferenceValue(metadataReference)) { + return metadataReference; + } + + const vehicleReference = normalizeReferenceValue(vehicles?.vehicle_1?.value?.reference); + if (hasReferenceValue(vehicleReference)) { + return vehicleReference; + } + + return ""; +}; + +function syncReferenceState(value: unknown) { + const normalizedValue = normalizeReferenceValue(value); + reference.value = normalizedValue; + metadata.setReference(normalizedValue); +} + +function hydrateReferenceFromSources() { + const currentDraft = normalizeReferenceValue(stepTwoReferenceInput.value); + + if (referenceAutosave.isDirty.value && hasReferenceValue(currentDraft)) { + syncReferenceState(currentDraft); + return currentDraft; + } + + const resolvedReference = resolveReferenceFromSources(); + if (!hasReferenceValue(resolvedReference)) { + if (hasReferenceValue(currentDraft)) { + syncReferenceState(currentDraft); + } + return currentDraft; + } + + const lastSavedReference = normalizeReferenceValue(referenceAutosave.lastSavedValue.value); + const canHydrateDraft = !hasReferenceValue(currentDraft) || currentDraft === lastSavedReference; + + if (canHydrateDraft && currentDraft !== resolvedReference) { + referenceAutosave.syncFromSource(resolvedReference); + } + + syncReferenceState(canHydrateDraft ? resolvedReference : currentDraft); + return canHydrateDraft ? resolvedReference : currentDraft; +} + watch(stepTwoNotesInput, (value) => { metadata.setNotes(value); @@ -494,13 +541,19 @@ watch(stepTwoNotesInput, (value) => { }); watch(stepTwoReferenceInput, (value) => { - metadata.setReference(value); + syncReferenceState(value); if (getNormalizedOrderId()) { referenceAutosave.scheduleSave(); } }); +watch(persistedReference, (newValue, oldValue) => { + if (newValue !== oldValue) { + hydrateReferenceFromSources(); + } +}); + watch(() => getNormalizedOrderId(), (normalizedOrderId) => { if (!normalizedOrderId) { return; @@ -747,10 +800,15 @@ watch(() => vehicles.vehicle_1.value?.last_order_id, (newValue, oldValue) => { lastOrders.set(1, null); // Clear the last order if the last_order_id is removed } }); +watch(() => vehicles.vehicle_1.value?.type, (newValue, oldValue) => { + if (newValue && newValue !== oldValue && !transactionItems.primaryItem.value) { + fetchPrimaryItemProduct(); + } +}); // Watch for changes in the vehicle 1 reference and update the reference field when it changes watch(() => vehicles.vehicle_1.value?.reference, (newValue, oldValue) => { if (newValue && newValue !== oldValue) { - reference.value = newValue; // Update the reference field + hydrateReferenceFromSources(); } }); diff --git a/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumbers.vue b/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumbers.vue index 209bb640..b321d327 100644 --- a/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumbers.vue +++ b/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumbers.vue @@ -1,17 +1,21 @@