Add e2e and unit tests for i18n coverage and POS workflows:

- Introduced i18n key usage validation with `tests/e2e/i18n.views.spec.ts` for deterministic locale coverage.
- Added `viewI18nKeyScanner` utility to scan and validate view translation keys.
- Created `PosDepartmentStep1` unit tests for duplicate warnings and booking selection flow.
- Enhanced POS mobile popup with `SelectOrderBookingPopupProps` and new header close options.
- Updated e2e tests with scenarios to verify booking selections, duplicate handling, and locale alignment.
- Added new `test:e2e:i18n:views` npm script for targeted i18n test execution.
This commit is contained in:
Jeppe Bundgaard
2026-04-14 10:49:20 +02:00
parent 7533622fb1
commit a4676f8f87
30 changed files with 2360 additions and 383 deletions
+1
View File
@@ -14,6 +14,7 @@
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"test:e2e:i18n:views": "playwright test tests/e2e/i18n.views.spec.ts --project=chromium-desktop",
"test:e2e:ci": "node scripts/run-playwright-ci-parallel.mjs",
"test:e2e:ci:serial": "playwright test --reporter=line,html",
"test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile",
+86
View File
@@ -39,6 +39,87 @@
gap: 0.75rem;
}
.pos-duplicate-warning {
margin: 0;
border: 1px solid #f2d38a;
border-radius: 0.95rem;
box-shadow: 0 10px 24px rgba(142, 108, 24, 0.08);
}
.pos-duplicate-warning__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.pos-duplicate-warning__header-copy {
display: inline-flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
}
.pos-duplicate-warning__icon {
color: #8a6300;
}
.pos-duplicate-warning__title {
margin: 0;
color: #604700;
font-size: 0.95rem;
font-weight: 700;
}
.pos-duplicate-warning__message {
margin: 0.75rem 0 0;
color: #5e5230;
line-height: 1.45;
}
.pos-duplicate-warning__details {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.85rem;
}
.pos-duplicate-warning__detail-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.9rem 1rem;
border: 1px solid rgba(142, 108, 24, 0.16);
border-radius: 0.8rem;
background: rgba(255, 255, 255, 0.8);
}
.pos-duplicate-warning__detail-copy {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.pos-duplicate-warning__detail-title {
margin: 0;
color: #2b3442;
font-weight: 700;
}
.pos-duplicate-warning__detail-content {
margin: 0;
color: #5c6a7c;
line-height: 1.45;
}
.pos-duplicate-warning__detail-actions {
margin: 0;
}
.pos-duplicate-warning__detail-actions .button {
margin: 0;
}
@media screen and (min-width: 1024px) {
.pos-rail--sticky {
position: sticky;
@@ -54,6 +135,11 @@
.pos-shell-actions {
grid-template-columns: minmax(0, 1fr);
}
.pos-duplicate-warning__header {
flex-direction: column;
align-items: stretch;
}
}
.box.has-sharp-edges.pos-card {
@@ -47,6 +47,7 @@ const createCustomerWishField = ({
isRequired,
warningStateWhenEmpty,
warningIconClass,
fillRow = false,
}) => {
const inputId = `${testIdBase}-input`;
const isEditing = ref(false);
@@ -104,6 +105,7 @@ const createCustomerWishField = ({
closeEditor,
testIdBase,
warningIconClass,
fillRow,
};
};
@@ -138,6 +140,7 @@ const safetySealField = createCustomerWishField({
isRequired: () => false,
warningStateWhenEmpty: null,
warningIconClass: "fas fa-shield-alt",
fillRow: true,
});
const fields = computed(() => {
@@ -148,7 +151,12 @@ const fields = computed(() => {
<template>
<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
v-for="field in fields"
:key="field.key"
class="pos-registration-slot"
:class="{ 'pos-registration-slot--fill-row': field.fillRow }"
>
<div
class="control pos-registration-control"
:class="{ 'is-loading': field.autosave.isSaving.value }"
@@ -228,4 +236,8 @@ const fields = computed(() => {
</div>
</template>
<style scoped></style>
<style scoped>
.pos-registration-slot--fill-row {
grid-column: 1 / -1;
}
</style>
@@ -22,7 +22,6 @@ import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue";
import { POS_STEP_1_VERSION } from "@/config.js";
import SelectVehicleFormPOS from "@/components/forms/department/pos/SelectVehicleFormPOS.vue";
import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/PosLastScannedLicensePlatesV2.vue";
import DefaultObjectSelector from "@/components/displays/modals/DefaultObjectSelector.vue";
import PosDesktopOrderBookingSelectorModal from "@/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
@@ -77,7 +76,7 @@ const desktopModalState = ref(null);
const duplicateCheckId = ref(0);
const duplicateOrders = ref([]);
const duplicateWarningKey = ref("");
const acknowledgedDuplicateWarningKey = ref("");
const duplicateDetailsExpanded = ref(false);
const pendingNextResolution = ref(false);
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
const isDesktopStep1Active = computed(() => getCurrentStep() === 1);
@@ -150,7 +149,7 @@ const setDesktopStep1Context = (context = {}) => {
if (!isDesktopStep1Active.value) {
duplicateOrders.value = [];
duplicateWarningKey.value = "";
acknowledgedDuplicateWarningKey.value = "";
duplicateDetailsExpanded.value = false;
desktopModalState.value = null;
return;
}
@@ -158,17 +157,15 @@ const setDesktopStep1Context = (context = {}) => {
if (!desktopStep1Context.value.reg1) {
duplicateOrders.value = [];
duplicateWarningKey.value = "";
acknowledgedDuplicateWarningKey.value = "";
if (desktopModalState.value !== "duplicate_details") {
desktopModalState.value = null;
}
duplicateDetailsExpanded.value = false;
desktopModalState.value = null;
return;
}
if (desktopStep1Context.value.requiresBookingSelection) {
duplicateOrders.value = [];
duplicateWarningKey.value = "";
acknowledgedDuplicateWarningKey.value = "";
duplicateDetailsExpanded.value = false;
desktopModalState.value = "booking_selection";
return;
}
@@ -284,33 +281,22 @@ const duplicateDetailsObjects = computed(() => {
}));
});
const desktopModalTitle = computed(() => {
if (desktopModalState.value === "duplicate_warning") {
return t("admin.pos.warning");
const isInlineDuplicateWarningVisible = computed(() => {
if (!isDesktopStep1Active.value || desktopModalState.value === "booking_selection") {
return false;
}
if (desktopModalState.value === "duplicate_details") {
return `${SessionUser.objects.global.language.possible_duplicates} - ${desktopStep1Context.value.reg1}`;
const normalizedContext = normalizeDuplicateContext(desktopStep1Context.value);
if (!normalizedContext.committed || !normalizedContext.reg1 || duplicateOrders.value.length === 0) {
return false;
}
return "";
return duplicateWarningKey.value === getDuplicateStateKey(normalizedContext);
});
const desktopModalMessage = computed(() => {
if (desktopModalState.value === "duplicate_warning") {
return t("admin.pos.duplicate_order_warning");
}
return "";
});
const desktopModalObjects = computed(() => {
if (desktopModalState.value === "duplicate_details") {
return duplicateDetailsObjects.value;
}
return [];
});
const duplicateDetailsToggleLabel = computed(() =>
duplicateDetailsExpanded.value ? t("admin.pos.hide_order_details") : t("admin.pos.show_order_details")
);
const resumePendingNextStep = async () => {
if (!pendingNextResolution.value) {
@@ -322,80 +308,16 @@ const resumePendingNextStep = async () => {
await nextStep({ isMobile: false, orderCreation: true });
};
const acceptDuplicateWarning = async () => {
acknowledgedDuplicateWarningKey.value = duplicateWarningKey.value || getDuplicateStateKey(desktopStep1Context.value);
desktopModalState.value = null;
await resumePendingNextStep();
const toggleDuplicateDetails = () => {
duplicateDetailsExpanded.value = !duplicateDetailsExpanded.value;
};
const cancelDuplicateWarning = () => {
desktopModalState.value = null;
pendingNextResolution.value = false;
};
const showDuplicateDetails = () => {
desktopModalState.value = "duplicate_details";
};
const showDuplicateWarning = () => {
desktopModalState.value = "duplicate_warning";
};
const desktopModalFooterButtons = computed(() => {
if (desktopModalState.value === "duplicate_warning") {
return [
{
label: t("admin.pos.continue"),
action: () => acceptDuplicateWarning(),
color: "primary",
testId: "pos-desktop-duplicate-warning-continue",
},
{
label: t("admin.pos.show_order_details"),
action: () => showDuplicateDetails(),
color: "dark",
testId: "pos-desktop-duplicate-warning-details",
},
{
label: t("admin.pos.cancel"),
action: () => cancelDuplicateWarning(),
color: "light",
testId: "pos-desktop-duplicate-warning-cancel",
},
];
}
if (desktopModalState.value === "duplicate_details") {
return [
{
label: "Tilbage",
action: () => showDuplicateWarning(),
color: "light",
testId: "pos-desktop-duplicate-details-back",
},
{
label: t("admin.pos.continue"),
action: () => acceptDuplicateWarning(),
color: "primary",
testId: "pos-desktop-duplicate-details-continue",
},
{
label: t("admin.pos.cancel"),
action: () => cancelDuplicateWarning(),
color: "light",
testId: "pos-desktop-duplicate-details-cancel",
},
];
}
return [];
});
const fetchDuplicateOrdersForContext = async (context) => {
const normalizedContext = normalizeDuplicateContext(context);
if (!normalizedContext.reg1) {
duplicateOrders.value = [];
duplicateWarningKey.value = "";
duplicateDetailsExpanded.value = false;
return [];
}
@@ -419,13 +341,18 @@ const fetchDuplicateOrdersForContext = async (context) => {
}
const orders = Array.isArray(response?.data?.data) ? response.data.data : [];
const nextDuplicateWarningKey = getDuplicateStateKey(normalizedContext);
if (duplicateWarningKey.value !== nextDuplicateWarningKey) {
duplicateDetailsExpanded.value = false;
}
duplicateOrders.value = orders;
duplicateWarningKey.value = getDuplicateStateKey(normalizedContext);
duplicateWarningKey.value = nextDuplicateWarningKey;
return orders;
} catch (error) {
console.error("Error checking for duplicate orders:", error);
duplicateOrders.value = [];
duplicateWarningKey.value = "";
duplicateDetailsExpanded.value = false;
return [];
}
};
@@ -435,6 +362,7 @@ const ensureDuplicateWarningState = async (context, options = {}) => {
if (!normalizedContext.reg1) {
duplicateOrders.value = [];
duplicateWarningKey.value = "";
duplicateDetailsExpanded.value = false;
if (desktopModalState.value !== "booking_selection") {
desktopModalState.value = null;
}
@@ -446,28 +374,15 @@ const ensureDuplicateWarningState = async (context, options = {}) => {
const orders = await fetchDuplicateOrdersForContext(normalizedContext);
if (orders.length === 0) {
if (desktopModalState.value === "duplicate_warning" || desktopModalState.value === "duplicate_details") {
desktopModalState.value = null;
}
duplicateDetailsExpanded.value = false;
return {
canProceed: true,
duplicateOrders: [],
};
}
if (acknowledgedDuplicateWarningKey.value === duplicateWarningKey.value) {
if (desktopModalState.value === "duplicate_warning" || desktopModalState.value === "duplicate_details") {
desktopModalState.value = null;
}
return {
canProceed: true,
duplicateOrders: orders,
};
}
showDuplicateWarning();
return {
canProceed: false,
canProceed: true,
duplicateOrders: orders,
};
};
@@ -479,10 +394,6 @@ const coordinateDesktopStep1 = async (options = {}) => {
...options,
};
if (normalizedOptions.reason === "next") {
pendingNextResolution.value = true;
}
const context =
(await selectVehicleFormRef.value?.finalizeDesktopStep1Context?.({
source: normalizedOptions.reason,
@@ -496,6 +407,8 @@ const coordinateDesktopStep1 = async (options = {}) => {
desktopModalState.value = null;
duplicateOrders.value = [];
duplicateWarningKey.value = "";
duplicateDetailsExpanded.value = false;
pendingNextResolution.value = false;
return {
canProceed: true,
context,
@@ -505,7 +418,9 @@ const coordinateDesktopStep1 = async (options = {}) => {
if (context.requiresBookingSelection || context.bookingResolution === "selection_required") {
duplicateOrders.value = [];
duplicateWarningKey.value = "";
duplicateDetailsExpanded.value = false;
desktopModalState.value = "booking_selection";
pendingNextResolution.value = normalizedOptions.reason === "next";
return {
canProceed: false,
context,
@@ -521,6 +436,7 @@ const coordinateDesktopStep1 = async (options = {}) => {
}
desktopModalState.value = null;
pendingNextResolution.value = false;
return {
canProceed: true,
context,
@@ -614,6 +530,16 @@ onMounted(() => {
onBeforeUnmount(() => {
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
});
watch(
isInlineDuplicateWarningVisible,
(isVisible) => {
if (!isVisible) {
duplicateDetailsExpanded.value = false;
}
},
{ immediate: true }
);
</script>
<template>
@@ -656,6 +582,61 @@ onBeforeUnmount(() => {
<Cancel tabindex="2" class="is-fullwidth" />
</ButtonsBox>
<div class="pos-shell-actions__rail">
<div
v-if="isInlineDuplicateWarningVisible"
class="notification is-warning is-light pos-duplicate-warning"
data-testid="pos-desktop-duplicate-warning-inline"
>
<div class="pos-duplicate-warning__header">
<div class="pos-duplicate-warning__header-copy">
<span class="icon pos-duplicate-warning__icon" aria-hidden="true">
<i class="fas fa-triangle-exclamation"></i>
</span>
<p class="pos-duplicate-warning__title">{{ t("admin.pos.warning") }}</p>
</div>
<button
type="button"
class="button is-small is-warning is-light"
data-testid="pos-desktop-duplicate-warning-toggle-details"
@click="toggleDuplicateDetails"
>
{{ duplicateDetailsToggleLabel }}
</button>
</div>
<p class="pos-duplicate-warning__message">
{{ t("admin.pos.duplicate_order_warning") }}
</p>
<div
v-if="duplicateDetailsExpanded"
class="pos-duplicate-warning__details"
data-testid="pos-desktop-duplicate-warning-details-inline"
>
<div
v-for="duplicateOrder in duplicateDetailsObjects"
:key="duplicateOrder.id"
class="pos-duplicate-warning__detail-card"
:data-testid="`pos-desktop-duplicate-warning-detail-${duplicateOrder.id}`"
>
<div class="pos-duplicate-warning__detail-copy">
<p class="pos-duplicate-warning__detail-title">{{ duplicateOrder.label }}</p>
<p class="pos-duplicate-warning__detail-content">{{ duplicateOrder.content }}</p>
</div>
<div class="buttons pos-duplicate-warning__detail-actions">
<button
v-for="button in duplicateOrder.buttons"
:key="button.label"
type="button"
class="button is-small"
:class="`is-${button.color || 'dark'}`"
:data-testid="button.testId"
@click="button.action()"
>
{{ button.label }}
</button>
</div>
</div>
</div>
</div>
<NextStepError class="is-fullwidth" :clear-automatically="true" :clear-delay="8000" />
<ButtonsBox class="pos-actions pos-actions--stacked">
<NextStep class="is-fullwidth" tabindex="6" />
@@ -675,19 +656,6 @@ onBeforeUnmount(() => {
@skip="handleBookingSkip"
@refresh-bookings="handleBookingRefresh"
/>
<DefaultObjectSelector
:isActive="
isDesktopStep1Active &&
(desktopModalState === 'duplicate_warning' || desktopModalState === 'duplicate_details')
"
:allowClose="false"
:teleportToBody="true"
:showRadio="false"
:title="desktopModalTitle"
:message="desktopModalMessage"
:objects="desktopModalObjects"
:footerButtons="desktopModalFooterButtons"
/>
</div>
</template>
@@ -17,7 +17,7 @@ import { useI18n } from "vue-i18n";
import PosDesktopOrderWorkspace from "@/components/displays/department/pos/order/PosDesktopOrderWorkspace.vue";
const { t } = useI18n();
const isGuestStripeCustomer = computed(() => Number(customer_id) === 999);
const isGuestStripeCustomer = computed(() => Number(customer_id.value) === 999);
</script>
<template>
@@ -3,6 +3,16 @@ import { popups } from '../objects/PosDepartmentStepMobileFlow.vue';
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
import { popupComponentKeyToComponent } from "@/components/displays/department/pos/steps/mobile/objects/PosPopup.vue";
const handleHeaderClose = () => {
const activePopup = popups.get();
if (activePopup?.onHeaderClose) {
activePopup.onHeaderClose();
return;
}
popups.clear();
};
</script>
<template>
@@ -14,9 +24,21 @@ import { popupComponentKeyToComponent } from "@/components/displays/department/p
<div class="card-header-icon">
<UnknownCustomer/>
</div>
<div class="card-header-title">
<div class="card-header-title popup-header-title">
{{ popups.get()?.title || 'Default Title' }}
</div>
<button
v-if="popups.get()?.showHeaderClose"
class="card-header-icon popup-header-close"
type="button"
:data-testid="popups.get()?.headerCloseTestId"
aria-label="Close popup"
@click.stop="handleHeaderClose"
>
<span class="icon is-small">
<i class="fas fa-times" aria-hidden="true"></i>
</span>
</button>
</template>
<template #content>
<component :is="popupComponentKeyToComponent(popups.get()?.component)" @close="popups.clear()" :style="{... popups.get()?.style || {} }"/>
@@ -86,6 +108,26 @@ import { popupComponentKeyToComponent } from "@/components/displays/department/p
flex-shrink: 0;
}
.popup-header-title {
flex: 1 1 auto;
min-width: 0;
}
.popup-header-close {
border: 0;
background: transparent;
color: #213047;
min-width: 44px;
min-height: 44px;
padding: 0.5rem;
margin-left: auto;
cursor: pointer;
}
.popup-header-close:hover {
color: #121b29;
}
.popup-content :deep(.card-content) {
flex: 1 1 auto;
min-height: 0;
@@ -1,22 +1,59 @@
<script setup lang="ts">
import { computed } from "vue";
import { popups } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { popups } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import {
getOrderBookingItemPreviewRows,
getOrderBookingItemPreviewTotal,
getOrderBookingNotesValue,
getOrderBookingPlateText,
getOrderBookingPoValue,
getOrderBookingReferenceValue,
getOrderBookingServiceText,
getOrderBookingServiceLabels,
hasOrderBookingDisplayDetails,
} from "@/components/displays/department/pos/utils/orderBookingDisplay.js";
type BookingRefreshResult = any[] | { bookings?: any[] } | void;
type OrderBookingPopupProps = {
bookings?: any[];
onSelect?: (booking: any) => Promise<void> | void;
onSkip?: () => Promise<void> | void;
onRefreshBookings?: () => Promise<BookingRefreshResult> | BookingRefreshResult;
departmentId?: number | string | null;
matchedPlate?: string;
};
const { t, locale } = useI18n();
const popupProps = computed(() => popups.get()?.props || {});
const bookings = computed(() => (Array.isArray(popupProps.value.bookings) ? popupProps.value.bookings : []));
const popupProps = computed<OrderBookingPopupProps>(() => {
return (popups.get()?.props || {}) as OrderBookingPopupProps;
});
const bookingsFromProps = computed(() => (Array.isArray(popupProps.value.bookings) ? popupProps.value.bookings : []));
const bookings = ref<any[]>([]);
const detailedBookingsById = ref<Record<number, any>>({});
const loadingBookingIds = ref<number[]>([]);
const failedBookingIds = ref<number[]>([]);
const isRefreshingBookings = ref(false);
const toPositiveInteger = (value: any) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const normalizePlateValue = (value: any) =>
String(value ?? "")
.trim()
.toUpperCase();
const formatBookingDateTime = (booking: any) => {
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
if (!rawValue) {
return t('admin.pos.not_found');
return t("admin.pos.not_found");
}
const parsedValue = new Date(rawValue);
@@ -25,53 +62,403 @@ const formatBookingDateTime = (booking: any) => {
}
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: 'short',
timeStyle: 'short',
dateStyle: "short",
timeStyle: "short",
}).format(parsedValue);
};
const onSelect = async (booking: any) => {
if (typeof popupProps.value.onSelect === 'function') {
await popupProps.value.onSelect(booking);
const getBookingDateValue = (booking: any) => {
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
if (!rawValue) {
return null;
}
const parsedValue = new Date(rawValue);
return Number.isNaN(parsedValue.getTime()) ? null : parsedValue;
};
const isSameCalendarDay = (left: Date | null, right: Date | null) => {
if (!(left instanceof Date) || !(right instanceof Date)) {
return false;
}
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
};
const getDetailedBooking = (booking: any) => {
const bookingId = toPositiveInteger(booking?.id);
return bookingId ? detailedBookingsById.value[bookingId] || null : null;
};
const getDisplayBooking = (booking: any) => {
const detailedBooking = getDetailedBooking(booking);
return detailedBooking ? { ...booking, ...detailedBooking } : booking;
};
const isBookingLoading = (booking: any) => {
const bookingId = toPositiveInteger(booking?.id);
return bookingId ? loadingBookingIds.value.includes(bookingId) : false;
};
const didBookingDetailFail = (booking: any) => {
const bookingId = toPositiveInteger(booking?.id);
return bookingId ? failedBookingIds.value.includes(bookingId) : false;
};
const getBookingServiceLabels = (booking: any) => getOrderBookingServiceLabels(getDisplayBooking(booking));
const getBookingItemPreviewRows = (booking: any) => getOrderBookingItemPreviewRows(getDisplayBooking(booking));
const getBookingItemPreviewTotal = (booking: any) => getOrderBookingItemPreviewTotal(getDisplayBooking(booking));
const getBookingNote = (booking: any) => getOrderBookingNotesValue(getDisplayBooking(booking));
const getBookingReference = (booking: any) => getOrderBookingReferenceValue(getDisplayBooking(booking));
const getBookingPo = (booking: any) => getOrderBookingPoValue(getDisplayBooking(booking));
const getBookingPlates = (booking: any) => getOrderBookingPlateText(getDisplayBooking(booking));
const formatCurrencyAmount = (value: any) => {
const parsedValue = Number(value);
if (!Number.isFinite(parsedValue)) {
return "-";
}
return SessionUser.functions.currency.toLocal(parsedValue);
};
const totalAmountLabel = computed(() => SessionUser.objects.orders.columns.total_net_amount?.label || "Total");
const resolvedBookingCount = computed(() => {
return bookings.value.filter((booking) => {
const displayBooking = getDisplayBooking(booking);
return hasOrderBookingDisplayDetails(displayBooking) || !isBookingLoading(booking) || didBookingDetailFail(booking);
}).length;
});
const isLoadingAnyBookingDetails = computed(() => loadingBookingIds.value.length > 0);
const isAnyBookingFetchInProgress = computed(() => isLoadingAnyBookingDetails.value || isRefreshingBookings.value);
const sortedBookings = computed(() => {
const now = new Date();
return bookings.value
.map((booking, index) => {
const displayBooking = getDisplayBooking(booking);
const bookingDate = getBookingDateValue(displayBooking);
const isToday = isSameCalendarDay(bookingDate, now);
return {
booking,
index,
bookingDate,
isToday,
};
})
.sort((left, right) => {
if (left.isToday !== right.isToday) {
return left.isToday ? -1 : 1;
}
if (left.bookingDate && right.bookingDate) {
const timeDifference = left.bookingDate.getTime() - right.bookingDate.getTime();
if (timeDifference !== 0) {
return timeDifference;
}
} else if (left.bookingDate || right.bookingDate) {
return left.bookingDate ? -1 : 1;
}
return left.index - right.index;
})
.map((entry) => entry.booking);
});
const fetchBookingDetails = async (bookingsToInspect: any[]) => {
const pendingBookings = (Array.isArray(bookingsToInspect) ? bookingsToInspect : []).filter(Boolean);
const bookingsToLoad = pendingBookings.filter((booking) => {
const bookingId = toPositiveInteger(booking?.id);
if (!bookingId) {
return false;
}
if (detailedBookingsById.value[bookingId]) {
return false;
}
if (loadingBookingIds.value.includes(bookingId)) {
return false;
}
return true;
});
if (bookingsToLoad.length === 0) {
return;
}
const bookingIdsToLoad = bookingsToLoad
.map((booking) => toPositiveInteger(booking?.id))
.filter((bookingId): bookingId is number => bookingId !== null);
loadingBookingIds.value = [...new Set([...loadingBookingIds.value, ...bookingIdsToLoad])];
const bookingResults = await Promise.allSettled(
bookingsToLoad.map((booking) =>
SessionUser.objects.order_bookings.get.single(toPositiveInteger(booking?.id), {
department_id: popupProps.value.departmentId ?? null,
})
)
);
const nextDetailedBookings = {
...detailedBookingsById.value,
};
const nextFailedBookingIds = new Set(failedBookingIds.value);
bookingsToLoad.forEach((booking, index) => {
const bookingId = bookingIdsToLoad[index];
const result = bookingResults[index];
if (result.status === "fulfilled" && result.value?.id) {
nextDetailedBookings[result.value.id] = result.value;
nextFailedBookingIds.delete(result.value.id);
return;
}
if (bookingId) {
nextFailedBookingIds.add(bookingId);
}
});
detailedBookingsById.value = nextDetailedBookings;
failedBookingIds.value = Array.from(nextFailedBookingIds);
loadingBookingIds.value = loadingBookingIds.value.filter((bookingId) => !bookingIdsToLoad.includes(bookingId));
};
watch(
bookingsFromProps,
(nextBookings) => {
bookings.value = Array.isArray(nextBookings) ? [...nextBookings] : [];
},
{ immediate: true }
);
watch(
() => bookings.value.map((booking) => booking?.id).join("|"),
() => {
void fetchBookingDetails(bookings.value);
},
{ immediate: true }
);
const onSelect = async (booking: any) => {
if (typeof popupProps.value.onSelect === "function") {
await popupProps.value.onSelect(getDisplayBooking(booking));
}
};
const emitRefreshBookings = async () => {
if (typeof popupProps.value.onRefreshBookings !== "function") {
return;
}
isRefreshingBookings.value = true;
try {
const refreshResult = await popupProps.value.onRefreshBookings();
if (Array.isArray(refreshResult)) {
bookings.value = refreshResult;
return;
}
if (Array.isArray(refreshResult?.bookings)) {
bookings.value = refreshResult.bookings;
}
} finally {
isRefreshingBookings.value = false;
}
};
const getActionBooking = (booking: any) => getDisplayBooking(booking);
const getActionCustomerNumber = (booking: any) => {
const displayBooking = getActionBooking(booking);
return toPositiveInteger(displayBooking?.customer_number ?? displayBooking?.customer_id);
};
const getActionDepartmentId = (booking: any) => {
const displayBooking = getActionBooking(booking);
return toPositiveInteger(displayBooking?.department ?? popupProps.value.departmentId);
};
const isBookingScheduledForToday = (booking: any) => {
return isSameCalendarDay(getBookingDateValue(getDisplayBooking(booking)), new Date());
};
</script>
<template>
<div class="booking-popup" data-testid="pos-mobile-order-booking-popup">
<p class="booking-popup__intro">{{ popups.get()?.message || t('admin.pos.order_booking_selector.help_text') }}</p>
<p class="booking-popup__intro">
{{ popups.get()?.message || t("admin.pos.order_booking_selector.help_text") }}
</p>
<div
v-if="isAnyBookingFetchInProgress"
class="booking-popup__loading"
data-testid="pos-mobile-order-booking-loading"
>
<span class="icon is-small">
<i class="fas fa-spinner fa-spin" aria-hidden="true" />
</span>
<span>
{{ SessionUser.objects.global.language.loading }}
{{ resolvedBookingCount }} / {{ bookings.length }}
</span>
</div>
<div class="booking-popup__list" data-testid="pos-mobile-order-booking-list">
<div
v-for="booking in bookings"
<article
v-for="booking in sortedBookings"
:key="booking.id"
class="booking-option"
:class="{ 'booking-option--today': isBookingScheduledForToday(booking) }"
: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 class="booking-option__header">
<div class="booking-option__header-main">
<p class="booking-option__eyebrow">Booking #{{ booking.id }}</p>
<h3 class="booking-option__title">{{ formatBookingDateTime(getDisplayBooking(booking)) }}</h3>
</div>
<div class="booking-option__header-actions">
<span
v-if="isBookingScheduledForToday(booking)"
class="tag booking-option__today-tag"
:data-testid="`pos-mobile-order-booking-today-${booking.id}`"
>
{{ t("global.time.today") }}
</span>
<span v-if="didBookingDetailFail(booking)" class="tag is-light booking-option__tag">
{{ t("admin.pos.not_found") }}
</span>
<div class="booking-option__settings" :data-testid="`pos-mobile-order-booking-settings-${booking.id}`">
<ActionSettingsWheelButton
:order_booking_id="toPositiveInteger(getActionBooking(booking)?.id)"
:order_id="toPositiveInteger(getActionBooking(booking)?.order_id)"
:customer_number="getActionCustomerNumber(booking)"
:department_id="getActionDepartmentId(booking)"
:reg_1="getActionBooking(booking)?.reg_1 ?? null"
:reg_2="getActionBooking(booking)?.reg_2 ?? null"
:refreshFunction="emitRefreshBookings"
:icon="'fas fa-ellipsis-v'"
trigger-button-variant="text"
>
<template #actions />
</ActionSettingsWheelButton>
</div>
</div>
</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') }}:
{{ getOrderBookingPlateText(booking) || t('admin.pos.not_found') }}
</div>
<div class="booking-option__details">
{{ t('admin.pos.order_booking_selector.reference_label') }}: {{ getOrderBookingReferenceValue(booking) || t('admin.pos.not_found') }}
</div>
<div v-if="getOrderBookingServiceText(booking)" class="booking-option__details">
{{ t('admin.pos.order_booking_selector.services_label') }}: {{ getOrderBookingServiceText(booking) }}
</div>
<button
class="button is-primary is-fullwidth mt-3"
type="button"
:data-testid="`pos-mobile-order-booking-use-${booking.id}`"
@click="onSelect(booking)"
<p class="booking-option__customer">
{{ getDisplayBooking(booking)?.customer_name || t("admin.pos.not_found") }}
</p>
<dl class="booking-option__meta-grid">
<div class="booking-option__meta-item">
<dt>{{ t("admin.pos.order_booking_selector.plates_label") }}</dt>
<dd>{{ getBookingPlates(booking) || t("admin.pos.not_found") }}</dd>
</div>
<div class="booking-option__meta-item">
<dt>{{ t("admin.pos.order_booking_selector.reference_label") }}</dt>
<dd>{{ getBookingReference(booking) || "-" }}</dd>
</div>
<div v-if="getBookingPo(booking)" class="booking-option__meta-item">
<dt>{{ SessionUser.objects.orders.columns.po.label }}</dt>
<dd>{{ getBookingPo(booking) }}</dd>
</div>
<div v-if="getBookingNote(booking)" class="booking-option__meta-item">
<dt>{{ SessionUser.objects.orders.columns.notes.label }}</dt>
<dd>{{ getBookingNote(booking) }}</dd>
</div>
</dl>
<div
v-if="getBookingItemPreviewRows(booking).length > 0 || getBookingServiceLabels(booking).length > 0 || isBookingLoading(booking)"
class="booking-option__services"
:data-testid="`pos-mobile-order-booking-services-${booking.id}`"
>
{{ t('admin.pos.order_booking_selector.use_booking') }}
</button>
</div>
<div class="booking-option__section-header">
<p class="booking-option__section-title">{{ t("admin.pos.order_booking_selector.services_label") }}</p>
<div
v-if="!isBookingLoading(booking) && getBookingItemPreviewTotal(booking) !== null"
class="booking-option__section-total"
:data-testid="`pos-mobile-order-booking-service-total-${booking.id}`"
>
<span>{{ totalAmountLabel }}</span>
<strong>{{ formatCurrencyAmount(getBookingItemPreviewTotal(booking)) }}</strong>
</div>
</div>
<div v-if="isBookingLoading(booking)" class="booking-option__item-preview-skeleton">
<div class="booking-option__item-preview-row is-skeleton" />
<div class="booking-option__item-preview-row is-skeleton" />
</div>
<div v-else-if="getBookingItemPreviewRows(booking).length > 0" class="booking-option__item-preview">
<div class="booking-option__item-preview-head">
<span>{{ t("global.quantity") }}</span>
<span>{{ t("admin.pos.order_booking_selector.services_label") }}</span>
<span>{{ t("admin.pos.price_dkk") }}</span>
</div>
<div
v-for="(item, index) in getBookingItemPreviewRows(booking)"
:key="item.key"
class="booking-option__item-preview-row"
:data-testid="`pos-mobile-order-booking-service-row-${booking.id}-${index}`"
>
<span class="booking-option__item-preview-qty">{{ item.quantity }}x</span>
<div class="booking-option__item-preview-main">
<span
class="booking-option__item-preview-name"
:class="{ 'booking-option__item-preview-name--addon': item.depth > 0 }"
>
{{ item.depth > 0 ? "+ " : "" }}{{ item.label }}
</span>
<span v-if="item.unitPrice !== null && item.quantity > 1" class="booking-option__item-preview-meta">
{{ formatCurrencyAmount(item.unitPrice) }} / stk
</span>
</div>
<span class="booking-option__item-preview-amount">
{{ item.totalPrice !== null ? formatCurrencyAmount(item.totalPrice) : "-" }}
</span>
</div>
</div>
<div v-else class="booking-option__service-list">
<span
v-for="service in getBookingServiceLabels(booking)"
:key="`${booking.id}-${service}`"
class="booking-option__service-pill"
>
{{ service }}
</span>
</div>
</div>
<div class="booking-option__actions">
<button
class="button is-primary is-fullwidth"
type="button"
:class="{ 'is-loading': isBookingLoading(booking) }"
:disabled="isBookingLoading(booking)"
:data-testid="`pos-mobile-order-booking-use-${booking.id}`"
@click="onSelect(booking)"
>
{{ t("admin.pos.order_booking_selector.use_booking") }}
</button>
</div>
</article>
</div>
</div>
</template>
@@ -84,12 +471,25 @@ const onSelect = async (booking: any) => {
}
.booking-popup__intro {
margin-bottom: 0.85rem;
margin-bottom: 0.75rem;
color: #4c5a6e;
font-size: 0.98rem;
line-height: 1.45;
}
.booking-popup__loading {
display: inline-flex;
align-items: center;
gap: 0.55rem;
margin-bottom: 0.8rem;
padding: 0.45rem 0.75rem;
border-radius: 999px;
background: rgba(15, 35, 77, 0.08);
color: #234;
font-size: 0.88rem;
font-weight: 600;
}
.booking-popup__list {
display: flex;
flex-direction: column;
@@ -97,19 +497,286 @@ const onSelect = async (booking: any) => {
}
.booking-option {
border: 1px solid #d8dde6;
border-radius: 8px;
padding: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.7rem;
border: 1px solid #d7e2f0;
border-radius: 14px;
padding: 0.85rem;
background: #fff;
box-shadow: 0 8px 22px rgba(21, 42, 76, 0.08);
overflow: visible;
}
.booking-option--today {
border-color: #3d7ef0;
background: linear-gradient(180deg, rgba(61, 126, 240, 0.08), rgba(61, 126, 240, 0.02)), #fff;
}
.booking-option__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.65rem;
}
.booking-option__header-main {
min-width: 0;
}
.booking-option__header-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.4rem;
flex-shrink: 0;
}
.booking-option__eyebrow {
margin: 0;
color: #5c6d84;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.booking-option__title {
margin: 0.15rem 0 0;
color: #0f234d;
font-size: 1rem;
font-weight: 800;
line-height: 1.25;
}
.booking-option__today-tag {
border-radius: 999px;
border: 1px solid rgba(61, 126, 240, 0.2);
background: rgba(61, 126, 240, 0.12);
color: #18408b;
font-weight: 700;
color: #1f2a37;
}
.booking-option__tag {
color: #49576b;
}
.booking-option__settings :deep(.dropdown-menu) {
z-index: 30;
}
.booking-option__settings :deep(.action-settings-wheel-trigger--text) {
border-radius: 999px;
padding: 0.2rem;
}
.booking-option__customer {
margin: 0;
color: #1b314f;
font-size: 0.95rem;
font-weight: 700;
}
.booking-option__meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
margin: 0;
}
.booking-option__meta-item dt,
.booking-option__section-title {
margin: 0 0 0.25rem;
color: #6b7c92;
font-size: 0.71rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.booking-option__meta-item dd {
margin: 0;
color: #14243b;
font-size: 0.9rem;
line-height: 1.4;
word-break: break-word;
}
.booking-option__section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
margin-bottom: 0.4rem;
}
.booking-option__details {
color: #52606d;
font-size: 0.95rem;
.booking-option__section-total {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
color: #173559;
font-size: 0.71rem;
font-weight: 700;
}
.booking-option__section-total strong {
color: #0f234d;
font-size: 0.85rem;
}
.booking-option__item-preview {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.booking-option__item-preview-head,
.booking-option__item-preview-row {
display: grid;
grid-template-columns: 2.5rem minmax(0, 1fr) auto;
gap: 0.45rem;
align-items: center;
}
.booking-option__item-preview-head {
padding: 0 0.1rem;
color: #6b7c92;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.booking-option__item-preview-head span:last-child {
text-align: right;
}
.booking-option__item-preview-row {
padding: 0.45rem 0.55rem;
border: 1px solid #e5edf7;
border-radius: 10px;
background: linear-gradient(180deg, #fdfefe 0%, #f7fbff 100%);
}
.booking-option__item-preview-qty {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 1.55rem;
padding: 0.1rem 0.38rem;
border-radius: 999px;
background: #e9f1fb;
color: #18408b;
font-size: 0.74rem;
font-weight: 800;
}
.booking-option__item-preview-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.booking-option__item-preview-name {
color: #14243b;
font-size: 0.84rem;
font-weight: 700;
line-height: 1.2;
word-break: break-word;
}
.booking-option__item-preview-name--addon {
color: #24456e;
font-weight: 600;
}
.booking-option__item-preview-meta {
color: #6a7890;
font-size: 0.72rem;
font-weight: 600;
}
.booking-option__item-preview-amount {
color: #0f234d;
font-size: 0.84rem;
font-weight: 800;
text-align: right;
white-space: nowrap;
}
.booking-option__item-preview-skeleton {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.booking-option__item-preview-row.is-skeleton {
min-height: 2.7rem;
border-color: transparent;
color: transparent;
background: linear-gradient(90deg, #ebf0f7 0%, #f7f9fc 50%, #ebf0f7 100%);
background-size: 200% 100%;
animation: mobile-booking-selector-pulse 1.2s linear infinite;
}
.booking-option__service-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.booking-option__service-pill {
display: inline-flex;
align-items: center;
min-height: 1.85rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
background: #eef4fb;
color: #173559;
font-size: 0.82rem;
font-weight: 600;
line-height: 1.2;
}
.booking-option__actions {
margin-top: 0.1rem;
}
.booking-option__actions .button {
border-radius: 10px;
font-weight: 700;
}
@keyframes mobile-booking-selector-pulse {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media screen and (max-width: 420px) {
.booking-option__meta-grid {
grid-template-columns: minmax(0, 1fr);
}
.booking-option__section-header {
align-items: flex-start;
flex-direction: column;
}
.booking-option__item-preview-head,
.booking-option__item-preview-row {
grid-template-columns: 2.3rem minmax(0, 1fr);
}
.booking-option__item-preview-head span:last-child,
.booking-option__item-preview-amount {
grid-column: 2;
text-align: left;
}
}
</style>
@@ -287,6 +287,8 @@ const addDefaultPopups = () => {
title: 'Vælg køretøj',
message: 'Vælg venligst et køretøj til denne transaktion',
component: 'select_vehicle', // This component should handle vehicle selection
showHeaderClose: true,
headerCloseTestId: 'pos-mobile-select-vehicle-header-close',
style: {height: '50vh'},
actionButtons: [
{...defaultActionButtons.value.confirm},
@@ -27,6 +27,15 @@ import PosDepartmentStepMobilePopupSelectOrderBooking
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 type SelectOrderBookingPopupProps = {
bookings?: any[];
onSelect?: (booking: any) => Promise<void> | void;
onSkip?: () => Promise<void> | void;
onRefreshBookings?: () => Promise<any[] | { bookings?: any[] } | void> | any[] | { bookings?: any[] } | void;
departmentId?: number | null;
matchedPlate?: string;
};
export const PopupComponents = {
select_customer: PosDepartmentStepMobilePopupSelectCustomer,
select_order_booking: PosDepartmentStepMobilePopupSelectOrderBooking,
@@ -53,6 +62,9 @@ export type PosPopup = {
status?: VehicleStatusKey;
hideHeader?: boolean;
hideFooter?: boolean;
showHeaderClose?: boolean;
onHeaderClose?: () => void;
headerCloseTestId?: string;
style?: Record<string, any>; // CSS styles
/** Action buttons */
actionButtons?: PosActionButton[];
@@ -12,10 +12,11 @@ const props = defineProps({
const { loadList } = usePaginatedListInstance();
const canEditDetails = () => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT");
const canEditPermissions = () => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT");
const canDisableAccess = () => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_DELETE");
const canResendInvite = () => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT");
const canResendInvite = (subuser) =>
(SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
const formatDateTime = (dateString) => {
if (!dateString) {
@@ -27,12 +28,28 @@ const formatDateTime = (dateString) => {
const formatPhone = (subuser) => {
if (!subuser?.phone_country_code || !subuser?.phone) {
return "-";
return subuser?.setup_required ? "Oplyses ved accept" : "-";
}
return `+${subuser.phone_country_code} ${subuser.phone}`;
};
const formatUsername = (subuser) => {
if (subuser?.username) {
return `@${subuser.username}`;
}
return subuser?.setup_required ? "Brugernavn oplyses ved accept" : "-";
};
const formatEmail = (subuser) => {
if (subuser?.email) {
return subuser.email;
}
return subuser?.setup_required ? "E-mail oplyses ved accept" : "-";
};
const permissionSummary = (subuser) =>
SessionUser.objects.subusers.functions.permissionSummary(subuser?.grant_permissions || []);
@@ -66,10 +83,6 @@ const refreshList = async () => {
await loadList();
};
const onEditSubuser = async (subuser) => {
await SessionUser.objects.subusers.functions.showEditForm(subuser, refreshList);
};
const onEditPermissions = async (subuser) => {
if (!subuser?.grant_id) {
return;
@@ -79,7 +92,7 @@ const onEditPermissions = async (subuser) => {
{
id: subuser.grant_id,
permissions: subuser.grant_permissions || [],
name: subuser.name || subuser.username || `Chauffør ${subuser.id}`,
name: subuser.name || formatPhone(subuser) || `Chauffør ${subuser.id}`,
},
refreshList
);
@@ -178,12 +191,16 @@ const onResendInvite = async (subuser) => {
<td>
<div class="has-text-weight-semibold">{{ subuser.name || "-" }}</div>
<div class="is-size-7 has-text-grey">@{{ subuser.username || "ingen-bruger" }}</div>
<div class="is-size-7 has-text-grey" :data-testid="`subuser-username-${subuser.id}`">
{{ formatUsername(subuser) }}
</div>
</td>
<td>
<div>{{ subuser.email || "-" }}</div>
<div class="is-size-7 has-text-grey">{{ formatPhone(subuser) }}</div>
<div :data-testid="`subuser-email-${subuser.id}`">{{ formatEmail(subuser) }}</div>
<div class="is-size-7 has-text-grey" :data-testid="`subuser-phone-${subuser.id}`">
{{ formatPhone(subuser) }}
</div>
</td>
<td>
@@ -217,16 +234,6 @@ const onResendInvite = async (subuser) => {
<td>
<div class="buttons is-justify-content-flex-end action-buttons">
<button
v-if="canEditDetails()"
class="button is-small"
type="button"
:data-testid="`subuser-edit-${subuser.id}`"
@click="onEditSubuser(subuser)"
>
Redigér
</button>
<button
v-if="canEditPermissions() && subuser.grant_id"
class="button is-small"
@@ -238,7 +245,7 @@ const onResendInvite = async (subuser) => {
</button>
<button
v-if="canResendInvite()"
v-if="canResendInvite(subuser)"
class="button is-small"
type="button"
:data-testid="`subuser-resend-${subuser.id}`"
@@ -3,7 +3,7 @@
import {defineComponent, defineEmits, defineProps, ref, watch, onMounted} from "vue";
import { useI18n } from "vue-i18n";
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, getVehiclePlateBookings, getPreferredVehiclePlateBooking, searchAndSelectCustomer, reg_1, reg_2 } from "@/components/shop/POSDepartmentProcess.vue";
import { vehicles_matching, searchVehicle, register_new_search, is_latest_search, clearCustomerSelection, isSearching, pendingBookings, loadPendingBookings, doesVehiclePlateHaveBooking, getVehiclePlateBookings, getPreferredVehiclePlateBooking, searchAndSelectCustomer, reg_1, reg_2, department_id } from "@/components/shop/POSDepartmentProcess.vue";
import { metadata, popups, setCustomerId, pos, actionButtons } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import {PosSearchResult} from "@/components/displays/department/pos/steps/mobile/objects/PosSearchResult.vue";
defineComponent({
@@ -138,22 +138,75 @@ const continueWithoutBookingSelection = (result: PosSearchResult) => {
});
};
const refreshOrderBookingSelection = async (result: PosSearchResult) => {
await loadPendingBookings();
const normalizedRegistrationNumber = normalizeRegistrationNumber(result?.registrationNumber || props.searchQuery);
const refreshedMatches = normalizedRegistrationNumber ? getVehiclePlateBookings(normalizedRegistrationNumber) : [];
const refreshedResult = {
...result,
bookingMatches: refreshedMatches,
};
if (refreshedMatches.length === 1) {
await applySelectedBooking(refreshedMatches[0], refreshedResult);
return {
handled: true,
bookings: refreshedMatches,
};
}
if (refreshedMatches.length === 0) {
continueWithoutBookingSelection(refreshedResult);
return {
handled: true,
bookings: [],
};
}
return {
handled: false,
bookings: refreshedMatches,
};
};
const openOrderBookingPopup = (result: PosSearchResult) => {
let activeResult: PosSearchResult = {
...result,
bookingMatches: Array.isArray(result.bookingMatches) ? result.bookingMatches : [],
};
const updateActiveResultMatches = (bookings: any[]) => {
activeResult = {
...activeResult,
bookingMatches: Array.isArray(bookings) ? bookings : [],
};
return activeResult.bookingMatches;
};
pos.views.manualInput.value = false;
popups.select('select_order_booking', {
title: popups.getByKey('select_order_booking')?.title,
message: popups.getByKey('select_order_booking')?.message,
showHeaderClose: true,
onHeaderClose: () => continueWithoutBookingSelection(activeResult),
headerCloseTestId: 'pos-mobile-order-booking-header-close',
props: {
bookings: result.bookingMatches || [],
onSelect: (booking: any) => applySelectedBooking(booking, result),
onSkip: () => continueWithoutBookingSelection(result),
bookings: updateActiveResultMatches(result.bookingMatches || []),
departmentId: Number.parseInt(String(department_id.value ?? ''), 10) || null,
matchedPlate: normalizeRegistrationNumber(activeResult.registrationNumber || props.searchQuery),
onSelect: (booking: any) => applySelectedBooking(booking, activeResult),
onSkip: () => continueWithoutBookingSelection(activeResult),
onRefreshBookings: async () => {
const refreshOutcome = await refreshOrderBookingSelection(activeResult);
return updateActiveResultMatches(refreshOutcome.bookings);
},
},
actionButtons: [
{
...actionButtons.default.value.cancel,
label: t('admin.pos.order_booking_selector.continue_without_booking'),
testId: 'pos-mobile-order-booking-skip',
onClick: () => continueWithoutBookingSelection(result),
onClick: () => continueWithoutBookingSelection(activeResult),
},
],
});
@@ -2,8 +2,6 @@
import Swal from "sweetalert2";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const escapeHtml = (value) => String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
@@ -11,7 +9,7 @@ const escapeHtml = (value) => String(value ?? "")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const buildSubuserFormHtml = (defaults = {}) => `
const buildInviteFormHtml = (defaults = {}) => `
<div class="subuser-form">
<div class="field">
<label class="label">Navn</label>
@@ -37,27 +35,16 @@ const buildSubuserFormHtml = (defaults = {}) => `
</div>
</div>
</div>
<div class="field">
<label class="label">Brugernavn</label>
<div class="control">
<input id="subuser-form-username" class="input" type="text" value="${escapeHtml(defaults.username || "")}" placeholder="Valgfrit brugernavn" />
</div>
</div>
<div class="field">
<label class="label">E-mail</label>
<div class="control">
<input id="subuser-form-email" class="input" type="email" value="${escapeHtml(defaults.email || "")}" placeholder="Valgfri e-mailadresse" />
</div>
</div>
<p class="help">
Chaufføren ejer selv sin konto. E-mail, brugernavn og øvrige profiloplysninger udfyldes af chaufføren ved accept.
</p>
</div>
`;
const readSubuserFormValues = () => {
const readInviteFormValues = () => {
const name = document.getElementById("subuser-form-name")?.value?.trim() || "";
const phoneCountryCodeRaw = document.getElementById("subuser-form-phone-country-code")?.value?.trim() || "";
const phoneRaw = document.getElementById("subuser-form-phone")?.value?.trim() || "";
const username = document.getElementById("subuser-form-username")?.value?.trim() || "";
const email = document.getElementById("subuser-form-email")?.value?.trim() || "";
if (name.length < 3) {
Swal.showValidationMessage("Navn skal være mindst 3 tegn.");
@@ -74,22 +61,10 @@ const readSubuserFormValues = () => {
return null;
}
if (username && username.length < 3) {
Swal.showValidationMessage("Brugernavn skal være mindst 3 tegn.");
return null;
}
if (email && !emailRegex.test(email)) {
Swal.showValidationMessage("Indtast en gyldig e-mailadresse.");
return null;
}
return {
name,
phone_country_code: Number.parseInt(phoneCountryCodeRaw, 10),
phone: Number.parseInt(phoneRaw, 10),
username: username || null,
email: email || null,
};
};
@@ -99,7 +74,9 @@ const showInviteFeedback = async (response) => {
const setupLink = invite.setup_link;
const statusText = delivery.status === "sent"
? "Invitationen blev sendt på SMS."
: (delivery.message || "SMS blev ikke sendt. Brug opsætningslinket nedenfor.");
: delivery.status === "not_required"
? (delivery.message || "Chaufførkontoen er allerede accepteret. Kun adgangen blev opdateret.")
: (delivery.message || "SMS blev ikke sendt. Brug opsætningslinket nedenfor.");
const html = `
<p>${escapeHtml(statusText)}</p>
@@ -114,17 +91,17 @@ const showInviteFeedback = async (response) => {
});
};
const submitSubuserForm = async ({ title, confirmButtonText, defaults = {}, endpoint, method, payloadBuilder }) => {
const submitInviteForm = async ({ title, confirmButtonText, defaults = {}, endpoint, method, payloadBuilder }) => {
const result = await Swal.fire({
title,
html: buildSubuserFormHtml(defaults),
html: buildInviteFormHtml(defaults),
width: 640,
focusConfirm: false,
showCancelButton: true,
confirmButtonText,
cancelButtonText: "Annuller",
preConfirm: async () => {
const values = readSubuserFormValues();
const values = readInviteFormValues();
if (!values) {
return false;
}
@@ -166,7 +143,7 @@ export const Subusers = {
return `${normalized.slice(0, 3).join(", ")} +${normalized.length - 3}`;
},
async showInviteForm(refreshCallback = null) {
const response = await submitSubuserForm({
const response = await submitInviteForm({
title: "Invitér chauffør",
confirmButtonText: "Send invitation",
endpoint: "/subusers/invite",
@@ -185,35 +162,6 @@ export const Subusers = {
return response;
},
async showEditForm(subuser, refreshCallback = null) {
const response = await submitSubuserForm({
title: `Redigér ${subuser?.name || "chauffør"}`,
confirmButtonText: "Gem ændringer",
defaults: subuser || {},
endpoint: "/subusers",
method: "PUT",
payloadBuilder: (values) => ({
id: subuser.id,
...values,
}),
});
if (!response) {
return null;
}
await Swal.fire({
title: "Chauffør opdateret",
icon: "success",
confirmButtonText: "Luk",
});
if (typeof refreshCallback === "function") {
await refreshCallback();
}
return response;
},
async resendInvite(subuser, refreshCallback = null) {
try {
const response = await authenticatedRequest("/subusers/invite/resend", "POST", { id: subuser.id });
+1
View File
@@ -340,6 +340,7 @@
"details": "Detaljer",
"download": "Last ned",
"duplicate_order_warning": "Er du sikker på, at dette ikke allerede er oprettet? Mulige dubletter af denne ordre er fundet. Du kan fortsætte med at oprette ordren eller se ordredetaljer.",
"hide_order_details": "Skjul ordredetaljer",
"duplicates": {
"subtitle": "Håndter dubletter",
"title": "Dubletter"
+11
View File
@@ -340,6 +340,7 @@
"details": "Details",
"download": "Herunterladen",
"duplicate_order_warning": "Sind Sie sicher, dass dies nicht bereits erfasst wurde? Es wurden m?gliche Duplikate dieses Auftrags gefunden. Sie k?nnen den Auftrag weiter erfassen oder die Auftragsdetails ansehen.",
"hide_order_details": "Auftragsdetails ausblenden",
"duplicates": {
"subtitle": "M?gliche Duplikate bearbeiten",
"title": "M?gliche Duplikate"
@@ -1454,6 +1455,7 @@
},
"overview": {
"cards": "Karten",
"charts": "Diagramme",
"list": "Liste",
"subtitle": "Sehen Sie, wie Ihre Abteilungen performen",
"title": "?bersicht"
@@ -2843,6 +2845,7 @@
"confirm_and_start": "Best?tigen und W?sche starten",
"customer_number": "Kundennummer",
"department_no_self_wash": "Diese Abteilung unterst?tzt keine Selbstw?sche.",
"department_no_self_wash_in_staffed_hours": "Diese Abteilung bietet waehrend der bemannten Oeffnungszeiten keine Selbstwaesche an.",
"enter_customer_number": "Kundennummer eingeben",
"enter_registration_number": "Kennzeichen eingeben",
"follow_steps": "Folgen Sie den 6 Schritten f?r das beste Ergebnis:",
@@ -3025,6 +3028,7 @@
},
"nav": {
"categories": "Kategorien",
"complaints": "Beschwerden",
"configuration": "Konfiguration",
"customers": "Kunden",
"dashboard": "Dashboard",
@@ -3230,6 +3234,7 @@
},
"nav": {
"categories": "Kategorien",
"complaints": "Beschwerden",
"configuration": "Konfiguration",
"customers": "Kunden",
"dashboard": "Dashboard",
@@ -4263,6 +4268,12 @@
"enter_code": "Geben Sie Ihren Bestätigungscode ein",
"back_to_login": "Zurück zur Anmeldung"
},
"system_status": {
"title": "Systemstatus",
"cards": {
"database": "Datenbank"
}
},
"superuser_invoice_distribution": {
"tab_label": "Verteilung",
"overview": {
+2
View File
@@ -340,6 +340,7 @@
"details": "Details",
"download": "Download",
"duplicate_order_warning": "Are you sure this hasn't already been written? Potential duplicates of this order have been found. You can continue writing the order, or view order details.",
"hide_order_details": "Hide order details",
"duplicates": {
"subtitle": "Handle possible duplicates",
"title": "Possible Duplicates"
@@ -1454,6 +1455,7 @@
},
"overview": {
"cards": "Cards",
"charts": "Charts",
"list": "List",
"subtitle": "See how your departments are performing",
"title": "Overview"
+16
View File
@@ -77,6 +77,11 @@
"title": "Bestillinger"
},
"daily_report": {
"titles": {
"customers": "Kunder",
"optional_products": "Tilleggsprodukter",
"operations": "Drift"
},
"count": {
"title": "Antall"
},
@@ -335,6 +340,7 @@
"details": "Detaljer",
"download": "Last ned",
"duplicate_order_warning": "Er du sikker på at dette ikke allerede er skrevet? Potensielle duplikater av denne bestillingen er funnet. Du kan fortsette å skrive bestillingen, eller se bestillingsdetaljer.",
"hide_order_details": "Skjul bestillingsdetaljer",
"duplicates": {
"subtitle": "Håndtere mulige duplikater",
"title": "Mulige duplikater"
@@ -1449,6 +1455,7 @@
},
"overview": {
"cards": "Kort",
"charts": "Diagrammer",
"list": "Liste",
"subtitle": "Se hvordan avdelingene dine presterer",
"title": "Oversikt"
@@ -2838,6 +2845,7 @@
"confirm_and_start": "Bekreft og start vask",
"customer_number": "Kundenummer",
"department_no_self_wash": "Denne avdelingen støtter ikke selvvask.",
"department_no_self_wash_in_staffed_hours": "Denne avdelingen tilbyr ikke selvvask i bemannede åpningstider.",
"enter_customer_number": "Skriv inn kundenummer",
"enter_registration_number": "Skriv inn registreringsnummer",
"follow_steps": "Følg de 6 trinnene for best resultat:",
@@ -3020,6 +3028,7 @@
},
"nav": {
"categories": "Kategorier",
"complaints": "Klager",
"configuration": "Konfigurasjon",
"customers": "Kunder",
"dashboard": "Dashbord",
@@ -3225,6 +3234,7 @@
},
"nav": {
"categories": "Kategorier",
"complaints": "Klager",
"configuration": "Konfigurasjon",
"customers": "Kunder",
"dashboard": "Dashbord",
@@ -4213,6 +4223,12 @@
"enter_code": "Skriv inn verifiseringskoden din",
"back_to_login": "Tilbake til innlogging"
},
"system_status": {
"title": "Systemstatus",
"cards": {
"database": "Database"
}
},
"superuser_invoice_distribution": {
"tab_label": "Distribusjon",
"overview": {
+16
View File
@@ -77,6 +77,11 @@
"title": "Bokningar"
},
"daily_report": {
"titles": {
"customers": "Kunder",
"optional_products": "Valfria produkter",
"operations": "Drift"
},
"count": {
"title": "Antal"
},
@@ -335,6 +340,7 @@
"details": "Details",
"download": "Ladda ner",
"duplicate_order_warning": "är du söker på att detta inte redan har skrivitsä Potentiella dubbletter av denna order har hittats. Du kan fortsätta skriva ordern eller visa orderdetaljer.",
"hide_order_details": "Dölj orderdetaljer",
"duplicates": {
"subtitle": "Hantera möjliga dubbletter",
"title": "Möjliga dubbletter"
@@ -1449,6 +1455,7 @@
},
"overview": {
"cards": "Cards",
"charts": "Diagram",
"list": "List",
"subtitle": "Se hur dina avdelningar presterar",
"title": "översikt"
@@ -2838,6 +2845,7 @@
"confirm_and_start": "Confirm and start wash",
"customer_number": "Kundnummer",
"department_no_self_wash": "Den här avdelningen stödjer inte självtvätt.",
"department_no_self_wash_in_staffed_hours": "Den här avdelningen erbjuder inte självtvätt under bemannade timmar.",
"enter_customer_number": "Ange kundnummer",
"enter_registration_number": "Enter registration number",
"follow_steps": "Följ de 6 stegen för bästa resultat:",
@@ -3020,6 +3028,7 @@
},
"nav": {
"categories": "Categories",
"complaints": "Klagomål",
"configuration": "Configuration",
"customers": "Kunder",
"dashboard": "Instrumentpanel",
@@ -3225,6 +3234,7 @@
},
"nav": {
"categories": "Categories",
"complaints": "Klagomål",
"configuration": "Configuration",
"customers": "Kunder",
"dashboard": "Instrumentpanel",
@@ -4213,6 +4223,12 @@
"enter_code": "Ange din verifieringskod",
"back_to_login": "Tillbaka till inloggning"
},
"system_status": {
"title": "Systemstatus",
"cards": {
"database": "Databas"
}
},
"superuser_invoice_distribution": {
"tab_label": "Fördelning",
"overview": {
@@ -261,7 +261,7 @@ const formatStripeAmount = (amount) => {
};
const getOrderInvoiceLabel = () => {
return SessionUser.objects.global.language.invoice || t('pos.order.invoice');
return SessionUser.objects.global.language.invoice || t('common.invoice');
};
const canEditOrderMetadata = () => {
@@ -70,7 +70,7 @@ const chartOptions = computed(() => ({
text: t("admin.daily_report.reports.night_washes.chart_title"),
},
noData: {
text: t("common.no_data"),
text: t("global.no_data"),
},
}));
@@ -649,7 +649,7 @@ const onClickCreateOrderAllApplicable = async () => {
v-bind:dropdown_content="{
content: [
{
text: t('tables.common.services'),
text: t('common.services'),
button: false,
action: () => {},
},
@@ -759,4 +759,4 @@ const onClickCreateOrderAllApplicable = async () => {
<style scoped>
</style>
</style>
@@ -30,9 +30,9 @@ watch(isAuthenticated, (newValue) => {
<template>
<div class="box">
<div class="content">
<h2>{{ $t('user_dashboard.microsoft_auth.title') }}</h2>
<p v-if="!isAuthenticated">{{ $t('user_dashboard.microsoft_auth.not_logged_in') }}</p>
<p v-else-if="isAuthenticated">{{ $t('user_dashboard.microsoft_auth.logged_in_as', { name: user.name }) }}</p>
<h2>{{ $t('user_dashboard.profile.microsoft_auth.title') }}</h2>
<p v-if="!isAuthenticated">{{ $t('user_dashboard.profile.microsoft_auth.not_logged_in') }}</p>
<p v-else-if="isAuthenticated">{{ $t('user_dashboard.profile.microsoft_auth.logged_in_as', { name: user.name }) }}</p>
<template v-if="isAuthenticated">
<!-- Get the calendars -->
{{ events }}
@@ -43,20 +43,20 @@ watch(isAuthenticated, (newValue) => {
<span class="icon is-small">
<i class="fab fa-microsoft"></i>
</span>
<span>{{ $t('user_dashboard.microsoft_auth.login') }}</span>
<span>{{ $t('user_dashboard.profile.microsoft_auth.login') }}</span>
</button>
<button class="button is-danger" @click="onClickLogout">
<span class="icon is-small">
<i class="fas fa-sign-out-alt"></i>
</span>
<span>{{ $t('user_dashboard.microsoft_auth.logout') }}</span>
<span>{{ $t('user_dashboard.profile.microsoft_auth.logout') }}</span>
</button>
<!-- Add an event -->
<button class="button is-info" @click="addNewBooking">
<span class="icon is-small">
<i class="fas fa-plus"></i>
</span>
<span>{{ $t('user_dashboard.microsoft_auth.create_event') }}</span>
<span>{{ $t('user_dashboard.profile.microsoft_auth.create_event') }}</span>
</button>
</div>
</div>
@@ -65,4 +65,4 @@ watch(isAuthenticated, (newValue) => {
<style scoped>
</style>
</style>
+18
View File
@@ -1203,7 +1203,25 @@ test.describe("Admin POS wash certificate completion", () => {
await openOrderDetail(page);
const safetySealField = page.getByTestId("pos-order-customer-wishes-safety-seal");
const referenceControl = page.getByTestId("pos-order-customer-wishes-reference-control");
const poControl = page.getByTestId("pos-order-customer-wishes-po-control");
const safetySealControl = page.getByTestId("pos-order-customer-wishes-safety-seal-control");
await expect(safetySealField).toBeVisible();
await expect(referenceControl).toBeVisible();
await expect(poControl).toBeVisible();
await expect(safetySealControl).toBeVisible();
const [referenceBox, poBox, safetySealBox] = await Promise.all([
referenceControl.boundingBox(),
poControl.boundingBox(),
safetySealControl.boundingBox(),
]);
expect(referenceBox).not.toBeNull();
expect(poBox).not.toBeNull();
expect(safetySealBox).not.toBeNull();
expect(safetySealBox!.width).toBeGreaterThan(referenceBox!.width * 1.8);
expect(safetySealBox!.width).toBeGreaterThan(poBox!.width * 1.8);
const updateRequest = waitForOrderMutation(
page,
+110
View File
@@ -0,0 +1,110 @@
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
import { scanViewTranslationKeys, type ViewTranslationKeyUsage } from "./support/viewI18nKeyScanner";
const ACTIVE_LOCALES = ["da", "en", "sv", "de", "no"] as const;
const LOCALES_DIRECTORY = path.join(process.cwd(), "src", "i18n", "locales");
const readLocale = (locale: (typeof ACTIVE_LOCALES)[number]) => {
const absolutePath = path.join(LOCALES_DIRECTORY, `${locale}.json`);
return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as Record<string, unknown>;
};
const hasKeyPath = (value: unknown, keyPath: string): boolean => {
let current: unknown = value;
for (const segment of keyPath.split(".")) {
if (!current || typeof current !== "object" || Array.isArray(current)) {
return false;
}
const objectValue = current as Record<string, unknown>;
if (!(segment in objectValue)) {
return false;
}
current = objectValue[segment];
}
return true;
};
const formatUsages = (usages: ViewTranslationKeyUsage[]) => {
const unique = new Set<string>();
for (const usage of usages) {
unique.add(`${usage.relativePath}:${usage.line}`);
}
return [...unique].sort().join(", ");
};
const formatNonLiteralCalls = (
calls: ReturnType<typeof scanViewTranslationKeys>["nonLiteralCalls"]
): string => {
return calls
.map(
(call) =>
`- ${call.relativePath}:${call.line}:${call.column} ${call.functionName}(...) -> ${call.snippet}`
)
.join("\n");
};
test.describe("View i18n key coverage", () => {
test("enforces literal view keys and locale coverage for all active locales", async ({}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"This translation gate runs only on the chromium-desktop project."
);
const scanResult = scanViewTranslationKeys();
expect(
scanResult.nonLiteralCalls,
[
"Found non-literal translation calls in src/views.",
"Use direct string literals in $t(...), t(...), te(...), and $tc(...) to keep this gate deterministic.",
formatNonLiteralCalls(scanResult.nonLiteralCalls),
].join("\n")
).toEqual([]);
const missingByLocale = new Map<string, { key: string; usages: ViewTranslationKeyUsage[] }[]>();
const sortedKeys = [...scanResult.keyUsageByKey.keys()].sort();
for (const locale of ACTIVE_LOCALES) {
const localeMessages = readLocale(locale);
const missingEntries: { key: string; usages: ViewTranslationKeyUsage[] }[] = [];
for (const key of sortedKeys) {
if (!hasKeyPath(localeMessages, key)) {
missingEntries.push({
key,
usages: scanResult.keyUsageByKey.get(key) ?? [],
});
}
}
if (missingEntries.length > 0) {
missingByLocale.set(locale, missingEntries);
}
}
if (missingByLocale.size > 0) {
const details = [...missingByLocale.entries()]
.map(([locale, entries]) => {
const lines = entries
.map((entry) => ` - ${entry.key} <- ${formatUsages(entry.usages)}`)
.join("\n");
return `${locale} (${entries.length} missing)\n${lines}`;
})
.join("\n\n");
expect(
false,
[
"Missing translation keys detected for view usage.",
"Each key below is used in src/views but absent from the locale JSON.",
details,
].join("\n\n")
).toBe(true);
}
});
});
+57 -33
View File
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
import { createPosFixture, mockApi, primeMockSession } from "./support/network.js";
import { createPosFixture, mockApi, seedAuthenticatedState } from "./support/network.js";
const ORDER_ID = 54518;
const DEPARTMENT_ID = 12;
@@ -37,7 +37,7 @@ function createCardOrderFixture(overrides = {}) {
department_id: DEPARTMENT_ID,
reference: "CARD-REF-54518",
notes: "Paid at terminal",
reg_1: "EC21235",
reg_1: "ZZ99999",
reg_2: "",
reg_3: "",
invoice_collection_id: 200,
@@ -72,6 +72,20 @@ function createStoredPaymentIntent(orderId, overrides = {}) {
};
}
async function primeSession(page, token = "desktop-card-token") {
await seedAuthenticatedState(page, token);
const sessionRequest = page
.waitForResponse(
(response) => {
return response.request().method() === "GET" && response.url().includes("/auth/session");
},
{ timeout: 10_000 }
)
.catch(() => null);
await page.goto("/login");
await sessionRequest;
}
async function bootDesktopCardPayment(page, fixture, permissions = DESKTOP_POS_PERMISSIONS) {
await mockApi(page, {
authenticated: true,
@@ -79,11 +93,21 @@ async function bootDesktopCardPayment(page, fixture, permissions = DESKTOP_POS_P
edgeGateways: false,
pos: fixture,
});
await primeMockSession(page, {
token: `desktop-card-${Date.now()}`,
});
await primeSession(page, `desktop-card-${Date.now()}`);
await page.goto(`/admin/${DEPARTMENT_ID}/modules/pos?id=${ORDER_ID}&customer_id=${CARD_CUSTOMER_ID}&step=3`);
await expect(page.getByTestId("pos-step-3")).toBeVisible({ timeout: 10_000 });
const stepThree = page.getByTestId("pos-step-3");
await expect(stepThree).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-order-panel-cart")).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-order-metadata-grid")).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-order-rail")).toBeVisible({ timeout: 10_000 });
await expect(
stepThree
.locator(
'[data-testid="pos-stripe-setup-required"], [data-testid="pos-stripe-create-intent"], [data-testid="pos-stripe-capture-intent"], [data-testid="pos-stripe-no-readers"], [data-testid="pos-stripe-payment-in-progress"], [data-testid="pos-stripe-payment-succeeded"], [data-testid="pos-stripe-error"]'
)
.first()
).toBeVisible({ timeout: 10_000 });
return stepThree;
}
test.describe("POS desktop card payments", () => {
@@ -101,16 +125,16 @@ test.describe("POS desktop card payments", () => {
},
});
await bootDesktopCardPayment(page, fixture, SUPERUSER_POS_PERMISSIONS);
const stepThree = await bootDesktopCardPayment(page, fixture, SUPERUSER_POS_PERMISSIONS);
await expect(page.getByTestId("pos-stripe-setup-required")).toBeVisible();
await expect(page.getByTestId("pos-stripe-setup-required")).toContainText(SETUP_REQUIRED_MESSAGE);
await expect(page.getByTestId("pos-stripe-open-setup")).toHaveAttribute(
await expect(stepThree.getByTestId("pos-stripe-setup-required")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-setup-required")).toContainText(SETUP_REQUIRED_MESSAGE);
await expect(stepThree.getByTestId("pos-stripe-open-setup")).toHaveAttribute(
"href",
`/superuser/departments/${DEPARTMENT_ID}/stripe/setup`
);
await expect(page.getByTestId("pos-stripe-create-intent")).toHaveCount(0);
await expect(page.getByTestId("pos-stripe-capture-intent")).toHaveCount(0);
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toHaveCount(0);
await expect(stepThree.getByTestId("pos-stripe-capture-intent")).toHaveCount(0);
});
test("misconfigured terminal location falls back to contact-superuser guidance for department operators", async ({
@@ -124,11 +148,11 @@ test.describe("POS desktop card payments", () => {
},
});
await bootDesktopCardPayment(page, fixture);
const stepThree = await bootDesktopCardPayment(page, fixture);
await expect(page.getByTestId("pos-stripe-setup-required")).toBeVisible();
await expect(page.getByTestId("pos-stripe-contact-superuser")).toBeVisible();
await expect(page.getByTestId("pos-stripe-open-setup")).toHaveCount(0);
await expect(stepThree.getByTestId("pos-stripe-setup-required")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-contact-superuser")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-open-setup")).toHaveCount(0);
});
test("configured department with zero readers keeps the unavailable-reader state", async ({ page }) => {
@@ -136,11 +160,11 @@ test.describe("POS desktop card payments", () => {
readers: [],
});
await bootDesktopCardPayment(page, fixture);
const stepThree = await bootDesktopCardPayment(page, fixture);
await expect(page.getByTestId("pos-stripe-no-readers")).toBeVisible();
await expect(page.getByTestId("pos-stripe-setup-required")).toHaveCount(0);
await expect(page.getByTestId("pos-stripe-create-intent")).toHaveCount(0);
await expect(stepThree.getByTestId("pos-stripe-no-readers")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-setup-required")).toHaveCount(0);
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toHaveCount(0);
});
test("existing requires_capture intent on reload restores the capture action", async ({ page }) => {
@@ -152,10 +176,10 @@ test.describe("POS desktop card payments", () => {
},
});
await bootDesktopCardPayment(page, fixture);
const stepThree = await bootDesktopCardPayment(page, fixture);
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible();
await expect(page.getByTestId("pos-stripe-create-intent")).toHaveCount(0);
await expect(stepThree.getByTestId("pos-stripe-capture-intent")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toHaveCount(0);
});
test("existing succeeded intent on reload restores the completion CTA", async ({ page }) => {
@@ -167,28 +191,28 @@ test.describe("POS desktop card payments", () => {
},
});
await bootDesktopCardPayment(page, fixture);
const stepThree = await bootDesktopCardPayment(page, fixture);
await expect(page.getByTestId("pos-stripe-payment-succeeded")).toBeVisible();
await expect(page.getByTestId("pos-next-step")).toBeVisible();
await expect(stepThree.getByTestId("pos-stripe-payment-succeeded")).toBeVisible();
await expect(stepThree.getByTestId("pos-next-step")).toBeVisible();
expect(fixture.ordersById[ORDER_ID].completed_at).toBeNull();
});
test("happy path create and capture exposes completion without auto-completing the order", async ({ page }) => {
const fixture = createCardOrderFixture();
await bootDesktopCardPayment(page, fixture);
const stepThree = await bootDesktopCardPayment(page, fixture);
await expect(page.getByTestId("pos-stripe-create-intent")).toBeVisible();
await page.getByTestId("pos-stripe-create-intent").click();
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toBeVisible();
await stepThree.getByTestId("pos-stripe-create-intent").click();
await expect(page.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-stripe-capture-intent")).toBeVisible({ timeout: 10_000 });
expect(fixture.ordersById[ORDER_ID].completed_at).toBeNull();
await page.getByTestId("pos-stripe-capture-intent").click();
await stepThree.getByTestId("pos-stripe-capture-intent").click();
await expect(page.getByTestId("pos-stripe-payment-succeeded")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-next-step")).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-stripe-payment-succeeded")).toBeVisible({ timeout: 10_000 });
await expect(stepThree.getByTestId("pos-next-step")).toBeVisible({ timeout: 10_000 });
await page.waitForTimeout(750);
expect(fixture.ordersById[ORDER_ID].completed_at).toBeNull();
});
+18 -31
View File
@@ -848,9 +848,11 @@ async function setupDesktopPosPage(page, fixture, { token = "pos-desktop-token"
}
function getActiveDesktopModal(page) {
return page.locator(
'[data-testid="pos-desktop-order-booking-modal"].is-active, [data-testid="default-object-selector"].is-active'
);
return page.locator('[data-testid="pos-desktop-order-booking-modal"].is-active');
}
function getInlineDuplicateWarning(page) {
return page.locator('[data-testid="pos-desktop-duplicate-warning-inline"]:visible').first();
}
async function commitDesktopReg1ByBlur(page, value) {
@@ -1768,14 +1770,14 @@ test.describe("POS flow", () => {
await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8130")).toBeVisible({
timeout: 10_000,
});
await expect(activeBookingSelector.getByTestId("pos-desktop-duplicate-warning-continue")).toHaveCount(0);
await expect(getInlineDuplicateWarning(page)).toHaveCount(0);
await activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8130").click();
await expect(activeBookingSelector.getByTestId("pos-desktop-duplicate-warning-continue")).toBeVisible({
await expect(activeBookingSelector).toHaveCount(0);
await expect(getInlineDuplicateWarning(page)).toBeVisible({
timeout: 10_000,
});
await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8130")).toHaveCount(0);
});
test("desktop duplicate warning appears only after committed input when there is no booking", async ({
@@ -1805,12 +1807,11 @@ test.describe("POS flow", () => {
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-duplicate-after-commit" });
const activeModal = getActiveDesktopModal(page);
await page.locator("#reg_1").fill("AB12345");
await expect(activeModal).toHaveCount(0);
await expect(getInlineDuplicateWarning(page)).toHaveCount(0);
await page.locator("#reference").click();
await expect(activeModal.getByTestId("pos-desktop-duplicate-warning-continue")).toBeVisible({
await expect(getInlineDuplicateWarning(page)).toBeVisible({
timeout: 10_000,
});
});
@@ -1857,17 +1858,16 @@ test.describe("POS flow", () => {
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-rewritten-duplicate" });
const activeModal = getActiveDesktopModal(page);
await commitDesktopReg1ByBlur(page, "TRAILDUP");
await expect(page.locator("#reg_1")).toHaveValue("TRACTORDUP");
await expect(page.locator("#reg_2")).toHaveValue("TRAILDUP");
await expect(activeModal.getByTestId("pos-desktop-duplicate-warning-continue")).toBeVisible({
await expect(getInlineDuplicateWarning(page)).toBeVisible({
timeout: 10_000,
});
});
test("desktop duplicate warning cancel keeps the current step-1 state", async ({ page }, testInfo) => {
test("desktop inline duplicate warning keeps the current step-1 state", async ({ page }, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"Desktop POS booking flow is validated on chromium-desktop."
@@ -1892,15 +1892,10 @@ test.describe("POS flow", () => {
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-duplicate-cancel" });
const activeModal = getActiveDesktopModal(page);
await commitDesktopReg1ByBlur(page, "AB12345");
await expect(activeModal.getByTestId("pos-desktop-duplicate-warning-cancel")).toBeVisible({
await expect(getInlineDuplicateWarning(page)).toBeVisible({
timeout: 10_000,
});
await activeModal.getByTestId("pos-desktop-duplicate-warning-cancel").click();
await expect(activeModal).toHaveCount(0);
await expect(page.locator("#reg_1")).toHaveValue("AB12345");
await expect(page.getByTestId("pos-step-2")).not.toBeVisible();
});
@@ -1932,20 +1927,19 @@ test.describe("POS flow", () => {
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-duplicate-details" });
const activeModal = getActiveDesktopModal(page);
await commitDesktopReg1ByBlur(page, "AB12345");
await activeModal.getByTestId("pos-desktop-duplicate-warning-details").click();
await getInlineDuplicateWarning(page).getByTestId("pos-desktop-duplicate-warning-toggle-details").click();
await expect(activeModal.getByTestId("pos-desktop-duplicate-details-back")).toBeVisible({
await expect(page.getByTestId("pos-desktop-duplicate-warning-details-inline")).toBeVisible({
timeout: 10_000,
});
await expect(activeModal.getByTestId("pos-desktop-duplicate-order-open-9206")).toBeVisible({
await expect(page.getByTestId("pos-desktop-duplicate-order-open-9206")).toBeVisible({
timeout: 10_000,
});
await expect(page.locator("#reg_1")).toHaveValue("AB12345");
});
test("desktop blocks next until duplicate warning is resolved", async ({ page }, testInfo) => {
test("desktop next proceeds when duplicates are detected during preflight", async ({ page }, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"Desktop POS booking flow is validated on chromium-desktop."
@@ -1970,19 +1964,12 @@ test.describe("POS flow", () => {
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-duplicate-next-block" });
const activeModal = getActiveDesktopModal(page);
await page.locator("#reg_1").fill("AB12345");
await page.locator('[data-testid="pos-next-step"]:visible').click();
await expect(activeModal.getByTestId("pos-desktop-duplicate-warning-continue")).toBeVisible({
timeout: 10_000,
});
expect(fixture.ordersById[9300]).toBeUndefined();
await activeModal.getByTestId("pos-desktop-duplicate-warning-continue").click();
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 10_000 });
await expect.poll(() => fixture.ordersById[9300]?.reg_1 || null, { timeout: 10_000 }).toBe("AB12345");
await expect(getActiveDesktopModal(page)).toHaveCount(0);
});
test("desktop keyboard selection opens the booking chooser for booked search results", async ({ page }, testInfo) => {
+225 -2
View File
@@ -80,6 +80,11 @@ function buildMobileMatchedVehicle(reg, overrides = {}) {
};
}
function buildTodayTimestamp(time = "08:00:00.000Z") {
const todayIsoDate = new Date().toISOString().split("T")[0];
return `${todayIsoDate}T${time}`;
}
function createMultiBookingFixture({ reg, vehicle = {}, bookings = [] }) {
const baseFixture = createMobilePosFixture();
return createMobilePosFixture({
@@ -248,6 +253,24 @@ async function expectOrderBookingPopupIds(page, bookingIds) {
.toEqual(bookingIds.map((bookingId) => `pos-mobile-order-booking-option-${bookingId}`));
}
async function openMobileOrderBookingSettings(page, bookingId) {
const settings = page.getByTestId(`pos-mobile-order-booking-settings-${bookingId}`);
await expect(settings).toBeVisible({ timeout: 10_000 });
await settings.locator(".dropdown-trigger button").click();
const dropdown = settings.locator(".dropdown-content");
await expect(dropdown).toBeVisible({ timeout: 10_000 });
return dropdown;
}
async function completeMobileOrderBookingFromSettings(page, bookingId) {
const dropdown = await openMobileOrderBookingSettings(page, bookingId);
await dropdown.locator("button.dropdown-item-action").first().click();
const popup = page.locator(".swal2-popup");
await expect(popup).toBeVisible({ timeout: 10_000 });
await popup.locator(".swal2-deny").click();
await expect(popup).toBeHidden({ timeout: 10_000 });
}
async function waitForMobileStepTwoReady(page) {
const stepTwoShell = page.getByTestId("pos-mobile-step-2");
const vehicleSelection = page.getByTestId("pos-mobile-vehicle-selection");
@@ -702,6 +725,204 @@ test.describe("POS mobile order flow", () => {
await waitForStepReset(page);
});
test("mobile booking popup sorts today's bookings first and highlights the today option", async ({ page }) => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const fixture = createMultiBookingFixture({
reg: "TODAYMOB",
vehicle: {
reference: "REF-TODAYMOB",
},
bookings: [
buildMobileOrderBooking(8601, {
reg_1: "TODAY-TRAILER-B",
reg_2: "TODAYMOB",
datetime: tomorrow.toISOString(),
reference: "TODAY-MOBILE-B",
reference_number: "TODAY-MOBILE-B",
}),
buildMobileOrderBooking(8600, {
reg_1: "TODAY-TRAILER-A",
reg_2: "TODAYMOB",
datetime: buildTodayTimestamp("08:00:00.000Z"),
reference: "TODAY-MOBILE-A",
reference_number: "TODAY-MOBILE-A",
}),
],
});
await setupMobilePosPage(page, fixture, {
token: "mobile-booking-today-priority-token",
seedState: {
customerId: null,
reg: "TODAYMOB",
reference: "",
includePrimaryItem: false,
vehicleType: null,
lastOrderId: null,
},
route: {
step: 1,
},
});
const popup = await waitForOrderBookingPopup(page);
await expectOrderBookingPopupIds(page, [8600, 8601]);
await expect(popup.getByTestId("pos-mobile-order-booking-today-8600")).toBeVisible({ timeout: 10_000 });
const isTodayCardHighlighted = await popup
.getByTestId("pos-mobile-order-booking-option-8600")
.evaluate((element) => element.classList.contains("booking-option--today"));
expect(isTodayCardHighlighted).toBe(true);
});
test("mobile booking popup hydrates stripped booking list details into service rows and totals", async ({ page }) => {
const fixture = createMultiBookingFixture({
reg: "HYDRATE1",
vehicle: {
reference: "REF-HYDRATE1",
},
bookings: [
buildMobileOrderBooking(8701, {
reg_1: "HYDRACT1",
reg_2: "HYDRATE1",
datetime: "2026-01-01T07:00:00.000Z",
reference: "HYDRATE-A",
reference_number: "HYDRATE-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(8702, {
reg_1: "HYDRACT2",
reg_2: "HYDRATE1",
datetime: "2026-01-01T09:00:00.000Z",
reference: "HYDRATE-B",
reference_number: "HYDRATE-B",
items: [{ id: 63, name: "Box trailer wash", price: 499, quantity: 1 }],
parsed_services: {
string: "Box trailer wash",
array: ["Box trailer wash"],
},
}),
],
});
fixture.orderBookingListStripsDetails = true;
await setupMobilePosPage(page, fixture, {
token: "mobile-booking-hydration-details-token",
seedState: {
customerId: null,
reg: "HYDRATE1",
reference: "",
includePrimaryItem: false,
vehicleType: null,
lastOrderId: null,
},
route: {
step: 1,
},
});
const popup = await waitForOrderBookingPopup(page);
await expectOrderBookingPopupIds(page, [8701, 8702]);
await expect(popup.getByTestId("pos-mobile-order-booking-settings-8701")).toBeVisible({ timeout: 10_000 });
await expect
.poll(() => fixture.requestCounters.orderBookingsGet, { timeout: 10_000 })
.toBeGreaterThanOrEqual(3);
await expect(popup.getByTestId("pos-mobile-order-booking-services-8701")).toContainText("Tank truck wash", {
timeout: 10_000,
});
await expect(popup.getByTestId("pos-mobile-order-booking-services-8701")).toContainText("Interior rinse", {
timeout: 10_000,
});
await expect(popup.getByTestId("pos-mobile-order-booking-service-row-8701-0")).toContainText("1x", {
timeout: 10_000,
});
await expect(popup.getByTestId("pos-mobile-order-booking-service-row-8701-1")).toContainText("Interior", {
timeout: 10_000,
});
await expect(popup.getByTestId("pos-mobile-order-booking-service-total-8701")).toContainText("698", {
timeout: 10_000,
});
});
test("mobile booking settings refresh auto-resolves to the last remaining booking after completion", async ({
page,
}) => {
const fixture = createMultiBookingFixture({
reg: "WHEELMOB",
vehicle: {
reference: "REF-WHEELMOB",
},
bookings: [
buildMobileOrderBooking(8801, {
reg_1: "WHEELTRACTA",
reg_2: "WHEELMOB",
datetime: "2026-01-01T07:00:00.000Z",
reference: "WHEEL-MOBILE-A",
reference_number: "WHEEL-MOBILE-A",
}),
buildMobileOrderBooking(8802, {
reg_1: "WHEELTRACTB",
reg_2: "WHEELMOB",
datetime: "2026-01-01T09:00:00.000Z",
reference: "WHEEL-MOBILE-B",
reference_number: "WHEEL-MOBILE-B",
}),
],
});
await setupMobilePosPage(page, fixture, {
token: "mobile-booking-wheel-refresh-token",
seedState: {
customerId: null,
reg: "WHEELMOB",
reference: "",
includePrimaryItem: false,
vehicleType: null,
lastOrderId: null,
},
route: {
step: 1,
},
});
await waitForOrderBookingPopup(page);
await expectOrderBookingPopupIds(page, [8801, 8802]);
await completeMobileOrderBookingFromSettings(page, 8801);
await expect(page.getByTestId("pos-mobile-order-booking-popup")).toHaveCount(0);
await expect
.poll(
async () => {
const snapshot = await getStoredPosSnapshot(page);
return {
bookingId: snapshot?.metadata?.bookingId ?? null,
reg1: snapshot?.vehicles?.vehicle_1?.reg ?? "",
reg2: snapshot?.vehicles?.vehicle_2?.reg ?? "",
reference: snapshot?.metadata?.reference ?? "",
};
},
{ timeout: 10_000 }
)
.toEqual({
bookingId: 8802,
reg1: "WHEELTRACTB",
reg2: "WHEELMOB",
reference: "WHEEL-MOBILE-B",
});
await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1);
await expect.poll(() => fixture.bookingsById[8801]?.status ?? "", { timeout: 10_000 }).toBe("completed");
expect(fixture.bookingsById[8802]?.status ?? "").toBe("pending");
});
test("booking popup keeps continue-without-booking visible and allows scrolling through long booking lists", async ({
page,
}) => {
@@ -795,7 +1016,8 @@ test.describe("POS mobile order flow", () => {
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-header-close")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-order-booking-header-close").click();
await expect(page.getByTestId("pos-mobile-order-booking-popup")).toBeHidden({ timeout: 10_000 });
await expect
@@ -1327,7 +1549,8 @@ test.describe("POS mobile order flow", () => {
await expect(page.getByTestId("pos-mobile-manual-input")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-reg-input-1").fill("cd-12 34");
await page.getByTestId("pos-mobile-popup").locator(".card-footer-item").last().click();
await expect(page.getByTestId("pos-mobile-select-vehicle-header-close")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-select-vehicle-header-close").click();
await expect(page.getByTestId("pos-mobile-popup")).toBeHidden({ timeout: 10_000 });
await expect.poll(() => fixture.ordersById[orderId]?.reg_1 ?? "", { timeout: 10_000 }).toBe("CD1234");
+50
View File
@@ -211,6 +211,11 @@ function getVisibleTestId(page, testId) {
return page.locator(`[data-testid="${testId}"]:visible`).first();
}
function buildTodayTimestamp(time = "10:00:00.000Z") {
const today = new Date().toISOString().split("T")[0];
return `${today}T${time}`;
}
function createRequiredWarningsPosFixture() {
const customerNumber = 12345679;
const baseFixture = createPosFixture();
@@ -333,6 +338,51 @@ test.describe("POS visuals", () => {
});
});
test("desktop step 1 duplicate warning snapshot", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
const posFixture = createPosFixture();
posFixture.ordersById[9206] = {
id: 9206,
customer_id: 12345679,
department_id: 12,
reference: "DUP-VISUAL",
po: "",
safety_seal: "",
notes: "",
reg_1: "AB12345",
reg_2: "",
reg_3: "",
invoice_collection_id: null,
booking_id: null,
completed_at: null,
closed_at: null,
created_at: buildTodayTimestamp(),
include_in_invoice: null,
};
posFixture.orderItemsByOrderId[9206] = [];
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: posFixture,
});
await primeSession(page, "pos-visual-desktop-step-1-duplicate-warning-token");
await page.goto("/admin/12/modules/pos");
const stepOne = page.getByTestId("pos-step-1");
await expect(stepOne).toBeVisible();
await page.locator("#reg_1").fill("AB12345");
await page.locator("#reference").click();
await expect(page.getByTestId("pos-desktop-duplicate-warning-inline")).toBeVisible({ timeout: 10_000 });
await expect(stepOne).toHaveScreenshot("pos-step-1-desktop-duplicate-warning.png", {
maxDiffPixels: 300,
});
});
test("desktop step 1 keeps customer notes visible without refetching on blur", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
+64 -37
View File
@@ -12,6 +12,9 @@ type MockSubuser = {
suspended_at: string | null;
two_factor_enabled: boolean;
setup_required: boolean;
invite_accepted: boolean;
can_resend_invite: boolean;
profile_editable_by_manager: boolean;
grant_id: number;
grant_enabled: boolean;
grant_note: string | null;
@@ -64,7 +67,7 @@ const baseSubuserSession = {
const initialSubusers = (): MockSubuser[] => [
{
id: 1,
username: "driver.pending",
username: null,
name: "Pending Driver",
email: null,
phone_country_code: 45,
@@ -74,6 +77,9 @@ const initialSubusers = (): MockSubuser[] => [
suspended_at: null,
two_factor_enabled: false,
setup_required: true,
invite_accepted: false,
can_resend_invite: true,
profile_editable_by_manager: false,
grant_id: 11,
grant_enabled: true,
grant_note: null,
@@ -92,6 +98,9 @@ const initialSubusers = (): MockSubuser[] => [
suspended_at: null,
two_factor_enabled: false,
setup_required: false,
invite_accepted: true,
can_resend_invite: false,
profile_editable_by_manager: false,
grant_id: 12,
grant_enabled: true,
grant_note: "Morgenhold",
@@ -110,6 +119,9 @@ const initialSubusers = (): MockSubuser[] => [
suspended_at: null,
two_factor_enabled: false,
setup_required: false,
invite_accepted: true,
can_resend_invite: false,
profile_editable_by_manager: false,
grant_id: 13,
grant_enabled: false,
grant_note: "Sat på pause",
@@ -132,8 +144,8 @@ const permissionNodePayload = [
},
{
key: "SUBUSERS_EDIT",
name: "Edit chauffeurs",
description: "Can edit chauffeurs",
name: "Edit grants",
description: "Can edit grants",
type: "EDIT",
default: false,
},
@@ -154,6 +166,9 @@ const jsonResponse = (route: Route, data: unknown, meta: Record<string, unknown>
});
const recalculateAccessState = (subuser: MockSubuser) => {
subuser.can_resend_invite = subuser.setup_required;
subuser.invite_accepted = !subuser.setup_required;
if (subuser.grant_enabled) {
subuser.access_state = subuser.setup_required ? "pending_setup" : "active";
return;
@@ -227,28 +242,27 @@ async function mockManagementApi(
}
if (url.pathname === "/subusers" && request.method() === "PUT") {
const payload = request.postDataJSON() as Record<string, string | number | null>;
const target = subusers.find((subuser) => subuser.id === Number(payload.id));
if (!target) {
return route.fulfill({ status: 404, body: JSON.stringify({ success: false }) });
}
target.name = (payload.name as string) ?? target.name;
target.username = (payload.username as string | null) ?? target.username;
target.email = (payload.email as string | null) ?? target.email;
target.phone_country_code = payload.phone_country_code ? Number(payload.phone_country_code) : target.phone_country_code;
target.phone = payload.phone ? Number(payload.phone) : target.phone;
target.updated_at = "2026-04-14 10:00:00";
return jsonResponse(route, { subuser: target, grant: { id: target.grant_id } });
return route.fulfill({
status: 403,
contentType: "application/json",
body: JSON.stringify({
success: false,
data: {
message: "Customers can only manage subuser grants. Drivers own their account profile.",
},
meta: {},
includes: [],
}),
});
}
if (url.pathname === "/subusers/invite" && request.method() === "POST") {
const payload = request.postDataJSON() as Record<string, string | number | null>;
const created: MockSubuser = {
id: nextId++,
username: (payload.username as string | null) || null,
username: null,
name: payload.name as string,
email: (payload.email as string | null) || null,
email: null,
phone_country_code: Number(payload.phone_country_code),
phone: Number(payload.phone),
created_at: "2026-04-14 10:00:00",
@@ -256,6 +270,9 @@ async function mockManagementApi(
suspended_at: null,
two_factor_enabled: false,
setup_required: true,
invite_accepted: false,
can_resend_invite: true,
profile_editable_by_manager: false,
grant_id: nextGrantId++,
grant_enabled: true,
grant_note: null,
@@ -283,6 +300,21 @@ async function mockManagementApi(
return route.fulfill({ status: 404, body: JSON.stringify({ success: false }) });
}
if (!target.setup_required) {
return route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
success: false,
data: {
message: "Driver account already accepted the invitation.",
},
meta: {},
includes: [],
}),
});
}
return jsonResponse(route, {
subuser: target,
grant: { id: target.grant_id },
@@ -355,40 +387,35 @@ async function mockManagementApi(
});
}
test("customer user can invite and manage chauffører from /user/subusers", async ({ page }) => {
test("customer user can invite and manage grant access from /user/subusers without editing the driver account", async ({ page }) => {
await primeSession(page, { token: "user-token" });
await mockManagementApi(page, { userPermissions: ["user"] });
await page.goto("/user/subusers");
await expect(page.getByRole("heading", { name: /underbrugere|chauffører/i })).toBeVisible();
await expect(page.getByRole("button", { name: /invitér chauffør/i })).toBeVisible();
await expect(page.getByRole("button", { name: /Invit.*chauff/i })).toBeVisible();
await expect(page.getByText("Pending Driver")).toBeVisible();
await expect(page.getByText("Disabled Driver")).toBeVisible();
await expect(page.getByTestId("subuser-email-1")).toHaveText("E-mail oplyses ved accept");
await expect(page.getByTestId("subuser-username-1")).toHaveText("Brugernavn oplyses ved accept");
await expect(page.getByTestId("subuser-email-2")).toHaveText("active@example.com");
await expect(page.getByTestId("subuser-resend-2")).toHaveCount(0);
await page.getByRole("button", { name: /invitér chauffør/i }).click();
await page.getByRole("button", { name: /Invit.*chauff/i }).click();
await page.fill("#subuser-form-name", "Invited Driver");
await page.fill("#subuser-form-phone-country-code", "45");
await page.fill("#subuser-form-phone", "44444444");
await page.fill("#subuser-form-username", "driver.invited");
await page.fill("#subuser-form-email", "invited@example.com");
await page.getByRole("button", { name: "Send invitation" }).click();
await expect(page.getByRole("heading", { name: "Chauffør oprettet" })).toBeVisible();
await page.getByRole("button", { name: "Luk" }).click();
await expect(page.getByText("Invited Driver")).toBeVisible();
await page.getByTestId("subuser-edit-100").click();
await page.fill("#subuser-form-name", "Updated Driver");
await page.fill("#subuser-form-phone-country-code", "45");
await page.fill("#subuser-form-phone", "45454545");
await page.fill("#subuser-form-email", "updated@example.com");
await page.getByRole("button", { name: "Gem ændringer" }).click();
await expect(page.getByRole("heading", { name: "Chauffør opdateret" })).toBeVisible();
await page.getByRole("button", { name: "Luk" }).click();
await expect(page.getByText("Updated Driver")).toBeVisible();
await expect(page.getByTestId("subuser-edit-100")).toHaveCount(0);
await expect(page.getByTestId("subuser-email-100")).toHaveText("E-mail oplyses ved accept");
await expect(page.getByTestId("subuser-username-100")).toHaveText("Brugernavn oplyses ved accept");
await page.getByTestId("subuser-permissions-100").click();
await expect(page.getByText("Tilladelsesnoder for Updated Driver")).toBeVisible();
await expect(page.getByText("Tilladelsesnoder for Invited Driver")).toBeVisible();
await page.getByTestId("permission-node-checkbox-SUBUSERS_LIST").click();
await page.getByRole("button", { name: "Gem" }).click();
await expect(page.getByText(/SUBUSERS_LIST/)).toBeVisible();
@@ -406,7 +433,7 @@ test("customer user can invite and manage chauffører from /user/subusers", asyn
await page.getByRole("button", { name: "Luk" }).click();
});
test("authorized subuser manager can access the chauffører page and legacy grants route redirects", async ({ page }) => {
test("authorized subuser managers can access the chauffør page and the legacy grants route redirects", async ({ page }) => {
await primeSession(page, {
token: "subuser-manager-token",
isSubuser: true,
@@ -420,7 +447,7 @@ test("authorized subuser manager can access the chauffører page and legacy gran
await page.goto("/user/subusers/grants");
await expect(page).toHaveURL("/user/subusers");
await expect(page.locator("label.label", { hasText: "Vælg kunde" })).toBeVisible();
await expect(page.getByRole("button", { name: /invitér chauffør/i })).toBeVisible();
await expect(page.getByRole("button", { name: /Invit.*chauff/i })).toBeVisible();
});
test("subuser self-service profile edits still save through /subusers/me", async ({ page }) => {
@@ -451,7 +478,7 @@ test("subuser self-service profile edits still save through /subusers/me", async
await page.click(".swal2-confirm");
});
test("subuser without SUBUSERS_LIST cannot access the chauffører page", async ({ page }) => {
test("subusers without SUBUSERS_LIST cannot access the chauffør page", async ({ page }) => {
await primeSession(page, {
token: "subuser-no-access-token",
isSubuser: true,
+268
View File
@@ -0,0 +1,268 @@
import fs from "node:fs";
import path from "node:path";
export interface ViewTranslationKeyUsage {
functionName: "$t" | "$tc" | "t" | "te";
key: string;
filePath: string;
relativePath: string;
line: number;
column: number;
}
export interface NonLiteralTranslationCall {
functionName: "$t" | "$tc" | "t" | "te";
filePath: string;
relativePath: string;
line: number;
column: number;
snippet: string;
}
export interface ViewTranslationScanResult {
scannedFiles: string[];
literalKeyUsages: ViewTranslationKeyUsage[];
keyUsageByKey: Map<string, ViewTranslationKeyUsage[]>;
nonLiteralCalls: NonLiteralTranslationCall[];
}
const TRANSLATION_CALL_PATTERN = /(\$t|\$tc|\bte|\bt)\s*\(/g;
const VIEW_EXTENSIONS = new Set([".vue", ".js", ".ts"]);
const toProjectRelativePath = (projectRoot: string, absolutePath: string) => {
return path.relative(projectRoot, absolutePath).split(path.sep).join("/");
};
const collectFilesRecursively = (directory: string): string[] => {
const collected: string[] = [];
if (!fs.existsSync(directory)) {
return collected;
}
const walk = (currentPath: string) => {
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
for (const entry of entries) {
const absolutePath = path.join(currentPath, entry.name);
if (entry.isDirectory()) {
walk(absolutePath);
continue;
}
if (VIEW_EXTENSIONS.has(path.extname(entry.name))) {
collected.push(absolutePath);
}
}
};
walk(directory);
collected.sort();
return collected;
};
const buildLineStarts = (content: string): number[] => {
const starts = [0];
for (let i = 0; i < content.length; i += 1) {
if (content[i] === "\n") {
starts.push(i + 1);
}
}
return starts;
};
const findLineIndex = (lineStarts: number[], index: number): number => {
let low = 0;
let high = lineStarts.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const lineStart = lineStarts[mid];
const nextLineStart = mid + 1 < lineStarts.length ? lineStarts[mid + 1] : Number.POSITIVE_INFINITY;
if (index >= lineStart && index < nextLineStart) {
return mid;
}
if (index < lineStart) {
high = mid - 1;
} else {
low = mid + 1;
}
}
return lineStarts.length - 1;
};
const getLineAndColumn = (lineStarts: number[], index: number) => {
const lineIndex = findLineIndex(lineStarts, index);
const lineStart = lineStarts[lineIndex] ?? 0;
return {
line: lineIndex + 1,
column: index - lineStart + 1,
};
};
const getLineSnippet = (content: string, lineStarts: number[], index: number): string => {
const lineIndex = findLineIndex(lineStarts, index);
const start = lineStarts[lineIndex] ?? 0;
const nextStart = lineIndex + 1 < lineStarts.length ? lineStarts[lineIndex + 1] : content.length;
return content.slice(start, nextStart).trim();
};
const skipWhitespace = (content: string, startIndex: number): number => {
let index = startIndex;
while (index < content.length && /\s/.test(content[index])) {
index += 1;
}
return index;
};
const parseQuotedStringLiteral = (
content: string,
quoteCharacter: "'" | '"',
quoteStartIndex: number
): { key: string; endIndex: number } | null => {
let index = quoteStartIndex + 1;
let rawValue = "";
while (index < content.length) {
const character = content[index];
if (character === "\\") {
if (index + 1 < content.length) {
rawValue += content.slice(index, index + 2);
index += 2;
continue;
}
rawValue += character;
index += 1;
continue;
}
if (character === quoteCharacter) {
const normalizedValue = rawValue
.replace(/\\\\/g, "\\")
.replace(quoteCharacter === "'" ? /\\'/g : /\\"/g, quoteCharacter);
return {
key: normalizedValue,
endIndex: index,
};
}
rawValue += character;
index += 1;
}
return null;
};
const normalizeFunctionName = (value: string): "$t" | "$tc" | "t" | "te" => {
if (value === "$t" || value === "$tc" || value === "t" || value === "te") {
return value;
}
return value.endsWith("te") ? "te" : "t";
};
export const scanViewTranslationKeys = (options?: {
projectRoot?: string;
viewsDirectory?: string;
}): ViewTranslationScanResult => {
const projectRoot = options?.projectRoot ?? process.cwd();
const viewsDirectory = options?.viewsDirectory ?? path.join(projectRoot, "src", "views");
const scannedFiles = collectFilesRecursively(viewsDirectory);
const literalKeyUsages: ViewTranslationKeyUsage[] = [];
const nonLiteralCalls: NonLiteralTranslationCall[] = [];
const keyUsageByKey = new Map<string, ViewTranslationKeyUsage[]>();
for (const filePath of scannedFiles) {
const content = fs.readFileSync(filePath, "utf8");
const lineStarts = buildLineStarts(content);
TRANSLATION_CALL_PATTERN.lastIndex = 0;
for (const match of content.matchAll(TRANSLATION_CALL_PATTERN)) {
if (match.index === undefined) {
continue;
}
const rawFunctionName = match[1] ?? "t";
const functionName = normalizeFunctionName(rawFunctionName);
const openParenOffset = match[0].lastIndexOf("(");
const openParenIndex = match.index + openParenOffset;
const argumentStart = skipWhitespace(content, openParenIndex + 1);
const position = getLineAndColumn(lineStarts, openParenIndex);
const relativePath = toProjectRelativePath(projectRoot, filePath);
const firstCharacter = content[argumentStart];
if (firstCharacter !== "'" && firstCharacter !== '"') {
nonLiteralCalls.push({
functionName,
filePath,
relativePath,
line: position.line,
column: position.column,
snippet: getLineSnippet(content, lineStarts, openParenIndex),
});
continue;
}
const parsedLiteral = parseQuotedStringLiteral(content, firstCharacter, argumentStart);
if (!parsedLiteral) {
nonLiteralCalls.push({
functionName,
filePath,
relativePath,
line: position.line,
column: position.column,
snippet: getLineSnippet(content, lineStarts, openParenIndex),
});
continue;
}
const keyPosition = getLineAndColumn(lineStarts, argumentStart);
const usage: ViewTranslationKeyUsage = {
functionName,
key: parsedLiteral.key,
filePath,
relativePath,
line: keyPosition.line,
column: keyPosition.column,
};
literalKeyUsages.push(usage);
const existingUsages = keyUsageByKey.get(usage.key) ?? [];
existingUsages.push(usage);
keyUsageByKey.set(usage.key, existingUsages);
}
}
literalKeyUsages.sort((left, right) => {
if (left.key !== right.key) {
return left.key.localeCompare(right.key);
}
if (left.relativePath !== right.relativePath) {
return left.relativePath.localeCompare(right.relativePath);
}
return left.line - right.line;
});
nonLiteralCalls.sort((left, right) => {
if (left.relativePath !== right.relativePath) {
return left.relativePath.localeCompare(right.relativePath);
}
if (left.line !== right.line) {
return left.line - right.line;
}
return left.column - right.column;
});
return {
scannedFiles,
literalKeyUsages,
keyUsageByKey,
nonLiteralCalls,
};
};
+426
View File
@@ -0,0 +1,426 @@
// @vitest-environment jsdom
import { defineComponent } from "vue";
import { flushPromises } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mountWithApp } from "./helpers/mountWithApp.js";
function buildContext(overrides = {}) {
return {
reg1: "",
reg2: "",
bookingMatches: [],
selectedBookingId: null,
skippedBooking: false,
requiresBookingSelection: false,
bookingResolution: "none",
committed: false,
source: "initial",
...overrides,
bookingMatches: Array.isArray(overrides.bookingMatches)
? overrides.bookingMatches.map((booking) => ({ ...booking }))
: [],
};
}
const posProcessState = vi.hoisted(() => ({
customerName: null,
departmentId: null,
currentStep: null,
nextStep: vi.fn(),
clearCache: vi.fn(),
preflightHandler: null,
}));
const sessionState = vi.hoisted(() => ({
request: vi.fn(),
redirectToDepartment: vi.fn(),
}));
const selectVehicleFormState = vi.hoisted(() => ({
commitContext: null,
finalizedContext: null,
bookingSelectionContext: null,
skipBookingContext: null,
refreshContext: null,
finalizeDesktopStep1Context: vi.fn(),
applyBookingChoice: vi.fn(),
skipBookingChoice: vi.fn(),
refreshOrderBookingMatches: vi.fn(),
}));
vi.mock("@/components/shop/POSDepartmentProcess.vue", async () => {
const { ref } = await import("vue");
posProcessState.customerName = ref("");
posProcessState.departmentId = ref(12);
posProcessState.currentStep = ref(1);
return {
customer_name: posProcessState.customerName,
isCustomerSelected: () => Boolean(posProcessState.customerName.value),
department_id: posProcessState.departmentId,
getDepartment: () => 12,
getCurrentStep: () => posProcessState.currentStep.value,
clearCache: posProcessState.clearCache,
nextStep: posProcessState.nextStep,
setDesktopStep1PreflightHandler: (handler = null) => {
posProcessState.preflightHandler = handler;
},
clearDesktopStep1PreflightHandler: (handler = null) => {
if (handler === null || posProcessState.preflightHandler === handler) {
posProcessState.preflightHandler = null;
}
},
};
});
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request: sessionState.request,
functions: {
redirectTo: {
department: sessionState.redirectToDepartment,
},
},
objects: {
orders: {
meta: {
endpoint: "/orders",
},
columns: {
reg_1: {
label: "Number plate",
},
reference: {
label: "Reference",
},
},
},
global: {
language: {
possible_duplicates: "Possible duplicates",
},
},
},
},
}));
vi.mock("@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js", () => ({
parsePosRouteSearch: vi.fn(() => ({
orderId: null,
customerId: null,
step: null,
})),
}));
vi.mock("@/components/displays/department/pos/utils/orderBookingDisplay.js", () => ({
getOrderBookingReferenceValue: vi.fn((booking) => booking?.reference || booking?.reference_number || ""),
getOrderBookingServiceText: vi.fn(() => "Service"),
}));
import PosDepartmentStep1 from "@/components/displays/department/pos/steps/PosDepartmentStep1.vue";
const SlotStub = defineComponent({
template: "<div><slot /></div>",
});
const ElementTabsBoxStub = defineComponent({
template: `
<div>
<slot />
<slot name="customer" />
<slot name="license_plates" />
</div>
`,
});
const NextStepErrorStub = defineComponent({
template: '<div data-testid="pos-next-step-error-stub"></div>',
});
const SelectVehicleFormPOSStub = defineComponent({
emits: ["update:desktopStep1Context", "commit:desktopStep1"],
setup(_, { emit, expose }) {
const finalizeDesktopStep1Context = (...args) => selectVehicleFormState.finalizeDesktopStep1Context(...args);
const applyBookingChoice = (...args) => selectVehicleFormState.applyBookingChoice(...args);
const skipBookingChoice = (...args) => selectVehicleFormState.skipBookingChoice(...args);
const refreshOrderBookingMatches = (...args) => selectVehicleFormState.refreshOrderBookingMatches(...args);
expose({
finalizeDesktopStep1Context,
applyBookingChoice,
skipBookingChoice,
refreshOrderBookingMatches,
});
return {
emitCommit() {
emit("commit:desktopStep1", buildContext(selectVehicleFormState.commitContext));
},
};
},
template: `
<div>
<button type="button" data-testid="emit-step1-commit" @click="emitCommit">Commit</button>
</div>
`,
});
const PosDesktopOrderBookingSelectorModalStub = defineComponent({
props: {
isActive: {
type: Boolean,
default: false,
},
bookings: {
type: Array,
default: () => [],
},
},
emits: ["select", "skip", "refresh-bookings"],
methods: {
emitSelect() {
this.$emit("select", this.bookings[0] || null);
},
},
template: `
<div v-if="isActive" data-testid="pos-desktop-order-booking-modal">
<button type="button" data-testid="pos-desktop-order-booking-use-first" @click="emitSelect">Select</button>
</div>
`,
});
function mountStepOne() {
return mountWithApp(PosDepartmentStep1, {
messages: {
en: {
admin: {
pos: {
license_plates: "License plates",
customer: "Customer",
not_found: "Not found",
order: "Order",
show_details: "Show details",
show_order_details: "Show order details",
hide_order_details: "Hide order details",
duplicate_order_warning: "Potential duplicates found.",
warning: "Warning",
order_booking_selector: {
title: "Select booking",
help_text: "Choose booking",
},
},
},
},
},
global: {
stubs: {
WhiteBox: SlotStub,
ButtonsBox: SlotStub,
ElementTabsBox: ElementTabsBoxStub,
PosSelectedCustomer: SlotStub,
PosLastScannedLicensePlatesV2: SlotStub,
NextStepError: NextStepErrorStub,
NextStep: defineComponent({
template: '<button type="button" data-testid="pos-next-step-stub">Next</button>',
}),
Cancel: defineComponent({
template: '<button type="button" data-testid="pos-cancel-stub">Cancel</button>',
}),
SelectVehicleFormPOS: SelectVehicleFormPOSStub,
PosDesktopOrderBookingSelectorModal: PosDesktopOrderBookingSelectorModalStub,
},
},
});
}
describe("PosDepartmentStep1 duplicate warning", () => {
beforeEach(() => {
posProcessState.customerName.value = "";
posProcessState.departmentId.value = 12;
posProcessState.currentStep.value = 1;
posProcessState.nextStep.mockReset();
posProcessState.clearCache.mockReset();
posProcessState.preflightHandler = null;
sessionState.request.mockReset();
sessionState.redirectToDepartment.mockReset();
selectVehicleFormState.commitContext = buildContext({
reg1: "AB12345",
committed: true,
source: "commit",
});
selectVehicleFormState.finalizedContext = buildContext({
reg1: "AB12345",
committed: true,
source: "commit",
});
selectVehicleFormState.bookingSelectionContext = buildContext({
reg1: "AB12345",
committed: true,
selectedBookingId: 8130,
bookingResolution: "selected",
source: "booking_selection",
});
selectVehicleFormState.skipBookingContext = buildContext({
reg1: "AB12345",
committed: true,
skippedBooking: true,
bookingResolution: "skipped",
source: "booking_skip",
});
selectVehicleFormState.refreshContext = buildContext({
reg1: "AB12345",
committed: false,
source: "booking_refresh",
});
selectVehicleFormState.finalizeDesktopStep1Context.mockReset();
selectVehicleFormState.finalizeDesktopStep1Context.mockImplementation(async () =>
buildContext(selectVehicleFormState.finalizedContext)
);
selectVehicleFormState.applyBookingChoice.mockReset();
selectVehicleFormState.applyBookingChoice.mockImplementation(async () =>
buildContext(selectVehicleFormState.bookingSelectionContext)
);
selectVehicleFormState.skipBookingChoice.mockReset();
selectVehicleFormState.skipBookingChoice.mockImplementation(async () =>
buildContext(selectVehicleFormState.skipBookingContext)
);
selectVehicleFormState.refreshOrderBookingMatches.mockReset();
selectVehicleFormState.refreshOrderBookingMatches.mockImplementation(async () =>
buildContext(selectVehicleFormState.refreshContext)
);
});
it("renders the inline warning and expands duplicate details after a committed duplicate match", async () => {
sessionState.request.mockResolvedValue({
data: {
data: [
{
id: 9206,
reg_1: "AB12345",
reference: "DUP-DETAILS",
customer_id: 12345679,
created_at: "2026-04-14T10:00:00.000Z",
},
],
},
});
const wrapper = mountStepOne();
await wrapper.get('[data-testid="emit-step1-commit"]').trigger("click");
await flushPromises();
expect(wrapper.get('[data-testid="pos-desktop-duplicate-warning-inline"]').text()).toContain(
"Potential duplicates found."
);
expect(wrapper.find('[data-testid="pos-desktop-duplicate-warning-details-inline"]').exists()).toBe(false);
await wrapper.get('[data-testid="pos-desktop-duplicate-warning-toggle-details"]').trigger("click");
await flushPromises();
expect(wrapper.get('[data-testid="pos-desktop-duplicate-warning-details-inline"]').text()).toContain(
"Order #9206"
);
await wrapper.get('[data-testid="pos-desktop-duplicate-order-open-9206"]').trigger("click");
expect(sessionState.redirectToDepartment).toHaveBeenCalledWith(12, "/modules/pos/orders/9206");
});
it("keeps booking selection ahead of the inline duplicate warning until booking resolution completes", async () => {
sessionState.request.mockResolvedValue({
data: {
data: [
{
id: 9207,
reg_1: "AB12345",
reference: "DUP-BOOKING",
customer_id: 12345679,
created_at: "2026-04-14T10:00:00.000Z",
},
],
},
});
const booking = {
id: 8130,
customer_name: "Pleno Logistics",
reg_1: "AB12345",
reference: "BOOKING-REF",
};
selectVehicleFormState.commitContext = buildContext({
reg1: "AB12345",
committed: true,
requiresBookingSelection: true,
bookingResolution: "selection_required",
bookingMatches: [booking],
source: "commit",
});
selectVehicleFormState.finalizedContext = buildContext(selectVehicleFormState.commitContext);
selectVehicleFormState.bookingSelectionContext = buildContext({
reg1: "AB12345",
committed: true,
selectedBookingId: booking.id,
bookingResolution: "selected",
bookingMatches: [booking],
source: "booking_selection",
});
selectVehicleFormState.finalizeDesktopStep1Context.mockReset();
selectVehicleFormState.finalizeDesktopStep1Context
.mockResolvedValueOnce(buildContext(selectVehicleFormState.commitContext))
.mockResolvedValue(buildContext(selectVehicleFormState.bookingSelectionContext));
const wrapper = mountStepOne();
await wrapper.get('[data-testid="emit-step1-commit"]').trigger("click");
await flushPromises();
expect(wrapper.get('[data-testid="pos-desktop-order-booking-modal"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="pos-desktop-duplicate-warning-inline"]').exists()).toBe(false);
await wrapper.get('[data-testid="pos-desktop-order-booking-use-first"]').trigger("click");
await flushPromises();
expect(wrapper.find('[data-testid="pos-desktop-order-booking-modal"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="pos-desktop-duplicate-warning-inline"]').exists()).toBe(true);
expect(selectVehicleFormState.applyBookingChoice).toHaveBeenCalledWith(booking, {
source: "booking_selection",
committed: true,
});
});
it("does not block desktop preflight when duplicates are found", async () => {
sessionState.request.mockResolvedValue({
data: {
data: [
{
id: 9208,
reg_1: "AB12345",
reference: "DUP-PREFLIGHT",
customer_id: 12345679,
created_at: "2026-04-14T10:00:00.000Z",
},
],
},
});
selectVehicleFormState.finalizedContext = buildContext({
reg1: "AB12345",
committed: true,
source: "next",
});
const wrapper = mountStepOne();
await flushPromises();
await expect(posProcessState.preflightHandler({ reason: "next" })).resolves.toBe(true);
await flushPromises();
expect(wrapper.get('[data-testid="pos-desktop-duplicate-warning-inline"]').exists()).toBe(true);
});
});