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:
Jeppe Bundgaard
2026-04-13 17:27:37 +02:00
parent ecfe27571c
commit 8f3ec2cf09
41 changed files with 4153 additions and 1412 deletions
+2 -1
View File
@@ -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
? {}
: {
+71
View File
@@ -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();
}
});
@@ -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>
@@ -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>
@@ -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,
@@ -81,4 +85,4 @@ export default defineComponent({
},
},
});
</script>
</script>
@@ -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>
</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,7 +20,8 @@ export type PosVehicle = {
last_order_id?: number;
// Booking ID (if applicable)
booking_id?: number;
booking_matches?: OrderBookingSummary[];
// additional data
[key: string]: any;
}
</script>
</script>
@@ -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;
@@ -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>
@@ -105,4 +142,4 @@ const toggleModal = () => {
<style scoped>
</style>
</style>
@@ -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
+11
View File
@@ -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"
+11
View File
@@ -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"
+11
View File
@@ -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"
+11
View File
@@ -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"
+11
View File
@@ -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"
+11
View File
@@ -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"
+158
View File
@@ -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");
+42 -4
View File
@@ -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"]');
+340
View File
@@ -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);
});
});
+654 -16
View File
@@ -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");
+76
View File
@@ -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,
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

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

+11 -1
View File
@@ -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();
+5 -2
View File
@@ -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");
});
});
+2 -2
View File
@@ -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
+4
View File
@@ -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: {
+52
View File
@@ -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;
+34 -8
View File
@@ -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");