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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
+43
-1
@@ -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;
|
||||
|
||||
+712
-45
@@ -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>
|
||||
|
||||
+2
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
@@ -11,7 +9,7 @@ const escapeHtml = (value) => String(value ?? "")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
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 });
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+7
-7
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user