Add booking selection popup and refactor POS booking flow logic:
- Introduced `PosDepartmentStepMobilePopupSelectOrderBooking.vue` for mobile booking selection. - Refactored vehicle and booking handling in `SelectVehicleFormPOS.vue` with better plate matching, booking persistence, and customer association logic. - Updated `PosVehicle` object type to include booking matches. - Enhanced POS transaction history and booking views with improved readability, functionality, and `data-testid` attributes. - Adjusted `PosPopup.vue` components to support the new booking popup.
This commit is contained in:
@@ -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
|
||||
? {}
|
||||
: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,194 +1,217 @@
|
||||
<script setup>
|
||||
import { customer_name, customer_id, customer_data, customer_attributes, loadCustomerAttributes, hideDiscountsCatalog, hidePricesCatalog, setHidePricesCatalog, setHideDiscountsCatalog, getHideDiscountsCatalog, getHidePricesCatalog, getUserDiscounts, user_discounts } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import {
|
||||
customer_name,
|
||||
customer_id,
|
||||
customer_data,
|
||||
customer_attributes,
|
||||
loadCustomerAttributes,
|
||||
hideDiscountsCatalog,
|
||||
hidePricesCatalog,
|
||||
setHidePricesCatalog,
|
||||
setHideDiscountsCatalog,
|
||||
getHideDiscountsCatalog,
|
||||
getHidePricesCatalog,
|
||||
getUserDiscounts,
|
||||
user_discounts,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { createAttribute, deleteAttribute } from "@/components/shop/CustomerAttributes.vue";
|
||||
import {computed, ref, watch} from 'vue';
|
||||
import 'bulma-switch/dist/css/bulma-switch.min.css';
|
||||
import 'bulma-block-list/src/block-list.scss';
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import {popperBox, popper, removePopperIfOpen, showPopperWithContent, showPopper} from "@/components/displays/PopperDefault.vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import "bulma-switch/dist/css/bulma-switch.min.css";
|
||||
import "bulma-block-list/src/block-list.scss";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {
|
||||
popperBox,
|
||||
popper,
|
||||
removePopperIfOpen,
|
||||
showPopperWithContent,
|
||||
showPopper,
|
||||
} from "@/components/displays/PopperDefault.vue";
|
||||
import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue";
|
||||
import CustomerDiscountsDepartmentDisplay
|
||||
from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
|
||||
import CustomerDiscountsDepartmentDisplay from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
|
||||
import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentBox.vue";
|
||||
|
||||
const panel_tabs = ref([
|
||||
{
|
||||
key: 'details',
|
||||
name: 'Detaljer',
|
||||
key: "details",
|
||||
name: "Detaljer",
|
||||
active: true,
|
||||
permission: 'get_user'
|
||||
permission: "get_user",
|
||||
},
|
||||
{
|
||||
key: 'rules',
|
||||
name: 'Regler',
|
||||
key: "rules",
|
||||
name: "Regler",
|
||||
active: false,
|
||||
permission: 'list_customer_attributes'
|
||||
permission: "list_customer_attributes",
|
||||
},
|
||||
{
|
||||
key: 'shortcuts',
|
||||
name: 'Genveje',
|
||||
key: "shortcuts",
|
||||
name: "Genveje",
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
key: 'discounts',
|
||||
name: 'Rabatter',
|
||||
key: "discounts",
|
||||
name: "Rabatter",
|
||||
active: false,
|
||||
permission: 'get_custom_prices_other'
|
||||
}
|
||||
permission: "get_custom_prices_other",
|
||||
},
|
||||
]);
|
||||
|
||||
const details = ref([
|
||||
{
|
||||
name: 'Kunde nummer',
|
||||
prop: 'customerNumber',
|
||||
icon: 'fas fa-user'
|
||||
name: "Kunde nummer",
|
||||
prop: "customerNumber",
|
||||
icon: "fas fa-user",
|
||||
},
|
||||
{
|
||||
name: 'Navn',
|
||||
prop: 'name',
|
||||
icon: 'fas fa-user'
|
||||
name: "Navn",
|
||||
prop: "name",
|
||||
icon: "fas fa-user",
|
||||
},
|
||||
{
|
||||
name: 'Adresse',
|
||||
prop: 'address',
|
||||
icon: 'fas fa-map-marker-alt'
|
||||
name: "Adresse",
|
||||
prop: "address",
|
||||
icon: "fas fa-map-marker-alt",
|
||||
},
|
||||
{
|
||||
name: 'Postnummer',
|
||||
prop: 'zip',
|
||||
icon: 'fas fa-map-marker-alt'
|
||||
name: "Postnummer",
|
||||
prop: "zip",
|
||||
icon: "fas fa-map-marker-alt",
|
||||
},
|
||||
{
|
||||
name: 'Kunde by',
|
||||
prop: 'city',
|
||||
icon: 'fas fa-map-marker-alt'
|
||||
name: "Kunde by",
|
||||
prop: "city",
|
||||
icon: "fas fa-map-marker-alt",
|
||||
},
|
||||
{
|
||||
name: 'Telefon',
|
||||
prop: 'mobilePhone',
|
||||
icon: 'fas fa-phone'
|
||||
name: "Telefon",
|
||||
prop: "mobilePhone",
|
||||
icon: "fas fa-phone",
|
||||
},
|
||||
{
|
||||
name: 'Email',
|
||||
prop: 'email',
|
||||
icon: 'fas fa-envelope'
|
||||
name: "Email",
|
||||
prop: "email",
|
||||
icon: "fas fa-envelope",
|
||||
},
|
||||
{
|
||||
name: 'CVR',
|
||||
prop: 'corporateIdentificationNumber',
|
||||
icon: 'fas fa-id-card'
|
||||
}
|
||||
name: "CVR",
|
||||
prop: "corporateIdentificationNumber",
|
||||
icon: "fas fa-id-card",
|
||||
},
|
||||
]);
|
||||
|
||||
const attributes = ref([
|
||||
{
|
||||
name: 'Kræver reference nr.',
|
||||
prop: 'requiresReferenceNumber',
|
||||
description: 'Når denne er sat, kræves der et reference nr. på ordren før den kan oprettes',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Kræver reference nr.",
|
||||
prop: "requiresReferenceNumber",
|
||||
description: "Når denne er sat, kræves der et reference nr. på ordren før den kan oprettes",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Må ikke ydes tillægsydelser',
|
||||
prop: 'restrictAdditionalServices',
|
||||
name: "Må ikke ydes tillægsydelser",
|
||||
prop: "restrictAdditionalServices",
|
||||
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "addons" til ordren',
|
||||
icon: 'fas fa-cogs'
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Registreringsnumre på faktura linjer',
|
||||
prop: 'requiresRegistrationNumbersInvoice',
|
||||
description: 'Når denne er sat, bliver der sendt registreringsnumre med på alle faktura linjer',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Registreringsnumre på faktura linjer",
|
||||
prop: "requiresRegistrationNumbersInvoice",
|
||||
description: "Når denne er sat, bliver der sendt registreringsnumre med på alle faktura linjer",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Fakturer alle ordrer individuelt',
|
||||
prop: 'invoiceAllOrdersIndividually',
|
||||
description: 'Når denne er sat, faktureres alle ordrer individuelt og ikke samlet',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Fakturer alle ordrer individuelt",
|
||||
prop: "invoiceAllOrdersIndividually",
|
||||
description: "Når denne er sat, faktureres alle ordrer individuelt og ikke samlet",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Må ikke ydes tank cleaning',
|
||||
prop: 'restrictTankCleaning',
|
||||
name: "Må ikke ydes tank cleaning",
|
||||
prop: "restrictTankCleaning",
|
||||
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "tank cleaning" til ordren',
|
||||
icon: 'fas fa-cogs'
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Må ikke ydes spot free',
|
||||
prop: 'restrictSpotFree',
|
||||
name: "Må ikke ydes spot free",
|
||||
prop: "restrictSpotFree",
|
||||
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "spot free" til ordren',
|
||||
icon: 'fas fa-cogs'
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Må ikke ydes indvendig vask',
|
||||
prop: 'restrictInteriorCleaning',
|
||||
name: "Må ikke ydes indvendig vask",
|
||||
prop: "restrictInteriorCleaning",
|
||||
description: 'Når denne er sat, kan der ikke tilføjes produkter i kategorien "interior cleaning" til ordren',
|
||||
icon: 'fas fa-cogs'
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Faktureres med Stripe',
|
||||
prop: 'invoiceWithStripe',
|
||||
description: 'Når denne er sat, faktureres ordren med Stripe',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Faktureres med Stripe",
|
||||
prop: "invoiceWithStripe",
|
||||
description: "Når denne er sat, faktureres ordren med Stripe",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Only tank cleaning',
|
||||
prop: 'onlyTankCleaning',
|
||||
name: "Only tank cleaning",
|
||||
prop: "onlyTankCleaning",
|
||||
description: 'Når denne er sat, bliver kunden kategoriseret som "Tank cleaning" kunde.',
|
||||
icon: 'fas fa-cogs'
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Vises priser på kunde og bookingside',
|
||||
prop: 'showPricesOnBookingPage',
|
||||
description: 'Når denne er sat, bliver kunden vist priser på kunde og bookingsiden.',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Vises priser på kunde og bookingside",
|
||||
prop: "showPricesOnBookingPage",
|
||||
description: "Når denne er sat, bliver kunden vist priser på kunde og bookingsiden.",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Bruger PO nummer',
|
||||
prop: 'usePONumbers',
|
||||
description: 'Når denne er sat, kan kunden angive et PO nummer på ordrer.',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Bruger PO nummer",
|
||||
prop: "usePONumbers",
|
||||
description: "Når denne er sat, kan kunden angive et PO nummer på ordrer.",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
{
|
||||
name: 'Undtaget fra månedligt administrations- og miljøgebyr',
|
||||
prop: 'exemptFromAdministrationFee',
|
||||
description: 'Når denne er sat, bliver kunden undtaget fra månedligt administrations- og miljøgebyr.',
|
||||
icon: 'fas fa-cogs'
|
||||
name: "Undtaget fra månedligt administrations- og miljøgebyr",
|
||||
prop: "exemptFromAdministrationFee",
|
||||
description: "Når denne er sat, bliver kunden undtaget fra månedligt administrations- og miljøgebyr.",
|
||||
icon: "fas fa-cogs",
|
||||
},
|
||||
]);
|
||||
|
||||
const shortcuts = ref([
|
||||
{
|
||||
name: 'Administrer kunde',
|
||||
icon: 'fas fa-user-edit',
|
||||
name: "Administrer kunde",
|
||||
icon: "fas fa-user-edit",
|
||||
// Open link in new tab
|
||||
action: () => {
|
||||
SessionUser.adminUser.customers.fromCustomerNumber.getUserId(customer_id.value).then(response => window.open('/superuser/users/' + response.data.data.user_id, '_blank'));
|
||||
SessionUser.adminUser.customers.fromCustomerNumber
|
||||
.getUserId(customer_id.value)
|
||||
.then((response) => window.open("/superuser/users/" + response.data.data.user_id, "_blank"));
|
||||
},
|
||||
visible: SessionUser.canAccessSuperUser()
|
||||
visible: SessionUser.canAccessSuperUser(),
|
||||
},
|
||||
{
|
||||
name: 'Vaskeabonnement & Specialaftaler',
|
||||
icon: 'fas fa-user-edit',
|
||||
name: "Vaskeabonnement & Specialaftaler",
|
||||
icon: "fas fa-user-edit",
|
||||
// Open link in new tab
|
||||
action: () => {
|
||||
SessionUser.adminUser.customers.fromCustomerNumber.getUserId(customer_id.value).then(response => window.open('/superuser/users/' + response.data.data.user_id + '/other', '_blank'));
|
||||
SessionUser.adminUser.customers.fromCustomerNumber
|
||||
.getUserId(customer_id.value)
|
||||
.then((response) => window.open("/superuser/users/" + response.data.data.user_id + "/other", "_blank"));
|
||||
},
|
||||
visible: SessionUser.canAccessSuperUser()
|
||||
visible: SessionUser.canAccessSuperUser(),
|
||||
},
|
||||
]);
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['default', 'order-detail', 'sidebar'].includes(value),
|
||||
default: "default",
|
||||
validator: (value) => ["default", "order-detail", "sidebar"].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const rootClasses = computed(() => ({
|
||||
'pos-selected-customer': true,
|
||||
"pos-selected-customer": true,
|
||||
[`pos-selected-customer--${props.variant}`]: true,
|
||||
}));
|
||||
const isSidebarVariant = computed(() => props.variant === 'sidebar');
|
||||
const isSidebarVariant = computed(() => props.variant === "sidebar");
|
||||
const isRulesTabActive = computed(() => panel_tabs.value[1]?.active === true);
|
||||
|
||||
const setActiveTab = (selectedIndex) => {
|
||||
@@ -198,37 +221,46 @@ const setActiveTab = (selectedIndex) => {
|
||||
};
|
||||
|
||||
const refreshCustomerAttributesForRules = async (selectedCustomerNumber = customer_id.value) => {
|
||||
if (!isRulesTabActive.value) {
|
||||
if (!selectedCustomerNumber) {
|
||||
return customer_attributes.value;
|
||||
}
|
||||
|
||||
return loadCustomerAttributes(selectedCustomerNumber);
|
||||
};
|
||||
|
||||
|
||||
|
||||
/** Check if an attribute is set in the customer attributes */
|
||||
const hasAttribute = (prop) => {
|
||||
return customer_attributes.value.some(attribute => attribute.attribute === prop);
|
||||
return customer_attributes.value.some((attribute) => attribute.attribute === prop);
|
||||
};
|
||||
|
||||
watch(isRulesTabActive, (isActive) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
void refreshCustomerAttributesForRules();
|
||||
}, { immediate: true });
|
||||
watch(
|
||||
isRulesTabActive,
|
||||
(isActive) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
void refreshCustomerAttributesForRules();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(() => customer_id.value, (nextCustomerId, previousCustomerId) => {
|
||||
if (!nextCustomerId || nextCustomerId === previousCustomerId || !isRulesTabActive.value) {
|
||||
return;
|
||||
}
|
||||
void refreshCustomerAttributesForRules(nextCustomerId);
|
||||
});
|
||||
watch(
|
||||
() => customer_id.value,
|
||||
(nextCustomerId, previousCustomerId) => {
|
||||
if (!nextCustomerId || nextCustomerId === previousCustomerId) {
|
||||
return;
|
||||
}
|
||||
void refreshCustomerAttributesForRules(nextCustomerId);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const isCustomerDetailsExpanded = ref(false);
|
||||
|
||||
const customer_data_has_empty_details = () => {
|
||||
return details.value.some(detail => customer_data.value[detail.prop] === null || customer_data.value[detail.prop] === '');
|
||||
return details.value.some(
|
||||
(detail) => customer_data.value[detail.prop] === null || customer_data.value[detail.prop] === ""
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -248,7 +280,7 @@ const customer_data_has_empty_details = () => {
|
||||
<li
|
||||
v-for="(tab, index) in panel_tabs"
|
||||
:key="tab.key"
|
||||
:class="{'is-active': tab.active}"
|
||||
:class="{ 'is-active': tab.active }"
|
||||
:data-testid="`pos-customer-tab-${tab.key}`"
|
||||
@click="setActiveTab(index)"
|
||||
>
|
||||
@@ -266,7 +298,10 @@ const customer_data_has_empty_details = () => {
|
||||
</div>
|
||||
<template v-if="panel_tabs[0].active" v-for="detail in details" :key="detail.name">
|
||||
<!-- Details that's not empty (unless isCustomerDetailsExpanded is true) -->
|
||||
<a class="panel-block pos-selected-customer__row" v-if="customer_data[detail.prop] || isCustomerDetailsExpanded">
|
||||
<a
|
||||
class="panel-block pos-selected-customer__row"
|
||||
v-if="customer_data[detail.prop] || isCustomerDetailsExpanded"
|
||||
>
|
||||
<span class="panel-icon pos-selected-customer__icon">
|
||||
<i :class="detail.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
@@ -276,42 +311,53 @@ const customer_data_has_empty_details = () => {
|
||||
</template>
|
||||
<!-- Details that's empty, (If there are any) -->
|
||||
<ExpandableContentBox
|
||||
v-if="panel_tabs[0].active && customer_data_has_empty_details()"
|
||||
@update:expanded="isCustomerDetailsExpanded = !isCustomerDetailsExpanded"
|
||||
v-bind:expanded="isCustomerDetailsExpanded"
|
||||
v-if="panel_tabs[0].active && customer_data_has_empty_details()"
|
||||
@update:expanded="isCustomerDetailsExpanded = !isCustomerDetailsExpanded"
|
||||
v-bind:expanded="isCustomerDetailsExpanded"
|
||||
/>
|
||||
<!-- Attributes -->
|
||||
<RequiresPermission permission="list_customer_attributes">
|
||||
<div class="panel-block pos-selected-customer__row" v-if="panel_tabs[1].active" v-for="attribute in attributes" :key="attribute.name">
|
||||
<div
|
||||
class="panel-block pos-selected-customer__row"
|
||||
v-if="panel_tabs[1].active"
|
||||
v-for="attribute in attributes"
|
||||
:key="attribute.name"
|
||||
>
|
||||
<span class="panel-icon pos-selected-customer__icon">
|
||||
<i :class="attribute.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="pos-selected-customer__label"
|
||||
@mouseover="showPopper(
|
||||
popperBox(
|
||||
attribute.name,
|
||||
attribute.description
|
||||
),
|
||||
$event.target
|
||||
)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
>{{ attribute.name }}</span>
|
||||
<span
|
||||
class="pos-selected-customer__label"
|
||||
@mouseover="showPopper(popperBox(attribute.name, attribute.description), $event.target)"
|
||||
@mouseleave="removePopperIfOpen()"
|
||||
>{{ attribute.name }}</span
|
||||
>
|
||||
<span class="pos-selected-customer__value">
|
||||
<span v-if="hasAttribute(attribute.prop)" @click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
|
||||
<span
|
||||
v-if="hasAttribute(attribute.prop)"
|
||||
@click="deleteAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())"
|
||||
>
|
||||
<span class="field">
|
||||
<input :id="attribute.prop"
|
||||
type="checkbox" :name="attribute.prop"
|
||||
class="switch" checked="checked"
|
||||
:disabled="!SessionUser.hasPermission('delete_customer_attribute')"
|
||||
<input
|
||||
:id="attribute.prop"
|
||||
type="checkbox"
|
||||
:name="attribute.prop"
|
||||
class="switch"
|
||||
checked="checked"
|
||||
:disabled="!SessionUser.hasPermission('delete_customer_attribute')"
|
||||
/>
|
||||
<label :for="attribute.prop"></label>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else @click="createAttribute(customer_id, attribute.prop).then(() => loadCustomerAttributes())">
|
||||
<span class="field">
|
||||
<input :id="attribute.prop"
|
||||
type="checkbox" :name="attribute.prop"
|
||||
class="switch" :disabled="!SessionUser.hasPermission('add_customer_attribute')"/>
|
||||
<input
|
||||
:id="attribute.prop"
|
||||
type="checkbox"
|
||||
:name="attribute.prop"
|
||||
class="switch"
|
||||
:disabled="!SessionUser.hasPermission('add_customer_attribute')"
|
||||
/>
|
||||
<label :for="attribute.prop"></label>
|
||||
</span>
|
||||
</span>
|
||||
@@ -332,12 +378,11 @@ const customer_data_has_empty_details = () => {
|
||||
<!-- Discounts -->
|
||||
<RequiresPermission permission="get_custom_prices_other">
|
||||
<div class="panel-block pos-selected-customer__discounts" v-if="panel_tabs[3].active">
|
||||
<CustomerDiscountsDepartmentDisplay/>
|
||||
<CustomerDiscountsDepartmentDisplay />
|
||||
</div>
|
||||
</RequiresPermission>
|
||||
</article>
|
||||
<div>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -476,5 +521,4 @@ const customer_data_has_empty_details = () => {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -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];
|
||||
<div class="pos-order-customer-wishes">
|
||||
<div class="pos-registration-grid pos-registration-grid--customer-wishes">
|
||||
<div v-for="field in fields" :key="field.key" class="pos-registration-slot">
|
||||
<div class="control pos-registration-control" :class="{ 'is-loading': field.autosave.isSaving.value }">
|
||||
<div
|
||||
class="control pos-registration-control"
|
||||
:class="{ 'is-loading': field.autosave.isSaving.value }"
|
||||
:data-testid="`${field.testIdBase}-control`"
|
||||
:data-warning-state="field.warningState.value || undefined"
|
||||
>
|
||||
<div
|
||||
v-if="field.isEditing.value"
|
||||
class="pos-registration-field pos-registration-field--editing"
|
||||
v-if="field.isEditing.value"
|
||||
class="pos-registration-field pos-registration-field--editing"
|
||||
:class="{
|
||||
'pos-registration-field--warning': field.warningState.value === 'warning',
|
||||
'pos-registration-field--danger': field.warningState.value === 'danger',
|
||||
}"
|
||||
>
|
||||
<label class="pos-registration-field__label" :for="field.inputId">{{ field.label.value }}</label>
|
||||
<div class="pos-registration-field__label-row">
|
||||
<label class="pos-registration-field__label" :for="field.inputId">{{ field.label.value }}</label>
|
||||
<span
|
||||
v-if="field.warningState.value"
|
||||
class="pos-registration-field__status-icon"
|
||||
:class="{
|
||||
'pos-registration-field__status-icon--warning': field.warningState.value === 'warning',
|
||||
'pos-registration-field__status-icon--danger': field.warningState.value === 'danger',
|
||||
}"
|
||||
:data-testid="`${field.testIdBase}-warning-icon`"
|
||||
>
|
||||
<i :class="field.warningIconClass" aria-hidden="true"></i>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
:id="field.inputId"
|
||||
v-model="field.autosave.draft.value"
|
||||
:data-testid="`${field.testIdBase}-input`"
|
||||
class="pos-registration-field__input pos-registration-field__input--customer-wishes"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@blur="field.closeEditor()"
|
||||
@keydown.enter.prevent="field.closeEditor()"
|
||||
:id="field.inputId"
|
||||
v-model="field.autosave.draft.value"
|
||||
:data-testid="`${field.testIdBase}-input`"
|
||||
class="pos-registration-field__input pos-registration-field__input--customer-wishes"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@blur="field.closeEditor()"
|
||||
@keydown.enter.prevent="field.closeEditor()"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="pos-registration-field"
|
||||
:class="{ 'pos-registration-field--add': !field.hasValue.value }"
|
||||
type="button"
|
||||
:data-testid="field.testIdBase"
|
||||
:aria-label="!field.hasValue.value ? `${t('common.add')} ${field.label.value}` : undefined"
|
||||
@click="field.openEditor()"
|
||||
v-else
|
||||
class="pos-registration-field"
|
||||
:class="{
|
||||
'pos-registration-field--add': !field.hasValue.value,
|
||||
'pos-registration-field--warning': field.warningState.value === 'warning',
|
||||
'pos-registration-field--danger': field.warningState.value === 'danger',
|
||||
}"
|
||||
type="button"
|
||||
:data-testid="field.testIdBase"
|
||||
:aria-label="!field.hasValue.value ? `${t('common.add')} ${field.label.value}` : undefined"
|
||||
@click="field.openEditor()"
|
||||
>
|
||||
<span class="pos-registration-field__label">{{ field.label.value }}</span>
|
||||
<span class="pos-registration-field__label-row">
|
||||
<span class="pos-registration-field__label">{{ field.label.value }}</span>
|
||||
<span
|
||||
v-if="field.warningState.value"
|
||||
class="pos-registration-field__status-icon"
|
||||
:class="{
|
||||
'pos-registration-field__status-icon--warning': field.warningState.value === 'warning',
|
||||
'pos-registration-field__status-icon--danger': field.warningState.value === 'danger',
|
||||
}"
|
||||
:data-testid="`${field.testIdBase}-warning-icon`"
|
||||
>
|
||||
<i :class="field.warningIconClass" aria-hidden="true"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="pos-registration-field__value"
|
||||
:class="{ 'pos-registration-field__value--muted': !field.hasValue.value }"
|
||||
class="pos-registration-field__value"
|
||||
:class="{ 'pos-registration-field__value--muted': !field.hasValue.value }"
|
||||
>
|
||||
{{ field.hasValue.value ? field.autosave.draft.value : `+ ${t('common.add')}` }}
|
||||
{{ field.hasValue.value ? field.autosave.draft.value : `+ ${t("common.add")}` }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -135,6 +207,4 @@ const fields = [referenceField, poField];
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+129
-56
@@ -1,17 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import PosDepartmentStepMobile1RegistrationNumber1
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber1.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumber2
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber2.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumber3
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber3.vue";
|
||||
import { activeVehicleIndex, setActiveVehicleIndex, vehicles, popups, metadata, reference } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import PosDepartmentStepMobileButtonClearAll
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumber1 from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber1.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumber2 from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber2.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumber3 from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber3.vue";
|
||||
import {
|
||||
activeVehicleIndex,
|
||||
setActiveVehicleIndex,
|
||||
vehicles,
|
||||
popups,
|
||||
metadata,
|
||||
reference,
|
||||
} from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
|
||||
import LongPressListener from "@/components/viewport/elements/wrappers/LongPressListener.vue";
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { getNotes } from "@/components/shop/CustomerNotes.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
import { customerRequiresReferenceNumber, isBlankPosMetadataValue } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
const isActive = (number: number): boolean => {
|
||||
return activeVehicleIndex.value === number;
|
||||
};
|
||||
@@ -24,13 +28,11 @@ const setActive = (number: number): void => {
|
||||
setActiveVehicleIndex(number);
|
||||
};
|
||||
|
||||
|
||||
const onLongPress = (number: number) => {
|
||||
console.warn('Long press detected in parent for number', number);
|
||||
console.warn("Long press detected in parent for number", number);
|
||||
// Show the select vehicle popup
|
||||
popups.select('select_vehicle', { props: { vehicleNumber: number } });
|
||||
|
||||
}
|
||||
popups.select("select_vehicle", { props: { vehicleNumber: number } });
|
||||
};
|
||||
|
||||
const showNewDemo = ref(true);
|
||||
const registrationRowElement = ref<HTMLElement | null>(null);
|
||||
@@ -45,23 +47,30 @@ const getCustomerNotes = async () => {
|
||||
customerNotes.value = response.data.data;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching customer notes:', error);
|
||||
console.error("Error fetching customer notes:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for changes to the customer number, and fetch the notes
|
||||
watch(() => metadata.getCustomerId(), (newCustomerId) => {
|
||||
if (newCustomerId) {
|
||||
getCustomerNotes();
|
||||
} else {
|
||||
customerNotes.value = [];
|
||||
}
|
||||
}, { immediate: true });
|
||||
watch(
|
||||
() => metadata.getCustomerId(),
|
||||
(newCustomerId) => {
|
||||
if (newCustomerId) {
|
||||
getCustomerNotes();
|
||||
} else {
|
||||
customerNotes.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const updateReferenceWidth = () => {
|
||||
const rowWidth = registrationRowElement.value?.getBoundingClientRect().width ?? 0;
|
||||
referenceWidth.value = Math.max(174, Math.round(rowWidth));
|
||||
};
|
||||
const showReferenceWarning = computed(() => {
|
||||
return customerRequiresReferenceNumber() && isBlankPosMetadataValue(reference.value);
|
||||
});
|
||||
|
||||
const observeRegistrationRow = () => {
|
||||
registrationRowResizeObserver?.disconnect();
|
||||
@@ -93,75 +102,100 @@ watch(registrationRowElement, async () => {
|
||||
observeRegistrationRow();
|
||||
});
|
||||
|
||||
watch(() => [
|
||||
vehicles.get(1)?.reg?.length ?? 0,
|
||||
vehicles.get(2)?.reg?.length ?? 0,
|
||||
vehicles.get(3)?.reg?.length ?? 0,
|
||||
], async () => {
|
||||
await nextTick();
|
||||
updateReferenceWidth();
|
||||
}, { flush: "post" });
|
||||
|
||||
watch(
|
||||
() => [vehicles.get(1)?.reg?.length ?? 0, vehicles.get(2)?.reg?.length ?? 0, vehicles.get(3)?.reg?.length ?? 0],
|
||||
async () => {
|
||||
await nextTick();
|
||||
updateReferenceWidth();
|
||||
},
|
||||
{ flush: "post" }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="registration-number-list">
|
||||
<template v-if="!showNewDemo">
|
||||
<LongPressListener @long-press="onLongPress(1)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber1 :active="isActive(1)" @click="setActive(1)"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber1 :active="isActive(1)" @click="setActive(1)" />
|
||||
</LongPressListener>
|
||||
<LongPressListener @long-press="onLongPress(2)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber2 :active="isActive(2)" @click="setActive(2)"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber2 :active="isActive(2)" @click="setActive(2)" />
|
||||
</LongPressListener>
|
||||
<LongPressListener @long-press="onLongPress(3)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber3 :active="isActive(3)" @click="setActive(3)"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber3 :active="isActive(3)" @click="setActive(3)" />
|
||||
</LongPressListener>
|
||||
<PosDepartmentStepMobileButtonClearAll/>
|
||||
<PosDepartmentStepMobileButtonClearAll />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="registration-summary">
|
||||
<div
|
||||
ref="registrationRowElement"
|
||||
class="registration-row"
|
||||
data-testid="pos-mobile-step-1-registration-row"
|
||||
>
|
||||
<div ref="registrationRowElement" class="registration-row" data-testid="pos-mobile-step-1-registration-row">
|
||||
<div class="registration-slot">
|
||||
<LongPressListener @long-press="onLongPress(1)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber1 :active="isActive(1)" @click="setActive(1)" :showLabel="false" :isWider="!vehicles.get(1)?.reg?.length && !vehicles.get(2)?.reg?.length && !vehicles.get(3)?.reg?.length"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber1
|
||||
:active="isActive(1)"
|
||||
@click="setActive(1)"
|
||||
:showLabel="false"
|
||||
:isWider="
|
||||
!vehicles.get(1)?.reg?.length && !vehicles.get(2)?.reg?.length && !vehicles.get(3)?.reg?.length
|
||||
"
|
||||
/>
|
||||
</LongPressListener>
|
||||
</div>
|
||||
<div class="registration-slot" v-if="vehicles.get(1)?.reg?.length || vehicles.get(2)?.reg?.length">
|
||||
<LongPressListener @long-press="onLongPress(2)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber2 :active="isActive(2)" @click="setActive(2)" :showLabel="false" :class="{'opacity-invisible': !vehicles.get(1)?.reg?.length}"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber2
|
||||
:active="isActive(2)"
|
||||
@click="setActive(2)"
|
||||
:showLabel="false"
|
||||
:class="{ 'opacity-invisible': !vehicles.get(1)?.reg?.length }"
|
||||
/>
|
||||
</LongPressListener>
|
||||
</div>
|
||||
<div class="registration-slot" v-if="vehicles.get(3)?.reg?.length">
|
||||
<LongPressListener @long-press="onLongPress(3)">
|
||||
<PosDepartmentStepMobile1RegistrationNumber3 :active="isActive(3)" @click="setActive(3)" :showLabel="false" :class="{'opacity-invisible': !vehicles.get(2)?.reg?.length}"/>
|
||||
<PosDepartmentStepMobile1RegistrationNumber3
|
||||
:active="isActive(3)"
|
||||
@click="setActive(3)"
|
||||
:showLabel="false"
|
||||
:class="{ 'opacity-invisible': !vehicles.get(2)?.reg?.length }"
|
||||
/>
|
||||
</LongPressListener>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reference -->
|
||||
<div
|
||||
class="mt-2 mb-2 has-text-centered custom-button-secondary reference-trigger"
|
||||
:class="{'opacity-invisible': !vehicles.get(1)?.reg?.length}"
|
||||
:class="{
|
||||
'opacity-invisible': !vehicles.get(1)?.reg?.length,
|
||||
'reference-trigger--danger': showReferenceWarning,
|
||||
}"
|
||||
:style="{ width: `${referenceWidth}px` }"
|
||||
data-testid="pos-mobile-step-1-reference-trigger"
|
||||
:data-warning-state="showReferenceWarning ? 'danger' : undefined"
|
||||
@click="popups.select('change_reference')"
|
||||
>
|
||||
<p v-if="reference.length > 0"
|
||||
class="has-overflow-ellipsis has-text-weight-bold has-text-white mx-3"
|
||||
style="max-height: 20px;"
|
||||
>{{ reference }}</p>
|
||||
<p v-else>
|
||||
<span class="is-italic">Indtast reference...</span>
|
||||
</p>
|
||||
<span class="reference-trigger__content">
|
||||
<span
|
||||
v-if="showReferenceWarning"
|
||||
class="reference-trigger__icon"
|
||||
data-testid="pos-mobile-step-1-reference-warning-icon"
|
||||
>
|
||||
<i class="fas fa-exclamation-circle" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span
|
||||
v-if="reference.length > 0"
|
||||
class="reference-trigger__text has-overflow-ellipsis has-text-weight-bold mx-3"
|
||||
:class="{ 'has-text-white': !showReferenceWarning }"
|
||||
>{{ reference }}</span
|
||||
>
|
||||
<span v-else class="reference-trigger__text is-italic">Indtast reference...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reference -->
|
||||
<div
|
||||
class="mt-2 mb-2 has-text-centered custom-button-secondary px-2 notes-trigger"
|
||||
:class="{'opacity-invisible': customerNotes.length === 0, 'has-background-warning': customerNotes.length > 0}"
|
||||
:class="{ 'opacity-invisible': customerNotes.length === 0, 'has-background-warning': customerNotes.length > 0 }"
|
||||
:style="{ width: `${referenceWidth}px` }"
|
||||
@click="popups.select('customer_notes')"
|
||||
v-if="customerNotes.length > 0"
|
||||
@@ -172,7 +206,15 @@ watch(() => [
|
||||
<span v-if="customerNotes.length > 0" class="icon is-small has-text-dark pr-1">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</span>
|
||||
<span class="has-overflow-ellipsis" :class="{'has-text-dark': customerNotes.length > 0, 'has-text-weight-bold': customerNotes.length > 0, 'is-italic': customerNotes.length === 0}">{{ SessionUser.objects.global.language.customer_notes}}</span>
|
||||
<span
|
||||
class="has-overflow-ellipsis"
|
||||
:class="{
|
||||
'has-text-dark': customerNotes.length > 0,
|
||||
'has-text-weight-bold': customerNotes.length > 0,
|
||||
'is-italic': customerNotes.length === 0,
|
||||
}"
|
||||
>{{ SessionUser.objects.global.language.customer_notes }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -206,7 +248,7 @@ watch(() => [
|
||||
}
|
||||
|
||||
.is-wider {
|
||||
width: 200px!important;
|
||||
width: 200px !important;
|
||||
}
|
||||
.opacity-invisible {
|
||||
opacity: 0.3;
|
||||
@@ -249,4 +291,35 @@ watch(() => [
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.reference-trigger--danger {
|
||||
background: #fff0f0;
|
||||
border-color: #dc9f9f;
|
||||
color: #8f2c2c;
|
||||
}
|
||||
|
||||
.reference-trigger__content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-inline: 0.45rem;
|
||||
}
|
||||
|
||||
.reference-trigger__text {
|
||||
display: inline-block;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.reference-trigger__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { popups } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const popupProps = computed(() => popups.get()?.props || {});
|
||||
const bookings = computed(() => (Array.isArray(popupProps.value.bookings) ? popupProps.value.bookings : []));
|
||||
|
||||
const formatBookingDateTime = (booking: any) => {
|
||||
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
|
||||
if (!rawValue) {
|
||||
return t('admin.pos.not_found');
|
||||
}
|
||||
|
||||
const parsedValue = new Date(rawValue);
|
||||
if (Number.isNaN(parsedValue.getTime())) {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value || undefined, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(parsedValue);
|
||||
};
|
||||
|
||||
const getReferenceValue = (booking: any) => {
|
||||
return String(booking?.reference ?? booking?.reference_number ?? '').trim();
|
||||
};
|
||||
|
||||
const getServicesValue = (booking: any) => {
|
||||
if (Array.isArray(booking?.parsed_services?.array) && booking.parsed_services.array.length > 0) {
|
||||
return booking.parsed_services.array.join(', ');
|
||||
}
|
||||
|
||||
return String(booking?.parsed_services?.string ?? booking?.wash_type ?? '').trim();
|
||||
};
|
||||
|
||||
const onSelect = async (booking: any) => {
|
||||
if (typeof popupProps.value.onSelect === 'function') {
|
||||
await popupProps.value.onSelect(booking);
|
||||
}
|
||||
};
|
||||
|
||||
const onSkip = async () => {
|
||||
if (typeof popupProps.value.onSkip === 'function') {
|
||||
await popupProps.value.onSkip();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-testid="pos-mobile-order-booking-popup">
|
||||
<p class="mb-3">{{ popups.get()?.message || t('admin.pos.order_booking_selector.help_text') }}</p>
|
||||
<div
|
||||
v-for="booking in bookings"
|
||||
:key="booking.id"
|
||||
class="booking-option mb-3"
|
||||
:data-testid="`pos-mobile-order-booking-option-${booking.id}`"
|
||||
>
|
||||
<div class="booking-option__title">
|
||||
{{ t('admin.pos.order_booking_selector.option_title', { id: booking.id, datetime: formatBookingDateTime(booking) }) }}
|
||||
</div>
|
||||
<div class="booking-option__details">
|
||||
{{ t('admin.pos.order_booking_selector.customer_label') }}: {{ booking?.customer_name || t('admin.pos.not_found') }}
|
||||
</div>
|
||||
<div class="booking-option__details">
|
||||
{{ t('admin.pos.order_booking_selector.plates_label') }}:
|
||||
{{ [booking?.reg_1, booking?.reg_2].filter(Boolean).join(' / ') || t('admin.pos.not_found') }}
|
||||
</div>
|
||||
<div class="booking-option__details">
|
||||
{{ t('admin.pos.order_booking_selector.reference_label') }}: {{ getReferenceValue(booking) || t('admin.pos.not_found') }}
|
||||
</div>
|
||||
<div class="booking-option__details">
|
||||
{{ t('admin.pos.order_booking_selector.services_label') }}: {{ getServicesValue(booking) || t('admin.pos.not_found') }}
|
||||
</div>
|
||||
<button
|
||||
class="button is-primary is-fullwidth mt-3"
|
||||
type="button"
|
||||
:data-testid="`pos-mobile-order-booking-use-${booking.id}`"
|
||||
@click="onSelect(booking)"
|
||||
>
|
||||
{{ t('admin.pos.order_booking_selector.use_booking') }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="button is-light is-fullwidth"
|
||||
type="button"
|
||||
data-testid="pos-mobile-order-booking-skip"
|
||||
@click="onSkip"
|
||||
>
|
||||
{{ t('admin.pos.order_booking_selector.continue_without_booking') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.booking-option {
|
||||
border: 1px solid #d8dde6;
|
||||
border-radius: 8px;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.booking-option__title {
|
||||
font-weight: 700;
|
||||
color: #1f2a37;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.booking-option__details {
|
||||
color: #52606d;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
</style>
|
||||
+31
@@ -234,6 +234,14 @@ const addDefaultPopups = () => {
|
||||
style: {height: '50vh'},
|
||||
actionButtons: [{...defaultActionButtons.value.cancel}, {...defaultActionButtons.value.addCustomer, label: 'Ny kunde'}],
|
||||
});
|
||||
addPopup({
|
||||
id: 'select_order_booking',
|
||||
title: i18n.global.t('admin.pos.order_booking_selector.title'),
|
||||
message: i18n.global.t('admin.pos.order_booking_selector.help_text'),
|
||||
component: 'select_order_booking',
|
||||
style: {maxHeight: '60vh'},
|
||||
actionButtons: [],
|
||||
});
|
||||
// Completed
|
||||
addPopup({
|
||||
id: 'completed_transaction',
|
||||
@@ -1117,6 +1125,7 @@ const notes = ref<string>('');
|
||||
const reference = ref<string>('');
|
||||
const washId = ref<string | null>(null); // The Wash ID is used to track the related wash event
|
||||
const bookingId = ref<number | null>(null); // The Booking ID is used to track the related booking event
|
||||
const bookingSelectionSkippedPlate = ref<string>('');
|
||||
const laneId = ref<number | null>(null); // The Lane ID is used to track the related lane event
|
||||
const loadingState = ref<boolean>(false); // Loading state for the transaction
|
||||
const loadingMessage = ref<string>('Indlæser...'); // Loading message for the transaction
|
||||
@@ -1181,6 +1190,15 @@ const setBookingId = (id: number | null) => {
|
||||
const getBookingId = () => {
|
||||
return bookingId.value;
|
||||
}
|
||||
const setBookingSelectionSkippedPlate = (plate: string | null | undefined) => {
|
||||
bookingSelectionSkippedPlate.value = String(plate ?? '').replace(/\s/g, '').toUpperCase();
|
||||
}
|
||||
const getBookingSelectionSkippedPlate = () => {
|
||||
return bookingSelectionSkippedPlate.value;
|
||||
}
|
||||
const clearBookingSelectionSkippedPlate = () => {
|
||||
bookingSelectionSkippedPlate.value = '';
|
||||
}
|
||||
// Function to set the lane ID
|
||||
const setLaneId = (id: number | null) => {
|
||||
laneId.value = id;
|
||||
@@ -1216,6 +1234,10 @@ const metadata = {
|
||||
bookingId,
|
||||
setBookingId,
|
||||
getBookingId,
|
||||
bookingSelectionSkippedPlate,
|
||||
setBookingSelectionSkippedPlate,
|
||||
getBookingSelectionSkippedPlate,
|
||||
clearBookingSelectionSkippedPlate,
|
||||
// Lane ID
|
||||
laneId,
|
||||
setLaneId,
|
||||
@@ -1365,6 +1387,7 @@ const resetMetadata = () => {
|
||||
metadata.reference.value = '';
|
||||
metadata.washId.value = null;
|
||||
metadata.bookingId.value = null;
|
||||
metadata.bookingSelectionSkippedPlate.value = '';
|
||||
metadata.laneId.value = null;
|
||||
metadata.clearLoadingState();
|
||||
}
|
||||
@@ -1529,6 +1552,7 @@ const buildSnapshot = () => ({
|
||||
reference: metadata.reference.value,
|
||||
washId: metadata.washId.value,
|
||||
bookingId: metadata.bookingId.value,
|
||||
bookingSelectionSkippedPlate: metadata.bookingSelectionSkippedPlate.value,
|
||||
laneId: metadata.laneId.value,
|
||||
},
|
||||
attachments: {
|
||||
@@ -1613,6 +1637,8 @@ const retrievePos = () => {
|
||||
metadata.reference.value = parsedData.metadata.reference ?? '';
|
||||
metadata.washId.value = parsedData.metadata.washId ?? null;
|
||||
metadata.bookingId.value = parsedData.metadata.bookingId ?? null;
|
||||
metadata.bookingSelectionSkippedPlate.value = parsedData.metadata.bookingSelectionSkippedPlate ?? '';
|
||||
metadata.laneId.value = parsedData.metadata.laneId ?? null;
|
||||
}
|
||||
|
||||
// Restore last vehicle orders
|
||||
@@ -1681,6 +1707,7 @@ watch(() => [
|
||||
metadata.reference.value,
|
||||
metadata.washId.value,
|
||||
metadata.bookingId.value,
|
||||
metadata.bookingSelectionSkippedPlate.value,
|
||||
metadata.laneId.value
|
||||
], savePos);
|
||||
|
||||
@@ -1841,6 +1868,10 @@ export const Customer = {
|
||||
bookingId,
|
||||
setBookingId,
|
||||
getBookingId,
|
||||
bookingSelectionSkippedPlate,
|
||||
setBookingSelectionSkippedPlate,
|
||||
getBookingSelectionSkippedPlate,
|
||||
clearBookingSelectionSkippedPlate,
|
||||
};
|
||||
|
||||
export const Meta = {
|
||||
|
||||
@@ -22,11 +22,14 @@ import PosDepartmentStepMobilePopupImage
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupImage.vue";
|
||||
import PosDepartmentStepMobilePopupAddCustomer
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupAddCustomer.vue";
|
||||
import PosDepartmentStepMobilePopupSelectOrderBooking
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupSelectOrderBooking.vue";
|
||||
|
||||
export type PopupComponentKey = 'select_customer' | 'completed_transaction' | 'complete_booking' | 'error' | 'add_product_note' | 'select_vehicle' | 'change_reference' | 'customer_notes' | 'image_viewer' | 'add_customer';
|
||||
export type PopupComponentKey = 'select_customer' | 'select_order_booking' | 'completed_transaction' | 'complete_booking' | 'error' | 'add_product_note' | 'select_vehicle' | 'change_reference' | 'customer_notes' | 'image_viewer' | 'add_customer';
|
||||
|
||||
export const PopupComponents = {
|
||||
select_customer: PosDepartmentStepMobilePopupSelectCustomer,
|
||||
select_order_booking: PosDepartmentStepMobilePopupSelectOrderBooking,
|
||||
completed_transaction: PosDepartmentStepMobilePopupCompletedTransaction,
|
||||
complete_booking: PosDepartmentStepMobilePopupCompleteBooking,
|
||||
error: PosDepartmentStepMobilePopupError,
|
||||
@@ -59,6 +62,7 @@ export default defineComponent({
|
||||
name: 'PosPopup',
|
||||
components: {
|
||||
PosDepartmentStepMobilePopupSelectCustomer,
|
||||
PosDepartmentStepMobilePopupSelectOrderBooking,
|
||||
PosDepartmentStepMobilePopupCompletedTransaction,
|
||||
PosDepartmentStepMobilePopupCompleteBooking,
|
||||
PosDepartmentStepMobilePopupError,
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { VehicleStatusKey } from './PosVehicleStatus.vue';
|
||||
import type { PosCustomerSuggestion } from './PosCustomerSuggestion.vue';
|
||||
export type OrderBookingSummary = {
|
||||
id: number;
|
||||
customer_name?: string;
|
||||
customer_number?: number;
|
||||
customer_id?: number;
|
||||
reg_1?: string;
|
||||
reg_2?: string;
|
||||
reference?: string;
|
||||
reference_number?: string;
|
||||
note?: string;
|
||||
notes?: string;
|
||||
po?: string;
|
||||
datetime?: string;
|
||||
created_at?: string;
|
||||
wash_type?: string;
|
||||
parsed_services?: {
|
||||
string?: string;
|
||||
array?: string[];
|
||||
};
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type PosSearchResult = {
|
||||
registrationNumber?: string;
|
||||
customerName: string;
|
||||
@@ -13,6 +35,7 @@ export type PosSearchResult = {
|
||||
lastOrderId?: number;
|
||||
washSubscription?: boolean;
|
||||
customerSuggestions?: PosCustomerSuggestion[]; // This is an array of previously billed customers.
|
||||
bookingId?: number; // If the search result is linked to a booking.
|
||||
bookingId?: number; // The explicitly selected booking id.
|
||||
bookingMatches?: OrderBookingSummary[];
|
||||
};
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { VehicleStatusKey } from './PosVehicleStatus.vue';
|
||||
import type { OrderBookingSummary } from './PosSearchResult.vue';
|
||||
export type PosVehicle = {
|
||||
id?: number;
|
||||
customer_id?: number;
|
||||
@@ -19,6 +20,7 @@ export type PosVehicle = {
|
||||
last_order_id?: number;
|
||||
// Booking ID (if applicable)
|
||||
booking_id?: number;
|
||||
booking_matches?: OrderBookingSummary[];
|
||||
// additional data
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
+18
-26
@@ -7,6 +7,7 @@ import GenericButton from "@/components/viewport/page/templates/generic/graphics
|
||||
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 type { PosSearchResult } from "../objects/PosSearchResult.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
import PosDepartmentStepMobileFixedBottomControl
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||
@@ -192,46 +193,37 @@ const flushAllRegistrations = async () => {
|
||||
};
|
||||
|
||||
// Function to set a value if it is not null
|
||||
function setIfNotNull(variable: string, value: { registrationNumber: string, customerStatus: any, customerId?: number, lastOrderId?: number } | null) {
|
||||
const createVehicleSelectionFromSearchResult = (value: PosSearchResult) => ({
|
||||
...value,
|
||||
reg: value.registrationNumber,
|
||||
status: value.customerStatus,
|
||||
customer_id: value.customerId,
|
||||
last_order_id: value.lastOrderId,
|
||||
booking_id: value.bookingId,
|
||||
booking_matches: value.bookingMatches || [],
|
||||
wash_subscription: value.washSubscription,
|
||||
});
|
||||
|
||||
function setIfNotNull(variable: string, value: PosSearchResult | null) {
|
||||
//console.warn("setIfNotNull", variable, value);
|
||||
if (value !== null) {
|
||||
const nextVehicleSelection = createVehicleSelectionFromSearchResult(value);
|
||||
|
||||
switch (variable) {
|
||||
case 'reg_1':
|
||||
//console.log("Setting reg_1 with value:", value);
|
||||
pos.vehicles.select(
|
||||
1,
|
||||
{
|
||||
reg: value.registrationNumber,
|
||||
status: value.customerStatus,
|
||||
customer_id: value.customerId,
|
||||
last_order_id: value.lastOrderId,
|
||||
}
|
||||
)
|
||||
pos.vehicles.select(1, nextVehicleSelection);
|
||||
setRegistrationDraft(1, value.registrationNumber);
|
||||
//reg_1_status.value = value.customerStatus;
|
||||
break;
|
||||
case 'reg_2':
|
||||
pos.vehicles.select(
|
||||
2,
|
||||
{
|
||||
reg: value.registrationNumber,
|
||||
status: value.customerStatus,
|
||||
customer_id: value.customerId,
|
||||
}
|
||||
)
|
||||
pos.vehicles.select(2, nextVehicleSelection);
|
||||
setRegistrationDraft(2, value.registrationNumber);
|
||||
//reg_2_status.value = value.customerStatus;
|
||||
|
||||
break;
|
||||
case 'reg_3':
|
||||
pos.vehicles.select(
|
||||
3,
|
||||
{
|
||||
reg: value.registrationNumber,
|
||||
status: value.customerStatus,
|
||||
customer_id: value.customerId,
|
||||
}
|
||||
)
|
||||
pos.vehicles.select(3, nextVehicleSelection);
|
||||
setRegistrationDraft(3, value.registrationNumber);
|
||||
//reg_3_status.value = value.customerStatus;
|
||||
break;
|
||||
|
||||
+152
-74
@@ -1,14 +1,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {defineEmits, onMounted, onUnmounted, ref} from "vue";
|
||||
import { setTransparency, setBackgroundColor, backgroundColors, setOverflow } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
|
||||
import { defineEmits, onMounted, onUnmounted, ref } from "vue";
|
||||
import {
|
||||
setTransparency,
|
||||
setBackgroundColor,
|
||||
backgroundColors,
|
||||
setOverflow,
|
||||
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
|
||||
import { pos } from "../objects/PosDepartmentStepMobileFlow.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 PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||
import PosDepartmentStepMobileButtonNextStep from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
import {PosOrder} from "@/components/displays/department/pos/steps/mobile/objects/PosOrder.vue";
|
||||
import { PosOrder } from "@/components/displays/department/pos/steps/mobile/objects/PosOrder.vue";
|
||||
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
||||
// Define the close event to emit when the component is closed
|
||||
const emit = defineEmits(["close"]);
|
||||
@@ -42,26 +44,26 @@ const getTransactionCustomer = (transaction: PosOrder) => {
|
||||
if (!customerNumber) {
|
||||
return;
|
||||
}
|
||||
SessionUser.objects.global.get.object('/users/customer', {customer_number: transaction.customer_id}).then((response) => {
|
||||
if (response?.customer_name) {
|
||||
transaction.customer_name = response.customer_name;
|
||||
}
|
||||
});
|
||||
}
|
||||
SessionUser.objects.global.get
|
||||
.object("/users/customer", { customer_number: transaction.customer_id })
|
||||
.then((response) => {
|
||||
if (response?.customer_name) {
|
||||
transaction.customer_name = response.customer_name;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getTransactionData = (transactions: PosOrder[]) => {
|
||||
const safeTransactions = Array.isArray(transactions)
|
||||
? transactions.filter((transaction) => !!transaction)
|
||||
: [];
|
||||
const safeTransactions = Array.isArray(transactions) ? transactions.filter((transaction) => !!transaction) : [];
|
||||
if (safeTransactions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validIds = [...new Set(
|
||||
safeTransactions
|
||||
.map((transaction) => toValidOrderId(transaction?.id))
|
||||
.filter((id): id is number => id !== null)
|
||||
)];
|
||||
const validIds = [
|
||||
...new Set(
|
||||
safeTransactions.map((transaction) => toValidOrderId(transaction?.id)).filter((id): id is number => id !== null)
|
||||
),
|
||||
];
|
||||
if (validIds.length === 0) {
|
||||
safeTransactions.forEach((transaction) => {
|
||||
transaction.error = SessionUser.objects.global.language.error_loading_transaction;
|
||||
@@ -97,7 +99,7 @@ const getTransactionData = (transactions: PosOrder[]) => {
|
||||
getTransactionCustomer(transaction);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const refreshInterval = setInterval(() => {
|
||||
syncListTransactionHistory();
|
||||
@@ -110,43 +112,50 @@ onUnmounted(() => {
|
||||
const isPendingHandheld = (transaction: PosOrder) => {
|
||||
if (!transaction?.pending_handheld) return false;
|
||||
return !transaction?.completed_at;
|
||||
}
|
||||
};
|
||||
|
||||
const hasError = (transaction: PosOrder) => {
|
||||
return !!transaction?.error;
|
||||
}
|
||||
};
|
||||
|
||||
const getTransactionError = (transaction: PosOrder) => {
|
||||
return transaction?.error || SessionUser.objects.global.language.unknown_error;
|
||||
}
|
||||
};
|
||||
|
||||
const liveTransactions = ref(null)
|
||||
const liveTransactions = ref(null);
|
||||
const isLoading = ref(true);
|
||||
|
||||
const syncListTransactionHistory = async () => {
|
||||
let dateToday = new Date().toISOString().split('T')[0]; // Get today's date in YYYY-MM-DD format
|
||||
let dateToday = new Date().toISOString().split("T")[0]; // Get today's date in YYYY-MM-DD format
|
||||
isLoading.value = true;
|
||||
// Fetch the list of orders created today for the current department
|
||||
SessionUser.objects.orders.get.list({filters: {
|
||||
'created_at-date_from': dateToday,
|
||||
'created_at-date_to': dateToday,
|
||||
'department_id': SessionUser.functions.getDepartmentIdFromUrl(),
|
||||
}, pagination: {
|
||||
page: 1, limit: 1000
|
||||
}}).then((response) => {
|
||||
liveTransactions.value = Array.isArray(response) ? response : [];
|
||||
// Clear the transactions
|
||||
pos.transactionHistory.clear();
|
||||
// Loop through the new transactions, and add them
|
||||
for (let i = 0; i < liveTransactions.value.length; i++) {
|
||||
if (liveTransactions.value[i]) {
|
||||
pos.transactionHistory.add(liveTransactions.value[i]);
|
||||
SessionUser.objects.orders.get
|
||||
.list({
|
||||
filters: {
|
||||
"created_at-date_from": dateToday,
|
||||
"created_at-date_to": dateToday,
|
||||
department_id: SessionUser.functions.getDepartmentIdFromUrl(),
|
||||
},
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
liveTransactions.value = Array.isArray(response) ? response : [];
|
||||
// Clear the transactions
|
||||
pos.transactionHistory.clear();
|
||||
// Loop through the new transactions, and add them
|
||||
for (let i = 0; i < liveTransactions.value.length; i++) {
|
||||
if (liveTransactions.value[i]) {
|
||||
pos.transactionHistory.add(liveTransactions.value[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
getTransactionData(pos.transactionHistory.get());
|
||||
}).finally(() => {
|
||||
isLoading.value = false;
|
||||
});
|
||||
getTransactionData(pos.transactionHistory.get());
|
||||
})
|
||||
.finally(() => {
|
||||
isLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: boolean) => {
|
||||
@@ -160,39 +169,60 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
|
||||
<!-- Visibility toggles -->
|
||||
<div class="columns is-mobile is-vcentered is-multiline">
|
||||
<div class="column is-half" v-for="(visibility, index) in [showPending, showCompleted]" :key="index">
|
||||
<WhiteBoxCard :toggleable="false" :forceState="false" :defaultOpen="false" @click="() => { index === 0 ? showPending = !showPending : showCompleted = !showCompleted }">
|
||||
<WhiteBoxCard
|
||||
:toggleable="false"
|
||||
:forceState="false"
|
||||
:defaultOpen="false"
|
||||
@click="
|
||||
() => {
|
||||
index === 0 ? (showPending = !showPending) : (showCompleted = !showCompleted);
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #header>
|
||||
<div class="card-header-title">
|
||||
<span v-if="index === 0">{{ SessionUser.objects.global.language.pending }}</span>
|
||||
<span v-else-if="index === 1">{{ SessionUser.objects.global.language.completed }}</span>
|
||||
<span v-else>{{ SessionUser.objects.global.language.error }}</span>
|
||||
<span v-if="index === 0">{{ SessionUser.objects.global.language.pending }}</span>
|
||||
<span v-else-if="index === 1">{{ SessionUser.objects.global.language.completed }}</span>
|
||||
<span v-else>{{ SessionUser.objects.global.language.error }}</span>
|
||||
</div>
|
||||
<div class="card-header-icon">
|
||||
<!-- Checkbox icon, if visibility is true, show checked, else show unchecked -->
|
||||
<span class="icon">
|
||||
<i v-if="visibility" class="fa-solid fa-check"></i>
|
||||
<i v-else class="fa-solid fa-square"></i>
|
||||
</span>
|
||||
<!-- Checkbox icon, if visibility is true, show checked, else show unchecked -->
|
||||
<span class="icon">
|
||||
<i v-if="visibility" class="fa-solid fa-check"></i>
|
||||
<i v-else class="fa-solid fa-square"></i>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
</template>
|
||||
<template #content> </template>
|
||||
</WhiteBoxCard>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="scrollable" style="overflow-x: hidden;">
|
||||
<div class="scrollable" style="overflow-x: hidden">
|
||||
<!-- Transaction history list -->
|
||||
<div class="columns is-multiline is-mobile mb-6">
|
||||
<template v-for="(transaction, index) in pos.transactionHistory.get()" :key="index">
|
||||
<div class="column is-12" v-if="isVisible(transaction, showPending, showCompleted)">
|
||||
<div class="box" @click="SessionUser.functions.redirectTo.department(transaction.department_id, 'modules/pos/orders/' + transaction.id)">
|
||||
<div
|
||||
class="box transaction-history-card"
|
||||
:data-testid="`pos-mobile-transaction-history-card-${transaction.id}`"
|
||||
@click="
|
||||
SessionUser.functions.redirectTo.department(
|
||||
transaction.department_id,
|
||||
'modules/pos/orders/' + transaction.id
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="columns is-mobile is-vcentered is-multiline">
|
||||
<div class="column is-12 pb-0">
|
||||
<p class="is-size-7 has-text-grey">{{ SessionUser.functions.date.toWordsWithTime(new Date(transaction.created_at)) }}</p>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
{{ SessionUser.functions.date.toWordsWithTime(new Date(transaction.created_at)) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-8 pt-0">
|
||||
<p class="is-size-6 has-text-weight-semibold">{{ transaction?.customer_name || transaction?.customer_id }}</p>
|
||||
<p class="is-size-6 has-text-weight-semibold">
|
||||
{{ transaction?.customer_name || transaction?.customer_id }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-4 has-text-right">
|
||||
<p class="is-size-6 has-text-weight-semibold">
|
||||
@@ -202,15 +232,39 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
|
||||
<div class="column is-12">
|
||||
<div class="columns is-vcentered is-mobile">
|
||||
<div class="column">
|
||||
<span class="icon" :class="hasError(transaction) ? 'has-text-danger' : isPendingHandheld(transaction) ? 'has-text-warning' : 'has-text-success'">
|
||||
<!-- If the pending_handheld is true, show a clock icon, else show a check icon -->
|
||||
<i v-if="hasError(transaction)" class="fa-solid fa-triangle-exclamation"></i>
|
||||
<i v-else-if="isPendingHandheld(transaction)" class="fa-solid fa-clock"></i>
|
||||
<i v-else class="fa-solid fa-check"></i>
|
||||
<span
|
||||
class="icon"
|
||||
:class="
|
||||
hasError(transaction)
|
||||
? 'has-text-danger'
|
||||
: isPendingHandheld(transaction)
|
||||
? 'has-text-warning'
|
||||
: 'has-text-success'
|
||||
"
|
||||
>
|
||||
<!-- If the pending_handheld is true, show a clock icon, else show a check icon -->
|
||||
<i v-if="hasError(transaction)" class="fa-solid fa-triangle-exclamation"></i>
|
||||
<i v-else-if="isPendingHandheld(transaction)" class="fa-solid fa-clock"></i>
|
||||
<i v-else class="fa-solid fa-check"></i>
|
||||
</span>
|
||||
<span class="is-size-7" :class="hasError(transaction) ? 'has-text-danger' : isPendingHandheld(transaction) ? 'has-text-warning' : 'has-text-success'">
|
||||
<span
|
||||
class="is-size-7"
|
||||
:class="
|
||||
hasError(transaction)
|
||||
? 'has-text-danger'
|
||||
: isPendingHandheld(transaction)
|
||||
? 'has-text-warning'
|
||||
: 'has-text-success'
|
||||
"
|
||||
>
|
||||
<!-- If there is an error, show the error message, else if pending_handheld is true, show "Confirmation needed", else show "Completed" -->
|
||||
{{ hasError(transaction) ? getTransactionError(transaction) : isPendingHandheld(transaction) ? SessionUser.objects.global.language.confirmation_needed : SessionUser.objects.global.language.completed }}
|
||||
{{
|
||||
hasError(transaction)
|
||||
? getTransactionError(transaction)
|
||||
: isPendingHandheld(transaction)
|
||||
? SessionUser.objects.global.language.confirmation_needed
|
||||
: SessionUser.objects.global.language.completed
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="column is-narrow has-text-right">
|
||||
@@ -234,18 +288,38 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
|
||||
<!-- Buttons -->
|
||||
<PosDepartmentStepMobileFixedBottomControl>
|
||||
<!-- Refresh button -->
|
||||
<PosDepartmentStepMobileButtonNextStep class="mb-2" :isWhite="false" :customAction="() => syncListTransactionHistory()" :customDisabled="false" :buttonClasses="['has-background-primary-dark', 'has-text-black', ...isLoading ? ['is-loading'] : []]">
|
||||
<PosDepartmentStepMobileButtonNextStep
|
||||
class="mb-2"
|
||||
:isWhite="false"
|
||||
:customAction="() => syncListTransactionHistory()"
|
||||
:customDisabled="false"
|
||||
:buttonClasses="['has-background-primary-dark', 'has-text-black', ...(isLoading ? ['is-loading'] : [])]"
|
||||
>
|
||||
<span class="pos-mobile-action-content" data-testid="pos-mobile-transaction-history-reload-action">
|
||||
<span class="pos-mobile-action-label has-text-white" data-testid="pos-mobile-transaction-history-reload-label">{{ SessionUser.objects.global.language.reload }}</span>
|
||||
<span class="pos-mobile-action-icon has-text-white" data-testid="pos-mobile-transaction-history-reload-icon">
|
||||
<span
|
||||
class="pos-mobile-action-label has-text-white"
|
||||
data-testid="pos-mobile-transaction-history-reload-label"
|
||||
>{{ SessionUser.objects.global.language.reload }}</span
|
||||
>
|
||||
<span
|
||||
class="pos-mobile-action-icon has-text-white"
|
||||
data-testid="pos-mobile-transaction-history-reload-icon"
|
||||
>
|
||||
<i class="fa-solid fa-arrows-rotate"></i>
|
||||
</span>
|
||||
</span>
|
||||
</PosDepartmentStepMobileButtonNextStep>
|
||||
<!-- Close button -->
|
||||
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => emit('close')" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
|
||||
<PosDepartmentStepMobileButtonNextStep
|
||||
:isWhite="false"
|
||||
:customAction="() => emit('close')"
|
||||
:customDisabled="false"
|
||||
:buttonClasses="['has-background-primary', 'has-text-black']"
|
||||
>
|
||||
<span class="pos-mobile-action-content" data-testid="pos-mobile-transaction-history-close-action">
|
||||
<span class="pos-mobile-action-label" data-testid="pos-mobile-transaction-history-close-label">{{ SessionUser.objects.global.language.close }}</span>
|
||||
<span class="pos-mobile-action-label" data-testid="pos-mobile-transaction-history-close-label">{{
|
||||
SessionUser.objects.global.language.close
|
||||
}}</span>
|
||||
<span class="pos-mobile-action-icon" data-testid="pos-mobile-transaction-history-close-icon">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</span>
|
||||
@@ -261,4 +335,8 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
|
||||
max-height: calc(100vh - 250px); /* Adjust based on header/footer height */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.transaction-history-card {
|
||||
border-top: 1px solid #dbdbdb;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,6 +14,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showRadio: {
|
||||
type: Boolean,
|
||||
default: true, // Whether to show radio buttons for selection
|
||||
@@ -22,6 +26,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false, // Whether to show the footer with action buttons
|
||||
},
|
||||
allowClose: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
objects: {
|
||||
// Array of objects to select from
|
||||
// All objects should have at least an id and a label
|
||||
@@ -32,11 +40,22 @@ const props = defineProps({
|
||||
buttons?: Array<{
|
||||
label: string,
|
||||
action: () => void, // Function to call when button is clicked
|
||||
color?: string,
|
||||
testId?: string,
|
||||
}>,
|
||||
// Add other properties as needed
|
||||
}>,
|
||||
required: true,
|
||||
},
|
||||
footerButtons: {
|
||||
type: Array as () => Array<{
|
||||
label: string,
|
||||
action: () => void,
|
||||
color?: string,
|
||||
testId?: string,
|
||||
}>,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['update:isActive', 'selectedObject']);
|
||||
@@ -49,22 +68,26 @@ const onChange = () => {
|
||||
};
|
||||
|
||||
const toggleModal = () => {
|
||||
if (!props.allowClose) {
|
||||
return;
|
||||
}
|
||||
emits('update:isActive', !props.isActive);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal" v-bind:class="{ 'is-active': props.isActive }">
|
||||
<div class="modal" v-bind:class="{ 'is-active': props.isActive }" data-testid="default-object-selector">
|
||||
<div class="modal-background" @click="toggleModal"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">{{ props.title || t('modals.select_an_object') }}</p>
|
||||
<button class="delete" aria-label="close" @click="toggleModal"></button>
|
||||
<button v-if="props.allowClose" class="delete" aria-label="close" @click="toggleModal"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<p v-if="props.message" class="mb-4">{{ props.message }}</p>
|
||||
<!-- Content ... -->
|
||||
<template v-for="obj in props.objects" :key="obj.id">
|
||||
<WhiteBox class="mb-3">
|
||||
<WhiteBox class="mb-3" :data-testid="`default-object-selector-option-${obj.id}`">
|
||||
<div class="field">
|
||||
<label class="label">{{ obj.label }}</label>
|
||||
<div class="control" v-if="props.showRadio">
|
||||
@@ -84,7 +107,9 @@ const toggleModal = () => {
|
||||
<button
|
||||
v-for="button in obj.buttons"
|
||||
:key="button.label"
|
||||
class="button is-small is-dark"
|
||||
class="button is-small"
|
||||
:class="`is-${button.color || 'dark'}`"
|
||||
:data-testid="button.testId"
|
||||
@click="button.action()"
|
||||
>
|
||||
{{ button.label }}
|
||||
@@ -94,7 +119,19 @@ const toggleModal = () => {
|
||||
</template>
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<div class="buttons" v-if="props.showFooter">
|
||||
<div class="buttons" v-if="props.footerButtons.length > 0">
|
||||
<button
|
||||
v-for="button in props.footerButtons"
|
||||
:key="button.label"
|
||||
class="button"
|
||||
:class="`is-${button.color || 'light'}`"
|
||||
:data-testid="button.testId"
|
||||
@click="button.action()"
|
||||
>
|
||||
{{ button.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="buttons" v-else-if="props.showFooter">
|
||||
<button class="button is-success">{{ t('modals.save_changes') }}</button>
|
||||
<button class="button">{{ t('common.cancel') }}</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
<script setup>
|
||||
import { computed, ref, defineEmits } from 'vue';
|
||||
import { customer_id, notes, isCustomerBarred, customer_name, reference, reg_1, reg_2, reg_3, searchAndSelectCustomer, selectCustomer, isCustomerSelected, customer_attributes } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { computed, ref, defineEmits, watch } from 'vue';
|
||||
import {
|
||||
customer_id,
|
||||
notes,
|
||||
isCustomerBarred,
|
||||
customer_name,
|
||||
reference,
|
||||
reg_1,
|
||||
reg_2,
|
||||
reg_3,
|
||||
order_notes,
|
||||
order_po,
|
||||
searchAndSelectCustomer,
|
||||
selectCustomer,
|
||||
isCustomerSelected,
|
||||
customer_attributes,
|
||||
selectedOrderBookingId,
|
||||
selectedOrderBookingPlate,
|
||||
setSelectedOrderBookingSelection,
|
||||
clearSelectedOrderBookingSelection,
|
||||
skipSelectedOrderBookingSelection,
|
||||
isSelectedOrderBookingSkippedForPlate,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import CustomerSearchField from "@/components/search/economic/customerSearchField.vue";
|
||||
import { searchCustomerResults } from "@/components/search/economic/customerSearch.vue";
|
||||
import PosNotes from "@/components/displays/department/pos/PosNotes.vue";
|
||||
@@ -14,6 +35,10 @@ import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
|
||||
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
|
||||
import OrderItemsTable from "@/components/displays/department/pos/order/orderItemsTable.vue";
|
||||
import VehicleCustomerSuggestionsPos from "@/components/forms/department/pos/input/vehicleCustomerSuggestionsPos.vue";
|
||||
import DefaultObjectSelector from "@/components/displays/modals/DefaultObjectSelector.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const isRegistrationNumbersExpanded = ref(false);
|
||||
const isOtherExpanded = ref(false);
|
||||
@@ -35,20 +60,79 @@ const keyDownNextTabIndexButton = (event, tabIndex) => {
|
||||
};
|
||||
|
||||
const vehicleObject = ref(null);
|
||||
const bookingObject = ref(null);
|
||||
const bookingMatches = ref([]);
|
||||
const isBookingSelectorActive = ref(false);
|
||||
|
||||
const normalizePlateValue = (value) => String(value ?? '').replace(/\s/g, '').toUpperCase();
|
||||
|
||||
const getCurrentVehiclePlate = () => {
|
||||
return normalizePlateValue(vehicleObject.value?.reg || reg_1.value);
|
||||
};
|
||||
|
||||
const formatBookingDateTime = (booking) => {
|
||||
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
|
||||
if (!rawValue) {
|
||||
return t('admin.pos.not_found');
|
||||
}
|
||||
|
||||
const parsedValue = new Date(rawValue);
|
||||
if (Number.isNaN(parsedValue.getTime())) {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value || undefined, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(parsedValue);
|
||||
};
|
||||
|
||||
const getBookingReferenceValue = (booking) => {
|
||||
return String(booking?.reference ?? booking?.reference_number ?? '').trim();
|
||||
};
|
||||
|
||||
const getBookingNotesValue = (booking) => {
|
||||
return String(booking?.notes ?? booking?.note ?? '').trim();
|
||||
};
|
||||
|
||||
const getBookingCustomerNumber = (booking) => {
|
||||
const parsedValue = Number.parseInt(String(booking?.customer_number ?? booking?.customer_id ?? ''), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const getBookingServicesValue = (booking) => {
|
||||
if (Array.isArray(booking?.parsed_services?.array) && booking.parsed_services.array.length > 0) {
|
||||
return booking.parsed_services.array.join(', ');
|
||||
}
|
||||
|
||||
return String(booking?.parsed_services?.string ?? booking?.wash_type ?? '').trim();
|
||||
};
|
||||
|
||||
const resetReferenceFromVehicle = () => {
|
||||
if (vehicleObject.value?.reference) {
|
||||
reference.value = vehicleObject.value.reference;
|
||||
return;
|
||||
}
|
||||
|
||||
reference.value = '';
|
||||
};
|
||||
|
||||
const setVehicleObject = (emittedVehicleObject) => {
|
||||
// Set the vehicle object to the ref
|
||||
vehicleObject.value = emittedVehicleObject;
|
||||
// Console log the reference
|
||||
if (vehicleObject.value && vehicleObject.value.reference) {
|
||||
// Prefer the vehicle reference unless a booking is already selected for the same plate
|
||||
if (
|
||||
vehicleObject.value &&
|
||||
vehicleObject.value.reference &&
|
||||
normalizePlateValue(selectedOrderBookingPlate.value) !== normalizePlateValue(vehicleObject.value.reg)
|
||||
) {
|
||||
reference.value = vehicleObject.value.reference;
|
||||
} else {
|
||||
} else if (!selectedOrderBookingId.value) {
|
||||
// Reset the reference, if the vehicle object does not have a reference
|
||||
reference.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const bookingObject = ref(null);
|
||||
const setBookingObject = (emittedBookingObject) => {
|
||||
// Set the booking object to the ref
|
||||
bookingObject.value = emittedBookingObject;
|
||||
@@ -58,6 +142,138 @@ const setBookingObject = (emittedBookingObject) => {
|
||||
}
|
||||
};
|
||||
|
||||
const setBookingMatches = (emittedBookingMatches) => {
|
||||
bookingMatches.value = Array.isArray(emittedBookingMatches) ? emittedBookingMatches : [];
|
||||
};
|
||||
|
||||
const applySelectedOrderBooking = async (booking) => {
|
||||
if (!booking?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
bookingObject.value = booking;
|
||||
isBookingSelectorActive.value = false;
|
||||
setSelectedOrderBookingSelection(booking, getCurrentVehiclePlate());
|
||||
|
||||
const bookingCustomerNumber = getBookingCustomerNumber(booking);
|
||||
if (bookingCustomerNumber) {
|
||||
await searchAndSelectCustomer(bookingCustomerNumber);
|
||||
}
|
||||
|
||||
const bookingReference = getBookingReferenceValue(booking);
|
||||
if (bookingReference !== '') {
|
||||
reference.value = bookingReference;
|
||||
} else {
|
||||
resetReferenceFromVehicle();
|
||||
}
|
||||
|
||||
order_notes.value = getBookingNotesValue(booking);
|
||||
order_po.value = String(booking?.po ?? '').trim();
|
||||
|
||||
if (vehicleObject.value) {
|
||||
vehicleObject.value = {
|
||||
...vehicleObject.value,
|
||||
booking_id: booking.id,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const continueWithoutBooking = () => {
|
||||
bookingObject.value = null;
|
||||
isBookingSelectorActive.value = false;
|
||||
skipSelectedOrderBookingSelection(getCurrentVehiclePlate());
|
||||
order_notes.value = '';
|
||||
order_po.value = '';
|
||||
resetReferenceFromVehicle();
|
||||
|
||||
if (vehicleObject.value) {
|
||||
vehicleObject.value = {
|
||||
...vehicleObject.value,
|
||||
booking_id: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const bookingSelectorObjects = computed(() => {
|
||||
return bookingMatches.value.map((booking) => {
|
||||
const plateText = [booking?.reg_1, booking?.reg_2].filter(Boolean).join(' / ');
|
||||
const contentSegments = [
|
||||
`${t('admin.pos.order_booking_selector.customer_label')}: ${booking?.customer_name || t('admin.pos.not_found')}`,
|
||||
`${t('admin.pos.order_booking_selector.plates_label')}: ${plateText || t('admin.pos.not_found')}`,
|
||||
`${t('admin.pos.order_booking_selector.reference_label')}: ${getBookingReferenceValue(booking) || t('admin.pos.not_found')}`,
|
||||
`${t('admin.pos.order_booking_selector.services_label')}: ${getBookingServicesValue(booking) || t('admin.pos.not_found')}`,
|
||||
];
|
||||
|
||||
return {
|
||||
id: booking.id,
|
||||
label: t('admin.pos.order_booking_selector.option_title', {
|
||||
id: booking.id,
|
||||
datetime: formatBookingDateTime(booking),
|
||||
}),
|
||||
content: contentSegments.join(' • '),
|
||||
buttons: [
|
||||
{
|
||||
label: t('admin.pos.order_booking_selector.use_booking'),
|
||||
action: () => applySelectedOrderBooking(booking),
|
||||
color: 'primary',
|
||||
testId: `pos-desktop-order-booking-use-${booking.id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
watch(bookingMatches, async (matches) => {
|
||||
const currentPlate = getCurrentVehiclePlate();
|
||||
|
||||
if (!currentPlate) {
|
||||
bookingObject.value = null;
|
||||
isBookingSelectorActive.value = false;
|
||||
clearSelectedOrderBookingSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(matches) || matches.length === 0) {
|
||||
bookingObject.value = null;
|
||||
isBookingSelectorActive.value = false;
|
||||
clearSelectedOrderBookingSelection();
|
||||
resetReferenceFromVehicle();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matches.length === 1) {
|
||||
const [singleBooking] = matches;
|
||||
if (Number(selectedOrderBookingId.value) !== Number(singleBooking.id)) {
|
||||
await applySelectedOrderBooking(singleBooking);
|
||||
} else {
|
||||
bookingObject.value = singleBooking;
|
||||
isBookingSelectorActive.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPersistedSelectionForCurrentPlate =
|
||||
normalizePlateValue(selectedOrderBookingPlate.value) === currentPlate &&
|
||||
matches.some((booking) => Number(booking.id) === Number(selectedOrderBookingId.value));
|
||||
|
||||
if (hasPersistedSelectionForCurrentPlate) {
|
||||
bookingObject.value =
|
||||
matches.find((booking) => Number(booking.id) === Number(selectedOrderBookingId.value)) || null;
|
||||
isBookingSelectorActive.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSelectedOrderBookingSkippedForPlate(currentPlate)) {
|
||||
bookingObject.value = null;
|
||||
isBookingSelectorActive.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
clearSelectedOrderBookingSelection({ clearSkippedPlate: false });
|
||||
bookingObject.value = null;
|
||||
isBookingSelectorActive.value = true;
|
||||
});
|
||||
|
||||
const ucFirst = (str) => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
@@ -145,6 +361,7 @@ const hasSecondaryContent = computed(() => {
|
||||
@update:vehicleObject="setVehicleObject"
|
||||
@update:focus="setFocusStateReg1"
|
||||
@update:bookingObject="setBookingObject"
|
||||
@update:bookingMatches="setBookingMatches"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -324,6 +541,22 @@ const hasSecondaryContent = computed(() => {
|
||||
<PosNotes :notes="notes" :is-loading="false" :isAddFormVisible="SessionUser.adminUser" :isOldNotesVisible="false" :is-label-visible="true" class="mt-3" v-if="isCustomerSelected()" />
|
||||
</template>
|
||||
</ExpandableContentBox>
|
||||
<DefaultObjectSelector
|
||||
v-model:isActive="isBookingSelectorActive"
|
||||
:allowClose="false"
|
||||
:showRadio="false"
|
||||
:title="t('admin.pos.order_booking_selector.title')"
|
||||
:message="t('admin.pos.order_booking_selector.help_text')"
|
||||
:objects="bookingSelectorObjects"
|
||||
:footerButtons="[
|
||||
{
|
||||
label: t('admin.pos.order_booking_selector.continue_without_booking'),
|
||||
action: continueWithoutBooking,
|
||||
color: 'light',
|
||||
testId: 'pos-desktop-order-booking-skip',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
<script setup>
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {ref, watch, defineEmits, onMounted} from "vue";
|
||||
import { reg_1, getCustomerName, searchAndSelectCustomer, customer_id, clearCache, clearCustomerSelection, department_id } from "@/components/shop/POSDepartmentProcess.vue"
|
||||
import {
|
||||
reg_1,
|
||||
getCustomerName,
|
||||
searchAndSelectCustomer,
|
||||
customer_id,
|
||||
clearCustomerSelection,
|
||||
department_id,
|
||||
loadPendingBookings,
|
||||
doesVehiclePlateHaveBooking,
|
||||
getVehiclePlateBookings,
|
||||
getPreferredVehiclePlateBooking,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue"
|
||||
import { defineProps } from "vue";
|
||||
// Define the props for the component
|
||||
const props = defineProps({
|
||||
@@ -11,25 +22,7 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:vehicleObject', 'update:focus', 'update:bookingObject']);
|
||||
|
||||
const pendingBookings = ref([]);
|
||||
// Get the department booking list
|
||||
const loadPendingBookings = () => {
|
||||
SessionUser.request(
|
||||
SessionUser.objects.bookings.meta.endpoint,
|
||||
'GET',
|
||||
{
|
||||
filters: 'department:' + department_id.value + ',status:pending',
|
||||
page: 1,
|
||||
limit: 100,
|
||||
}
|
||||
).then(response => {
|
||||
pendingBookings.value = response.data.data;
|
||||
}).catch(error => {
|
||||
console.error("Error:", error);
|
||||
});
|
||||
};
|
||||
const emit = defineEmits(['update:vehicleObject', 'update:focus', 'update:bookingObject', 'update:bookingMatches']);
|
||||
|
||||
// Load the pending bookings when the component is mounted
|
||||
onMounted(() => {
|
||||
@@ -42,37 +35,17 @@ watch(department_id, (newValue) => {
|
||||
}
|
||||
});
|
||||
|
||||
const getVehiclePlateBooking = (vehiclePlate) => {
|
||||
if (!vehiclePlate || !doesVehiclePlateHaveBooking(vehiclePlate)) {
|
||||
// If the vehicle plate is empty or doesn't have a booking, return null
|
||||
return null;
|
||||
}
|
||||
// Find the booking for the given vehicle plate
|
||||
return pendingBookings.value.find(booking => booking.regNrTraekker === vehiclePlate || booking.regNrTrailer === vehiclePlate);
|
||||
};
|
||||
|
||||
const doesVehiclePlateHaveBooking = (vehiclePlate) => {
|
||||
// Check if the vehicle has a booking in the pending bookings list
|
||||
return pendingBookings.value.some(booking => booking.regNrTraekker === vehiclePlate || booking.regNrTrailer === vehiclePlate);
|
||||
};
|
||||
|
||||
// Function to emit the booking object to the parent component
|
||||
const emitBookingObject = (vehicle) => {
|
||||
// Check if the vehicle has a booking
|
||||
if (vehicle && vehicle.reg && doesVehiclePlateHaveBooking(vehicle.reg)) {
|
||||
// Get the booking object for the vehicle
|
||||
const booking = getVehiclePlateBooking(vehicle.reg);
|
||||
if (booking) {
|
||||
// Emit the booking object to the parent component
|
||||
emit('update:bookingObject', booking);
|
||||
} else {
|
||||
// Emit null if no booking is found
|
||||
emit('update:bookingObject', null);
|
||||
}
|
||||
} else {
|
||||
// Emit null if no vehicle is provided
|
||||
emit('update:bookingObject', null);
|
||||
const bookingMatches = vehicle?.reg ? getVehiclePlateBookings(vehicle.reg) : [];
|
||||
emit('update:bookingMatches', bookingMatches);
|
||||
|
||||
if (bookingMatches.length === 1) {
|
||||
emit('update:bookingObject', getPreferredVehiclePlateBooking(vehicle.reg));
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update:bookingObject', null);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
import {defineComponent, defineEmits, defineProps, ref, watch, onMounted} from "vue";
|
||||
import { VehicleStatusKey, statusKeyToComponent } from "@/components/displays/department/pos/steps/mobile/objects/PosVehicleStatus.vue";
|
||||
import { vehicles_matching, searchVehicle, register_new_search, is_latest_search, clearCustomerSelection, isSearching, pendingBookings, loadPendingBookings, doesVehiclePlateHaveBooking, getVehiclePlateBooking, searchAndSelectCustomer, reg_1, reg_2 } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { setCustomerId, pos } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
import { vehicles_matching, searchVehicle, register_new_search, is_latest_search, clearCustomerSelection, isSearching, pendingBookings, loadPendingBookings, doesVehiclePlateHaveBooking, getVehiclePlateBookings, getPreferredVehiclePlateBooking, searchAndSelectCustomer, reg_1, reg_2 } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { metadata, popups, setCustomerId, pos } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||
import {PosSearchResult} from "@/components/displays/department/pos/steps/mobile/objects/PosSearchResult.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
defineComponent({
|
||||
name: "RegistrationNumberSearchResult"
|
||||
});
|
||||
@@ -41,92 +39,149 @@ const searchResults = ref<PosSearchResult[]>([]);
|
||||
// customerStatus: generateCustomerStatus(),
|
||||
//}));
|
||||
|
||||
const applyBookingAutomatically = (booking: any) => {
|
||||
// If the booking id is not null, ignore everything. (To prevent running this function multiple times)
|
||||
if (pos.metadata.getBookingId()) {
|
||||
const normalizeRegistrationNumber = (value: string | null | undefined) => String(value ?? '').replace(/\s+/g, '').toUpperCase();
|
||||
|
||||
const applySearchResultCustomerSelection = (result: PosSearchResult) => {
|
||||
if (!props.modifyCustomerOnChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.customerId) {
|
||||
searchAndSelectCustomer(result.customerId);
|
||||
setCustomerId(result.customerId);
|
||||
return;
|
||||
}
|
||||
|
||||
setCustomerId(null);
|
||||
};
|
||||
|
||||
const emitSelectedResult = (result: PosSearchResult | null) => {
|
||||
emits('select', result);
|
||||
};
|
||||
|
||||
const applySelectedBooking = async (booking: any, result: PosSearchResult) => {
|
||||
if (!booking?.id) {
|
||||
return;
|
||||
}
|
||||
pos.metadata.setBookingId(booking.id); // Set the booking ID to prevent multiple prompts, this is reset if the user cancels.
|
||||
// If the input registration numbers manually is displayed, hide it.
|
||||
|
||||
const bookingMatches = Array.isArray(result?.bookingMatches) ? result.bookingMatches : [booking];
|
||||
const customerNumber = Number.parseInt(String(booking?.customer_number ?? booking?.customer_id ?? result?.customerId ?? 0), 10) || 0;
|
||||
|
||||
metadata.setBookingId(booking.id);
|
||||
metadata.clearBookingSelectionSkippedPlate?.();
|
||||
pos.views.manualInput.value = false;
|
||||
//console.warn("applyBookingAutomatically:", booking);
|
||||
Swal.fire({
|
||||
title: 'Booking fundet',
|
||||
text: `En booking for ${booking.customer_name} er fundet. Vil du anvende den?`,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Ja',
|
||||
cancelButtonText: 'Nej',
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
//console.warn("User confirmed to apply booking:", booking);
|
||||
// Apply the booking
|
||||
pos.metadata.setBookingId(booking.id);
|
||||
pos.metadata.setNotes(booking.note || `Booking ID: ${booking.id}`);
|
||||
pos.metadata.setReference(booking.reference || '');
|
||||
// Set the registration numbers
|
||||
reg_1.value = booking.reg_1;
|
||||
reg_2.value = booking.reg_2 || '';
|
||||
// Select the vehicles
|
||||
pos.vehicles.select(1, {
|
||||
reg: booking.reg_1,
|
||||
type: 0, // TODO: Determine type from booking if possible
|
||||
customer_id: booking.customer_number,
|
||||
status: 'booked',
|
||||
barred: false,
|
||||
booking_id: booking.id,
|
||||
});
|
||||
if (booking.reg_2) {
|
||||
pos.vehicles.select(2, {
|
||||
reg: booking.reg_2,
|
||||
type: 0, //TODO: Determine type from booking if possible
|
||||
customer_id: booking.customer_number,
|
||||
status: 'booked',
|
||||
barred: false,
|
||||
booking_id: booking.id,
|
||||
});
|
||||
}
|
||||
pos.vehicles.select(3, null)
|
||||
// Set the customer ID
|
||||
searchAndSelectCustomer(booking.customer_number);
|
||||
setCustomerId(booking.customer_number);
|
||||
// Go to the next step
|
||||
//console.warn('Navigating to next step in POS flow.');
|
||||
} else {
|
||||
// If the user cancels, reset the booking ID to allow future prompts
|
||||
pos.metadata.setBookingId(null);
|
||||
}
|
||||
pos.metadata.setNotes(booking.note || booking.notes || `Booking ID: ${booking.id}`);
|
||||
pos.metadata.setReference(booking.reference || booking.reference_number || '');
|
||||
reg_1.value = booking.reg_1 || result?.registrationNumber || '';
|
||||
reg_2.value = booking.reg_2 || '';
|
||||
|
||||
pos.vehicles.select(1, {
|
||||
reg: booking.reg_1 || result?.registrationNumber || '',
|
||||
type: result?.type || 0,
|
||||
customer_id: customerNumber,
|
||||
status: 'booked',
|
||||
barred: false,
|
||||
booking_id: booking.id,
|
||||
booking_matches: bookingMatches,
|
||||
reference: booking.reference || booking.reference_number || result?.reference || null,
|
||||
last_order_id: result?.lastOrderId || null,
|
||||
wash_subscription: result?.washSubscription,
|
||||
});
|
||||
|
||||
if (booking.reg_2) {
|
||||
pos.vehicles.select(2, {
|
||||
reg: booking.reg_2,
|
||||
type: 0,
|
||||
customer_id: customerNumber,
|
||||
status: 'booked',
|
||||
barred: false,
|
||||
booking_id: booking.id,
|
||||
booking_matches: bookingMatches,
|
||||
});
|
||||
}
|
||||
|
||||
pos.vehicles.select(3, null);
|
||||
|
||||
if (customerNumber > 0) {
|
||||
await searchAndSelectCustomer(customerNumber);
|
||||
setCustomerId(customerNumber);
|
||||
}
|
||||
|
||||
popups.clear();
|
||||
emitSelectedResult({
|
||||
...result,
|
||||
registrationNumber: booking.reg_1 || result?.registrationNumber,
|
||||
customerName: booking.customer_name || result?.customerName || "Unknown Customer",
|
||||
customerId: customerNumber,
|
||||
customerStatus: 'booked',
|
||||
reference: booking.reference || booking.reference_number || result?.reference || null,
|
||||
bookingId: booking.id,
|
||||
bookingMatches,
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = (result: PosSearchResult) => {
|
||||
//console.warn("onSelect:", result);
|
||||
// Check if the vehicle has a booking
|
||||
if (result?.bookingId && result.bookingId > 0) {
|
||||
//console.warn("Vehicle has a booking:", getVehiclePlateBooking(result.registrationNumber));
|
||||
SessionUser.objects.order_bookings.get.single(result.bookingId).then((booking) => {
|
||||
// Apply the booking automatically
|
||||
applyBookingAutomatically(booking);
|
||||
}).catch((error) => {
|
||||
console.error("Error fetching booking:", error);
|
||||
});
|
||||
const continueWithoutBookingSelection = (result: PosSearchResult) => {
|
||||
metadata.setBookingId(null);
|
||||
metadata.setBookingSelectionSkippedPlate?.(normalizeRegistrationNumber(result?.registrationNumber || props.searchQuery));
|
||||
popups.clear();
|
||||
applySearchResultCustomerSelection(result);
|
||||
emitSelectedResult({
|
||||
...result,
|
||||
bookingId: null,
|
||||
});
|
||||
};
|
||||
|
||||
const openOrderBookingPopup = (result: PosSearchResult) => {
|
||||
pos.views.manualInput.value = false;
|
||||
popups.select('select_order_booking', {
|
||||
title: popups.getByKey('select_order_booking')?.title,
|
||||
message: popups.getByKey('select_order_booking')?.message,
|
||||
props: {
|
||||
bookings: result.bookingMatches || [],
|
||||
onSelect: (booking: any) => applySelectedBooking(booking, result),
|
||||
onSkip: () => continueWithoutBookingSelection(result),
|
||||
},
|
||||
actionButtons: [],
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = async (result: PosSearchResult) => {
|
||||
if (!result) {
|
||||
emitSelectedResult(null);
|
||||
return;
|
||||
}
|
||||
// Search and select the customer based on the registration number
|
||||
if (props.modifyCustomerOnChange) {
|
||||
if (result?.customerId) {
|
||||
// If the customer ID is present, search and select the customer
|
||||
searchAndSelectCustomer(result.customerId);
|
||||
setCustomerId(result.customerId); // This is to ensure data is kept where it's relevant.
|
||||
} else {
|
||||
// If the customer ID is not present, emit the result as null
|
||||
setCustomerId(null);
|
||||
|
||||
const bookingMatches = Array.isArray(result.bookingMatches) ? result.bookingMatches : [];
|
||||
if (bookingMatches.length === 1) {
|
||||
await applySelectedBooking(bookingMatches[0], result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (bookingMatches.length > 1) {
|
||||
const normalizedRegistrationNumber = normalizeRegistrationNumber(result.registrationNumber || props.searchQuery);
|
||||
if (metadata.getBookingSelectionSkippedPlate?.() === normalizedRegistrationNumber) {
|
||||
metadata.setBookingId(null);
|
||||
applySearchResultCustomerSelection(result);
|
||||
emitSelectedResult({
|
||||
...result,
|
||||
bookingId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existingBooking = bookingMatches.find((booking: any) => Number(booking.id) === Number(metadata.getBookingId?.()));
|
||||
if (existingBooking) {
|
||||
await applySelectedBooking(existingBooking, result);
|
||||
return;
|
||||
}
|
||||
|
||||
openOrderBookingPopup(result);
|
||||
return;
|
||||
}
|
||||
// Emit the selected result to the parent component
|
||||
emits('select', result);
|
||||
|
||||
metadata.setBookingId(null);
|
||||
applySearchResultCustomerSelection(result);
|
||||
emitSelectedResult(result);
|
||||
};
|
||||
|
||||
const emitVehicleObject = (vehicle: any) => {
|
||||
@@ -196,10 +251,10 @@ watch(() => vehicles_matching.value, (newValue) => {
|
||||
if (newValue) {
|
||||
//console.warn("vehicles_matching changed:", newValue);
|
||||
const result = newValue.map(vehicle => {
|
||||
// Check if the vehicle has a booking
|
||||
if (doesVehiclePlateHaveBooking(vehicle.reg)) {
|
||||
vehicle.customer_name = getVehiclePlateBooking(vehicle.reg).customer_name;
|
||||
//vehicle.customer_id = getVehiclePlateBooking(vehicle.reg).customer_id;
|
||||
const bookingMatches = getVehiclePlateBookings(vehicle.reg);
|
||||
const preferredBooking = bookingMatches[0] || null;
|
||||
if (preferredBooking) {
|
||||
vehicle.customer_name = preferredBooking.customer_name;
|
||||
}
|
||||
// Transform the vehicle object to the SearchResult type
|
||||
//console.warn('Transforming vehicle to PosSearchResult:', vehicle);
|
||||
@@ -213,7 +268,8 @@ watch(() => vehicles_matching.value, (newValue) => {
|
||||
reference: vehicle?.reference || null,
|
||||
lastOrderId: vehicle?.last_order_id || null,
|
||||
washSubscription: vehicle?.wash_subscription || null,
|
||||
bookingId: vehicle?.booking_id || null,
|
||||
bookingId: bookingMatches.length === 1 ? bookingMatches[0].id : vehicle?.booking_id || null,
|
||||
bookingMatches,
|
||||
} as PosSearchResult;
|
||||
});
|
||||
//console.warn('Transformed PosSearchResult:', result);
|
||||
@@ -245,6 +301,7 @@ const automaticallySelect = () => {
|
||||
lastOrderId: match?.lastOrderId || null,
|
||||
washSubscription: match?.washSubscription,
|
||||
bookingId: match?.bookingId || null,
|
||||
bookingMatches: match?.bookingMatches || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -253,8 +310,7 @@ const automaticallySelect = () => {
|
||||
const determineCustomerStatus = (vehicle: any): VehicleStatusKey => {
|
||||
//console.warn('Determine customer status for vehicle:', vehicle);
|
||||
// Determine the customer status based on the vehicle object
|
||||
if (vehicle?.booking_id) {
|
||||
//console.warn('Vehicle has a booking:', getVehiclePlateBooking(vehicle.reg));
|
||||
if ((Array.isArray(vehicle?.bookingMatches) && vehicle.bookingMatches.length > 0) || doesVehiclePlateHaveBooking(vehicle?.reg) || vehicle?.booking_id) {
|
||||
return 'booked';
|
||||
} else if (vehicle?.status === 'known') {
|
||||
//console.warn('Vehicle is known:', vehicle);
|
||||
@@ -279,8 +335,7 @@ const importPendingBookings = () => {
|
||||
pendingBookings.value.forEach(booking => {
|
||||
const vehicle = vehicles_matching.value.find(v => v.reg.toUpperCase() === booking.reg_1.toUpperCase() || v.reg.toUpperCase() === booking.reg_2?.toUpperCase());
|
||||
if (vehicle) {
|
||||
vehicle.customer_name = booking.customer_name;
|
||||
//vehicle.customer_id = booking.customer_id;
|
||||
vehicle.customer_name = getPreferredVehiclePlateBooking(vehicle.reg)?.customer_name || booking.customer_name;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -360,6 +360,17 @@
|
||||
"order": "Ordre",
|
||||
"customer_wishes": "Kundeønsker",
|
||||
"order_note": "Ordrenote",
|
||||
"order_booking_selector": {
|
||||
"title": "Vælg booking",
|
||||
"help_text": "Flere ventende bookinger matcher dette køretøj. Vælg den korrekte booking eller fortsæt uden booking.",
|
||||
"option_title": "Booking #{id} • {datetime}",
|
||||
"use_booking": "Brug booking",
|
||||
"continue_without_booking": "Fortsæt uden booking",
|
||||
"customer_label": "Kunde",
|
||||
"plates_label": "Nummerplader",
|
||||
"reference_label": "Reference",
|
||||
"services_label": "Ydelser"
|
||||
},
|
||||
"orders": {
|
||||
"subtitle": "Administrer ordrer",
|
||||
"title": "Ordrer"
|
||||
|
||||
@@ -360,6 +360,17 @@
|
||||
"order": "Auftrag",
|
||||
"customer_wishes": "Kundenwünsche",
|
||||
"order_note": "Auftragsnotiz",
|
||||
"order_booking_selector": {
|
||||
"title": "Buchung auswählen",
|
||||
"help_text": "Mehrere offene Buchungen passen zu diesem Fahrzeug. Wählen Sie die richtige Buchung oder fahren Sie ohne Buchung fort.",
|
||||
"option_title": "Buchung #{id} • {datetime}",
|
||||
"use_booking": "Buchung verwenden",
|
||||
"continue_without_booking": "Ohne Buchung fortfahren",
|
||||
"customer_label": "Kunde",
|
||||
"plates_label": "Kennzeichen",
|
||||
"reference_label": "Referenz",
|
||||
"services_label": "Leistungen"
|
||||
},
|
||||
"orders": {
|
||||
"subtitle": "?bersicht des Waschprotokolls",
|
||||
"title": "Bestellungen"
|
||||
|
||||
@@ -360,6 +360,17 @@
|
||||
"order": "Order",
|
||||
"customer_wishes": "Customer wishes",
|
||||
"order_note": "Order note",
|
||||
"order_booking_selector": {
|
||||
"title": "Select booking",
|
||||
"help_text": "Multiple pending bookings match this vehicle. Choose the correct booking or continue without one.",
|
||||
"option_title": "Booking #{id} • {datetime}",
|
||||
"use_booking": "Use booking",
|
||||
"continue_without_booking": "Continue without booking",
|
||||
"customer_label": "Customer",
|
||||
"plates_label": "Plates",
|
||||
"reference_label": "Reference",
|
||||
"services_label": "Services"
|
||||
},
|
||||
"orders": {
|
||||
"subtitle": "Overview of wash log",
|
||||
"title": "Wash Log"
|
||||
|
||||
@@ -355,6 +355,17 @@
|
||||
"order": "Bestille",
|
||||
"customer_wishes": "Kundeønsker",
|
||||
"order_note": "Bestillingsnotat",
|
||||
"order_booking_selector": {
|
||||
"title": "Velg booking",
|
||||
"help_text": "Flere ventende bookinger matcher dette kjøretøyet. Velg riktig booking eller fortsett uten booking.",
|
||||
"option_title": "Booking #{id} • {datetime}",
|
||||
"use_booking": "Bruk booking",
|
||||
"continue_without_booking": "Fortsett uten booking",
|
||||
"customer_label": "Kunde",
|
||||
"plates_label": "Registreringsnumre",
|
||||
"reference_label": "Referanse",
|
||||
"services_label": "Tjenester"
|
||||
},
|
||||
"orders": {
|
||||
"subtitle": "Oversikt over vaskelogg",
|
||||
"title": "Ordrer"
|
||||
|
||||
@@ -355,6 +355,17 @@
|
||||
"order": "Order",
|
||||
"customer_wishes": "Kundönskemål",
|
||||
"order_note": "Order note",
|
||||
"order_booking_selector": {
|
||||
"title": "Välj bokning",
|
||||
"help_text": "Flera väntande bokningar matchar det här fordonet. Välj rätt bokning eller fortsätt utan bokning.",
|
||||
"option_title": "Bokning #{id} • {datetime}",
|
||||
"use_booking": "Använd bokning",
|
||||
"continue_without_booking": "Fortsätt utan bokning",
|
||||
"customer_label": "Kund",
|
||||
"plates_label": "Registreringsnummer",
|
||||
"reference_label": "Referens",
|
||||
"services_label": "Tjänster"
|
||||
},
|
||||
"orders": {
|
||||
"subtitle": "översikt över tvättlogg",
|
||||
"title": "Ordrar"
|
||||
|
||||
@@ -297,6 +297,17 @@
|
||||
"not_found": "Not found",
|
||||
"order": "Order",
|
||||
"order_note": "Order note",
|
||||
"order_booking_selector": {
|
||||
"title": "Välj bokning",
|
||||
"help_text": "Flera väntande bokningar matchar det här fordonet. Välj rätt bokning eller fortsätt utan bokning.",
|
||||
"option_title": "Bokning #{id} • {datetime}",
|
||||
"use_booking": "Använd bokning",
|
||||
"continue_without_booking": "Fortsätt utan bokning",
|
||||
"customer_label": "Kund",
|
||||
"plates_label": "Registreringsnummer",
|
||||
"reference_label": "Referens",
|
||||
"services_label": "Tjänster"
|
||||
},
|
||||
"orders": {
|
||||
"subtitle": "Overview of wash log",
|
||||
"title": "Ordrar"
|
||||
|
||||
@@ -246,6 +246,35 @@ async function waitForOrderMutation(
|
||||
});
|
||||
}
|
||||
|
||||
function createRequiredWarningsPosFixture() {
|
||||
const customerNumber = 12345679;
|
||||
const baseFixture = createPosFixture();
|
||||
|
||||
return createPosFixture({
|
||||
customerAttributesByNumber: {
|
||||
[customerNumber]: [
|
||||
{
|
||||
id: 11,
|
||||
customer_number: customerNumber,
|
||||
attribute: "requiresReferenceNumber",
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
customer_number: customerNumber,
|
||||
attribute: "usePONumbers",
|
||||
},
|
||||
],
|
||||
},
|
||||
ordersById: {
|
||||
54518: {
|
||||
...baseFixture.ordersById[54518],
|
||||
reference: "",
|
||||
po: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Admin POS Orders - desktop settings", () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only order settings coverage");
|
||||
@@ -1097,6 +1126,9 @@ test.describe("Admin POS Orders - desktop settings", () => {
|
||||
|
||||
await page.goto("/admin/12/modules/pos/orders");
|
||||
|
||||
const paperclipAction = getVisibleTestId(page, "pos-order-list-attachments-54518").locator(
|
||||
".dropdown-trigger button"
|
||||
);
|
||||
const attachmentDropdown = getVisibleTestId(page, `pos-order-list-attachments-${orderId}`);
|
||||
const attachmentAction = attachmentDropdown.locator(".dropdown-trigger button");
|
||||
const settingsAction = getVisibleTestId(page, `pos-order-list-settings-${orderId}`).locator(
|
||||
@@ -1109,14 +1141,35 @@ test.describe("Admin POS Orders - desktop settings", () => {
|
||||
await expect(attachmentAction).not.toHaveClass(/is-text/);
|
||||
await expect(attachmentAction).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(attachmentAction).toHaveCSS("text-decoration-line", "none");
|
||||
await expect(attachmentAction).toHaveCSS("justify-content", "center");
|
||||
await expect(attachmentAction.locator(".fa-plus")).toBeVisible();
|
||||
await expect(attachmentAction.locator(".ml-2")).toHaveCount(0);
|
||||
|
||||
const paperclipBox = await paperclipAction.boundingBox();
|
||||
const attachmentBox = await attachmentAction.boundingBox();
|
||||
const settingsBox = await settingsAction.boundingBox();
|
||||
const plusIconBox = await attachmentAction.locator(".fa-plus").boundingBox();
|
||||
|
||||
expect(paperclipBox).not.toBeNull();
|
||||
expect(attachmentBox).not.toBeNull();
|
||||
expect(settingsBox).not.toBeNull();
|
||||
expect(plusIconBox).not.toBeNull();
|
||||
expect(Math.abs((attachmentBox?.width ?? 0) - (paperclipBox?.width ?? 0))).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs((attachmentBox?.height ?? 0) - (paperclipBox?.height ?? 0))).toBeLessThanOrEqual(1);
|
||||
expect(
|
||||
Math.abs(
|
||||
(plusIconBox?.x ?? 0) +
|
||||
(plusIconBox?.width ?? 0) / 2 -
|
||||
((attachmentBox?.x ?? 0) + (attachmentBox?.width ?? 0) / 2)
|
||||
)
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(
|
||||
Math.abs(
|
||||
(plusIconBox?.y ?? 0) +
|
||||
(plusIconBox?.height ?? 0) / 2 -
|
||||
((attachmentBox?.y ?? 0) + (attachmentBox?.height ?? 0) / 2)
|
||||
)
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(attachmentBox?.x ?? 0).toBeLessThan(settingsBox?.x ?? 0);
|
||||
|
||||
await attachmentAction.click();
|
||||
@@ -1133,6 +1186,111 @@ test.describe("Admin POS Orders - desktop settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Admin POS Orders - desktop required warning states", () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only required warning coverage");
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: POS_PERMISSIONS,
|
||||
edgeGateways: false,
|
||||
pos: createRequiredWarningsPosFixture(),
|
||||
});
|
||||
await primeOperatorSession(page, "pos-orders-required-warnings-token");
|
||||
});
|
||||
|
||||
test("shows required reference and po warnings in order detail and clears them without changing field size", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openOrderDetail(page);
|
||||
|
||||
const referenceControl = page.getByTestId("pos-order-customer-wishes-reference-control");
|
||||
const poControl = page.getByTestId("pos-order-customer-wishes-po-control");
|
||||
|
||||
await expect(referenceControl).toHaveAttribute("data-warning-state", "danger");
|
||||
await expect(poControl).toHaveAttribute("data-warning-state", "warning");
|
||||
await expect(page.getByTestId("pos-order-customer-wishes-reference-warning-icon")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-order-customer-wishes-po-warning-icon")).toBeVisible();
|
||||
|
||||
const warningReferenceBox = await referenceControl.boundingBox();
|
||||
const warningPoBox = await poControl.boundingBox();
|
||||
expect(warningReferenceBox).not.toBeNull();
|
||||
expect(warningPoBox).not.toBeNull();
|
||||
expect(Math.abs((warningReferenceBox?.height ?? 0) - (warningPoBox?.height ?? 0))).toBeLessThanOrEqual(2);
|
||||
|
||||
const referenceValue = "REF1";
|
||||
const poValue = "PO1";
|
||||
|
||||
const referenceRequest = waitForOrderMutation(
|
||||
page,
|
||||
"PUT",
|
||||
"/orders",
|
||||
(body) => Number(body.id) === 54518 && body.reference === referenceValue
|
||||
);
|
||||
await page.getByTestId("pos-order-customer-wishes-reference").click();
|
||||
const referenceInput = page.getByTestId("pos-order-customer-wishes-reference-input");
|
||||
await referenceInput.fill(referenceValue);
|
||||
await expect(referenceControl).not.toHaveAttribute("data-warning-state", "danger");
|
||||
await expect(page.getByTestId("pos-order-customer-wishes-reference-warning-icon")).toHaveCount(0);
|
||||
await referenceInput.press("Enter");
|
||||
await expect(referenceInput).toHaveCount(0);
|
||||
await referenceRequest;
|
||||
await expect(referenceControl).not.toHaveClass(/is-loading/);
|
||||
|
||||
const poRequest = waitForOrderMutation(
|
||||
page,
|
||||
"PUT",
|
||||
"/orders",
|
||||
(body) => Number(body.id) === 54518 && body.po === poValue
|
||||
);
|
||||
await page.getByTestId("pos-order-customer-wishes-po").click();
|
||||
const poInput = page.getByTestId("pos-order-customer-wishes-po-input");
|
||||
await poInput.fill(poValue);
|
||||
await expect(poControl).not.toHaveAttribute("data-warning-state", "warning");
|
||||
await expect(page.getByTestId("pos-order-customer-wishes-po-warning-icon")).toHaveCount(0);
|
||||
await poInput.press("Enter");
|
||||
await expect(poInput).toHaveCount(0);
|
||||
await poRequest;
|
||||
await expect(poControl).not.toHaveClass(/is-loading/);
|
||||
|
||||
await expect(page.getByTestId("pos-order-customer-wishes-reference")).toContainText(referenceValue);
|
||||
await expect(page.getByTestId("pos-order-customer-wishes-po")).toContainText(poValue);
|
||||
await expect(referenceControl).not.toHaveClass(/is-loading/);
|
||||
await expect(poControl).not.toHaveClass(/is-loading/);
|
||||
|
||||
const clearedReferenceBox = await referenceControl.boundingBox();
|
||||
const clearedPoBox = await poControl.boundingBox();
|
||||
expect(clearedReferenceBox).not.toBeNull();
|
||||
expect(clearedPoBox).not.toBeNull();
|
||||
expect(Math.abs((clearedReferenceBox?.height ?? 0) - (clearedPoBox?.height ?? 0))).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("shows the same required warnings in the shared desktop step 2 workspace", async ({ page }) => {
|
||||
await page.goto("/admin/12/modules/pos?step=1");
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible();
|
||||
|
||||
await page.locator("#reg_1").fill("WARN123");
|
||||
await page.locator("#pos_select_customer_input").fill("12345679");
|
||||
await expect(page.locator(".customer-drop-down-select").first()).toBeVisible();
|
||||
await page.locator(".customer-drop-down-select").first().click();
|
||||
|
||||
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
|
||||
const stepTwo = page.getByTestId("pos-step-2");
|
||||
await expect(stepTwo).toBeVisible();
|
||||
|
||||
await expect(stepTwo.getByTestId("pos-order-customer-wishes-reference-control")).toHaveAttribute(
|
||||
"data-warning-state",
|
||||
"danger"
|
||||
);
|
||||
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po-control")).toHaveAttribute(
|
||||
"data-warning-state",
|
||||
"warning"
|
||||
);
|
||||
await expect(stepTwo.getByTestId("pos-order-customer-wishes-reference-warning-icon")).toBeVisible();
|
||||
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po-warning-icon")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Admin POS Orders - desktop attachment discovery", () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only attachment discovery coverage");
|
||||
|
||||
@@ -24,6 +24,28 @@ const isBenignNavigationError = (error: unknown) => {
|
||||
);
|
||||
};
|
||||
|
||||
async function ensureAuthFieldVisible(page: Page, targetPath: string, selector: string) {
|
||||
const field = page.locator(selector);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
if (await field.isVisible().catch(() => false)) {
|
||||
return field;
|
||||
}
|
||||
|
||||
if (attempt === 0) {
|
||||
try {
|
||||
await page.goto(targetPath, { waitUntil: "domcontentloaded" });
|
||||
} catch (error) {
|
||||
if (!isBenignNavigationError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await expect(field).toBeVisible({ timeout: AUTH_TIMEOUT });
|
||||
return field;
|
||||
}
|
||||
|
||||
async function readStoredToken(page: Page) {
|
||||
try {
|
||||
return await page.evaluate(() => window.localStorage.getItem("token"));
|
||||
@@ -34,7 +56,7 @@ async function readStoredToken(page: Page) {
|
||||
|
||||
async function settleAuthenticatedNavigation(page: Page, targetPath: string, targetUrl: RegExp) {
|
||||
await expect.poll(() => readStoredToken(page), { timeout: AUTH_TIMEOUT }).not.toBeNull();
|
||||
if (!targetUrl.test(page.url())) {
|
||||
const navigateToTarget = async () => {
|
||||
try {
|
||||
await page.goto(targetPath, { waitUntil: "domcontentloaded" });
|
||||
} catch (error) {
|
||||
@@ -42,8 +64,22 @@ async function settleAuthenticatedNavigation(page: Page, targetPath: string, tar
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!targetUrl.test(page.url())) {
|
||||
await navigateToTarget();
|
||||
}
|
||||
|
||||
try {
|
||||
await expect.poll(() => page.url(), { timeout: AUTH_TIMEOUT }).toMatch(targetUrl);
|
||||
} catch (error) {
|
||||
if ((await readStoredToken(page)) === null) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await navigateToTarget();
|
||||
await expect.poll(() => page.url(), { timeout: AUTH_TIMEOUT }).toMatch(targetUrl);
|
||||
}
|
||||
await expect.poll(() => page.url(), { timeout: AUTH_TIMEOUT }).toMatch(targetUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,6 +98,7 @@ export async function loginAsUser(
|
||||
) {
|
||||
const creds = credentials || userCredentials;
|
||||
await page.goto("/login");
|
||||
await ensureAuthFieldVisible(page, "/login", 'input[name="customer_number"]');
|
||||
await page.fill('input[name="customer_number"]', creds.customerNumber);
|
||||
await page.fill('input[name="password"]', creds.password);
|
||||
await page.click('button[id="login-button"]');
|
||||
@@ -92,6 +129,7 @@ export async function loginAsSubuserByPhone(
|
||||
) {
|
||||
const creds = credentials || subuserPhoneCredentials;
|
||||
await page.goto("/login/driver");
|
||||
await ensureAuthFieldVisible(page, "/login/driver", 'input[name="phone_country_code"]');
|
||||
await page.fill('input[name="phone_country_code"]', creds.phoneCountryCode);
|
||||
await page.fill('input[name="phone"]', creds.phone);
|
||||
await page.fill('input[name="password"]', creds.password);
|
||||
@@ -118,8 +156,7 @@ export async function loginAsSubuserByUsername(
|
||||
const creds = credentials || subuserUsernameCredentials;
|
||||
await page.goto("/login/driver");
|
||||
await page.click('button[id="subuser_login_method_username_button"]');
|
||||
await page.waitForSelector('input[name="username"]');
|
||||
await page.waitForSelector('input[name="password"]');
|
||||
await ensureAuthFieldVisible(page, "/login/driver", 'input[name="username"]');
|
||||
await page.fill('input[name="username"]', creds.username);
|
||||
await page.fill('input[name="password"]', creds.password);
|
||||
await page.click('button[id="subuser-login-button"]');
|
||||
@@ -141,6 +178,7 @@ export async function loginAsSubuserByUsername(
|
||||
export async function loginAsOperator(page: Page, credentials?: { userId: string; password: string }) {
|
||||
const creds = credentials || operatorCredentials;
|
||||
await page.goto("/admin/login");
|
||||
await ensureAuthFieldVisible(page, "/admin/login", 'input[name="user_id"]');
|
||||
await page.fill('input[name="user_id"]', creds.userId);
|
||||
await page.fill('input[name="password"]', creds.password);
|
||||
await page.click('button[id="operator_login_button"]');
|
||||
|
||||
@@ -137,9 +137,12 @@ function createPosFixture() {
|
||||
},
|
||||
],
|
||||
unknownVehicles: [],
|
||||
orderBookings: [],
|
||||
ordersById,
|
||||
orderItemsByOrderId,
|
||||
markCompletedOrderIds: [],
|
||||
completedBookingIds: [],
|
||||
bookingOrderAssignments: [],
|
||||
nextOrderId: 9300,
|
||||
nextOrderItemId: 9800,
|
||||
};
|
||||
@@ -160,6 +163,40 @@ function buildOrderItem(product, body, id) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildOrderBooking(id, overrides = {}) {
|
||||
return {
|
||||
id,
|
||||
customer_number: 12345,
|
||||
customer_name: "Pleno Logistics",
|
||||
department: 1,
|
||||
datetime: "2026-01-01T08:00:00.000Z",
|
||||
reg_1: "AB12345",
|
||||
reg_2: "",
|
||||
reg_3: "",
|
||||
reference: `BOOKING-${id}`,
|
||||
reference_number: `BOOKING-${id}`,
|
||||
notes: `Booking note ${id}`,
|
||||
note: `Booking note ${id}`,
|
||||
po: `PO-${id}`,
|
||||
status: "pending",
|
||||
order_id: null,
|
||||
wash_type: "Tankvogn med hænger",
|
||||
parsed_services: {
|
||||
string: "Tankvogn med hænger",
|
||||
array: ["Tankvogn med hænger"],
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: 53,
|
||||
name: "Tankvogn med hænger",
|
||||
price: 599,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function mockPosApi(page, fixture) {
|
||||
await page.route(API_HOST, async (route) => {
|
||||
const request = route.request();
|
||||
@@ -203,6 +240,97 @@ async function mockPosApi(page, fixture) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/order-bookings") && method === "GET") {
|
||||
const bookingId = Number(parsedUrl.searchParams.get("id") || 0);
|
||||
const filters = String(parsedUrl.searchParams.get("filters") || "");
|
||||
let bookings = Array.isArray(fixture.orderBookings) ? [...fixture.orderBookings] : [];
|
||||
|
||||
if (filters.includes("order_id:null")) {
|
||||
bookings = bookings.filter((booking) => booking.order_id === null || booking.order_id === undefined);
|
||||
}
|
||||
|
||||
if (filters.includes("department:")) {
|
||||
const departmentFilter = Number(filters.split("department:")[1]?.split(",")[0] || 0);
|
||||
if (departmentFilter > 0) {
|
||||
bookings = bookings.filter((booking) => Number(booking.department) === departmentFilter);
|
||||
}
|
||||
}
|
||||
|
||||
if (bookingId > 0) {
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data: bookings.find((booking) => Number(booking.id) === bookingId) || null,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data: bookings,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (/\/order-bookings\/\d+$/.test(pathname) && method === "GET") {
|
||||
const bookingId = Number(pathname.split("/").pop());
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data: (fixture.orderBookings || []).find((booking) => Number(booking.id) === bookingId) || null,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/order-bookings") && method === "PUT") {
|
||||
const body = request.postDataJSON?.() || {};
|
||||
const bookingId = Number(body.id);
|
||||
const bookingIndex = (fixture.orderBookings || []).findIndex((booking) => Number(booking.id) === bookingId);
|
||||
if (bookingIndex >= 0) {
|
||||
fixture.orderBookings[bookingIndex] = {
|
||||
...fixture.orderBookings[bookingIndex],
|
||||
order_id: body.order_id ?? body.value ?? null,
|
||||
};
|
||||
fixture.bookingOrderAssignments.push({
|
||||
id: bookingId,
|
||||
order_id: fixture.orderBookings[bookingIndex].order_id,
|
||||
});
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data: bookingIndex >= 0 ? fixture.orderBookings[bookingIndex] : null,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/order-bookings/complete") && method === "POST") {
|
||||
const body = request.postDataJSON?.() || {};
|
||||
const bookingId = Number(body.id);
|
||||
const bookingIndex = (fixture.orderBookings || []).findIndex((booking) => Number(booking.id) === bookingId);
|
||||
if (bookingIndex >= 0) {
|
||||
fixture.orderBookings[bookingIndex] = {
|
||||
...fixture.orderBookings[bookingIndex],
|
||||
status: "completed",
|
||||
};
|
||||
fixture.completedBookingIds.push(bookingId);
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data: bookingIndex >= 0 ? fixture.orderBookings[bookingIndex] : { id: bookingId, status: "completed" },
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/vehicles") && method === "GET") {
|
||||
const search = (parsedUrl.searchParams.get("search") || "").toUpperCase();
|
||||
const matches = fixture.vehicles.filter((vehicle) => vehicle.reg.includes(search));
|
||||
@@ -351,6 +479,24 @@ async function mockPosApi(page, fixture) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/orders") && method === "PUT") {
|
||||
const body = request.postDataJSON?.() || {};
|
||||
const orderId = Number(body.id);
|
||||
if (fixture.ordersById[orderId]) {
|
||||
fixture.ordersById[orderId] = {
|
||||
...fixture.ordersById[orderId],
|
||||
...body,
|
||||
};
|
||||
}
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data: fixture.ordersById[orderId] || null,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/order") && method === "GET") {
|
||||
const id = Number(parsedUrl.searchParams.get("id"));
|
||||
await route.fulfill(
|
||||
@@ -583,6 +729,26 @@ function seedMobilePosState(page) {
|
||||
}, state);
|
||||
}
|
||||
|
||||
async function setupDesktopPosPage(page, fixture, { token = "pos-desktop-token" } = {}) {
|
||||
const permissions = ["admin", "department_access_1"];
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions,
|
||||
sessionData: {
|
||||
display_name: "POS Desktop",
|
||||
},
|
||||
});
|
||||
await mockPosApi(page, fixture);
|
||||
await primeSession(page, {
|
||||
token,
|
||||
permissions,
|
||||
});
|
||||
|
||||
await page.goto("/admin/1/modules/pos");
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
test.describe("POS flow", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await suppressVueDevtoolsOverlay(page);
|
||||
@@ -722,4 +888,178 @@ test.describe("POS flow", () => {
|
||||
|
||||
expect(consoleProblems.join("\n")).not.toContain("Extraneous non-props attributes");
|
||||
});
|
||||
|
||||
test("desktop auto-applies a single matching order booking and completes that booking", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"Desktop POS booking flow is validated on chromium-desktop."
|
||||
);
|
||||
|
||||
const fixture = createPosFixture();
|
||||
fixture.orderBookings = [
|
||||
buildOrderBooking(8101, {
|
||||
reference: "SINGLE-BOOKING-REF",
|
||||
reference_number: "SINGLE-BOOKING-REF",
|
||||
notes: "Single desktop booking",
|
||||
note: "Single desktop booking",
|
||||
items: [
|
||||
{ id: 53, name: "Tankvogn med hænger", price: 599, quantity: 1 },
|
||||
{ id: 63, name: "Dolly", price: 275, quantity: 1 },
|
||||
],
|
||||
parsed_services: {
|
||||
string: "Tankvogn med hænger, Dolly",
|
||||
array: ["Tankvogn med hænger", "Dolly"],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-single-booking" });
|
||||
|
||||
const activeBookingSelector = page.locator('[data-testid="default-object-selector"].is-active');
|
||||
await page.locator("#reg_1").fill("AB12345");
|
||||
await expect(activeBookingSelector).toHaveCount(0);
|
||||
|
||||
await page.locator('[data-testid="pos-next-step"]:visible').click();
|
||||
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await expect
|
||||
.poll(() => fixture.ordersById[9300]?.reference || null, { timeout: 10_000 })
|
||||
.toBe("SINGLE-BOOKING-REF");
|
||||
await expect
|
||||
.poll(() => (fixture.orderItemsByOrderId[9300] || []).map((item) => Number(item.product_id)), { timeout: 10_000 })
|
||||
.toEqual([53, 63]);
|
||||
|
||||
await page.locator('[data-testid="pos-next-step"]:visible').click();
|
||||
await expect(page.getByTestId("pos-step-3")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.locator('[data-testid="pos-next-step"]:visible').click();
|
||||
|
||||
await expect.poll(() => fixture.ordersById[9300]?.booking_id || null, { timeout: 10_000 }).toBe(8101);
|
||||
await expect
|
||||
.poll(() => fixture.bookingOrderAssignments, { timeout: 10_000 })
|
||||
.toContainEqual({
|
||||
id: 8101,
|
||||
order_id: 9300,
|
||||
});
|
||||
await expect.poll(() => fixture.completedBookingIds, { timeout: 10_000 }).toContain(8101);
|
||||
});
|
||||
|
||||
test("desktop opens a chooser for multiple matching order bookings and hydrates the selected booking", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"Desktop POS booking flow is validated on chromium-desktop."
|
||||
);
|
||||
|
||||
const fixture = createPosFixture();
|
||||
fixture.orderBookings = [
|
||||
buildOrderBooking(8102, {
|
||||
datetime: "2026-01-01T07:00:00.000Z",
|
||||
reference: "BOOKING-A-REF",
|
||||
reference_number: "BOOKING-A-REF",
|
||||
items: [
|
||||
{ id: 53, name: "Tankvogn med hænger", price: 599, quantity: 1 },
|
||||
{ id: 63, name: "Dolly", price: 275, quantity: 1 },
|
||||
],
|
||||
parsed_services: {
|
||||
string: "Tankvogn med hænger, Dolly",
|
||||
array: ["Tankvogn med hænger", "Dolly"],
|
||||
},
|
||||
}),
|
||||
buildOrderBooking(8103, {
|
||||
datetime: "2026-01-01T09:00:00.000Z",
|
||||
reference: "BOOKING-B-REF",
|
||||
reference_number: "BOOKING-B-REF",
|
||||
items: [{ id: 63, name: "Dolly", price: 275, quantity: 1 }],
|
||||
parsed_services: {
|
||||
string: "Dolly",
|
||||
array: ["Dolly"],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-multi-booking" });
|
||||
|
||||
const activeBookingSelector = page.locator('[data-testid="default-object-selector"].is-active');
|
||||
await page.locator("#reg_1").fill("AB12345");
|
||||
await expect(activeBookingSelector).toBeVisible({ timeout: 10_000 });
|
||||
await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8102")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8103")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8102").click();
|
||||
await expect(page.locator("#reference")).toHaveValue("BOOKING-A-REF");
|
||||
|
||||
await page.locator('[data-testid="pos-next-step"]:visible').click();
|
||||
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await expect
|
||||
.poll(() => (fixture.orderItemsByOrderId[9300] || []).map((item) => Number(item.product_id)), { timeout: 10_000 })
|
||||
.toEqual([53, 63]);
|
||||
|
||||
await page.locator('[data-testid="pos-next-step"]:visible').click();
|
||||
await expect(page.getByTestId("pos-step-3")).toBeVisible({ timeout: 10_000 });
|
||||
await page.locator('[data-testid="pos-next-step"]:visible').click();
|
||||
|
||||
await expect.poll(() => fixture.ordersById[9300]?.booking_id || null, { timeout: 10_000 }).toBe(8102);
|
||||
await expect
|
||||
.poll(() => fixture.bookingOrderAssignments, { timeout: 10_000 })
|
||||
.toContainEqual({
|
||||
id: 8102,
|
||||
order_id: 9300,
|
||||
});
|
||||
await expect.poll(() => fixture.completedBookingIds, { timeout: 10_000 }).toContain(8102);
|
||||
expect(fixture.completedBookingIds).not.toContain(8103);
|
||||
});
|
||||
|
||||
test("desktop continue without booking skips hydration and completion requests", async ({ page }, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"Desktop POS booking flow is validated on chromium-desktop."
|
||||
);
|
||||
|
||||
const fixture = createPosFixture();
|
||||
fixture.orderBookings = [
|
||||
buildOrderBooking(8104, {
|
||||
reference: "SKIP-A-REF",
|
||||
reference_number: "SKIP-A-REF",
|
||||
}),
|
||||
buildOrderBooking(8105, {
|
||||
datetime: "2026-01-01T09:00:00.000Z",
|
||||
reference: "SKIP-B-REF",
|
||||
reference_number: "SKIP-B-REF",
|
||||
}),
|
||||
];
|
||||
|
||||
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-skip-booking" });
|
||||
|
||||
const activeBookingSelector = page.locator('[data-testid="default-object-selector"].is-active');
|
||||
await page.locator("#reg_1").fill("AB12345");
|
||||
await expect(activeBookingSelector).toBeVisible({ timeout: 10_000 });
|
||||
await activeBookingSelector.getByTestId("pos-desktop-order-booking-skip").click();
|
||||
|
||||
await page.locator('[data-testid="pos-next-step"]:visible').click();
|
||||
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await expect.poll(() => (fixture.orderItemsByOrderId[9300] || []).length, { timeout: 10_000 }).toBe(0);
|
||||
|
||||
await page.getByTestId("pos-product-card-53").click();
|
||||
await expect(page.getByTestId("pos-add-to-cart-53")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-add-to-cart-53").click();
|
||||
await expect.poll(() => (fixture.orderItemsByOrderId[9300] || []).length, { timeout: 10_000 }).toBe(1);
|
||||
|
||||
await page.locator('[data-testid="pos-next-step"]:visible').click();
|
||||
await expect(page.getByTestId("pos-step-3")).toBeVisible({ timeout: 10_000 });
|
||||
await page.locator('[data-testid="pos-next-step"]:visible').click();
|
||||
|
||||
await expect.poll(() => fixture.bookingOrderAssignments.length, { timeout: 10_000 }).toBe(0);
|
||||
await expect.poll(() => fixture.completedBookingIds.length, { timeout: 10_000 }).toBe(0);
|
||||
await expect.poll(() => fixture.ordersById[9300]?.booking_id ?? null, { timeout: 10_000 }).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
DEFAULT_BOOKING_ID,
|
||||
DEFAULT_DEPARTMENT_ID,
|
||||
REGULAR_CUSTOMER_ID,
|
||||
buildMobilePosState,
|
||||
createAttachmentFile,
|
||||
createMobilePosFixture,
|
||||
gotoMobilePos,
|
||||
getStoredPosSnapshot,
|
||||
setupMobilePosPage,
|
||||
suppressVueDevtoolsOverlay,
|
||||
@@ -30,6 +32,100 @@ function buildRegularOrder(orderId, overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMobileOrderBooking(id, overrides = {}) {
|
||||
return {
|
||||
id,
|
||||
customer_number: REGULAR_CUSTOMER_ID,
|
||||
customer_name: "Pleno Logistics",
|
||||
department: DEFAULT_DEPARTMENT_ID,
|
||||
reg_1: "BOOK123",
|
||||
reg_2: "",
|
||||
reg_3: "",
|
||||
reference: `BOOKING-REF-${id}`,
|
||||
reference_number: `BOOKING-REF-${id}`,
|
||||
notes: `Booking notes ${id}`,
|
||||
note: `Booking notes ${id}`,
|
||||
po: `PO-${id}`,
|
||||
order_id: null,
|
||||
status: "pending",
|
||||
items: [],
|
||||
parsed_services: {
|
||||
string: "",
|
||||
array: [],
|
||||
},
|
||||
created_at: "2026-01-01T09:00:00.000Z",
|
||||
datetime: "2026-01-01T09:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMobileMatchedVehicle(reg, overrides = {}) {
|
||||
return {
|
||||
id: 7800,
|
||||
reg,
|
||||
customer_id: REGULAR_CUSTOMER_ID,
|
||||
customer_name: "Pleno Logistics",
|
||||
type: 53,
|
||||
status: "verified",
|
||||
barred: false,
|
||||
wash_subscription: false,
|
||||
addons: {
|
||||
enabled: 1,
|
||||
available: 2,
|
||||
list: [71, 41],
|
||||
},
|
||||
reference: `REF-${reg}`,
|
||||
last_order_id: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMultiBookingFixture({ reg, vehicle = {}, bookings = [] }) {
|
||||
const baseFixture = createMobilePosFixture();
|
||||
return createMobilePosFixture({
|
||||
vehicles: [...baseFixture.vehicles, buildMobileMatchedVehicle(reg, vehicle)],
|
||||
bookingsById: {
|
||||
...baseFixture.bookingsById,
|
||||
...Object.fromEntries(bookings.map((booking) => [booking.id, booking])),
|
||||
},
|
||||
bookingStatusById: {
|
||||
...baseFixture.bookingStatusById,
|
||||
...Object.fromEntries(bookings.map((booking) => [booking.id, booking.status || "pending"])),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openMobileStep1FromStoredSnapshot(page, fixture, seedState, setupOptions = {}) {
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-step1-persistent-snapshot-token",
|
||||
seedState: false,
|
||||
route: {
|
||||
step: 1,
|
||||
},
|
||||
...setupOptions,
|
||||
});
|
||||
|
||||
const snapshot = buildMobilePosState({
|
||||
fixture,
|
||||
customerId: null,
|
||||
reg: "",
|
||||
reference: "",
|
||||
includePrimaryItem: false,
|
||||
vehicleType: null,
|
||||
lastOrderId: null,
|
||||
...seedState,
|
||||
});
|
||||
|
||||
await page.evaluate((payload) => {
|
||||
window.localStorage.setItem("pos", JSON.stringify(payload));
|
||||
}, snapshot);
|
||||
|
||||
await gotoMobilePos(page, {
|
||||
departmentId: fixture.departmentId ?? DEFAULT_DEPARTMENT_ID,
|
||||
step: 1,
|
||||
});
|
||||
}
|
||||
|
||||
async function selectCustomerFromPopup(page, customerNumber = REGULAR_CUSTOMER_ID) {
|
||||
await expect(page.getByTestId("pos-mobile-customer-popup")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-mobile-customer-search-input").fill(String(customerNumber));
|
||||
@@ -97,6 +193,15 @@ async function selectPrimaryProduct(page, productId = 53) {
|
||||
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function openVehicleSelectionFromPrimaryProduct(page, productName = "Tank truck wash") {
|
||||
const currentPrimaryProduct = page.getByText(productName, { exact: true });
|
||||
await expect(currentPrimaryProduct).toBeVisible({ timeout: 10_000 });
|
||||
await currentPrimaryProduct.dispatchEvent("pointerdown");
|
||||
await page.waitForTimeout(650);
|
||||
await currentPrimaryProduct.dispatchEvent("pointerup");
|
||||
await expect(page.getByTestId("pos-mobile-vehicle-selection")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function waitForBookingHydration(page, { primaryId, addonProductIds = [] } = {}) {
|
||||
await expect
|
||||
.poll(
|
||||
@@ -118,6 +223,24 @@ async function waitForBookingHydration(page, { primaryId, addonProductIds = [] }
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForOrderBookingPopup(page) {
|
||||
const popup = page.getByTestId("pos-mobile-order-booking-popup");
|
||||
await expect(popup).toBeVisible({ timeout: 10_000 });
|
||||
return popup;
|
||||
}
|
||||
|
||||
async function expectOrderBookingPopupIds(page, bookingIds) {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page
|
||||
.locator('[data-testid^="pos-mobile-order-booking-option-"]')
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toEqual(bookingIds.map((bookingId) => `pos-mobile-order-booking-option-${bookingId}`));
|
||||
}
|
||||
|
||||
async function createOrderFromStep1(
|
||||
page,
|
||||
fixture,
|
||||
@@ -155,6 +278,17 @@ async function createOrderFromStep1(
|
||||
}
|
||||
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
const customerPopupAppeared = await page
|
||||
.getByTestId("pos-mobile-customer-popup")
|
||||
.waitFor({ state: "visible", timeout: 5_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!customerPopupAppeared) {
|
||||
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
await selectCustomerFromPopup(page, customerNumber);
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
}
|
||||
@@ -213,25 +347,26 @@ test.describe("POS mobile order flow", () => {
|
||||
});
|
||||
|
||||
test("transaction history keeps each wash visually separated with a border", async ({ page }) => {
|
||||
const todayDate = new Date().toISOString().split("T")[0];
|
||||
const fixture = createMobilePosFixture({
|
||||
ordersById: {
|
||||
9201: buildRegularOrder(9201, {
|
||||
customer_name: "VOGNMAND JIMMY CHRISTENSEN ApS",
|
||||
total_net_amount: 746,
|
||||
pending_handheld: true,
|
||||
created_at: "2026-04-09T12:08:00.000Z",
|
||||
created_at: `${todayDate}T12:08:00.000Z`,
|
||||
}),
|
||||
9202: buildRegularOrder(9202, {
|
||||
customer_name: "BYGMA Roskilde A/S",
|
||||
total_net_amount: 507,
|
||||
pending_handheld: true,
|
||||
created_at: "2026-04-09T11:51:00.000Z",
|
||||
created_at: `${todayDate}T11:51:00.000Z`,
|
||||
}),
|
||||
9203: buildRegularOrder(9203, {
|
||||
customer_name: "VOLVO ENTREPRENØRMASKINER A/S",
|
||||
total_net_amount: 512,
|
||||
pending_handheld: true,
|
||||
created_at: "2026-04-09T10:10:00.000Z",
|
||||
created_at: `${todayDate}T10:10:00.000Z`,
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -318,6 +453,59 @@ test.describe("POS mobile order flow", () => {
|
||||
await expect(page.locator("#input_field")).toHaveAttribute("placeholder", "Tilføj note...");
|
||||
});
|
||||
|
||||
test("step 1 reference trigger shows the required warning state and preserves row alignment", async ({ page }) => {
|
||||
const fixture = createMobilePosFixture({
|
||||
customerAttributesByNumber: {
|
||||
[REGULAR_CUSTOMER_ID]: [
|
||||
{
|
||||
id: 9,
|
||||
customer_number: REGULAR_CUSTOMER_ID,
|
||||
attribute: "requiresReferenceNumber",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-required-reference-warning-token",
|
||||
seedState: {
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
reg: "WARN123",
|
||||
reference: "",
|
||||
includePrimaryItem: false,
|
||||
vehicleType: null,
|
||||
lastOrderId: null,
|
||||
},
|
||||
route: {
|
||||
step: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const referenceTrigger = page.getByTestId("pos-mobile-step-1-reference-trigger");
|
||||
await expect(referenceTrigger).toBeVisible({ timeout: 10_000 });
|
||||
await expect(referenceTrigger).toHaveAttribute("data-warning-state", "danger");
|
||||
await expect(page.getByTestId("pos-mobile-step-1-reference-warning-icon")).toBeVisible();
|
||||
|
||||
const [registrationRowBox, referenceTriggerBox] = await Promise.all([
|
||||
page.getByTestId("pos-mobile-step-1-registration-row").boundingBox(),
|
||||
referenceTrigger.boundingBox(),
|
||||
]);
|
||||
|
||||
expect(registrationRowBox).not.toBeNull();
|
||||
expect(referenceTriggerBox).not.toBeNull();
|
||||
expect(Math.abs((registrationRowBox?.width ?? 0) - (referenceTriggerBox?.width ?? 0))).toBeLessThanOrEqual(4);
|
||||
expect(Math.abs((registrationRowBox?.x ?? 0) - (referenceTriggerBox?.x ?? 0))).toBeLessThanOrEqual(4);
|
||||
|
||||
await referenceTrigger.click();
|
||||
await expect(page.getByTestId("pos-mobile-reference-input")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-mobile-reference-input").fill("MOBILE-WARN-REF");
|
||||
await page.getByTestId("pos-mobile-reference-input").press("Enter");
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-reference-input")).toHaveCount(0);
|
||||
await expect(referenceTrigger).not.toHaveAttribute("data-warning-state", "danger");
|
||||
await expect(page.getByTestId("pos-mobile-step-1-reference-warning-icon")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("@smoke manual input happy path creates, completes, and resets", async ({ page }) => {
|
||||
const fixture = createMobilePosFixture();
|
||||
await createOrderFromStep1(page, fixture, {
|
||||
@@ -334,6 +522,363 @@ test.describe("POS mobile order flow", () => {
|
||||
await waitForStepReset(page);
|
||||
});
|
||||
|
||||
test("matched vehicle manual input seeds the step 2 reference and primary product defaults", async ({ page }) => {
|
||||
const fixture = createMobilePosFixture();
|
||||
await createOrderFromStep1(page, fixture, {
|
||||
reg: "AB12345",
|
||||
reference: "",
|
||||
manualInput: true,
|
||||
extraSeedState: {
|
||||
reference: "",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("REF-AB12345");
|
||||
await expect(page.getByTestId("pos-mobile-vehicle-selection")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
return snapshot?.transactionItems?.primaryItem?.id ?? null;
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(53);
|
||||
});
|
||||
|
||||
test("multiple matching bookings open immediately, keep the chosen booking, and complete that exact booking", async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = createMultiBookingFixture({
|
||||
reg: "MULTI123",
|
||||
vehicle: {
|
||||
reference: "VEHICLE-MULTI-REF",
|
||||
},
|
||||
bookings: [
|
||||
buildMobileOrderBooking(8201, {
|
||||
reg_1: "MULTI123",
|
||||
datetime: "2026-01-01T07:00:00.000Z",
|
||||
reference: "MULTI-BOOKING-A",
|
||||
reference_number: "MULTI-BOOKING-A",
|
||||
items: [
|
||||
{ id: 53, name: "Tank truck wash", price: 599, quantity: 1 },
|
||||
{ id: 71, name: "Interior rinse", price: 99, quantity: 1 },
|
||||
],
|
||||
parsed_services: {
|
||||
string: "Tank truck wash, Interior rinse",
|
||||
array: ["Tank truck wash", "Interior rinse"],
|
||||
},
|
||||
}),
|
||||
buildMobileOrderBooking(8202, {
|
||||
reg_1: "MULTI123",
|
||||
datetime: "2026-01-01T09:00:00.000Z",
|
||||
reference: "MULTI-BOOKING-B",
|
||||
reference_number: "MULTI-BOOKING-B",
|
||||
notes: "Selected mobile booking",
|
||||
note: "Selected mobile booking",
|
||||
items: [{ id: 63, name: "Box trailer wash", price: 499, quantity: 1 }],
|
||||
parsed_services: {
|
||||
string: "Box trailer wash",
|
||||
array: ["Box trailer wash"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-multi-booking-select-token",
|
||||
seedState: {
|
||||
customerId: null,
|
||||
reg: "MULTI123",
|
||||
reference: "",
|
||||
includePrimaryItem: false,
|
||||
vehicleType: null,
|
||||
lastOrderId: null,
|
||||
},
|
||||
route: {
|
||||
step: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await waitForOrderBookingPopup(page);
|
||||
await expectOrderBookingPopupIds(page, [8201, 8202]);
|
||||
await page.getByTestId("pos-mobile-order-booking-use-8202").click();
|
||||
await expect(page.getByTestId("pos-mobile-order-booking-popup")).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
return {
|
||||
bookingId: snapshot?.metadata?.bookingId ?? null,
|
||||
vehicleBookingId: snapshot?.vehicles?.vehicle_1?.booking_id ?? null,
|
||||
reference: snapshot?.metadata?.reference ?? "",
|
||||
};
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toEqual({
|
||||
bookingId: 8202,
|
||||
vehicleBookingId: 8202,
|
||||
reference: "MULTI-BOOKING-B",
|
||||
});
|
||||
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
await waitForBookingHydration(page, {
|
||||
primaryId: 63,
|
||||
addonProductIds: [],
|
||||
});
|
||||
await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("MULTI-BOOKING-B");
|
||||
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
|
||||
await expect.poll(() => fixture.requestCounters.bookingSetOrderId, { timeout: 10_000 }).toBe(1);
|
||||
await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
fixture.requestLog.bookingOrderAssignments.some(
|
||||
(entry) => Number(entry?.id) === 8202 && Number(entry?.order_id) === 9300
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() => fixture.requestLog.bookingCompletions.some((entry) => Number(entry?.id) === 8202), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(true);
|
||||
expect(fixture.requestLog.bookingCompletions.some((entry) => Number(entry?.id) === 8201)).toBe(false);
|
||||
await waitForStepReset(page);
|
||||
});
|
||||
|
||||
test("manual input can continue without booking and keeps the flow unbooked", async ({ page }) => {
|
||||
const fixture = createMultiBookingFixture({
|
||||
reg: "SKIP123",
|
||||
vehicle: {
|
||||
reference: "SKIP-VEHICLE-REF",
|
||||
},
|
||||
bookings: [
|
||||
buildMobileOrderBooking(8301, {
|
||||
reg_1: "SKIP123",
|
||||
datetime: "2026-01-01T07:00:00.000Z",
|
||||
reference: "SKIP-BOOKING-A",
|
||||
reference_number: "SKIP-BOOKING-A",
|
||||
}),
|
||||
buildMobileOrderBooking(8302, {
|
||||
reg_1: "SKIP123",
|
||||
datetime: "2026-01-01T09:00:00.000Z",
|
||||
reference: "SKIP-BOOKING-B",
|
||||
reference_number: "SKIP-BOOKING-B",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-multi-booking-skip-token",
|
||||
seedState: {
|
||||
customerId: null,
|
||||
reg: "",
|
||||
reference: "",
|
||||
includePrimaryItem: false,
|
||||
vehicleType: null,
|
||||
lastOrderId: null,
|
||||
},
|
||||
route: {
|
||||
step: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByTestId("pos-mobile-manual-input-toggle").click();
|
||||
await expect(page.getByTestId("pos-mobile-manual-input")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-mobile-reg-input-1").fill("skip123");
|
||||
|
||||
await waitForOrderBookingPopup(page);
|
||||
await expectOrderBookingPopupIds(page, [8301, 8302]);
|
||||
await page.getByTestId("pos-mobile-order-booking-skip").click();
|
||||
await expect(page.getByTestId("pos-mobile-order-booking-popup")).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
return {
|
||||
bookingId: snapshot?.metadata?.bookingId ?? null,
|
||||
skippedPlate: snapshot?.metadata?.bookingSelectionSkippedPlate ?? "",
|
||||
};
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toEqual({
|
||||
bookingId: null,
|
||||
skippedPlate: "SKIP123",
|
||||
});
|
||||
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("SKIP-VEHICLE-REF");
|
||||
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
|
||||
await expect.poll(() => fixture.requestCounters.bookingSetOrderId, { timeout: 2_500 }).toBe(0);
|
||||
await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 2_500 }).toBe(0);
|
||||
await waitForStepReset(page);
|
||||
});
|
||||
|
||||
test("reloading after explicitly selecting a booking keeps the choice and does not reopen the chooser", async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = createMultiBookingFixture({
|
||||
reg: "RESTSEL1",
|
||||
bookings: [
|
||||
buildMobileOrderBooking(8401, {
|
||||
reg_1: "RESTSEL1",
|
||||
datetime: "2026-01-01T07:00:00.000Z",
|
||||
reference: "RESTORE-SELECT-A",
|
||||
reference_number: "RESTORE-SELECT-A",
|
||||
}),
|
||||
buildMobileOrderBooking(8402, {
|
||||
reg_1: "RESTSEL1",
|
||||
datetime: "2026-01-01T09:00:00.000Z",
|
||||
reference: "RESTORE-SELECT-B",
|
||||
reference_number: "RESTORE-SELECT-B",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await openMobileStep1FromStoredSnapshot(
|
||||
page,
|
||||
fixture,
|
||||
{
|
||||
reg: "RESTSEL1",
|
||||
},
|
||||
{
|
||||
token: "mobile-restore-selected-booking-token",
|
||||
}
|
||||
);
|
||||
|
||||
await waitForOrderBookingPopup(page);
|
||||
await page.getByTestId("pos-mobile-order-booking-use-8402").click();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
return {
|
||||
bookingId: snapshot?.metadata?.bookingId ?? null,
|
||||
vehicleBookingId: snapshot?.vehicles?.vehicle_1?.booking_id ?? null,
|
||||
};
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toEqual({
|
||||
bookingId: 8402,
|
||||
vehicleBookingId: 8402,
|
||||
});
|
||||
|
||||
const orderBookingsGetBeforeReload = fixture.requestCounters.orderBookingsGet;
|
||||
await page.reload();
|
||||
|
||||
await expect
|
||||
.poll(() => fixture.requestCounters.orderBookingsGet, { timeout: 10_000 })
|
||||
.toBeGreaterThan(orderBookingsGetBeforeReload);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
return {
|
||||
bookingId: snapshot?.metadata?.bookingId ?? null,
|
||||
vehicleBookingId: snapshot?.vehicles?.vehicle_1?.booking_id ?? null,
|
||||
};
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toEqual({
|
||||
bookingId: 8402,
|
||||
vehicleBookingId: 8402,
|
||||
});
|
||||
await page.waitForTimeout(1_000);
|
||||
await expect(page.getByTestId("pos-mobile-order-booking-popup")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("reloading after explicitly skipping a booking keeps the skip decision and does not reopen the chooser", async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = createMultiBookingFixture({
|
||||
reg: "RESTSKIP",
|
||||
bookings: [
|
||||
buildMobileOrderBooking(8501, {
|
||||
reg_1: "RESTSKIP",
|
||||
datetime: "2026-01-01T07:00:00.000Z",
|
||||
reference: "RESTORE-SKIP-A",
|
||||
reference_number: "RESTORE-SKIP-A",
|
||||
}),
|
||||
buildMobileOrderBooking(8502, {
|
||||
reg_1: "RESTSKIP",
|
||||
datetime: "2026-01-01T09:00:00.000Z",
|
||||
reference: "RESTORE-SKIP-B",
|
||||
reference_number: "RESTORE-SKIP-B",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await openMobileStep1FromStoredSnapshot(
|
||||
page,
|
||||
fixture,
|
||||
{
|
||||
reg: "RESTSKIP",
|
||||
},
|
||||
{
|
||||
token: "mobile-restore-skipped-booking-token",
|
||||
}
|
||||
);
|
||||
|
||||
await waitForOrderBookingPopup(page);
|
||||
await page.getByTestId("pos-mobile-order-booking-skip").click();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
return {
|
||||
bookingId: snapshot?.metadata?.bookingId ?? null,
|
||||
skippedPlate: snapshot?.metadata?.bookingSelectionSkippedPlate ?? "",
|
||||
};
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toEqual({
|
||||
bookingId: null,
|
||||
skippedPlate: "RESTSKIP",
|
||||
});
|
||||
|
||||
const orderBookingsGetBeforeReload = fixture.requestCounters.orderBookingsGet;
|
||||
await page.reload();
|
||||
|
||||
await expect
|
||||
.poll(() => fixture.requestCounters.orderBookingsGet, { timeout: 10_000 })
|
||||
.toBeGreaterThan(orderBookingsGetBeforeReload);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
return {
|
||||
bookingId: snapshot?.metadata?.bookingId ?? null,
|
||||
skippedPlate: snapshot?.metadata?.bookingSelectionSkippedPlate ?? "",
|
||||
};
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toEqual({
|
||||
bookingId: null,
|
||||
skippedPlate: "RESTSKIP",
|
||||
});
|
||||
await page.waitForTimeout(1_000);
|
||||
await expect(page.getByTestId("pos-mobile-order-booking-popup")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("required reference prompt retries and creates the order after confirmation", async ({ page }) => {
|
||||
const fixture = createMobilePosFixture({
|
||||
customerAttributesByNumber: {
|
||||
@@ -472,12 +1017,12 @@ 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 after a typing pause", async ({ page }) => {
|
||||
test("step 2 query bootstrap seeds the vehicle reference and auto-loads the primary product", async ({ page }) => {
|
||||
const orderId = 9401;
|
||||
const fixture = createMobilePosFixture({
|
||||
ordersById: {
|
||||
[orderId]: buildRegularOrder(orderId, {
|
||||
reference: "BOOTSTRAP-REF",
|
||||
reference: "",
|
||||
reg_1: "AB12345",
|
||||
}),
|
||||
},
|
||||
@@ -485,16 +1030,21 @@ test.describe("POS mobile order flow", () => {
|
||||
[orderId]: [],
|
||||
},
|
||||
});
|
||||
const seedState = buildMobilePosState({
|
||||
fixture,
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
reg: "AB12345",
|
||||
reference: "",
|
||||
includePrimaryItem: false,
|
||||
vehicleType: 53,
|
||||
lastOrderId: null,
|
||||
});
|
||||
seedState.metadata.reference = "";
|
||||
seedState.vehicles.vehicle_1.reference = "REF-AB12345";
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-step2-bootstrap-token",
|
||||
seedState: {
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
reg: "AB12345",
|
||||
reference: "BOOTSTRAP-REF",
|
||||
includePrimaryItem: false,
|
||||
vehicleType: 53,
|
||||
},
|
||||
seedState,
|
||||
route: {
|
||||
step: 2,
|
||||
orderId,
|
||||
@@ -503,7 +1053,8 @@ test.describe("POS mobile order flow", () => {
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-mobile-last-order")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("REF-AB12345");
|
||||
await expect(page.getByTestId("pos-mobile-vehicle-selection")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
@@ -544,6 +1095,94 @@ test.describe("POS mobile order flow", () => {
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("manual step 2 reference and product changes survive reload without being replaced by vehicle defaults", async ({
|
||||
page,
|
||||
}) => {
|
||||
const orderId = 9404;
|
||||
const fixture = createMobilePosFixture({
|
||||
ordersById: {
|
||||
[orderId]: buildRegularOrder(orderId, {
|
||||
reference: "",
|
||||
reg_1: "AB12345",
|
||||
}),
|
||||
},
|
||||
orderItemsByOrderId: {
|
||||
[orderId]: [],
|
||||
},
|
||||
});
|
||||
const seedState = buildMobilePosState({
|
||||
fixture,
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
reg: "AB12345",
|
||||
reference: "",
|
||||
includePrimaryItem: false,
|
||||
vehicleType: 53,
|
||||
lastOrderId: null,
|
||||
});
|
||||
seedState.metadata.reference = "";
|
||||
seedState.vehicles.vehicle_1.reference = "REF-AB12345";
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-step2-no-overwrite-token",
|
||||
seedState,
|
||||
route: {
|
||||
step: 2,
|
||||
orderId,
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("REF-AB12345");
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
return snapshot?.transactionItems?.primaryItem?.id ?? null;
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(53);
|
||||
|
||||
await page.getByTestId("pos-mobile-reference-step-2-input").fill("MANUAL-STEP2-REF");
|
||||
await expect.poll(() => fixture.ordersById[orderId]?.reference ?? "").toBe("MANUAL-STEP2-REF");
|
||||
|
||||
await openVehicleSelectionFromPrimaryProduct(page);
|
||||
await selectPrimaryProduct(page, 63);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
return snapshot?.transactionItems?.primaryItem?.id ?? null;
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(63);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const flow = await import(
|
||||
"/src/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue"
|
||||
);
|
||||
const currentVehicle = flow.vehicles.get(1);
|
||||
flow.vehicles.select(1, {
|
||||
...(currentVehicle ?? {}),
|
||||
type: 53,
|
||||
reference: "REF-AB12345",
|
||||
});
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("MANUAL-STEP2-REF");
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
return snapshot?.transactionItems?.primaryItem?.id ?? null;
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(63);
|
||||
});
|
||||
|
||||
test("step 2 registration popup flushes edits on close and survives reload", async ({ page }) => {
|
||||
const orderId = 9405;
|
||||
const fixture = createMobilePosFixture({
|
||||
@@ -587,9 +1226,7 @@ test.describe("POS mobile order flow", () => {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
fixture.requestLog.orderUpdates.some(
|
||||
(entry) => Number(entry?.id) === orderId && entry?.reg_1 === "CD-12 34"
|
||||
),
|
||||
fixture.requestLog.orderUpdates.some((entry) => Number(entry?.id) === orderId && entry?.reg_1 === "CD-12 34"),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
@@ -811,6 +1448,7 @@ test.describe("POS mobile order flow", () => {
|
||||
addonProductIds: [71],
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("BOOKING-REF-8101");
|
||||
await expect.poll(() => fixture.ordersById[orderId]?.reference ?? "").toBe("BOOKING-REF-8101");
|
||||
await expect.poll(() => fixture.ordersById[orderId]?.notes ?? "").toBe("Booking notes from planner");
|
||||
await expect.poll(() => fixture.ordersById[orderId]?.po ?? "").toBe("PO-8101");
|
||||
|
||||
@@ -203,6 +203,35 @@ function getVisibleTestId(page, testId) {
|
||||
return page.locator(`[data-testid="${testId}"]:visible`).first();
|
||||
}
|
||||
|
||||
function createRequiredWarningsPosFixture() {
|
||||
const customerNumber = 12345679;
|
||||
const baseFixture = createPosFixture();
|
||||
|
||||
return createPosFixture({
|
||||
customerAttributesByNumber: {
|
||||
[customerNumber]: [
|
||||
{
|
||||
id: 11,
|
||||
customer_number: customerNumber,
|
||||
attribute: "requiresReferenceNumber",
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
customer_number: customerNumber,
|
||||
attribute: "usePONumbers",
|
||||
},
|
||||
],
|
||||
},
|
||||
ordersById: {
|
||||
54518: {
|
||||
...baseFixture.ordersById[54518],
|
||||
reference: "",
|
||||
po: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("POS visuals", () => {
|
||||
test("desktop step 1 snapshot", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
||||
@@ -366,6 +395,35 @@ test.describe("POS visuals", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("desktop order detail required warnings snapshot", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: POS_PERMISSIONS,
|
||||
edgeGateways: false,
|
||||
pos: createRequiredWarningsPosFixture(),
|
||||
});
|
||||
await primeSession(page, "pos-visual-desktop-required-warnings-token");
|
||||
|
||||
await page.goto("/admin/12/modules/pos/orders/54518");
|
||||
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
|
||||
await expect(page.getByTestId("pos-order-customer-wishes-reference-control")).toHaveAttribute(
|
||||
"data-warning-state",
|
||||
"danger"
|
||||
);
|
||||
await expect(page.getByTestId("pos-order-customer-wishes-po-control")).toHaveAttribute(
|
||||
"data-warning-state",
|
||||
"warning"
|
||||
);
|
||||
await expect(page.getByTestId("pos-order-detail")).toHaveScreenshot(
|
||||
"pos-order-detail-desktop-required-warnings.png",
|
||||
{
|
||||
maxDiffPixels: 300,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("desktop step 2 shared workspace snapshot", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
||||
|
||||
@@ -408,12 +466,21 @@ test.describe("POS visuals", () => {
|
||||
});
|
||||
await primeSession(page, "pos-visual-desktop-step-3-token");
|
||||
|
||||
const customerResponse = page.waitForResponse((response) => {
|
||||
return (
|
||||
response.request().method() === "GET" && response.url().includes("/users/customer?customer_number=12345679")
|
||||
);
|
||||
});
|
||||
await page.goto("/admin/12/modules/pos?id=54518&customer_id=12345679&step=3");
|
||||
await customerResponse;
|
||||
const stepThree = page.getByTestId("pos-step-3");
|
||||
await expect(stepThree).toBeVisible();
|
||||
await expect(stepThree.getByTestId("pos-order-panel-cart")).toBeVisible();
|
||||
await expect(stepThree.getByTestId("pos-order-metadata-grid")).toBeVisible();
|
||||
await expect(stepThree.getByTestId("pos-order-rail")).toBeVisible();
|
||||
await expect(stepThree).toContainText("Tilføj flere varer");
|
||||
await expect(stepThree.getByRole("button", { name: /Slet alle/i })).toBeVisible();
|
||||
await expect(stepThree.getByRole("button", { name: /Fuldfør/i })).toBeVisible();
|
||||
await expect(stepThree).toHaveScreenshot("pos-step-3-desktop.png", {
|
||||
maxDiffPixels: 300,
|
||||
});
|
||||
@@ -430,12 +497,21 @@ test.describe("POS visuals", () => {
|
||||
});
|
||||
await primeSession(page, "pos-visual-desktop-step-4-token");
|
||||
|
||||
const customerResponse = page.waitForResponse((response) => {
|
||||
return (
|
||||
response.request().method() === "GET" && response.url().includes("/users/customer?customer_number=12345679")
|
||||
);
|
||||
});
|
||||
await page.goto("/admin/12/modules/pos?id=54518&customer_id=12345679&step=4");
|
||||
await customerResponse;
|
||||
const stepFour = page.getByTestId("pos-step-4");
|
||||
await expect(stepFour).toBeVisible();
|
||||
await expect(stepFour.getByTestId("pos-order-panel-cart")).toBeVisible();
|
||||
await expect(stepFour.getByTestId("pos-order-metadata-grid")).toBeVisible();
|
||||
await expect(stepFour.getByTestId("pos-order-rail")).toBeVisible();
|
||||
await expect(stepFour).toContainText("Tilføj flere varer");
|
||||
await expect(stepFour.getByRole("button", { name: /Slet alle/i })).toBeVisible();
|
||||
await expect(stepFour.getByRole("button", { name: /Fuldfør/i })).toBeVisible();
|
||||
await expect(stepFour).toHaveScreenshot("pos-step-4-desktop.png", {
|
||||
maxDiffPixels: 300,
|
||||
});
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 46 KiB |
@@ -15,6 +15,14 @@ const POS_PERMISSIONS = [
|
||||
"get_custom_prices_other",
|
||||
];
|
||||
|
||||
function toLoopbackRequestUrl(url: string) {
|
||||
const normalized = new URL(url);
|
||||
if (normalized.hostname === "localhost") {
|
||||
normalized.hostname = "127.0.0.1";
|
||||
}
|
||||
return normalized.toString();
|
||||
}
|
||||
|
||||
async function gotoHealthyRoute(page, path: string) {
|
||||
await page.goto(path);
|
||||
await settlePage(page);
|
||||
@@ -94,7 +102,9 @@ test.describe("Local production release gate", () => {
|
||||
|
||||
const manifestHref = await page.locator('link[rel="manifest"]').first().getAttribute("href");
|
||||
expect(manifestHref).toBeTruthy();
|
||||
const manifestResponse = await page.request.get(new URL(manifestHref!, LOCAL_PROD_BASE_URL).toString());
|
||||
const manifestResponse = await page.request.get(
|
||||
toLoopbackRequestUrl(new URL(manifestHref!, LOCAL_PROD_BASE_URL).toString())
|
||||
);
|
||||
expect(manifestResponse.ok()).toBeTruthy();
|
||||
|
||||
await page.reload();
|
||||
|
||||
@@ -414,7 +414,10 @@ test.describe("Self-serve wash", () => {
|
||||
await expect(page.getByTestId("self-serve-question-11")).toBeVisible();
|
||||
await page.getByTestId("self-serve-question-11-yes").click();
|
||||
|
||||
await expect(page.getByText("Prepare the truck")).toBeVisible();
|
||||
await expect(page.getByText("QUESTION_ANSWERED")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("Ingen aktive tasks.")).toBeVisible();
|
||||
await expect(page.getByText("Ingen session-events endnu.")).toBeVisible();
|
||||
const answeredQuestion = page.locator("li", { hasText: "Is the tarp removed?:" }).last();
|
||||
await expect(answeredQuestion).toBeVisible();
|
||||
await expect(answeredQuestion.locator("strong")).toHaveText("Ja");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,8 @@ import { userCredentials, loginAsSubuserByPhone } from "./fixtures";
|
||||
|
||||
// Navigate to profile page helper
|
||||
async function goToUserProfile(page) {
|
||||
await page.click('a[href="/user/profile"]');
|
||||
await expect(page).toHaveURL("/user/profile");
|
||||
await page.goto("/user/profile");
|
||||
await expect(page).toHaveURL(/\/user\/profile(?:[?#].*)?$/);
|
||||
}
|
||||
|
||||
// Helper to expand a category section by clicking its header
|
||||
|
||||
@@ -693,6 +693,9 @@ export function buildMobilePosState(options = {}) {
|
||||
status: options.vehicleStatus ?? (bookingId ? "booked" : "verified"),
|
||||
barred: false,
|
||||
booking_id: bookingId,
|
||||
booking_matches: Object.prototype.hasOwnProperty.call(options, "bookingMatches")
|
||||
? clone(options.bookingMatches)
|
||||
: [],
|
||||
addons: [],
|
||||
reference,
|
||||
last_order_id: Object.prototype.hasOwnProperty.call(options, "lastOrderId")
|
||||
@@ -728,6 +731,7 @@ export function buildMobilePosState(options = {}) {
|
||||
reference,
|
||||
washId: options.washId ?? null,
|
||||
bookingId,
|
||||
bookingSelectionSkippedPlate: options.bookingSelectionSkippedPlate ?? "",
|
||||
laneId: options.laneId ?? null,
|
||||
},
|
||||
attachments: {
|
||||
|
||||
@@ -912,6 +912,8 @@ export function createPosFixture(overrides = {}) {
|
||||
],
|
||||
unknownVehicles: [],
|
||||
orderBookings: [],
|
||||
bookingOrderAssignments: [],
|
||||
completedBookingIds: [],
|
||||
nextOrderBookingId: 9001,
|
||||
numberPlateScanners: [
|
||||
{ id: 1, name: "North scanner" },
|
||||
@@ -1248,6 +1250,30 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/order-bookings") && method === "PUT") {
|
||||
const body = request.postDataJSON?.() || {};
|
||||
const bookingId = Number(body.id || 0);
|
||||
const bookings = Array.isArray(posFixture.orderBookings) ? posFixture.orderBookings : [];
|
||||
const bookingIndex = bookings.findIndex((booking) => Number(booking.id) === bookingId);
|
||||
|
||||
if (bookingIndex >= 0) {
|
||||
bookings[bookingIndex] = {
|
||||
...bookings[bookingIndex],
|
||||
order_id: body.order_id ?? body.value ?? null,
|
||||
};
|
||||
posFixture.bookingOrderAssignments = Array.isArray(posFixture.bookingOrderAssignments)
|
||||
? posFixture.bookingOrderAssignments
|
||||
: [];
|
||||
posFixture.bookingOrderAssignments.push({
|
||||
id: bookingId,
|
||||
order_id: bookings[bookingIndex].order_id,
|
||||
});
|
||||
}
|
||||
|
||||
await route.fulfill(json({ success: true, data: bookingIndex >= 0 ? bookings[bookingIndex] : null }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/\/order-bookings\/\d+$/.test(pathname) && method === "GET") {
|
||||
const bookingId = Number(pathname.split("/").pop());
|
||||
const booking = (posFixture.orderBookings || []).find((entry) => entry.id === bookingId) || null;
|
||||
@@ -1255,6 +1281,32 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/order-bookings/complete") && method === "POST") {
|
||||
const body = request.postDataJSON?.() || {};
|
||||
const bookingId = Number(body.id || 0);
|
||||
const bookings = Array.isArray(posFixture.orderBookings) ? posFixture.orderBookings : [];
|
||||
const bookingIndex = bookings.findIndex((booking) => Number(booking.id) === bookingId);
|
||||
|
||||
if (bookingIndex >= 0) {
|
||||
bookings[bookingIndex] = {
|
||||
...bookings[bookingIndex],
|
||||
status: "completed",
|
||||
};
|
||||
posFixture.completedBookingIds = Array.isArray(posFixture.completedBookingIds)
|
||||
? posFixture.completedBookingIds
|
||||
: [];
|
||||
posFixture.completedBookingIds.push(bookingId);
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data: bookingIndex >= 0 ? bookings[bookingIndex] : { id: bookingId, status: "completed" },
|
||||
})
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/department/numberplatescanners") && method === "GET") {
|
||||
await route.fulfill(json({ success: true, data: posFixture.numberPlateScanners || [] }));
|
||||
return true;
|
||||
|
||||
@@ -7,6 +7,7 @@ const INVALID_RESET_TOKEN = "Invalid or expired token";
|
||||
const USER_TOKEN = "mock-user-session-token";
|
||||
const SUBUSER_TOKEN = "mock-subuser-session-token";
|
||||
const OPERATOR_TOKEN = "mock-operator-session-token";
|
||||
const AUTH_FORM_TIMEOUT = 15_000;
|
||||
|
||||
function json(route, body, status = 200) {
|
||||
return route.fulfill({
|
||||
@@ -71,6 +72,30 @@ async function settleAuthenticatedNavigation(page, token, targetPath, targetUrl)
|
||||
await expect.poll(() => page.url(), { timeout: 15_000 }).toMatch(targetUrl);
|
||||
}
|
||||
|
||||
async function openAuthPage(page, path, selector) {
|
||||
const field = page.locator(selector);
|
||||
await page.goto(path);
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
if (await field.isVisible().catch(() => false)) {
|
||||
return field;
|
||||
}
|
||||
|
||||
if (attempt === 0) {
|
||||
try {
|
||||
await page.goto(path, { waitUntil: "domcontentloaded" });
|
||||
} catch (error) {
|
||||
if (!isBenignNavigationError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await expect(field).toBeVisible({ timeout: AUTH_FORM_TIMEOUT });
|
||||
return field;
|
||||
}
|
||||
|
||||
async function installSessionRoute(page, { token, sessionData }) {
|
||||
await page.route("**/auth/session", async (route) => {
|
||||
const authorization = route.request().headers().authorization;
|
||||
@@ -194,7 +219,7 @@ async function loginAsMockUser(page) {
|
||||
await installSessionRoute(page, { token: USER_TOKEN, sessionData: { permissions: ["user"] } });
|
||||
await installUserLoginRoute(page);
|
||||
|
||||
await page.goto("/login");
|
||||
await openAuthPage(page, "/login", 'input[name="customer_number"]');
|
||||
await page.fill('input[name="customer_number"]', "12345679");
|
||||
await page.fill('input[name="password"]', "5679");
|
||||
await page.click('#login-button');
|
||||
@@ -214,7 +239,7 @@ async function loginAsMockSubuserByPhone(page) {
|
||||
});
|
||||
await installSubuserLoginRoute(page);
|
||||
|
||||
await page.goto("/login/driver");
|
||||
await openAuthPage(page, "/login/driver", 'input[name="phone_country_code"]');
|
||||
await page.fill('input[name="phone_country_code"]', "45");
|
||||
await page.fill('input[name="phone"]', "42331128");
|
||||
await page.fill('input[name="password"]', "Test1234");
|
||||
@@ -235,8 +260,9 @@ async function loginAsMockSubuserByUsername(page) {
|
||||
});
|
||||
await installSubuserLoginRoute(page);
|
||||
|
||||
await page.goto("/login/driver");
|
||||
await openAuthPage(page, "/login/driver", 'button[id="subuser_login_method_username_button"]');
|
||||
await page.click('#subuser_login_method_username_button');
|
||||
await expect(page.locator('input[name="username"]')).toBeVisible({ timeout: AUTH_FORM_TIMEOUT });
|
||||
await page.fill('input[name="username"]', "testsubuser");
|
||||
await page.fill('input[name="password"]', "Test1234");
|
||||
await page.click('#subuser-login-button');
|
||||
@@ -254,7 +280,7 @@ async function loginAsMockOperator(page) {
|
||||
});
|
||||
await installOperatorLoginRoute(page);
|
||||
|
||||
await page.goto("/admin/login");
|
||||
await openAuthPage(page, "/admin/login", 'input[name="user_id"]');
|
||||
await page.fill('input[name="user_id"]', "11");
|
||||
await page.fill('input[name="password"]', "aef18KHAPGiu90");
|
||||
await page.click('#operator_login_button');
|
||||
@@ -269,7 +295,7 @@ test("[AUTH][User][Customer number] should fail to login using invalid password"
|
||||
await preparePublicAuthFlow(page);
|
||||
await installUserLoginRoute(page, { outcome: "error" });
|
||||
|
||||
await page.goto("/login");
|
||||
await openAuthPage(page, "/login", 'input[name="customer_number"]');
|
||||
await page.fill('input[name="customer_number"]', "12345679");
|
||||
await page.fill('input[name="password"]', "invalidpassword");
|
||||
await page.click('#login-button');
|
||||
@@ -280,7 +306,7 @@ test("[AUTH][User][Customer number] should fail to login using invalid customer
|
||||
await preparePublicAuthFlow(page);
|
||||
await installUserLoginRoute(page, { outcome: "error" });
|
||||
|
||||
await page.goto("/login");
|
||||
await openAuthPage(page, "/login", 'input[name="customer_number"]');
|
||||
await page.fill('input[name="customer_number"]', "99999999999");
|
||||
await page.fill('input[name="password"]', "5679");
|
||||
await page.click('#login-button');
|
||||
@@ -291,7 +317,7 @@ test("[AUTH][User][Customer number] should request password reset successfully",
|
||||
await preparePublicAuthFlow(page);
|
||||
await installPasswordResetRequestRoute(page);
|
||||
|
||||
await page.goto("/login");
|
||||
await openAuthPage(page, "/login", 'input[name="customer_number"]');
|
||||
await page.click("#forgot-password-button");
|
||||
await expect(page).toHaveURL("/auth/password-reset");
|
||||
await page.fill('input[name="customer_number"]', "12345679");
|
||||
@@ -303,7 +329,7 @@ test("[AUTH][User][Customer number] should not show failure to request password
|
||||
await preparePublicAuthFlow(page);
|
||||
await installPasswordResetRequestRoute(page);
|
||||
|
||||
await page.goto("/login");
|
||||
await openAuthPage(page, "/login", 'input[name="customer_number"]');
|
||||
await page.click("#forgot-password-button");
|
||||
await expect(page).toHaveURL("/auth/password-reset");
|
||||
await page.fill('input[name="customer_number"]', "99999999999");
|
||||
|
||||
Reference in New Issue
Block a user