Add POS order booking modal and utilities with tests:

- Introduced `PosDesktopOrderBookingSelectorModal.vue` to streamline booking selection for desktop POS.
- Added `orderBookingDisplay.js` utility for handling booking display and reference value normalization.
- Created unit test `select-vehicle-form-pos.spec.js` to validate booking selection flow and registration matching.
- Implemented `run-playwright-ci-parallel.mjs` script to optimize Playwright CI with parallel testing.
- Updated styles and responsiveness for enhanced booking modal UX.
This commit is contained in:
Jeppe Bundgaard
2026-04-13 21:12:34 +02:00
parent 7d9d928a2d
commit 0f37b0632a
39 changed files with 3904 additions and 838 deletions
+2 -1
View File
@@ -14,7 +14,8 @@
"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:ci": "playwright test --reporter=line,html",
"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",
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
+22 -6
View File
@@ -1,8 +1,27 @@
import path from "node:path";
import { defineConfig, devices } from "@playwright/test";
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${devPort}`;
const isCI = !!process.env.CI;
const artifactNamespace = (process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "").trim();
const artifactRoot = artifactNamespace
? path.join("output", "playwright", artifactNamespace)
: path.join("output", "playwright");
const htmlReportOutputFolder = path.join(artifactRoot, "report");
const configuredWorkers = Number(process.env.PLAYWRIGHT_WORKERS || 2);
const workers = Number.isFinite(configuredWorkers) && configuredWorkers > 0 ? configuredWorkers : 2;
const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim();
const reporter =
reporterMode === "line-html"
? [
["line"],
["html", { open: "never", outputFolder: htmlReportOutputFolder }],
]
: [
["list"],
["html", { open: "never", outputFolder: htmlReportOutputFolder }],
];
export default defineConfig({
testDir: "./tests/e2e",
@@ -11,18 +30,15 @@ export default defineConfig({
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: 2,
workers,
...(process.env.PLAYWRIGHT_BASE_URL
? {}
: {
globalSetup: "./playwright.global-setup.mjs",
globalTeardown: "./playwright.global-teardown.mjs",
}),
reporter: [
["list"],
["html", { open: "never", outputFolder: "output/playwright/report" }]
],
outputDir: "output/playwright/test-results",
reporter,
outputDir: path.join(artifactRoot, "test-results"),
use: {
baseURL,
trace: "retain-on-failure",
+12 -1
View File
@@ -6,7 +6,10 @@ import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
const baseURL = `http://localhost:${devPort}`;
const pidFile = path.resolve(process.cwd(), "output/playwright/dev-server.json");
const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`)
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, "-");
const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`);
async function getListeningProcessOnWindows(port) {
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], {
@@ -124,6 +127,10 @@ export default async function globalSetup() {
{
cwd: process.cwd(),
detached: true,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: "ignore",
windowsHide: true,
}
@@ -131,6 +138,10 @@ export default async function globalSetup() {
: spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], {
cwd: process.cwd(),
detached: true,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: "ignore",
});
+5 -1
View File
@@ -4,7 +4,11 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const pidFile = path.resolve(process.cwd(), "output/playwright/dev-server.json");
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`)
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, "-");
const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`);
async function killProcessTree(pid) {
if (!pid) {
+232
View File
@@ -0,0 +1,232 @@
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
import { promisify } from "node:util";
const forwardedArgs = process.argv.slice(2);
const workingDirectory = process.cwd();
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
const basePort = normalizePositiveInt(process.env.PLAYWRIGHT_PARALLEL_BASE_PORT, 5191);
const perProcessWorkers = normalizePositiveInt(process.env.PLAYWRIGHT_PARALLEL_WORKERS, 1);
const reportIndexDirectory = path.join(workingDirectory, "output", "playwright", "ci-parallel-report");
const execFileAsync = promisify(execFile);
const activeChildren = new Set();
let isShuttingDown = false;
const groups = [
{
name: "chromium",
projects: ["chromium-desktop", "chromium-mobile"],
},
{
name: "firefox",
projects: ["firefox-desktop"],
},
{
name: "webkit",
projects: ["webkit-desktop", "webkit-mobile"],
},
];
function normalizePositiveInt(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function getArtifactNamespace(group) {
return `ci-parallel-${group.name}`;
}
function getDevServerPidFile(artifactNamespace) {
return path.join(workingDirectory, "output", "playwright", `dev-server-${artifactNamespace}.json`);
}
async function killProcessTree(pid) {
if (!pid) {
return;
}
if (process.platform === "win32") {
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"]).catch(() => {});
return;
}
try {
process.kill(-pid, "SIGTERM");
} catch {
try {
process.kill(pid, "SIGTERM");
} catch {
// ignore
}
}
}
async function cleanupDevServerArtifacts(artifactNamespace) {
const pidFile = getDevServerPidFile(artifactNamespace);
try {
const file = await fs.readFile(pidFile, "utf8");
const { pid } = JSON.parse(file);
await killProcessTree(pid);
} catch {
// ignore missing pid files or already-exited processes
}
await fs.rm(pidFile, { force: true }).catch(() => {});
}
function prefixStream(stream, prefix) {
const lineReader = readline.createInterface({ input: stream });
lineReader.on("line", (line) => {
process.stdout.write(`[${prefix}] ${line}\n`);
});
}
function spawnGroup(group, index) {
const devPort = basePort + index;
const artifactNamespace = getArtifactNamespace(group);
const args = [
"test",
...group.projects.flatMap((project) => ["--project", project]),
...forwardedArgs,
];
const child = spawn(process.execPath, [playwrightCliPath, ...args], {
cwd: workingDirectory,
env: {
...process.env,
PLAYWRIGHT_BASE_URL: "",
PLAYWRIGHT_DEV_PORT: String(devPort),
PLAYWRIGHT_WORKERS: String(perProcessWorkers),
PLAYWRIGHT_ARTIFACT_NAMESPACE: artifactNamespace,
PLAYWRIGHT_REPORTER_MODE: "line-html",
PLAYWRIGHT: "1",
},
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
activeChildren.add(child);
prefixStream(child.stdout, group.name);
prefixStream(child.stderr, `${group.name}:err`);
return new Promise((resolve) => {
child.on("close", (code) => {
activeChildren.delete(child);
resolve({
name: group.name,
code: code ?? 1,
artifactNamespace,
projects: group.projects,
port: devPort,
});
});
});
}
async function writeCombinedReportIndex(results) {
await fs.rm(reportIndexDirectory, { recursive: true, force: true });
await fs.mkdir(reportIndexDirectory, { recursive: true });
const rows = results
.map((result) => {
const status = result.code === 0 ? "passed" : "failed";
const statusColor = result.code === 0 ? "#166534" : "#991b1b";
const reportHref = `../${result.artifactNamespace}/report/index.html`;
return `
<tr>
<td>${result.name}</td>
<td>${result.projects.join(", ")}</td>
<td>${result.port}</td>
<td style="color: ${statusColor}; font-weight: 600;">${status}</td>
<td><a href="${reportHref}">Open report</a></td>
</tr>`;
})
.join("\n");
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Playwright Parallel Reports</title>
<style>
body { font-family: Segoe UI, Arial, sans-serif; margin: 32px; color: #111827; }
h1 { margin-bottom: 8px; }
p { color: #4b5563; }
table { border-collapse: collapse; width: 100%; margin-top: 24px; }
th, td { border: 1px solid #d1d5db; padding: 10px 12px; text-align: left; }
th { background: #f3f4f6; }
a { color: #1d4ed8; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h1>Playwright Parallel Reports</h1>
<p>Each child run used its own Vite port, dev-server pid file, and artifact directory.</p>
<table>
<thead>
<tr>
<th>Group</th>
<th>Projects</th>
<th>Port</th>
<th>Status</th>
<th>Report</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
</body>
</html>`;
await fs.writeFile(path.join(reportIndexDirectory, "index.html"), html, "utf8");
}
async function shutdown(signal) {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
process.stderr.write(`Received ${signal}. Stopping parallel Playwright children...\n`);
await Promise.all([...activeChildren].map((child) => killProcessTree(child.pid)));
await Promise.all(groups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group))));
}
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
void shutdown(signal).finally(() => {
process.exit(130);
});
});
}
async function main() {
console.log(
`Starting Playwright CI in parallel with ${groups.length} processes and ${perProcessWorkers} worker(s) per process.`
);
await Promise.all(groups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group))));
const results = await Promise.all(groups.map((group, index) => spawnGroup(group, index)));
await writeCombinedReportIndex(results);
await Promise.all(groups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group))));
const failedRuns = results.filter((result) => result.code !== 0);
if (failedRuns.length > 0) {
console.error(
`Parallel Playwright CI failed for: ${failedRuns.map((result) => `${result.name} (${result.code})`).join(", ")}`
);
process.exit(1);
}
console.log(
`Parallel Playwright CI passed. Combined report index: ${path.relative(workingDirectory, path.join(reportIndexDirectory, "index.html"))}`
);
}
await main();
@@ -1,67 +1,95 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { Colors } from "@/ThemeConfig.vue";
import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
import NextStepError from "@/components/forms/department/pos/error/NextStepError.vue";
import PosSelectedCustomer from "@/components/displays/department/pos/PosSelectedCustomer.vue";
import Cancel from "@/components/forms/department/pos/buttons/Cancel.vue";
import { clearCache } from "@/components/shop/POSDepartmentProcess.vue";
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import ButtonsBox from "@/components/displays/boxes/ButtonsBox.vue";
import { customer_name, isCustomerSelected, department_id, getDepartment, reg_1 } from "@/components/shop/POSDepartmentProcess.vue";
import {computed, onMounted, ref} from "vue";
import {
customer_name,
isCustomerSelected,
department_id,
getDepartment,
clearCache,
nextStep,
setDesktopStep1PreflightHandler,
clearDesktopStep1PreflightHandler,
} from "@/components/shop/POSDepartmentProcess.vue";
import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue";
import {POS_STEP_1_VERSION} from "@/config.js";
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 { deleteEverything } from "@/components/shop/POSDepartmentProcess.vue";
import {watch} from "vue";
import Swal from "sweetalert2";
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";
const { t } = useI18n();
const { t, locale } = useI18n();
const routeState = parsePosRouteSearch(window.location.search);
const shouldResetFreshStepOne = routeState.orderId === null
&& routeState.customerId === null
&& (routeState.step === null || routeState.step === 1);
const shouldResetFreshStepOne =
routeState.orderId === null &&
routeState.customerId === null &&
(routeState.step === null || routeState.step === 1);
if (shouldResetFreshStepOne) {
clearCache();
}
// Define the tabs
const right_tabs = ref([
{
name: t('admin.pos.license_plates'),
name: t("admin.pos.license_plates"),
slot: "license_plates",
icon: "fas fa-car",
hidden: false,
},
{
name: t('admin.pos.customer'),
name: t("admin.pos.customer"),
slot: "customer",
icon: "fas fa-user",
hidden: () => !isCustomerSelected(), // Hide the tab if no customer is selected
}
hidden: () => !isCustomerSelected(),
},
]);
const getPreferredRailTab = () => (customer_name.value ? 'customer' : 'license_plates');
const getPreferredRailTab = () => (customer_name.value ? "customer" : "license_plates");
const forceActiveTab = ref(getPreferredRailTab());
const selectVehicleFormRef = ref(null);
const createEmptyDesktopStep1Context = () => ({
reg1: "",
reg2: "",
bookingMatches: [],
selectedBookingId: null,
skippedBooking: false,
requiresBookingSelection: false,
bookingResolution: "none",
committed: false,
source: "initial",
});
const desktopStep1Context = ref(createEmptyDesktopStep1Context());
const desktopModalState = ref(null);
const duplicateCheckId = ref(0);
const duplicateOrders = ref([]);
const duplicateWarningKey = ref("");
const acknowledgedDuplicateWarningKey = ref("");
const pendingNextResolution = ref(false);
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
const setTab = (tab) => {
forceActiveTab.value = tab;
};
// Watch for changes in the selected customer and keep the rail synced.
watch(customer_name, (newValue) => {
forceActiveTab.value = newValue ? 'customer' : 'license_plates';
}, { immediate: true });
watch(
customer_name,
(newValue) => {
forceActiveTab.value = newValue ? "customer" : "license_plates";
},
{ immediate: true }
);
// attempt to focus on the reg_1 input field
const focusOnReg1 = () => {
setTimeout(() => {
const reg1Input = document.getElementById("reg_1");
@@ -71,301 +99,551 @@ const focusOnReg1 = () => {
}, 100);
};
focusOnReg1();
const getDepartmentVersion = () => {
let result = 1;
if (parseInt(getDepartment()) === 6) {
result = 2;
} else {
result = POS_STEP_1_VERSION;
return 2;
}
//console.log("Department version: " + result, "Department ID: " + department_id.value);
return result;
}
return POS_STEP_1_VERSION;
};
const department_version = ref(getDepartmentVersion());
const duplicateCheckId = ref(0);
// Select the "pos_select_customer_input" input field, and focus on it on page load
onMounted(() => {
focusOnReg1();
if (getDepartmentVersion() === 1) {
document.getElementById("pos_select_customer_input").focus();
document.getElementById("pos_select_customer_input")?.focus();
}
});
// Watch for changes in the reg_1 value
watch(() => reg_1.value, (newValue, oldValue) => {
// If the new value is different from the old value, check for duplicate orders
if (newValue !== oldValue) {
checkDuplicateOrders(newValue);
}
});
const normalizeDuplicateContext = (context = desktopStep1Context.value) => {
return {
...createEmptyDesktopStep1Context(),
...(context || {}),
reg1: String(context?.reg1 ?? "").trim().toUpperCase(),
reg2: String(context?.reg2 ?? "").trim().toUpperCase(),
bookingMatches: Array.isArray(context?.bookingMatches) ? context.bookingMatches : [],
};
};
const checkDuplicateOrders = (reg) => {
// Check if the reg is empty
if (!reg || reg.trim() === "") {
const getDuplicateStateKey = (context = desktopStep1Context.value) => {
const normalizedContext = normalizeDuplicateContext(context);
return [
normalizedContext.reg1,
normalizedContext.reg2,
normalizedContext.selectedBookingId ?? "",
normalizedContext.bookingResolution,
normalizedContext.skippedBooking ? "skip" : "",
].join("|");
};
const setDesktopStep1Context = (context = {}) => {
desktopStep1Context.value = normalizeDuplicateContext(context);
if (!desktopStep1Context.value.reg1) {
duplicateOrders.value = [];
duplicateWarningKey.value = "";
acknowledgedDuplicateWarningKey.value = "";
if (desktopModalState.value !== "duplicate_details") {
desktopModalState.value = null;
}
return;
}
// Create a token to ignore stale responses
const thisCheckId = Date.now();
duplicateCheckId.value = thisCheckId;
// Reset previous potential duplicates to avoid duplicate keys in the UI
potentialDuplicateOrderObjects.value = {};
potentialDuplicateOrders.value.objects = [];
// Check if the reg_1 value has recently had any orders
SessionUser.request(
SessionUser.objects.orders.meta.endpoint,
"GET",
{
filters: `reg_1:${reg},department_id:${department_id.value},created_at-date_from:${new Date().toISOString().split('T')[0]},created_at-date_to:${new Date().toISOString().split('T')[0]}`,
limit: 5, // Limit to 5 orders
}
).then((response) => {
// Ignore stale responses
if (thisCheckId !== duplicateCheckId.value) {
return;
}
// Check if there are any potential duplicate orders
const orders = response?.data?.data || [];
if (orders.length > 0) {
// Store the potential duplicate orders in the potentialDuplicateOrderObjects
const idsInList = new Set();
orders.forEach(order => {
// Prevent pushing the same order multiple times defensively
if (idsInList.has(order.id)) return;
idsInList.add(order.id);
potentialDuplicateOrderObjects.value[order.id] = {
loaded: false,
loading: false,
};
potentialDuplicateOrders.value.objects.push({
id: order.id,
label: `${t('admin.pos.order')} #${order.id} - ${order.created_at}`,
content: order, // Store the order content
buttons: [
{
label: t('admin.pos.show_details'),
action: () => {
// Action to show order details
SessionUser.functions.redirectTo.department(department_id.value, '/modules/pos/orders/' + order.id);
},
},
],
});
});
// Show the warning about duplicate orders
showDuplicateOrdersWarning();
}
}).catch(error => {
console.error("Error checking for duplicate orders:", error);
});
};
const showDuplicateOrdersWarning = () => {
const actions = {
confirm: () => {
// User confirmed to continue with the order.
// Continue with the order (do nothing here, just continue)
},
showOrderDetails: () => {
// User wants to see order details.
// Show order details
showDuplicateOrdersDetails();
},
cancel: () => {
// User canceled the order.
deleteEverything();
}
if (desktopStep1Context.value.requiresBookingSelection) {
duplicateOrders.value = [];
duplicateWarningKey.value = "";
acknowledgedDuplicateWarningKey.value = "";
desktopModalState.value = "booking_selection";
return;
}
if (desktopModalState.value === "booking_selection") {
desktopModalState.value = null;
}
Swal.fire({
title: t('admin.pos.warning'),
text: t('admin.pos.duplicate_order_warning'),
icon: 'warning',
showCancelButton: true,
confirmButtonText: t('admin.pos.continue'),
confirmButtonColor: Colors.buttons.default.backgroundColor,
cancelButtonText: t('admin.pos.cancel'),
cancelButtonColor: Colors.buttons.warning.activeColor,
showDenyButton: true,
denyButtonText: t('admin.pos.show_order_details'),
denyButtonColor: Colors.buttons.default.secondaryBackgroundColor,
}).then((result) => {
if (result.isConfirmed) {
actions.confirm();
} else if (result.isDenied) {
actions.showOrderDetails();
} else {
actions.cancel();
}
}).catch((error) => {
console.error("Error showing duplicate orders warning:", error);
});
};
const showDuplicateOrdersDetails = () => {
// Load the order details for the selected orders
const orderIds = Object.keys(potentialDuplicateOrderObjects.value);
potentialDuplicateOrders.value.objects = orderIds.map(id => ({
id: parseInt(id),
label: `${t('admin.pos.order')} #${id} - ${potentialDuplicateOrderObjects.value[id].loaded ? potentialDuplicateOrderObjects.value[id].created_at : t('admin.pos.loading')}`,
content: potentialDuplicateOrderObjects.value[id]
? parseOrderContent(potentialDuplicateOrderObjects.value[id])
: t('admin.pos.loading'),
const formatBookingDateTime = (booking) => {
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
if (!rawValue) {
return t("admin.pos.not_found");
}
const parsedValue = new Date(rawValue);
if (Number.isNaN(parsedValue.getTime())) {
return rawValue;
}
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: "short",
timeStyle: "short",
}).format(parsedValue);
};
const getBookingReferenceValue = (booking) => {
return String(booking?.reference ?? booking?.reference_number ?? "").trim();
};
const getBookingServicesValue = (booking) => {
if (Array.isArray(booking?.parsed_services?.array) && booking.parsed_services.array.length > 0) {
return booking.parsed_services.array.join(", ");
}
return String(booking?.parsed_services?.string ?? booking?.wash_type ?? "").trim();
};
const formatDuplicateOrderDate = (value) => {
if (!value) {
return t("admin.pos.not_found");
}
const parsedValue = new Date(value);
if (Number.isNaN(parsedValue.getTime())) {
return String(value);
}
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: "short",
timeStyle: "short",
}).format(parsedValue);
};
const getDuplicateOrderContent = (order) => {
return [
`${SessionUser.objects.orders.columns.reg_1.label}: ${order?.reg_1 || t("admin.pos.not_found")}`,
`${SessionUser.objects.orders.columns.reference.label}: ${order?.reference || t("admin.pos.not_found")}`,
`${t("admin.pos.customer")}: ${order?.customer_id || t("admin.pos.not_found")}`,
].join(" • ");
};
const bookingSelectionObjects = computed(() => {
return desktopStep1Context.value.bookingMatches.map((booking) => {
const plateText = [booking?.reg_1, booking?.reg_2].filter(Boolean).join(" / ");
const contentSegments = [
`${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`,
`${t("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`,
`${t("admin.pos.order_booking_selector.reference_label")}: ${getBookingReferenceValue(booking) || t("admin.pos.not_found")}`,
`${t("admin.pos.order_booking_selector.services_label")}: ${getBookingServicesValue(booking) || t("admin.pos.not_found")}`,
];
return {
id: booking.id,
label: t("admin.pos.order_booking_selector.option_title", {
id: booking.id,
datetime: formatBookingDateTime(booking),
}),
content: contentSegments.join(" • "),
buttons: [
{
label: t("admin.pos.order_booking_selector.use_booking"),
action: () => handleBookingSelection(booking),
color: "primary",
testId: `pos-desktop-order-booking-use-${booking.id}`,
},
],
};
});
});
const duplicateDetailsObjects = computed(() => {
return duplicateOrders.value.map((order) => ({
id: Number(order.id),
label: `${t("admin.pos.order")} #${order.id} - ${formatDuplicateOrderDate(order.created_at)}`,
content: getDuplicateOrderContent(order),
buttons: [
{
label: t('admin.pos.show_details'),
label: t("admin.pos.show_details"),
action: () => {
// Action to show order details
SessionUser.functions.redirectTo.department(department_id.value, 'modules/pos/orders/' + id, true);
SessionUser.functions.redirectTo.department(department_id.value, `/modules/pos/orders/${order.id}`);
},
color: "dark",
testId: `pos-desktop-duplicate-order-open-${order.id}`,
},
],
}));
potentialDuplicateOrders.value.isActive = true;
// Simulate loading content for each order ( if not already loaded )
orderIds.forEach(id => {
if ( !potentialDuplicateOrderObjects.value[id].loaded && !potentialDuplicateOrderObjects.value[id].loading ) {
loadPotentialDuplicateOrdersContent(id);
}
});
}
});
const parseOrderContent = (order) => {
// Parse the order content to display in the modal
if (!order) {
return t('admin.pos.no_content_available');
const desktopModalTitle = computed(() => {
if (desktopModalState.value === "duplicate_warning") {
return t("admin.pos.warning");
}
return ``;
if (desktopModalState.value === "duplicate_details") {
return `${SessionUser.objects.global.language.possible_duplicates} - ${desktopStep1Context.value.reg1}`;
}
return "";
});
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 resumePendingNextStep = async () => {
if (!pendingNextResolution.value) {
return;
}
pendingNextResolution.value = false;
await nextTick();
await nextStep({ isMobile: false, orderCreation: true });
};
const loadPotentialDuplicateOrdersContent = (id) => {
// Simulate loading content for each order
potentialDuplicateOrderObjects.value[id].loading = true;
SessionUser.objects.orders.get.single(id).then(order => {
// Update the content of the order in potentialDuplicateOrderObjects
potentialDuplicateOrderObjects.value[id] = order;
potentialDuplicateOrderObjects.value[id].loaded = true;
potentialDuplicateOrderObjects.value[id].loading = false;
// Update the corresponding order in potentialDuplicateOrders
showDuplicateOrdersDetails();
}).catch(error => {
console.error("Error loading order content:", error);
// Handle error, e.g., show a message or log it
})
const acceptDuplicateWarning = async () => {
acknowledgedDuplicateWarningKey.value = duplicateWarningKey.value || getDuplicateStateKey(desktopStep1Context.value);
desktopModalState.value = null;
await resumePendingNextStep();
};
const potentialDuplicateOrderObjects = ref({
//13669: { loaded: false, loading: false },
//13664: { loaded: false, loading: false },
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 potentialDuplicateOrders = ref({
isActive: false,
selectedObject: null,
objects: [
/**
* {
* id: 13669,
* label: "Ordre #13669",
* content: null, // Content will be loaded later
* buttons: [
* {
* label: "Vis detaljer",
* action: () => {
* // Action to show order details
* console.log("Show details for order #13669");
* loadPotentialDuplicateOrdersContent(13669);
* },
* },
* ],
* },
*/
// Add more dummy orders as needed
],
const fetchDuplicateOrdersForContext = async (context) => {
const normalizedContext = normalizeDuplicateContext(context);
if (!normalizedContext.reg1) {
duplicateOrders.value = [];
duplicateWarningKey.value = "";
return [];
}
const requestId = Date.now();
duplicateCheckId.value = requestId;
try {
const response = await SessionUser.request(SessionUser.objects.orders.meta.endpoint, "GET", {
filters: `reg_1:${normalizedContext.reg1},department_id:${department_id.value},created_at-date_from:${new Date().toISOString().split("T")[0]},created_at-date_to:${new Date().toISOString().split("T")[0]}`,
limit: 5,
});
if (duplicateCheckId.value !== requestId) {
return duplicateOrders.value;
}
if (getDuplicateStateKey(normalizedContext) !== getDuplicateStateKey(desktopStep1Context.value)) {
return duplicateOrders.value;
}
const orders = Array.isArray(response?.data?.data) ? response.data.data : [];
duplicateOrders.value = orders;
duplicateWarningKey.value = getDuplicateStateKey(normalizedContext);
return orders;
} catch (error) {
console.error("Error checking for duplicate orders:", error);
duplicateOrders.value = [];
duplicateWarningKey.value = "";
return [];
}
};
const ensureDuplicateWarningState = async (context, options = {}) => {
const normalizedContext = normalizeDuplicateContext(context);
if (!normalizedContext.reg1) {
duplicateOrders.value = [];
duplicateWarningKey.value = "";
if (desktopModalState.value !== "booking_selection") {
desktopModalState.value = null;
}
return {
canProceed: true,
duplicateOrders: [],
};
}
const orders = await fetchDuplicateOrdersForContext(normalizedContext);
if (orders.length === 0) {
if (desktopModalState.value === "duplicate_warning" || desktopModalState.value === "duplicate_details") {
desktopModalState.value = null;
}
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,
duplicateOrders: orders,
};
};
const coordinateDesktopStep1 = async (options = {}) => {
const normalizedOptions = {
reason: "commit",
finalizeReg1Input: false,
...options,
};
if (normalizedOptions.reason === "next") {
pendingNextResolution.value = true;
}
const context =
(await selectVehicleFormRef.value?.finalizeDesktopStep1Context?.({
source: normalizedOptions.reason,
finalizeReg1Input: normalizedOptions.finalizeReg1Input,
committed: true,
})) || desktopStep1Context.value;
setDesktopStep1Context(context);
if (!context.reg1) {
desktopModalState.value = null;
duplicateOrders.value = [];
duplicateWarningKey.value = "";
return {
canProceed: true,
context,
};
}
if (context.requiresBookingSelection || context.bookingResolution === "selection_required") {
duplicateOrders.value = [];
duplicateWarningKey.value = "";
desktopModalState.value = "booking_selection";
return {
canProceed: false,
context,
};
}
const duplicateState = await ensureDuplicateWarningState(context, normalizedOptions);
if (!duplicateState.canProceed) {
return {
canProceed: false,
context,
};
}
desktopModalState.value = null;
return {
canProceed: true,
context,
};
};
const handleDesktopStep1Commit = async (context) => {
setDesktopStep1Context(context);
desktopStep1CoordinationPromise = coordinateDesktopStep1({
reason: context?.source || "commit",
finalizeReg1Input: false,
});
await desktopStep1CoordinationPromise;
};
const handleDesktopStep1Preflight = async ({ reason } = {}) => {
await desktopStep1CoordinationPromise;
if (desktopModalState.value !== null) {
if ((reason || "next") === "next") {
pendingNextResolution.value = true;
}
return false;
}
desktopStep1CoordinationPromise = coordinateDesktopStep1({
reason: reason || "next",
finalizeReg1Input: true,
});
const result = await desktopStep1CoordinationPromise;
return result.canProceed;
};
const handleBookingSelection = async (booking) => {
const context = await selectVehicleFormRef.value?.applyBookingChoice?.(booking, {
source: "booking_selection",
committed: true,
});
setDesktopStep1Context(context);
desktopStep1CoordinationPromise = coordinateDesktopStep1({
reason: "booking_selection",
finalizeReg1Input: false,
});
const result = await desktopStep1CoordinationPromise;
if (result.canProceed) {
await resumePendingNextStep();
}
};
const handleBookingSkip = async () => {
const context = await selectVehicleFormRef.value?.skipBookingChoice?.({
source: "booking_skip",
committed: true,
});
setDesktopStep1Context(context);
desktopStep1CoordinationPromise = coordinateDesktopStep1({
reason: "booking_skip",
finalizeReg1Input: false,
});
const result = await desktopStep1CoordinationPromise;
if (result.canProceed) {
await resumePendingNextStep();
}
};
onMounted(() => {
setDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
});
// Computed value to the potential duplicate orders
const getPotentialDuplicateOrders = computed(() => {
return potentialDuplicateOrders.value.objects.map(order => ({
id: order.id,
label: order.label,
content: order.content,
}));
onBeforeUnmount(() => {
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
});
</script>
<template>
<div data-testid="pos-step-1">
<!--
{{department_version}}
{{department_id}}
{{getDepartment()}}
-->
<!-- Version 2 -->
<div class="pos-shell">
<!-- Choose vehicle -->
<div class="pos-main">
<WhiteBox class="pos-card">
<template #default>
<SelectVehicleFormPOS />
</template>
</WhiteBox>
</div>
<!-- Last seen License Plates in department -->
<div class="pos-rail pos-rail--sticky">
<WhiteBox class="pos-card pos-card--flush">
<template #default>
<ElementTabsBox
:tabs="right_tabs"
default-active-tab="license_plates"
class="pos-card-tabs"
:allowCompactWhenOneTab="true"
@update:activeTab="setTab"
v-bind:force-active-tab="forceActiveTab"
>
<template #customer>
<PosSelectedCustomer variant="sidebar" />
</template>
<template #license_plates>
<PosLastScannedLicensePlatesV2 />
</template>
</ElementTabsBox>
</template>
</WhiteBox>
</div>
<div class="pos-shell-actions">
<ButtonsBox class="pos-actions pos-actions--stacked">
<Cancel
tabindex="2"
class="is-fullwidth"
<div class="pos-shell">
<div class="pos-main">
<WhiteBox class="pos-card">
<template #default>
<SelectVehicleFormPOS
ref="selectVehicleFormRef"
@update:desktopStep1Context="setDesktopStep1Context"
@commit:desktopStep1="handleDesktopStep1Commit"
/>
</template>
</WhiteBox>
</div>
<div class="pos-rail pos-rail--sticky">
<WhiteBox class="pos-card pos-card--flush">
<template #default>
<ElementTabsBox
:tabs="right_tabs"
default-active-tab="license_plates"
class="pos-card-tabs"
:allowCompactWhenOneTab="true"
:force-active-tab="forceActiveTab"
@update:activeTab="setTab"
>
<template #customer>
<PosSelectedCustomer variant="sidebar" />
</template>
<template #license_plates>
<PosLastScannedLicensePlatesV2 />
</template>
</ElementTabsBox>
</template>
</WhiteBox>
</div>
<div class="pos-shell-actions">
<ButtonsBox class="pos-actions pos-actions--stacked">
<Cancel tabindex="2" class="is-fullwidth" />
</ButtonsBox>
<div class="pos-shell-actions__rail">
<NextStepError class="is-fullwidth" :clear-automatically="true" :clear-delay="8000" />
<ButtonsBox class="pos-actions pos-actions--stacked">
<NextStep class="is-fullwidth" tabindex="6" />
</ButtonsBox>
<div class="pos-shell-actions__rail">
<NextStepError class="is-fullwidth" :clear-automatically="true" :clear-delay="8000"/>
<ButtonsBox class="pos-actions pos-actions--stacked">
<NextStep class="is-fullwidth" tabindex="6"/>
</ButtonsBox>
</div>
</div>
</div>
<DefaultObjectSelector
v-bind:isActive="potentialDuplicateOrders.isActive"
:title="SessionUser.objects.global.language.possible_duplicates + ' - ' + reg_1"
v-bind:objects="potentialDuplicateOrders.objects"
@update:isActive="(value) => {
potentialDuplicateOrders.isActive = value;
}"
@selectedObject="(object) => { potentialDuplicateOrders.selectedObject = object; }"
:show-radio="false"
/>
</div>
<PosDesktopOrderBookingSelectorModal
:isActive="desktopModalState === 'booking_selection'"
:allowClose="false"
:title="t('admin.pos.order_booking_selector.title')"
:message="t('admin.pos.order_booking_selector.help_text')"
:bookings="desktopStep1Context.bookingMatches"
:departmentId="department_id"
:matchedPlate="desktopStep1Context.reg1"
@select="handleBookingSelection"
@skip="handleBookingSkip"
/>
<DefaultObjectSelector
:isActive="desktopModalState === 'duplicate_warning' || desktopModalState === 'duplicate_details'"
:allowClose="false"
:teleportToBody="true"
:showRadio="false"
:title="desktopModalTitle"
:message="desktopModalMessage"
:objects="desktopModalObjects"
:footerButtons="desktopModalFooterButtons"
/>
</div>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -0,0 +1,628 @@
<script setup>
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
getOrderBookingNotesValue,
getOrderBookingPlateText,
getOrderBookingPoValue,
getOrderBookingReferenceValue,
getOrderBookingServiceLabels,
hasOrderBookingDisplayDetails,
} from "@/components/displays/department/pos/utils/orderBookingDisplay.js";
const props = defineProps({
isActive: {
type: Boolean,
default: false,
},
allowClose: {
type: Boolean,
default: true,
},
title: {
type: String,
default: "",
},
message: {
type: String,
default: "",
},
bookings: {
type: Array,
default: () => [],
},
departmentId: {
type: [Number, String],
default: null,
},
matchedPlate: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:isActive", "select", "skip"]);
const { t, locale } = useI18n();
const detailedBookingsById = ref({});
const loadingBookingIds = ref([]);
const failedBookingIds = ref([]);
const toPositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const normalizePlateValue = (value) => String(value ?? "").trim().toUpperCase();
const formatBookingDateTime = (booking) => {
const rawValue = booking?.datetime || booking?.created_at || booking?.date || null;
if (!rawValue) {
return t("admin.pos.not_found");
}
const parsedValue = new Date(rawValue);
if (Number.isNaN(parsedValue.getTime())) {
return rawValue;
}
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: "short",
timeStyle: "short",
}).format(parsedValue);
};
const getDetailedBooking = (booking) => {
const bookingId = toPositiveInteger(booking?.id);
return bookingId ? detailedBookingsById.value[bookingId] || null : null;
};
const getDisplayBooking = (booking) => {
const detailedBooking = getDetailedBooking(booking);
return detailedBooking ? { ...booking, ...detailedBooking } : booking;
};
const isBookingLoading = (booking) => {
const bookingId = toPositiveInteger(booking?.id);
return bookingId ? loadingBookingIds.value.includes(bookingId) : false;
};
const didBookingDetailFail = (booking) => {
const bookingId = toPositiveInteger(booking?.id);
return bookingId ? failedBookingIds.value.includes(bookingId) : false;
};
const getBookingServiceLabels = (booking) => {
return getOrderBookingServiceLabels(getDisplayBooking(booking));
};
const getBookingNote = (booking) => {
return getOrderBookingNotesValue(getDisplayBooking(booking));
};
const getBookingReference = (booking) => {
return getOrderBookingReferenceValue(getDisplayBooking(booking));
};
const getBookingPo = (booking) => {
return getOrderBookingPoValue(getDisplayBooking(booking));
};
const getBookingPlates = (booking) => {
return getOrderBookingPlateText(getDisplayBooking(booking));
};
const getLoadingCount = computed(() => loadingBookingIds.value.length);
const isLoadingAnyBookingDetails = computed(() => getLoadingCount.value > 0);
const resolvedBookingCount = computed(() => {
return props.bookings.filter((booking) => {
const displayBooking = getDisplayBooking(booking);
return (
hasOrderBookingDisplayDetails(displayBooking) ||
!isBookingLoading(booking) ||
didBookingDetailFail(booking)
);
}).length;
});
const summaryChips = computed(() => {
const chips = [];
if (props.bookings.length > 0) {
chips.push(`${props.bookings.length} ${SessionUser.objects.order_bookings.meta.labels.multiple}`);
}
if (props.matchedPlate) {
chips.push(`${SessionUser.objects.orders.columns.reg_1.label}: ${normalizePlateValue(props.matchedPlate)}`);
}
return chips;
});
const fetchBookingDetails = async (bookings) => {
const pendingBookings = (Array.isArray(bookings) ? bookings : []).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 !== 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: props.departmentId,
})
)
);
const nextDetailedBookings = {
...detailedBookingsById.value,
};
const nextFailedBookingIds = new Set(failedBookingIds.value);
bookingResults.forEach((result) => {
if (result.status === "fulfilled" && result.value?.id) {
nextDetailedBookings[result.value.id] = result.value;
nextFailedBookingIds.delete(result.value.id);
return;
}
const failedBookingId = toPositiveInteger(result?.reason?.bookingId);
if (failedBookingId) {
nextFailedBookingIds.add(failedBookingId);
}
});
bookingsToLoad.forEach((booking, index) => {
const bookingId = bookingIdsToLoad[index];
const result = bookingResults[index];
if (result?.status !== "fulfilled" && bookingId) {
nextFailedBookingIds.add(bookingId);
}
});
detailedBookingsById.value = nextDetailedBookings;
failedBookingIds.value = Array.from(nextFailedBookingIds);
loadingBookingIds.value = loadingBookingIds.value.filter((bookingId) => !bookingIdsToLoad.includes(bookingId));
};
watch(
() => [props.isActive, props.bookings.map((booking) => booking?.id).join("|")],
([isActive]) => {
if (!isActive) {
return;
}
void fetchBookingDetails(props.bookings);
},
{ immediate: true }
);
const onSelect = async (booking) => {
emit("select", getDisplayBooking(booking));
};
const onSkip = async () => {
emit("skip");
};
const closeModal = () => {
if (!props.allowClose) {
return;
}
emit("update:isActive", false);
};
</script>
<template>
<Teleport to="body">
<div
class="modal pos-desktop-order-booking-modal"
:class="{ 'is-active': isActive }"
data-testid="pos-desktop-order-booking-modal"
>
<div class="modal-background" @click="closeModal" />
<div class="modal-card pos-desktop-order-booking-modal__card">
<header class="modal-card-head pos-desktop-order-booking-modal__head">
<div class="pos-desktop-order-booking-modal__head-content">
<div>
<p class="modal-card-title">{{ title || t("admin.pos.order_booking_selector.title") }}</p>
<p v-if="message" class="pos-desktop-order-booking-modal__message">{{ message }}</p>
</div>
<div class="pos-desktop-order-booking-modal__summary">
<span
v-for="chip in summaryChips"
:key="chip"
class="pos-desktop-order-booking-modal__summary-chip"
>
{{ chip }}
</span>
</div>
</div>
<button v-if="allowClose" class="delete" aria-label="close" @click="closeModal" />
</header>
<section class="modal-card-body pos-desktop-order-booking-modal__body">
<div
v-if="isLoadingAnyBookingDetails"
class="pos-desktop-order-booking-modal__loading"
data-testid="pos-desktop-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="pos-desktop-order-booking-modal__grid">
<article
v-for="booking in bookings"
:key="booking.id"
class="pos-desktop-order-booking-card"
:data-testid="`pos-desktop-order-booking-option-${booking.id}`"
>
<div class="pos-desktop-order-booking-card__header">
<div>
<p class="pos-desktop-order-booking-card__eyebrow">Booking #{{ booking.id }}</p>
<h3 class="pos-desktop-order-booking-card__title">{{ formatBookingDateTime(getDisplayBooking(booking)) }}</h3>
</div>
<span
v-if="didBookingDetailFail(booking)"
class="tag is-light pos-desktop-order-booking-card__tag"
>
{{ t("admin.pos.not_found") }}
</span>
</div>
<p class="pos-desktop-order-booking-card__customer">
{{ getDisplayBooking(booking)?.customer_name || t("admin.pos.not_found") }}
</p>
<dl class="pos-desktop-order-booking-card__meta-grid">
<div class="pos-desktop-order-booking-card__meta-item">
<dt>{{ t("admin.pos.order_booking_selector.plates_label") }}</dt>
<dd>{{ getBookingPlates(booking) || t("admin.pos.not_found") }}</dd>
</div>
<div class="pos-desktop-order-booking-card__meta-item">
<dt>{{ t("admin.pos.order_booking_selector.reference_label") }}</dt>
<dd>{{ getBookingReference(booking) || "—" }}</dd>
</div>
<div v-if="getBookingPo(booking)" class="pos-desktop-order-booking-card__meta-item">
<dt>{{ SessionUser.objects.orders.columns.po.label }}</dt>
<dd>{{ getBookingPo(booking) }}</dd>
</div>
<div v-if="getBookingNote(booking)" class="pos-desktop-order-booking-card__meta-item">
<dt>{{ SessionUser.objects.orders.columns.notes.label }}</dt>
<dd>{{ getBookingNote(booking) }}</dd>
</div>
</dl>
<div
v-if="getBookingServiceLabels(booking).length > 0 || isBookingLoading(booking)"
class="pos-desktop-order-booking-card__services"
:data-testid="`pos-desktop-order-booking-services-${booking.id}`"
>
<p class="pos-desktop-order-booking-card__section-title">
{{ t("admin.pos.order_booking_selector.services_label") }}
</p>
<div v-if="isBookingLoading(booking)" class="pos-desktop-order-booking-card__service-skeleton">
<span class="pos-desktop-order-booking-card__service-pill is-skeleton" />
<span class="pos-desktop-order-booking-card__service-pill is-skeleton" />
</div>
<div v-else class="pos-desktop-order-booking-card__service-list">
<span
v-for="service in getBookingServiceLabels(booking)"
:key="`${booking.id}-${service}`"
class="pos-desktop-order-booking-card__service-pill"
>
{{ service }}
</span>
</div>
</div>
<div class="pos-desktop-order-booking-card__actions">
<button
class="button is-light"
type="button"
:class="{ 'is-loading': isBookingLoading(booking) }"
:disabled="isBookingLoading(booking)"
:data-testid="`pos-desktop-order-booking-use-${booking.id}`"
@click="onSelect(booking)"
>
{{ t("admin.pos.order_booking_selector.use_booking") }}
</button>
</div>
</article>
</div>
</section>
<footer class="modal-card-foot pos-desktop-order-booking-modal__foot">
<button
class="button is-light"
type="button"
data-testid="pos-desktop-order-booking-skip"
@click="onSkip"
>
{{ t("admin.pos.order_booking_selector.continue_without_booking") }}
</button>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.pos-desktop-order-booking-modal.modal {
z-index: 120;
}
.pos-desktop-order-booking-modal__card {
width: min(1180px, calc(100vw - 2.5rem));
max-width: 1180px;
max-height: calc(100vh - 2rem);
border-radius: 20px;
overflow: hidden;
box-shadow: 0 28px 80px rgba(10, 24, 61, 0.35);
}
.pos-desktop-order-booking-modal__head {
align-items: flex-start;
padding: 1.5rem 1.75rem 1.1rem;
background:
linear-gradient(135deg, rgba(15, 35, 77, 0.98), rgba(10, 24, 61, 0.95)),
linear-gradient(135deg, #0f234d, #102d62);
border-bottom: none;
}
.pos-desktop-order-booking-modal__head-content {
display: flex;
flex: 1;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.pos-desktop-order-booking-modal__head :deep(.modal-card-title) {
color: #fff;
font-size: 1.85rem;
line-height: 1.15;
}
.pos-desktop-order-booking-modal__message {
margin-top: 0.55rem;
max-width: 48rem;
color: rgba(255, 255, 255, 0.82);
font-size: 0.98rem;
}
.pos-desktop-order-booking-modal__summary {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.pos-desktop-order-booking-modal__summary-chip {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0.15rem 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
font-size: 0.84rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.pos-desktop-order-booking-modal__body {
padding: 1.25rem 1.5rem 1.35rem;
background:
linear-gradient(180deg, #f8fbff 0%, #f2f6fb 100%);
}
.pos-desktop-order-booking-modal__loading {
display: inline-flex;
align-items: center;
gap: 0.55rem;
margin-bottom: 1rem;
padding: 0.55rem 0.85rem;
border-radius: 999px;
background: rgba(15, 35, 77, 0.08);
color: #234;
font-size: 0.92rem;
font-weight: 600;
}
.pos-desktop-order-booking-modal__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
align-items: start;
}
.pos-desktop-order-booking-card {
display: flex;
flex-direction: column;
gap: 0.9rem;
min-height: 100%;
padding: 1rem;
border: 1px solid #d7e2f0;
border-radius: 18px;
background: #fff;
box-shadow: 0 10px 24px rgba(21, 42, 76, 0.08);
}
.pos-desktop-order-booking-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.pos-desktop-order-booking-card__eyebrow {
margin: 0;
color: #5c6d84;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.pos-desktop-order-booking-card__title {
margin: 0.2rem 0 0;
color: #0f234d;
font-size: 1.15rem;
font-weight: 800;
line-height: 1.2;
}
.pos-desktop-order-booking-card__customer {
margin: 0;
color: #1b314f;
font-size: 1rem;
font-weight: 700;
}
.pos-desktop-order-booking-card__tag {
color: #49576b;
}
.pos-desktop-order-booking-card__meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem 0.9rem;
margin: 0;
}
.pos-desktop-order-booking-card__meta-item {
min-width: 0;
}
.pos-desktop-order-booking-card__meta-item dt,
.pos-desktop-order-booking-card__section-title {
margin: 0 0 0.25rem;
color: #6b7c92;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pos-desktop-order-booking-card__meta-item dd {
margin: 0;
color: #14243b;
font-size: 0.94rem;
line-height: 1.45;
word-break: break-word;
}
.pos-desktop-order-booking-card__services {
padding-top: 0.1rem;
}
.pos-desktop-order-booking-card__service-list,
.pos-desktop-order-booking-card__service-skeleton {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.pos-desktop-order-booking-card__service-pill {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0.3rem 0.7rem;
border-radius: 999px;
background: #eef4fb;
color: #173559;
font-size: 0.88rem;
font-weight: 600;
line-height: 1.2;
}
.pos-desktop-order-booking-card__service-pill.is-skeleton {
min-width: 6rem;
color: transparent;
background: linear-gradient(90deg, #ebf0f7 0%, #f7f9fc 50%, #ebf0f7 100%);
background-size: 200% 100%;
animation: pos-booking-selector-pulse 1.2s linear infinite;
}
.pos-desktop-order-booking-card__actions {
margin-top: auto;
padding-top: 0.2rem;
}
.pos-desktop-order-booking-card__actions .button {
width: 100%;
border-radius: 12px;
font-weight: 700;
}
.pos-desktop-order-booking-modal__foot {
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.5rem 1.25rem;
background: rgba(248, 251, 255, 0.96);
border-top: 1px solid #e1e8f2;
}
.pos-desktop-order-booking-modal__foot .button {
border-radius: 12px;
font-weight: 700;
}
@keyframes pos-booking-selector-pulse {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media screen and (max-width: 768px) {
.pos-desktop-order-booking-modal__card {
width: calc(100vw - 1rem);
max-height: calc(100vh - 1rem);
}
.pos-desktop-order-booking-modal__head,
.pos-desktop-order-booking-modal__body,
.pos-desktop-order-booking-modal__foot {
padding-left: 1rem;
padding-right: 1rem;
}
.pos-desktop-order-booking-card__meta-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -19,7 +19,7 @@ import PosDepartmentStep2MobileVehicleSelection
import PosDepartmentStepMobileButtonNextStep
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import { primaryItem } from "./objects/PosDepartmentStepMobileFlow.vue";
import { order_id, order_notes, reference as persistedReference, reg_1, reg_2, reg_3, department_id, customer_id, getCustomerEmail, customer_name, isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
import { order_id, order_notes, order_po, reference as persistedReference, reg_1, reg_2, reg_3, department_id, customer_id, getCustomerEmail, customer_name, isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobileButtonClearAll
@@ -153,6 +153,12 @@ const getFirstWashProduct = async (): Promise<PosProduct | null> => {
}
};
const normalizeRegistrationValue = (value: string | null | undefined) =>
String(value ?? '')
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]/g, '');
const applyPendingBookingFromSelection = async () => {
const booking: any = await getSelectedBooking();
if (!booking) return false;
@@ -168,6 +174,7 @@ const applyPendingBookingFromSelection = async () => {
if (booking.po && typeof booking.po === 'string' && booking.po.trim() !== '') {
try {
await SessionUser.objects.orders.set.po(order_id.value, booking.po);
order_po.value = booking.po.trim();
} catch (e) {
console.error('Failed to set PO on order from booking', e);
}
@@ -184,22 +191,85 @@ const applyPendingBookingFromSelection = async () => {
}
items = Array.isArray(fullBookingData.items) ? fullBookingData.items : [];
// Apply reference and notes from full booking
if (fullBookingData.reference && fullBookingData.reference.trim() !== '') {
const bookingReference = String(fullBookingData.reference ?? fullBookingData.reference_number ?? '').trim();
if (bookingReference !== '') {
try {
await SessionUser.objects.orders.set.reference(order_id.value, fullBookingData.reference);
persistedReference.value = fullBookingData.reference;
await SessionUser.objects.orders.set.reference(order_id.value, bookingReference);
persistedReference.value = bookingReference;
metadata.setReference(bookingReference);
hydrateReferenceFromSources();
} catch (setRefError) {
console.error('Failed to set reference on order from booking:', setRefError);
}
}
if (fullBookingData.notes !== undefined && fullBookingData.notes !== null && fullBookingData.notes.trim() !== '') {
const bookingNotes = String(fullBookingData.notes ?? fullBookingData.note ?? '').trim();
if (bookingNotes !== '') {
try {
await SessionUser.objects.orders.set.notes(order_id.value, fullBookingData.notes);
await SessionUser.objects.orders.set.notes(order_id.value, bookingNotes);
order_notes.value = bookingNotes;
notes.value = bookingNotes;
metadata.setNotes(bookingNotes);
} catch (setNotesError) {
console.error('Failed to set notes on order from booking:', setNotesError);
}
}
const bookingReg1 = normalizeRegistrationValue(fullBookingData.reg_1 ?? booking.reg_1 ?? reg_1.value);
const bookingReg2 = normalizeRegistrationValue(fullBookingData.reg_2 ?? booking.reg_2 ?? '');
const currentReg1 = normalizeRegistrationValue(reg_1.value);
const currentReg2 = normalizeRegistrationValue(reg_2.value);
if (bookingReg1 && bookingReg1 !== currentReg1) {
reg_1.value = bookingReg1;
await SessionUser.objects.orders.set.reg_1(order_id.value, bookingReg1);
}
if (bookingReg2 !== currentReg2) {
reg_2.value = bookingReg2;
await SessionUser.objects.orders.set.reg_2(order_id.value, bookingReg2);
}
const bookingMatches = Array.isArray(vehicles.vehicle_1.value?.booking_matches)
? vehicles.vehicle_1.value.booking_matches
: [booking];
const bookingCustomerNumber =
Number.parseInt(
String(
fullBookingData?.customer_number ??
fullBookingData?.customer_id ??
booking?.customer_number ??
booking?.customer_id ??
vehicles.vehicle_1.value?.customer_id ??
customer_id.value ??
0
),
10
) || 0;
vehicles.select(1, {
...(vehicles.vehicle_1.value || {}),
reg: bookingReg1 || vehicles.vehicle_1.value?.reg || '',
customer_id: bookingCustomerNumber,
status: 'booked',
barred: false,
booking_id: booking.id,
booking_matches: bookingMatches,
reference: bookingReference || vehicles.vehicle_1.value?.reference || null,
});
if (bookingReg2) {
vehicles.select(2, {
...(vehicles.vehicle_2.value || {}),
reg: bookingReg2,
customer_id: bookingCustomerNumber,
status: 'booked',
barred: false,
booking_id: booking.id,
booking_matches: bookingMatches,
});
} else {
vehicles.select(2, null);
}
} catch (fetchError) {
console.error('Failed to fetch full booking for items:', fetchError);
lastAppliedBookingId.value = booking.id;
@@ -424,7 +494,6 @@ const getNormalizedOrderId = () => {
const normalizeReferenceValue = (value: unknown) => String(value ?? "");
const hasReferenceValue = (value: unknown) => normalizeReferenceValue(value).trim() !== "";
const normalizeRegistrationValue = (value: string | null | undefined) => String(value ?? "").trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
const syncVehicleRegistrationFromOrder = (vehicleIndex: number, value: string | null | undefined) => {
const normalizedValue = normalizeRegistrationValue(value);
@@ -7,7 +7,7 @@ import { popupComponentKeyToComponent } from "@/components/displays/department/p
<template>
<div class="popup-container" data-testid="pos-mobile-popup" :data-popup-id="popups.get()?.id || undefined" v-if="popups.isSet.value">
<div class="popup-content">
<div class="popup-content" :style="{ ...(popups.get()?.style || {}) }">
<!-- Icon? and title? -->
<WhiteBoxCard :defaultOpen="true" :hideHeader="popups.get()?.hideHeader || false">
<template #header>
@@ -28,6 +28,7 @@ import { popupComponentKeyToComponent } from "@/components/displays/department/p
>
<a class="card-footer-item"
:class="'is-' + (actionButton?.color || 'primary')"
:data-testid="actionButton.testId"
@click="actionButton.onClick ? actionButton.onClick() : null">
{{ actionButton.label }}
</a>
@@ -47,18 +48,58 @@ import { popupComponentKeyToComponent } from "@/components/displays/department/p
height: 100%;
display: flex;
justify-content: center;
align-items: center;
align-items: flex-start;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
padding: max(4.5rem, env(safe-area-inset-top, 0px) + 1rem) 1rem max(1rem, env(safe-area-inset-bottom, 0px) + 0.75rem);
overflow-y: auto;
overscroll-behavior: contain;
}
.popup-content {
background-color: white;
border: 0;
border-radius: 8px;
border-radius: 12px;
padding: 0;
overflow: hidden;
width: 80%;
width: min(92vw, 26rem);
max-width: 26rem;
max-height: min(80dvh, calc(100vh - 6rem));
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(7, 18, 46, 0.32);
}
.popup-content h2 {
}
.popup-content :deep(.white-box) {
height: 100%;
margin-bottom: 0;
}
.popup-content :deep(.card) {
display: flex;
flex-direction: column;
height: 100%;
}
.popup-content :deep(.card-header) {
flex-shrink: 0;
}
.popup-content :deep(.card-content) {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.popup-content :deep(.card-footer) {
flex-shrink: 0;
background: #fff;
border-top: 1px solid #e5ebf3;
}
.popup-content :deep(.card-footer-item) {
font-weight: 700;
}
</style>
@@ -2,6 +2,11 @@
import { computed } from "vue";
import { popups } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import { useI18n } from "vue-i18n";
import {
getOrderBookingPlateText,
getOrderBookingReferenceValue,
getOrderBookingServiceText,
} from "@/components/displays/department/pos/utils/orderBookingDisplay.js";
const { t, locale } = useI18n();
@@ -25,77 +30,72 @@ const formatBookingDateTime = (booking: any) => {
}).format(parsedValue);
};
const getReferenceValue = (booking: any) => {
return String(booking?.reference ?? booking?.reference_number ?? '').trim();
};
const getServicesValue = (booking: any) => {
if (Array.isArray(booking?.parsed_services?.array) && booking.parsed_services.array.length > 0) {
return booking.parsed_services.array.join(', ');
}
return String(booking?.parsed_services?.string ?? booking?.wash_type ?? '').trim();
};
const onSelect = async (booking: any) => {
if (typeof popupProps.value.onSelect === 'function') {
await popupProps.value.onSelect(booking);
}
};
const onSkip = async () => {
if (typeof popupProps.value.onSkip === 'function') {
await popupProps.value.onSkip();
}
};
</script>
<template>
<div data-testid="pos-mobile-order-booking-popup">
<p class="mb-3">{{ popups.get()?.message || t('admin.pos.order_booking_selector.help_text') }}</p>
<div
v-for="booking in bookings"
:key="booking.id"
class="booking-option mb-3"
:data-testid="`pos-mobile-order-booking-option-${booking.id}`"
>
<div class="booking-option__title">
{{ t('admin.pos.order_booking_selector.option_title', { id: booking.id, datetime: formatBookingDateTime(booking) }) }}
</div>
<div class="booking-option__details">
{{ t('admin.pos.order_booking_selector.customer_label') }}: {{ booking?.customer_name || t('admin.pos.not_found') }}
</div>
<div class="booking-option__details">
{{ t('admin.pos.order_booking_selector.plates_label') }}:
{{ [booking?.reg_1, booking?.reg_2].filter(Boolean).join(' / ') || t('admin.pos.not_found') }}
</div>
<div class="booking-option__details">
{{ t('admin.pos.order_booking_selector.reference_label') }}: {{ getReferenceValue(booking) || t('admin.pos.not_found') }}
</div>
<div class="booking-option__details">
{{ t('admin.pos.order_booking_selector.services_label') }}: {{ getServicesValue(booking) || t('admin.pos.not_found') }}
</div>
<button
class="button is-primary is-fullwidth mt-3"
type="button"
:data-testid="`pos-mobile-order-booking-use-${booking.id}`"
@click="onSelect(booking)"
<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>
<div class="booking-popup__list" data-testid="pos-mobile-order-booking-list">
<div
v-for="booking in bookings"
:key="booking.id"
class="booking-option"
:data-testid="`pos-mobile-order-booking-option-${booking.id}`"
>
{{ t('admin.pos.order_booking_selector.use_booking') }}
</button>
<div class="booking-option__title">
{{ t('admin.pos.order_booking_selector.option_title', { id: booking.id, datetime: formatBookingDateTime(booking) }) }}
</div>
<div class="booking-option__details">
{{ t('admin.pos.order_booking_selector.customer_label') }}: {{ booking?.customer_name || t('admin.pos.not_found') }}
</div>
<div class="booking-option__details">
{{ t('admin.pos.order_booking_selector.plates_label') }}:
{{ 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)"
>
{{ t('admin.pos.order_booking_selector.use_booking') }}
</button>
</div>
</div>
<button
class="button is-light is-fullwidth"
type="button"
data-testid="pos-mobile-order-booking-skip"
@click="onSkip"
>
{{ t('admin.pos.order_booking_selector.continue_without_booking') }}
</button>
</div>
</template>
<style scoped>
.booking-popup {
display: flex;
flex-direction: column;
min-height: 0;
}
.booking-popup__intro {
margin-bottom: 0.85rem;
color: #4c5a6e;
font-size: 0.98rem;
line-height: 1.45;
}
.booking-popup__list {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.booking-option {
border: 1px solid #d8dde6;
border-radius: 8px;
@@ -6,6 +6,7 @@ export type PosActionButton = {
description?: string; // Optional description
onClick?: () => void;
icon?: string; // Optional icon name
testId?: string; // Optional test id
disabled?: boolean; // Is the button disabled?
disabled_description?: string; // Optional description when disabled
color?: string | 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark'; // Button color
@@ -239,7 +239,7 @@ const addDefaultPopups = () => {
title: i18n.global.t('admin.pos.order_booking_selector.title'),
message: i18n.global.t('admin.pos.order_booking_selector.help_text'),
component: 'select_order_booking',
style: {maxHeight: '60vh'},
style: { maxHeight: '72dvh' },
actionButtons: [],
});
// Completed
@@ -0,0 +1,95 @@
const normalizeDisplayValue = (value) => String(value ?? "").trim();
const appendServiceName = (collection, value) => {
const normalizedValue = normalizeDisplayValue(value);
if (!normalizedValue) {
return;
}
collection.push(normalizedValue);
};
const collectBookingItemNames = (item, collection) => {
if (!item || typeof item !== "object") {
return;
}
appendServiceName(collection, item?.product?.name ?? item?.name ?? item?.product_name ?? "");
const itemAddons = Array.isArray(item?.addons) ? item.addons : [];
itemAddons.forEach((addon) => collectBookingItemNames(addon, collection));
};
const getUniqueValues = (values = []) => {
const seenValues = new Set();
return values.filter((value) => {
const normalizedValue = normalizeDisplayValue(value);
if (!normalizedValue) {
return false;
}
const dedupeKey = normalizedValue.toLowerCase();
if (seenValues.has(dedupeKey)) {
return false;
}
seenValues.add(dedupeKey);
return true;
});
};
export const getOrderBookingReferenceValue = (booking) => {
return normalizeDisplayValue(booking?.reference ?? booking?.reference_number ?? "");
};
export const getOrderBookingPoValue = (booking) => {
return normalizeDisplayValue(booking?.po ?? booking?.po_number ?? "");
};
export const getOrderBookingNotesValue = (booking) => {
return normalizeDisplayValue(booking?.notes ?? booking?.note ?? "");
};
export const getOrderBookingPlateText = (booking) => {
return [booking?.reg_1, booking?.reg_2, booking?.reg_3]
.map((plate) => normalizeDisplayValue(plate))
.filter(Boolean)
.join(" / ");
};
export const getOrderBookingServiceLabels = (booking) => {
const serviceNames = [];
const bookingItems = Array.isArray(booking?.items) ? booking.items : [];
bookingItems.forEach((item) => collectBookingItemNames(item, serviceNames));
const uniqueServiceNames = getUniqueValues(serviceNames);
if (uniqueServiceNames.length > 0) {
return uniqueServiceNames;
}
const parsedServicesArray = Array.isArray(booking?.parsed_services?.array)
? getUniqueValues(booking.parsed_services.array)
: [];
if (parsedServicesArray.length > 0) {
return parsedServicesArray;
}
const parsedServiceString = normalizeDisplayValue(booking?.parsed_services?.string ?? booking?.wash_type ?? "");
return parsedServiceString ? [parsedServiceString] : [];
};
export const getOrderBookingServiceText = (booking) => {
return getOrderBookingServiceLabels(booking).join(", ");
};
export const hasOrderBookingDisplayDetails = (booking) => {
if (!booking || typeof booking !== "object") {
return false;
}
if (Array.isArray(booking?.items) && booking.items.length > 0) {
return true;
}
return getOrderBookingServiceLabels(booking).length > 0;
};
@@ -30,6 +30,10 @@ const props = defineProps({
type: Boolean,
default: true,
},
teleportToBody: {
type: Boolean,
default: false,
},
objects: {
// Array of objects to select from
// All objects should have at least an id and a label
@@ -76,70 +80,82 @@ const toggleModal = () => {
</script>
<template>
<div class="modal" v-bind:class="{ 'is-active': props.isActive }" data-testid="default-object-selector">
<div class="modal-background" @click="toggleModal"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ props.title || t('modals.select_an_object') }}</p>
<button v-if="props.allowClose" class="delete" aria-label="close" @click="toggleModal"></button>
</header>
<section class="modal-card-body">
<p v-if="props.message" class="mb-4">{{ props.message }}</p>
<!-- Content ... -->
<template v-for="obj in props.objects" :key="obj.id">
<WhiteBox class="mb-3" :data-testid="`default-object-selector-option-${obj.id}`">
<div class="field">
<label class="label">{{ obj.label }}</label>
<div class="control" v-if="props.showRadio">
<input
type="radio"
:value="obj"
v-model="selectedObject"
@change="onChange"
/>
<span>{{ obj.label }}</span>
<Teleport to="body" :disabled="!props.teleportToBody">
<div
class="modal default-object-selector"
v-bind:class="{ 'is-active': props.isActive }"
data-testid="default-object-selector"
>
<div class="modal-background" @click="toggleModal"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ props.title || t('modals.select_an_object') }}</p>
<button v-if="props.allowClose" class="delete" aria-label="close" @click="toggleModal"></button>
</header>
<section class="modal-card-body">
<p v-if="props.message" class="mb-4">{{ props.message }}</p>
<template v-for="obj in props.objects" :key="obj.id">
<WhiteBox class="mb-3" :data-testid="`default-object-selector-option-${obj.id}`">
<div class="field">
<label class="label">{{ obj.label }}</label>
<div class="control" v-if="props.showRadio">
<input
type="radio"
:value="obj"
v-model="selectedObject"
@change="onChange"
/>
<span>{{ obj.label }}</span>
</div>
</div>
</div>
<div v-if="obj.content" class="content">
{{ obj.content }}
</div>
<div class="buttons" v-if="obj.buttons">
<button
v-for="button in obj.buttons"
:key="button.label"
class="button is-small"
:class="`is-${button.color || 'dark'}`"
:data-testid="button.testId"
@click="button.action()"
>
{{ button.label }}
</button>
</div>
</WhiteBox>
</template>
</section>
<footer class="modal-card-foot">
<div class="buttons" v-if="props.footerButtons.length > 0">
<button
v-for="button in props.footerButtons"
:key="button.label"
class="button"
:class="`is-${button.color || 'light'}`"
:data-testid="button.testId"
@click="button.action()"
>
{{ button.label }}
</button>
</div>
<div class="buttons" v-else-if="props.showFooter">
<button class="button is-success">{{ t('modals.save_changes') }}</button>
<button class="button">{{ t('common.cancel') }}</button>
</div>
</footer>
<div v-if="obj.content" class="content">
{{ obj.content }}
</div>
<div class="buttons" v-if="obj.buttons">
<button
v-for="button in obj.buttons"
:key="button.label"
class="button is-small"
:class="`is-${button.color || 'dark'}`"
:data-testid="button.testId"
@click="button.action()"
>
{{ button.label }}
</button>
</div>
</WhiteBox>
</template>
</section>
<footer class="modal-card-foot">
<div class="buttons" v-if="props.footerButtons.length > 0">
<button
v-for="button in props.footerButtons"
:key="button.label"
class="button"
:class="`is-${button.color || 'light'}`"
:data-testid="button.testId"
@click="button.action()"
>
{{ button.label }}
</button>
</div>
<div class="buttons" v-else-if="props.showFooter">
<button class="button is-success">{{ t('modals.save_changes') }}</button>
<button class="button">{{ t('common.cancel') }}</button>
</div>
</footer>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.default-object-selector.modal {
z-index: 120;
}
.default-object-selector .modal-card {
width: min(960px, calc(100vw - 2rem));
max-width: 960px;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
<script setup>
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { ref, watch, defineEmits, onMounted } from "vue";
import { ref, watch, defineEmits, defineExpose, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import {
reg_1,
@@ -10,6 +10,7 @@ import {
clearCustomerSelection,
department_id,
pendingBookings,
hasLoadedPendingBookings,
loadPendingBookings,
doesVehiclePlateHaveBooking,
getVehiclePlateBookings,
@@ -24,7 +25,13 @@ const props = defineProps({
},
});
const emit = defineEmits(["update:vehicleObject", "update:focus", "update:bookingObject", "update:bookingMatches"]);
const emit = defineEmits([
"update:vehicleObject",
"update:focus",
"update:bookingObject",
"update:bookingMatches",
"commit:selection",
]);
const { locale } = useI18n();
// Load the pending bookings when the component is mounted
@@ -44,9 +51,86 @@ const resolveBookingPlate = (vehicle = null, plateOverride = null) => {
.toUpperCase();
};
const getOrderBookingSortTimestamp = (booking) => {
const dateValue = booking?.datetime ?? booking?.created_at ?? booking?.date ?? null;
const parsedValue = dateValue ? Date.parse(dateValue) : Number.NaN;
if (!Number.isNaN(parsedValue)) {
return parsedValue;
}
const fallbackId = Number.parseInt(String(booking?.id ?? 0), 10);
return Number.isFinite(fallbackId) ? fallbackId : Number.MAX_SAFE_INTEGER;
};
const sortBookingMatches = (bookings = []) => {
return [...bookings].sort((left, right) => {
const timeDifference = getOrderBookingSortTimestamp(left) - getOrderBookingSortTimestamp(right);
if (timeDifference !== 0) {
return timeDifference;
}
return Number.parseInt(String(left?.id ?? 0), 10) - Number.parseInt(String(right?.id ?? 0), 10);
});
};
const getVehicleEmbeddedBookingMatches = (vehicle = null) => {
if (Array.isArray(vehicle?.booking_matches)) {
return vehicle.booking_matches.filter(Boolean);
}
if (Array.isArray(vehicle?.bookingMatches)) {
return vehicle.bookingMatches.filter(Boolean);
}
return [];
};
const mergeBookingMatches = (...collections) => {
const bookingMatchesByKey = new Map();
collections.flat().filter(Boolean).forEach((booking) => {
const bookingKey =
booking?.id ??
[booking?.reg_1, booking?.reg_2, booking?.datetime, booking?.reference, booking?.reference_number]
.filter(Boolean)
.join("|");
if (!bookingKey) {
return;
}
if (!bookingMatchesByKey.has(bookingKey)) {
bookingMatchesByKey.set(bookingKey, booking);
}
});
return sortBookingMatches(Array.from(bookingMatchesByKey.values()));
};
const vehicleIndicatesBooking = (vehicle = null) => {
return (
!!vehicle?.booking_id ||
vehicle?.status === "booked" ||
getVehicleEmbeddedBookingMatches(vehicle).length > 0
);
};
const getBookingMatchesForSelection = (vehicle = null, plateOverride = null) => {
const bookingPlate = resolveBookingPlate(vehicle, plateOverride);
return bookingPlate ? getVehiclePlateBookings(bookingPlate) : [];
const plateMatches = bookingPlate ? getVehiclePlateBookings(bookingPlate) : [];
const embeddedMatches = getVehicleEmbeddedBookingMatches(vehicle);
return mergeBookingMatches(plateMatches, embeddedMatches);
};
const ensureBookingMatchesForSelection = async (vehicle = null, plateOverride = null) => {
let bookingMatches = getBookingMatchesForSelection(vehicle, plateOverride);
if (!hasLoadedPendingBookings.value || (bookingMatches.length === 0 && vehicleIndicatesBooking(vehicle))) {
await loadPendingBookings();
bookingMatches = getBookingMatchesForSelection(vehicle, plateOverride);
}
return bookingMatches;
};
// Function to emit the booking object to the parent component
@@ -302,6 +386,7 @@ watch(reg_1, (newValue) => {
const search_id = register_new_search();
console.log("reg_1 changed:", newValue);
const shouldKeepCustomerSelection = shouldPreserveCustomerSelection(newValue);
selectedDropdownItem.value = -1;
// Check if the new value is empty, if so, clear the vehicles_matching array
if (!newValue) {
unselectCustomerOnChange();
@@ -339,15 +424,114 @@ const getSearchIndexByVehicleId = (vehicle_id) => {
return vehicles_matching.value.findIndex((vehicle) => vehicle.id === vehicle_id);
};
const selectDropdownItem = (index) => {
// Select a vehicle from the dropdown
if (vehicles_matching.value[index]) {
selectVehicle(vehicles_matching.value[index].id);
} else {
emitVehicleObject(null);
const isDropdownItemActive = (vehicle_id) => {
return getSearchIndexByVehicleId(vehicle_id) === selectedDropdownItem.value;
};
const getDropdownSelectionContext = (index) => {
const vehicle = vehicles_matching.value[index] || null;
const plateOverride = vehicle?.reg ?? reg_1.value;
return {
vehicle,
plateOverride,
};
};
const clearTextSelection = () => {
if (typeof window === "undefined" || typeof window.getSelection !== "function") {
return;
}
// Go to the next input field
focusNextField();
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
selection.removeAllRanges();
}
};
const commitSelection = async (options = {}) => {
const normalizedOptions = {
vehicle: null,
plateOverride: reg_1.value,
source: "manual",
focusNextField: true,
clearDropdownResultsWithoutVehicle: true,
...options,
};
const currentPlate = String(normalizedOptions.plateOverride ?? reg_1.value ?? "")
.trim()
.toUpperCase();
const plateOverride = currentPlate;
const vehicle = normalizedOptions.vehicle;
clearTextSelection();
if (!currentPlate) {
showSelector.value = false;
vehicles_matching.value = [];
emitVehicleObject(null, currentPlate);
emit("commit:selection", {
source: normalizedOptions.source,
plate: currentPlate,
bookingMatchCount: 0,
});
return {
vehicle: null,
bookingMatches: [],
plate: currentPlate,
};
}
const bookingMatches = await ensureBookingMatchesForSelection(vehicle, plateOverride);
showSelector.value = false;
if (vehicle) {
selectVehicle(vehicle.id);
} else {
if (normalizedOptions.clearDropdownResultsWithoutVehicle) {
vehicles_matching.value = [];
}
if (bookingMatches.length === 0) {
clearCustomerSelection();
}
emitVehicleObject(null, plateOverride);
}
if (normalizedOptions.focusNextField && bookingMatches.length <= 1) {
if (bookingMatches.length === 1 || !vehicleIndicatesBooking(vehicle)) {
focusNextField();
}
}
emit("commit:selection", {
source: normalizedOptions.source,
plate: currentPlate,
bookingMatchCount: bookingMatches.length,
});
return {
vehicle,
bookingMatches,
plate: currentPlate,
};
};
const selectDropdownItem = async (index, options = {}) => {
const normalizedOptions = {
source: "dropdown",
focusNextField: true,
...options,
};
const { vehicle, plateOverride } = getDropdownSelectionContext(index);
return await commitSelection({
vehicle,
plateOverride,
source: normalizedOptions.source,
focusNextField: normalizedOptions.focusNextField,
clearDropdownResultsWithoutVehicle: false,
});
};
const keyDownNextTabIndexButton = (event, tabIndex) => {
@@ -360,57 +544,53 @@ const keyDownNextTabIndexButton = (event, tabIndex) => {
}
}
};
const arrowKeyHandler = (event) => {
const arrowKeyHandler = async (event) => {
// Handle arrow key navigation in the dropdown
if (event.key === "ArrowDown") {
event.preventDefault();
clearTextSelection();
if (selectedDropdownItem.value < vehicles_matching.value.length - 1) {
selectedDropdownItem.value++;
}
}
if (event.key === "ArrowUp") {
event.preventDefault();
clearTextSelection();
if (selectedDropdownItem.value > -1) {
selectedDropdownItem.value--;
}
}
if (event.key === "Enter") {
event.preventDefault();
selectDropdownItem(selectedDropdownItem.value);
clearTextSelection();
await selectDropdownItem(selectedDropdownItem.value, { source: "keyboard" });
selectedDropdownItem.value = -1;
// Imitate the tab key press to move to the next input
focusNextField();
}
if (event.key === "Tab") {
event.preventDefault();
selectDropdownItem(selectedDropdownItem.value);
clearTextSelection();
await selectDropdownItem(selectedDropdownItem.value, { source: "keyboard" });
selectedDropdownItem.value = -1;
// Set the active input to the next field
focusNextField();
}
};
const selectedDropdownItem = ref(-1);
const showSelector = ref(false);
const lostfocus = () => {
const lostfocus = async () => {
emitFocus(false);
// Delay to prevent the dropdown from closing immediately
setTimeout(() => {
showSelector.value = false;
}, 200);
// Check if the current input value is in the vehicles_matching array
const currentValue = reg_1.value;
const vehicle = vehicles_matching.value.find((vehicle) => vehicle.reg === currentValue);
if (vehicle) {
syncMatchedVehicleSelection(vehicle);
} else {
const bookingMatches = getBookingMatchesForSelection(null, currentValue);
// If not found, clear the vehicles_matching array
vehicles_matching.value = [];
if (bookingMatches.length === 0) {
clearCustomerSelection();
}
emitVehicleObject(null, currentValue);
}
const currentValue = String(reg_1.value ?? "").trim().toUpperCase();
const vehicle = vehicles_matching.value.find((matchingVehicle) => matchingVehicle.reg === currentValue) || null;
await commitSelection({
vehicle,
plateOverride: currentValue,
source: "blur",
focusNextField: false,
clearDropdownResultsWithoutVehicle: true,
});
};
const isSearching = ref(false);
@@ -462,12 +642,32 @@ const emitFocus = (isFocused) => {
emit("update:focus", isFocused);
};
const finalizeSelection = async (options = {}) => {
const normalizedOptions = {
source: "next",
focusNextField: false,
...options,
};
const currentValue = String(reg_1.value ?? "").trim().toUpperCase();
const vehicle = vehicles_matching.value.find((matchingVehicle) => matchingVehicle.reg === currentValue) || null;
return await commitSelection({
vehicle,
plateOverride: currentValue,
source: normalizedOptions.source,
focusNextField: normalizedOptions.focusNextField,
clearDropdownResultsWithoutVehicle: true,
});
};
defineExpose({
finalizeSelection,
});
const isVehicleBooked = (vehicle) => {
return (
doesVehiclePlateHaveBooking(vehicle?.reg) ||
!!vehicle?.booking_id ||
vehicle?.status === "booked" ||
(Array.isArray(vehicle?.booking_matches) && vehicle.booking_matches.length > 0)
vehicleIndicatesBooking(vehicle)
);
};
@@ -476,7 +676,7 @@ const getVehicleBookingDateLabel = (vehicle) => {
return "";
}
const preferredBooking = getPreferredVehiclePlateBooking(vehicle?.reg);
const preferredBooking = getBookingMatchesForSelection(vehicle, vehicle?.reg)[0] || null;
const rawValue =
preferredBooking?.datetime ??
preferredBooking?.created_at ??
@@ -597,14 +797,14 @@ const getCurrentIconColor = () => {
<div class="dropdown-menu">
<div class="dropdown-content">
<a
class="dropdown-item is-clickable"
class="dropdown-item is-clickable license-plate-dropdown-item"
v-for="result in vehicles_matching"
:key="result.id"
@mousedown="selectVehicle(result.id)"
@mousedown.prevent="selectDropdownItem(getSearchIndexByVehicleId(result.id))"
:class="{
'is-active': getSearchIndexByVehicleId(result.id) === selectedDropdownItem,
'is-drop-down-selected': getSearchIndexByVehicleId(result.customerNumber) === selectedDropdownItem,
'license-plate-dropdown-item--active': isDropdownItemActive(result.id),
}"
:aria-selected="isDropdownItemActive(result.id)"
>
<div class="license-plate-result">
<div class="license-plate-result__text">
@@ -641,11 +841,42 @@ const getCurrentIconColor = () => {
</template>
<style scoped>
.license-plate-dropdown-item,
.license-plate-dropdown-item * {
user-select: none;
-webkit-user-select: none;
}
.license-plate-dropdown-item {
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
}
.license-plate-dropdown-item:hover {
background-color: #f3f7fb;
}
.license-plate-dropdown-item--active {
background-color: #102a63;
color: #ffffff;
}
.license-plate-dropdown-item--active .license-plate-result__booking-icon,
.license-plate-dropdown-item--active .license-plate-result__booking-date {
color: rgba(255, 255, 255, 0.92) !important;
}
.license-plate-dropdown-item--active .license-plate-result__text > span:last-child {
color: #ffffff;
}
.license-plate-result {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
user-select: none;
-webkit-user-select: none;
}
.license-plate-result__text {
@@ -653,6 +884,8 @@ const getCurrentIconColor = () => {
align-items: center;
gap: 0.25rem;
min-width: 0;
user-select: none;
-webkit-user-select: none;
}
.license-plate-result__booking-icon {
@@ -664,6 +897,8 @@ const getCurrentIconColor = () => {
align-items: center;
gap: 0.3rem;
flex: 0 0 auto;
user-select: none;
-webkit-user-select: none;
}
.license-plate-result__booking-date {
@@ -1,14 +1,17 @@
<script setup lang="ts">
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 { metadata, popups, setCustomerId, pos } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.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({
name: "RegistrationNumberSearchResult"
});
const { t } = useI18n();
const emits = defineEmits(['select']);
const props = defineProps({
@@ -66,17 +69,19 @@ const applySelectedBooking = async (booking: any, result: PosSearchResult) => {
const bookingMatches = Array.isArray(result?.bookingMatches) ? result.bookingMatches : [booking];
const customerNumber = Number.parseInt(String(booking?.customer_number ?? booking?.customer_id ?? result?.customerId ?? 0), 10) || 0;
const bookingReg1 = normalizeRegistrationNumber(booking?.reg_1 || result?.registrationNumber || '');
const bookingReg2 = normalizeRegistrationNumber(booking?.reg_2 || '');
metadata.setBookingId(booking.id);
metadata.clearBookingSelectionSkippedPlate?.();
pos.views.manualInput.value = false;
pos.metadata.setNotes(booking.note || booking.notes || `Booking ID: ${booking.id}`);
pos.metadata.setReference(booking.reference || booking.reference_number || '');
reg_1.value = booking.reg_1 || result?.registrationNumber || '';
reg_2.value = booking.reg_2 || '';
reg_1.value = bookingReg1;
reg_2.value = bookingReg2;
pos.vehicles.select(1, {
reg: booking.reg_1 || result?.registrationNumber || '',
reg: bookingReg1,
type: result?.type || 0,
customer_id: customerNumber,
status: 'booked',
@@ -88,9 +93,9 @@ const applySelectedBooking = async (booking: any, result: PosSearchResult) => {
wash_subscription: result?.washSubscription,
});
if (booking.reg_2) {
if (bookingReg2) {
pos.vehicles.select(2, {
reg: booking.reg_2,
reg: bookingReg2,
type: 0,
customer_id: customerNumber,
status: 'booked',
@@ -98,6 +103,8 @@ const applySelectedBooking = async (booking: any, result: PosSearchResult) => {
booking_id: booking.id,
booking_matches: bookingMatches,
});
} else {
pos.vehicles.select(2, null);
}
pos.vehicles.select(3, null);
@@ -110,7 +117,7 @@ const applySelectedBooking = async (booking: any, result: PosSearchResult) => {
popups.clear();
emitSelectedResult({
...result,
registrationNumber: booking.reg_1 || result?.registrationNumber,
registrationNumber: bookingReg1 || result?.registrationNumber,
customerName: booking.customer_name || result?.customerName || "Unknown Customer",
customerId: customerNumber,
customerStatus: 'booked',
@@ -141,7 +148,14 @@ const openOrderBookingPopup = (result: PosSearchResult) => {
onSelect: (booking: any) => applySelectedBooking(booking, result),
onSkip: () => continueWithoutBookingSelection(result),
},
actionButtons: [],
actionButtons: [
{
...actionButtons.default.value.cancel,
label: t('admin.pos.order_booking_selector.continue_without_booking'),
testId: 'pos-mobile-order-booking-skip',
onClick: () => continueWithoutBookingSelection(result),
},
],
});
};
+136 -3
View File
@@ -50,8 +50,19 @@ export const getHideDiscountsCatalog = () => {
export const step = ref(1);
export const nextStepDelay = ref(0); // 2 seconds delay
export const isCreatingOrder = ref(false);
export const desktopStep1PreflightHandler = ref(null);
let createOrderRequest = null;
export const setDesktopStep1PreflightHandler = (handler = null) => {
desktopStep1PreflightHandler.value = typeof handler === "function" ? handler : null;
};
export const clearDesktopStep1PreflightHandler = (handler = null) => {
if (handler === null || desktopStep1PreflightHandler.value === handler) {
desktopStep1PreflightHandler.value = null;
}
};
/** Define the next step delay function */
export const setNextStepDelay = (delay) => {
nextStepDelay.value = delay;
@@ -71,6 +82,31 @@ export const nextStep = async (options = { isMobile: false, orderCreation: true
if (nextStepDelay.value > 0) {
return;
}
if (
normalizedOptions.isMobile === false &&
step.value === 1 &&
normalizedOptions.orderCreation !== false &&
typeof desktopStep1PreflightHandler.value === "function"
) {
const canProceedFromPreflight = await desktopStep1PreflightHandler.value({ reason: "next" });
if (!canProceedFromPreflight) {
return;
}
}
if (
normalizedOptions.isMobile === false &&
step.value === 1 &&
normalizedOptions.orderCreation !== false &&
reg_1.value
) {
if (!hasLoadedPendingBookings.value || isLoadingPendingBookings.value) {
await loadPendingBookings();
}
if (doesVehiclePlateRequireBookingSelection(reg_1.value)) {
return;
}
}
// Set the delay to 2 seconds
setNextStepDelay(2);
// Clear the errors
@@ -587,6 +623,7 @@ export const createOrder = (options = { isMobile: false }) => {
department_id: getDepartment(),
reference: reference.value,
notes: order_notes.value,
po: order_po.value,
reg_1: reg_1.value,
reg_2: reg_2.value,
reg_3: reg_3.value,
@@ -1358,6 +1395,9 @@ export const vehicles_matching = ref([]);
export const isSearching = ref(false);
export const latest_search_id = ref(0);
export const pendingBookings = ref([]);
export const hasLoadedPendingBookings = ref(false);
export const isLoadingPendingBookings = ref(false);
let pendingBookingsRequest = null;
export const is_latest_search = (search_id) => {
// Check if the search ID is the latest
@@ -1366,17 +1406,30 @@ export const is_latest_search = (search_id) => {
// Get the department booking list
export const loadPendingBookings = () => {
SessionUser.request(SessionUser.objects.order_bookings.meta.endpoint, "GET", {
filters: "department:" + department_id.value + ",order_id:null",
if (pendingBookingsRequest) {
return pendingBookingsRequest;
}
isLoadingPendingBookings.value = true;
pendingBookingsRequest = SessionUser.request(SessionUser.objects.order_bookings.meta.endpoint, "GET", {
filters: "department:" + department_id.value + ",order_id:is null",
page: 1,
limit: 100,
})
.then((response) => {
pendingBookings.value = Array.isArray(response?.data?.data) ? response.data.data : [];
hasLoadedPendingBookings.value = true;
})
.catch((error) => {
console.error("Error:", error);
hasLoadedPendingBookings.value = true;
})
.finally(() => {
isLoadingPendingBookings.value = false;
pendingBookingsRequest = null;
});
return pendingBookingsRequest;
};
const getOrderBookingSortTimestamp = (booking) => {
@@ -1407,6 +1460,10 @@ const getOrderBookingNotesValue = (booking) => {
return String(booking?.notes ?? booking?.note ?? "").trim();
};
const getOrderBookingReg1Value = (booking) => {
return normalizeRegistrationValue(booking?.reg_1 ?? booking?.regNr ?? "");
};
const getOrderBookingReg2Value = (booking) => {
return normalizeRegistrationValue(booking?.reg_2 ?? booking?.regNrTrailer ?? "");
};
@@ -1415,6 +1472,41 @@ const getOrderBookingCustomerNumber = (booking) => {
return resolveCustomerNumber(booking?.customer_number ?? booking?.customer_id ?? booking?.customerNumber);
};
const dedupeOrderBookings = (bookings = []) => {
const bookingsByKey = new Map();
bookings.filter(Boolean).forEach((booking) => {
const normalizedBookingId = toPositiveInteger(booking?.id);
const bookingKey =
normalizedBookingId ??
[
normalizeVehiclePlateForBookingSelection(booking?.reg_1),
normalizeVehiclePlateForBookingSelection(booking?.reg_2),
booking?.datetime,
booking?.reference,
booking?.reference_number,
]
.filter(Boolean)
.join("|");
if (!bookingKey || bookingsByKey.has(bookingKey)) {
return;
}
bookingsByKey.set(bookingKey, booking);
});
return Array.from(bookingsByKey.values());
};
const resolveVehiclePlateBookingMatches = (vehiclePlate, bookingMatches = null) => {
if (Array.isArray(bookingMatches)) {
return sortPendingOrderBookings(dedupeOrderBookings(bookingMatches));
}
return getVehiclePlateBookings(vehiclePlate);
};
export const getVehiclePlateBookings = (vehiclePlate) => {
const normalizedVehiclePlate = normalizeVehiclePlateForBookingSelection(vehiclePlate);
if (!normalizedVehiclePlate) {
@@ -1459,6 +1551,41 @@ export const getVehiclePlateBooking = (vehiclePlate) => {
return getPreferredVehiclePlateBooking(vehiclePlate);
};
export const getSelectedVehiclePlateBooking = (vehiclePlate, bookingMatches = null) => {
const normalizedVehiclePlate = normalizeVehiclePlateForBookingSelection(vehiclePlate);
const normalizedBookingId = toPositiveInteger(selectedOrderBookingId.value);
if (!normalizedVehiclePlate || !normalizedBookingId) {
return null;
}
if (normalizeVehiclePlateForBookingSelection(selectedOrderBookingPlate.value) !== normalizedVehiclePlate) {
return null;
}
const matchingBookings = resolveVehiclePlateBookingMatches(normalizedVehiclePlate, bookingMatches);
return (
matchingBookings.find((booking) => toPositiveInteger(booking?.id) === normalizedBookingId) || null
);
};
export const doesVehiclePlateRequireBookingSelection = (vehiclePlate, bookingMatches = null) => {
const normalizedVehiclePlate = normalizeVehiclePlateForBookingSelection(vehiclePlate);
if (!normalizedVehiclePlate) {
return false;
}
const matchingBookings = resolveVehiclePlateBookingMatches(normalizedVehiclePlate, bookingMatches);
if (matchingBookings.length <= 1) {
return false;
}
if (isSelectedOrderBookingSkippedForPlate(normalizedVehiclePlate)) {
return false;
}
return !getSelectedVehiclePlateBooking(normalizedVehiclePlate, matchingBookings);
};
const getSelectedPendingOrderBooking = () => {
const normalizedBookingId = toPositiveInteger(selectedOrderBookingId.value);
if (!normalizedBookingId) {
@@ -1537,8 +1664,14 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
reference.value = bookingReference;
}
const bookingReg1 = getOrderBookingReg1Value(booking);
if (bookingReg1 !== "" && bookingReg1 !== reg_1.value) {
await SessionUser.objects.orders.set.reg_1(normalizedOrderId, bookingReg1);
reg_1.value = bookingReg1;
}
const bookingReg2 = getOrderBookingReg2Value(booking);
if (bookingReg2 !== "" && bookingReg2 !== reg_2.value) {
if (bookingReg2 !== reg_2.value) {
await SessionUser.objects.orders.set.reg_2(normalizedOrderId, bookingReg2);
reg_2.value = bookingReg2;
}
+1 -1
View File
@@ -364,7 +364,7 @@
"title": "Vælg booking",
"help_text": "Flere ventende bookinger matcher dette køretøj. Vælg den korrekte booking eller fortsæt uden booking.",
"option_title": "Booking #{id} • {datetime}",
"use_booking": "Brug booking",
"use_booking": "Vælg",
"continue_without_booking": "Fortsæt uden booking",
"customer_label": "Kunde",
"plates_label": "Nummerplader",
+1 -1
View File
@@ -364,7 +364,7 @@
"title": "Buchung auswählen",
"help_text": "Mehrere offene Buchungen passen zu diesem Fahrzeug. Wählen Sie die richtige Buchung oder fahren Sie ohne Buchung fort.",
"option_title": "Buchung #{id} • {datetime}",
"use_booking": "Buchung verwenden",
"use_booking": "Auswählen",
"continue_without_booking": "Ohne Buchung fortfahren",
"customer_label": "Kunde",
"plates_label": "Kennzeichen",
+1 -1
View File
@@ -364,7 +364,7 @@
"title": "Select booking",
"help_text": "Multiple pending bookings match this vehicle. Choose the correct booking or continue without one.",
"option_title": "Booking #{id} • {datetime}",
"use_booking": "Use booking",
"use_booking": "Select",
"continue_without_booking": "Continue without booking",
"customer_label": "Customer",
"plates_label": "Plates",
+1 -1
View File
@@ -359,7 +359,7 @@
"title": "Velg booking",
"help_text": "Flere ventende bookinger matcher dette kjøretøyet. Velg riktig booking eller fortsett uten booking.",
"option_title": "Booking #{id} • {datetime}",
"use_booking": "Bruk booking",
"use_booking": "Velg",
"continue_without_booking": "Fortsett uten booking",
"customer_label": "Kunde",
"plates_label": "Registreringsnumre",
+1 -1
View File
@@ -359,7 +359,7 @@
"title": "Välj bokning",
"help_text": "Flera väntande bokningar matchar det här fordonet. Välj rätt bokning eller fortsätt utan bokning.",
"option_title": "Bokning #{id} • {datetime}",
"use_booking": "Använd bokning",
"use_booking": "Välj",
"continue_without_booking": "Fortsätt utan bokning",
"customer_label": "Kund",
"plates_label": "Registreringsnummer",
+1 -1
View File
@@ -301,7 +301,7 @@
"title": "Välj bokning",
"help_text": "Flera väntande bokningar matchar det här fordonet. Välj rätt bokning eller fortsätt utan bokning.",
"option_title": "Bokning #{id} • {datetime}",
"use_booking": "Använd bokning",
"use_booking": "Välj",
"continue_without_booking": "Fortsätt utan bokning",
"customer_label": "Kund",
"plates_label": "Registreringsnummer",
+13
View File
@@ -5,6 +5,18 @@ const EXEMPT_ROUTE_NAMES = new Set([
OUTDATED_INSTALLATION_ROUTE_NAME,
OUTDATED_GATEWAY_ROUTE_NAME,
]);
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
const isLoopbackOrigin = (currentOrigin) => {
if (!currentOrigin) return false;
try {
const url = new URL(currentOrigin);
return LOOPBACK_HOSTNAMES.has(url.hostname);
} catch (_error) {
return false;
}
};
const normalizeAllowedOrigins = (allowedOrigins = []) => {
return new Set(
@@ -22,6 +34,7 @@ const normalizeAllowedOrigins = (allowedOrigins = []) => {
export const isOriginAllowed = (currentOrigin, allowedOrigins = []) => {
if (!currentOrigin) return true;
if (isLoopbackOrigin(currentOrigin)) return true;
const allowedOriginSet = normalizeAllowedOrigins(allowedOrigins);
return allowedOriginSet.has(currentOrigin);
};
+22 -18
View File
@@ -46,19 +46,20 @@ async function primeSuperuserSession(page) {
await primeMockSession(page, { token });
}
test.describe("Invoice distribution smoke", () => {
test.beforeEach(async ({ page }) => {
await suppressVueDevtoolsOverlay(page);
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
invoiceDistribution: true,
});
await primeSuperuserSession(page);
async function prepareInvoiceDistributionPage(page, overrides = {}) {
await suppressVueDevtoolsOverlay(page);
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
invoiceDistribution: true,
...overrides,
});
await primeSuperuserSession(page);
}
test.describe("Invoice distribution smoke", () => {
test("@smoke overview loads and quick-open month action works", async ({ page }) => {
await prepareInvoiceDistributionPage(page);
await page.goto("/superuser/invoices?activeTab=distribution");
await expect(page.getByTestId("distribution-overview-page")).toBeVisible({ timeout: 15_000 });
@@ -79,6 +80,7 @@ test.describe("Invoice distribution smoke", () => {
});
test("@smoke monthly tabs keep URL query-state in sync", async ({ page }) => {
await prepareInvoiceDistributionPage(page);
await page.goto(
"/superuser/invoices/distribution/2026/3/customers?customerSearch=acme&customerSource=fixed_pricing&customerDepartment=Copenhagen&compareMode=line_by_line"
);
@@ -101,6 +103,7 @@ test.describe("Invoice distribution smoke", () => {
});
test("@smoke compare flow shows progress and mismatch-first results", async ({ page }) => {
await prepareInvoiceDistributionPage(page);
await page.goto("/superuser/invoices/distribution/2026/3/compare?compareMode=line_by_line");
await page.getByTestId("distribution-compare-submit").click();
@@ -121,6 +124,7 @@ test.describe("Invoice distribution smoke", () => {
});
test("@smoke mobile layout sanity keeps primary controls visible", async ({ page }) => {
await prepareInvoiceDistributionPage(page);
await page.goto("/superuser/invoices/distribution/2026/3/overview");
await expect(page.getByTestId("distribution-month-toolbar")).toBeVisible();
@@ -132,14 +136,10 @@ test.describe("Invoice distribution smoke", () => {
});
test("@smoke fallback mode keeps results and surfaces warning banners", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
invoiceDistribution: true,
await prepareInvoiceDistributionPage(page, {
invoiceDistributionForceLegacyFallback: true,
invoiceDistributionForceCompareFallback: true,
});
await primeSuperuserSession(page);
await page.goto("/superuser/invoices?activeTab=distribution");
await expect(page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()).toBeVisible({
@@ -147,10 +147,14 @@ test.describe("Invoice distribution smoke", () => {
});
await page.goto("/superuser/invoices/distribution/2026/3/compare?compareMode=line_by_line");
await expect(page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()).toBeVisible();
await expect(page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()).toBeVisible({
timeout: 15_000,
});
await page.getByTestId("distribution-compare-submit").click();
await expect(page.getByTestId("distribution-compare-table")).toBeVisible();
await expect(page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()).toBeVisible();
await expect(page.getByTestId("distribution-compare-table")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()).toBeVisible({
timeout: 15_000,
});
});
});
@@ -71,14 +71,10 @@ async function bootstrapAuthenticatedSuperuser(page) {
return token;
}
async function openQueueHistory(page, token) {
await page.goto("/");
await page.evaluate((value) => {
window.localStorage.setItem("token", value);
}, token);
async function openQueueHistory(page) {
await page.goto("/superuser/invoices?activeTab=queue");
await expect(page).toHaveURL(/activeTab=queue/);
await expect(page.getByTestId("economic-queue-history-page")).toBeVisible();
await expect(page.getByTestId("economic-queue-history-page")).toBeVisible({ timeout: 15_000 });
}
test.describe("Invoice transfer queue history reliability", () => {
@@ -120,7 +116,7 @@ test.describe("Invoice transfer queue history reliability", () => {
);
});
await openQueueHistory(page, token);
await openQueueHistory(page);
await expect(page.getByTestId("economic-queue-history-status-9301")).toContainText("PROCESSING");
await expect
@@ -179,7 +175,7 @@ test.describe("Invoice transfer queue history reliability", () => {
);
});
await openQueueHistory(page, token);
await openQueueHistory(page);
await page.getByTestId("economic-queue-history-status-filter").selectOption("FAILED");
await page.getByTestId("economic-queue-history-limit-filter").selectOption("10");
@@ -285,7 +281,7 @@ test.describe("Invoice transfer queue history reliability", () => {
);
});
await openQueueHistory(page, token);
await openQueueHistory(page);
await page.getByTestId("economic-queue-history-run-now").click();
@@ -383,7 +379,7 @@ test.describe("Invoice transfer queue history reliability", () => {
);
});
await openQueueHistory(page, token);
await openQueueHistory(page);
await expect(page.getByTestId("economic-queue-history-retry-9401")).toHaveCount(0);
await expect(page.getByTestId("economic-queue-history-retry-9403")).toBeDisabled();
@@ -446,7 +442,7 @@ test.describe("Invoice transfer queue history reliability", () => {
);
});
await openQueueHistory(page, token);
await openQueueHistory(page);
await expect(page.getByTestId("economic-queue-history-status-9601")).toContainText("PROCESSING");
await expect(page.getByTestId("economic-queue-history-poll-error")).toContainText("gateway timeout", {
@@ -513,7 +509,7 @@ test.describe("Invoice transfer queue history reliability", () => {
}
});
await openQueueHistory(page, token);
await openQueueHistory(page);
await expect.poll(() => queueListCalls, { timeout: 20_000 }).toBeGreaterThanOrEqual(3);
expect(maxInFlight).toBe(1);
@@ -607,7 +603,7 @@ test.describe("Invoice transfer queue history reliability", () => {
);
});
await openQueueHistory(page, token);
await openQueueHistory(page);
await expect(page.getByTestId("economic-queue-history-run-now")).toBeVisible();
await expect(page.getByTestId("economic-queue-history-refresh")).toBeVisible();
+2 -2
View File
@@ -362,7 +362,7 @@ async function openPeriodView(page) {
await setupPeriodEndpoints(page, periodRequests);
await page.goto("/superuser/invoices?activeTab=period");
await expect(page).toHaveURL(/activeTab=period/);
await expect(page.getByTestId("invoicing-period-view")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({ timeout: 15_000 });
return { periodRequests };
}
@@ -500,7 +500,7 @@ test.describe("Invoicing period tab", () => {
await page.goto("/superuser/invoices?activeTab=period");
await expect(page).toHaveURL(/activeTab=period/);
await expect(page.getByTestId("invoicing-period-view")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-6001")).toBeVisible();
+646 -17
View File
@@ -12,6 +12,17 @@ function json(body, status = 200) {
};
}
function toOrderBookingListEntry(booking, stripDetails = false) {
if (!stripDetails || !booking || typeof booking !== "object") {
return booking;
}
const summaryBooking = { ...booking };
delete summaryBooking.items;
delete summaryBooking.parsed_services;
return summaryBooking;
}
function suppressVueDevtoolsOverlay(page) {
return page.addInitScript(() => {
const style = document.createElement("style");
@@ -87,6 +98,7 @@ function createPosFixture() {
department_id: 1,
reference: "REF-9201",
notes: "",
po: "",
reg_1: "AB12345",
reg_2: "",
reg_3: "",
@@ -138,6 +150,8 @@ function createPosFixture() {
],
unknownVehicles: [],
orderBookings: [],
orderBookingsDelayMs: 0,
duplicateOrders: [],
ordersById,
orderItemsByOrderId,
markCompletedOrderIds: [],
@@ -197,6 +211,11 @@ function buildOrderBooking(id, overrides = {}) {
};
}
function buildTodayTimestamp(time = "10:00:00.000Z") {
const today = new Date().toISOString().split("T")[0];
return `${today}T${time}`;
}
async function mockPosApi(page, fixture) {
await page.route(API_HOST, async (route) => {
const request = route.request();
@@ -245,7 +264,11 @@ async function mockPosApi(page, fixture) {
const filters = String(parsedUrl.searchParams.get("filters") || "");
let bookings = Array.isArray(fixture.orderBookings) ? [...fixture.orderBookings] : [];
if (filters.includes("order_id:null")) {
if (Number(fixture.orderBookingsDelayMs || 0) > 0) {
await new Promise((resolve) => setTimeout(resolve, Number(fixture.orderBookingsDelayMs)));
}
if (filters.includes("order_id:null") || filters.includes("order_id:is null")) {
bookings = bookings.filter((booking) => booking.order_id === null || booking.order_id === undefined);
}
@@ -269,7 +292,9 @@ async function mockPosApi(page, fixture) {
await route.fulfill(
json({
success: true,
data: bookings,
data: bookings.map((booking) =>
toOrderBookingListEntry(booking, fixture.orderBookingListStripsDetails === true)
),
})
);
return;
@@ -453,10 +478,42 @@ async function mockPosApi(page, fixture) {
}
if (pathname.endsWith("/orders") && method === "GET") {
const filters = String(parsedUrl.searchParams.get("filters") || "");
const regFilter = filters.includes("reg_1:") ? filters.split("reg_1:")[1]?.split(",")[0] || "" : "";
const departmentFilter = filters.includes("department_id:")
? Number(filters.split("department_id:")[1]?.split(",")[0] || 0)
: 0;
const createdFrom = filters.includes("created_at-date_from:")
? filters.split("created_at-date_from:")[1]?.split(",")[0] || null
: null;
const createdTo = filters.includes("created_at-date_to:")
? filters.split("created_at-date_to:")[1]?.split(",")[0] || null
: null;
let duplicateOrders = Array.isArray(fixture.duplicateOrders) ? [...fixture.duplicateOrders] : [];
if (regFilter) {
duplicateOrders = duplicateOrders.filter(
(order) => String(order?.reg_1 || "").toUpperCase() === String(regFilter).toUpperCase()
);
}
if (departmentFilter > 0) {
duplicateOrders = duplicateOrders.filter((order) => Number(order?.department_id) === departmentFilter);
}
if (createdFrom) {
duplicateOrders = duplicateOrders.filter((order) => String(order?.created_at || "") >= createdFrom);
}
if (createdTo) {
duplicateOrders = duplicateOrders.filter((order) => String(order?.created_at || "") <= `${createdTo}T23:59:59.999Z`);
}
await route.fulfill(
json({
success: true,
data: [],
data: duplicateOrders,
})
);
return;
@@ -471,6 +528,7 @@ async function mockPosApi(page, fixture) {
department_id: Number(body.department_id),
reference: body.reference || "",
notes: body.notes || "",
po: body.po || "",
reg_1: body.reg_1 || "",
reg_2: body.reg_2 || "",
reg_3: body.reg_3 || "",
@@ -761,6 +819,20 @@ async function setupDesktopPosPage(page, fixture, { token = "pos-desktop-token"
await expect(page.getByTestId("pos-step-1")).toBeVisible({ timeout: 10_000 });
}
function getActiveDesktopModal(page) {
return page.locator(
'[data-testid="pos-desktop-order-booking-modal"].is-active, [data-testid="default-object-selector"].is-active'
);
}
async function commitDesktopReg1ByBlur(page, value) {
const reg1Input = page.locator("#reg_1");
await reg1Input.fill(value);
await reg1Input.evaluate((element) => {
element.blur();
});
}
test.describe("POS flow", () => {
test.beforeEach(async ({ page }) => {
await suppressVueDevtoolsOverlay(page);
@@ -833,6 +905,48 @@ test.describe("POS flow", () => {
await expect(page.getByRole("button", { name: /Kopier sidste vask/i })).toHaveCount(0);
});
test("desktop keeps customer selection mounted only once when an unlinked vehicle card is expanded", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop", "Desktop POS step 1 is validated on chromium-desktop.");
const fixture = createPosFixture();
fixture.vehicles = [
{
...fixture.vehicles[0],
reg: "AB12345",
customer_id: null,
customer_name: "",
last_order_id: null,
},
];
await setupDesktopPosPage(page, fixture, { token: "pos-unlinked-vehicle-expanded-customer-selector" });
await page.locator("#reg_1").fill("AB12345");
const customerSearchInputs = page.locator("#pos_select_customer_input");
const visibleCustomerSearchInputs = page.locator("#pos_select_customer_input:visible");
const cardPaymentButtons = page.locator("button", {
hasText: "Vælg direkte betaling med betalingskort",
});
const visibleCardPaymentButtons = page.locator("button:visible", {
hasText: "Vælg direkte betaling med betalingskort",
});
await expect(customerSearchInputs).toHaveCount(1);
await expect(visibleCustomerSearchInputs).toHaveCount(1);
await expect(cardPaymentButtons).toHaveCount(1);
await expect(visibleCardPaymentButtons).toHaveCount(1);
await page.locator(".pos-vehicle-form__expandable .has-text-centered").click();
await expect(customerSearchInputs).toHaveCount(1);
await expect(visibleCustomerSearchInputs).toHaveCount(1);
await expect(cardPaymentButtons).toHaveCount(1);
await expect(visibleCardPaymentButtons).toHaveCount(1);
});
test("desktop flow creates transaction, renders cart, and reaches completion step", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop", "Desktop POS flow is validated on chromium-desktop.");
@@ -970,13 +1084,22 @@ test.describe("POS flow", () => {
);
const fixture = createPosFixture();
fixture.vehicles = [
{
...fixture.vehicles[0],
reg: "TRAILER1",
reference: "REF-TRAILER1",
},
];
fixture.orderBookings = [
buildOrderBooking(8101, {
reg_1: "TRACTOR1",
reg_2: "TRAILER1",
reference: "SINGLE-BOOKING-REF",
reference_number: "SINGLE-BOOKING-REF",
reg_2: "TRAILER-1",
notes: "Single desktop booking",
note: "Single desktop booking",
po: "SINGLE-BOOKING-PO",
items: [
{ id: 53, name: "Tankvogn med hænger", price: 599, quantity: 1 },
{ id: 63, name: "Dolly", price: 275, quantity: 1 },
@@ -990,10 +1113,11 @@ test.describe("POS flow", () => {
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-single-booking" });
const activeBookingSelector = page.locator('[data-testid="default-object-selector"].is-active');
await page.locator("#reg_1").fill("AB12345");
const activeBookingSelector = getActiveDesktopModal(page);
await page.locator("#reg_1").fill("TRAILER1");
await expect(activeBookingSelector).toHaveCount(0);
await expect(page.locator("#reg_2")).toHaveValue("TRAILER-1");
await expect(page.locator("#reg_1")).toHaveValue("TRACTOR1");
await expect(page.locator("#reg_2")).toHaveValue("TRAILER1");
await page.locator('[data-testid="pos-next-step"]:visible').click();
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 10_000 });
@@ -1001,7 +1125,9 @@ test.describe("POS flow", () => {
await expect
.poll(() => fixture.ordersById[9300]?.reference || null, { timeout: 10_000 })
.toBe("SINGLE-BOOKING-REF");
await expect.poll(() => fixture.ordersById[9300]?.reg_2 || null, { timeout: 10_000 }).toBe("TRAILER-1");
await expect.poll(() => fixture.ordersById[9300]?.po || null, { timeout: 10_000 }).toBe("SINGLE-BOOKING-PO");
await expect.poll(() => fixture.ordersById[9300]?.reg_1 || null, { timeout: 10_000 }).toBe("TRACTOR1");
await expect.poll(() => fixture.ordersById[9300]?.reg_2 || null, { timeout: 10_000 }).toBe("TRAILER1");
await expect
.poll(() => (fixture.orderItemsByOrderId[9300] || []).map((item) => Number(item.product_id)), { timeout: 10_000 })
.toEqual([53, 63]);
@@ -1030,12 +1156,21 @@ test.describe("POS flow", () => {
);
const fixture = createPosFixture();
fixture.vehicles = [
{
...fixture.vehicles[0],
reg: "MULTITRL",
reference: "REF-MULTITRL",
},
];
fixture.orderBookings = [
buildOrderBooking(8102, {
reg_1: "TRACTORA",
reg_2: "MULTITRL",
datetime: "2026-01-01T07:00:00.000Z",
reference: "BOOKING-A-REF",
reference_number: "BOOKING-A-REF",
reg_2: "TRAILER-A",
po: "BOOKING-A-PO",
items: [
{ id: 53, name: "Tankvogn med hænger", price: 599, quantity: 1 },
{ id: 63, name: "Dolly", price: 275, quantity: 1 },
@@ -1046,10 +1181,11 @@ test.describe("POS flow", () => {
},
}),
buildOrderBooking(8103, {
reg_1: "TRACTORB",
reg_2: "MULTITRL",
datetime: "2026-01-01T09:00:00.000Z",
reference: "BOOKING-B-REF",
reference_number: "BOOKING-B-REF",
reg_2: "TRAILER-B",
items: [{ id: 63, name: "Dolly", price: 275, quantity: 1 }],
parsed_services: {
string: "Dolly",
@@ -1057,11 +1193,12 @@ test.describe("POS flow", () => {
},
}),
];
fixture.orderBookingListStripsDetails = true;
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-multi-booking" });
const activeBookingSelector = page.locator('[data-testid="default-object-selector"].is-active');
await page.locator("#reg_1").fill("AB12345");
const activeBookingSelector = getActiveDesktopModal(page);
await page.locator("#reg_1").fill("MULTITRL");
await expect(activeBookingSelector).toBeVisible({ timeout: 10_000 });
await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8102")).toBeVisible({
timeout: 10_000,
@@ -1069,15 +1206,26 @@ test.describe("POS flow", () => {
await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8103")).toBeVisible({
timeout: 10_000,
});
await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-services-8102")).toContainText(
"Tankvogn",
{ timeout: 10_000 }
);
await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-services-8102")).toContainText(
"Dolly",
{ timeout: 10_000 }
);
await activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8102").click();
await expect(page.locator("#reg_1")).toHaveValue("TRACTORA");
await expect(page.locator("#reference")).toHaveValue("BOOKING-A-REF");
await expect(page.locator("#reg_2")).toHaveValue("TRAILER-A");
await expect(page.locator("#reg_2")).toHaveValue("MULTITRL");
await page.locator('[data-testid="pos-next-step"]:visible').click();
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 10_000 });
await expect.poll(() => fixture.ordersById[9300]?.reg_2 || null, { timeout: 10_000 }).toBe("TRAILER-A");
await expect.poll(() => fixture.ordersById[9300]?.po || null, { timeout: 10_000 }).toBe("BOOKING-A-PO");
await expect.poll(() => fixture.ordersById[9300]?.reg_1 || null, { timeout: 10_000 }).toBe("TRACTORA");
await expect.poll(() => fixture.ordersById[9300]?.reg_2 || null, { timeout: 10_000 }).toBe("MULTITRL");
await expect
.poll(() => (fixture.orderItemsByOrderId[9300] || []).map((item) => Number(item.product_id)), { timeout: 10_000 })
.toEqual([53, 63]);
@@ -1097,6 +1245,487 @@ test.describe("POS flow", () => {
expect(fixture.completedBookingIds).not.toContain(8103);
});
test("desktop shows booking selection before duplicate warning when multiple bookings need selection", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"Desktop POS booking flow is validated on chromium-desktop."
);
const fixture = createPosFixture();
fixture.vehicles = [
{
...fixture.vehicles[0],
reg: "MULTIDUP",
reference: "REF-MULTIDUP",
},
];
fixture.orderBookings = [
buildOrderBooking(8130, {
reg_1: "TRACTORE",
reg_2: "MULTIDUP",
datetime: "2026-01-01T07:00:00.000Z",
reference: "MULTI-DUP-A",
reference_number: "MULTI-DUP-A",
}),
buildOrderBooking(8131, {
reg_1: "TRACTORF",
reg_2: "MULTIDUP",
datetime: "2026-01-01T09:00:00.000Z",
reference: "MULTI-DUP-B",
reference_number: "MULTI-DUP-B",
}),
];
fixture.orderBookingsDelayMs = 600;
fixture.duplicateOrders = [
{
id: 9202,
customer_id: fixture.customer.customerNumber,
department_id: 1,
reference: "DUP-REF",
notes: "",
reg_1: "TRACTORE",
reg_2: "MULTIDUP",
reg_3: "",
booking_id: null,
completed_at: null,
created_at: buildTodayTimestamp("10:15:00.000Z"),
},
];
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-duplicate-booking-priority" });
const activeBookingSelector = getActiveDesktopModal(page);
await commitDesktopReg1ByBlur(page, "MULTIDUP");
await expect(activeBookingSelector).toBeVisible({ timeout: 10_000 });
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 activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8130").click();
await expect(activeBookingSelector.getByTestId("pos-desktop-duplicate-warning-continue")).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 ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"Desktop POS booking flow is validated on chromium-desktop."
);
const fixture = createPosFixture();
fixture.duplicateOrders = [
{
id: 9203,
customer_id: fixture.customer.customerNumber,
department_id: 1,
reference: "DUP-NO-BOOKING",
notes: "",
reg_1: "AB12345",
reg_2: "",
reg_3: "",
booking_id: null,
completed_at: null,
created_at: buildTodayTimestamp("08:30:00.000Z"),
},
];
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 page.locator("#reference").click();
await expect(activeModal.getByTestId("pos-desktop-duplicate-warning-continue")).toBeVisible({
timeout: 10_000,
});
});
test("desktop single booking duplicate warning uses the rewritten primary registration", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"Desktop POS booking flow is validated on chromium-desktop."
);
const fixture = createPosFixture();
fixture.vehicles = [
{
...fixture.vehicles[0],
reg: "TRAILDUP",
reference: "REF-TRAILDUP",
},
];
fixture.orderBookings = [
buildOrderBooking(8132, {
reg_1: "TRACTORDUP",
reg_2: "TRAILDUP",
reference: "TRAILER-DUP-BOOKING",
reference_number: "TRAILER-DUP-BOOKING",
}),
];
fixture.duplicateOrders = [
{
id: 9204,
customer_id: fixture.customer.customerNumber,
department_id: 1,
reference: "TRACTOR-DUPLICATE",
notes: "",
reg_1: "TRACTORDUP",
reg_2: "TRAILDUP",
reg_3: "",
booking_id: null,
completed_at: null,
created_at: buildTodayTimestamp("11:00:00.000Z"),
},
];
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({
timeout: 10_000,
});
});
test("desktop duplicate warning cancel 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."
);
const fixture = createPosFixture();
fixture.duplicateOrders = [
{
id: 9205,
customer_id: fixture.customer.customerNumber,
department_id: 1,
reference: "DUP-CANCEL",
notes: "",
reg_1: "AB12345",
reg_2: "",
reg_3: "",
booking_id: null,
completed_at: null,
created_at: buildTodayTimestamp("09:00:00.000Z"),
},
];
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({
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();
});
test("desktop duplicate warning can open duplicate details without losing the current state", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"Desktop POS booking flow is validated on chromium-desktop."
);
const fixture = createPosFixture();
fixture.duplicateOrders = [
{
id: 9206,
customer_id: fixture.customer.customerNumber,
department_id: 1,
reference: "DUP-DETAILS",
notes: "",
reg_1: "AB12345",
reg_2: "",
reg_3: "",
booking_id: null,
completed_at: null,
created_at: buildTodayTimestamp("10:00:00.000Z"),
},
];
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 expect(activeModal.getByTestId("pos-desktop-duplicate-details-back")).toBeVisible({
timeout: 10_000,
});
await expect(activeModal.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.skip(
testInfo.project.name !== "chromium-desktop",
"Desktop POS booking flow is validated on chromium-desktop."
);
const fixture = createPosFixture();
fixture.duplicateOrders = [
{
id: 9207,
customer_id: fixture.customer.customerNumber,
department_id: 1,
reference: "DUP-BLOCK-NEXT",
notes: "",
reg_1: "AB12345",
reg_2: "",
reg_3: "",
booking_id: null,
completed_at: null,
created_at: buildTodayTimestamp("12:00:00.000Z"),
},
];
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");
});
test("desktop keyboard selection opens the booking chooser for booked search results", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"Desktop POS booking flow is validated on chromium-desktop."
);
const fixture = createPosFixture();
fixture.vehicles = [
{
...fixture.vehicles[0],
reg: "EC21233",
reference: "REF-EC21233",
},
{
...fixture.vehicles[0],
id: 7002,
reg: "EC21234",
reference: "REF-EC21234",
},
{
...fixture.vehicles[0],
id: 7003,
reg: "EC21235",
reference: "REF-EC21235",
},
];
fixture.unknownVehicles = [
{
reg_1: "EC2123",
},
];
fixture.orderBookings = [
buildOrderBooking(8121, {
reg_1: "EC21233",
datetime: "2026-01-01T06:00:00.000Z",
reference: "KEYBOARD-BOOKING-0",
reference_number: "KEYBOARD-BOOKING-0",
}),
buildOrderBooking(8122, {
reg_1: "EC21234",
datetime: "2026-01-01T07:00:00.000Z",
reference: "KEYBOARD-BOOKING-A",
reference_number: "KEYBOARD-BOOKING-A",
reg_2: "KEY-TRAILER-A",
}),
buildOrderBooking(8123, {
reg_1: "EC21234",
datetime: "2026-01-01T09:00:00.000Z",
reference: "KEYBOARD-BOOKING-B",
reference_number: "KEYBOARD-BOOKING-B",
reg_2: "KEY-TRAILER-B",
}),
];
fixture.orderBookingsDelayMs = 600;
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-keyboard-booking" });
const activeBookingSelector = getActiveDesktopModal(page);
const reg1Input = page.locator("#reg_1");
const activeDropdownItems = page.locator(".license-plate-dropdown-item--active");
await reg1Input.fill("EC2123");
await reg1Input.press("ArrowDown");
await reg1Input.press("ArrowDown");
await expect(activeDropdownItems).toHaveCount(1);
await expect(activeDropdownItems.first()).toContainText("EC21234");
await expect
.poll(() => page.evaluate(() => window.getSelection()?.toString() || ""), { timeout: 10_000 })
.toBe("");
await reg1Input.press("Enter");
await expect(reg1Input).toHaveValue("EC21234");
await expect(activeBookingSelector).toBeVisible({ timeout: 10_000 });
await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8122")).toBeVisible({
timeout: 10_000,
});
await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8123")).toBeVisible({
timeout: 10_000,
});
});
test("desktop dropdown click opens the booking chooser for booked search results", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"Desktop POS booking flow is validated on chromium-desktop."
);
const fixture = createPosFixture();
fixture.vehicles = [
{
...fixture.vehicles[0],
reg: "EC21233",
reference: "REF-EC21233",
},
{
...fixture.vehicles[0],
id: 7002,
reg: "EC21234",
reference: "REF-EC21234",
},
{
...fixture.vehicles[0],
id: 7003,
reg: "EC21235",
reference: "REF-EC21235",
},
];
fixture.orderBookings = [
buildOrderBooking(8124, {
reg_1: "EC21234",
datetime: "2026-01-01T07:00:00.000Z",
reference: "MOUSE-BOOKING-A",
reference_number: "MOUSE-BOOKING-A",
reg_2: "MOUSE-TRAILER-A",
}),
buildOrderBooking(8125, {
reg_1: "EC21234",
datetime: "2026-01-01T09:00:00.000Z",
reference: "MOUSE-BOOKING-B",
reference_number: "MOUSE-BOOKING-B",
reg_2: "MOUSE-TRAILER-B",
}),
];
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-mouse-booking" });
const activeBookingSelector = getActiveDesktopModal(page);
const reg1Input = page.locator("#reg_1");
await reg1Input.fill("EC21");
await page
.locator(".dropdown-item")
.filter({ hasText: "EC21234 - Pleno Logistics" })
.first()
.click();
await expect(reg1Input).toHaveValue("EC21234");
await expect(activeBookingSelector).toBeVisible({ timeout: 10_000 });
await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8124")).toBeVisible({
timeout: 10_000,
});
await expect(activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8125")).toBeVisible({
timeout: 10_000,
});
});
test("desktop blocks next until a multiple-booking chooser is resolved", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"Desktop POS booking flow is validated on chromium-desktop."
);
const fixture = createPosFixture();
fixture.vehicles = [
{
...fixture.vehicles[0],
reg: "MULTITRL",
reference: "REF-MULTITRL",
},
];
fixture.orderBookings = [
buildOrderBooking(8126, {
reg_1: "TRACTORC",
reg_2: "MULTITRL",
datetime: "2026-01-01T07:00:00.000Z",
reference: "BLOCKING-BOOKING-A",
reference_number: "BLOCKING-BOOKING-A",
}),
buildOrderBooking(8127, {
reg_1: "TRACTORD",
reg_2: "MULTITRL",
datetime: "2026-01-01T09:00:00.000Z",
reference: "BLOCKING-BOOKING-B",
reference_number: "BLOCKING-BOOKING-B",
}),
];
fixture.orderBookingsDelayMs = 600;
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-blocked-booking" });
const activeBookingSelector = getActiveDesktopModal(page);
await page.locator("#reg_1").fill("MULTITRL");
await page.locator('[data-testid="pos-next-step"]:visible').click();
await expect(activeBookingSelector).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-step-2")).not.toBeVisible();
expect(fixture.ordersById[9300]).toBeUndefined();
await activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8126").click();
await expect(page.locator("#reg_1")).toHaveValue("TRACTORC");
await expect(page.locator("#reg_2")).toHaveValue("MULTITRL");
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 10_000 });
await expect.poll(() => fixture.ordersById[9300]?.reg_1 || null, { timeout: 10_000 }).toBe("TRACTORC");
await expect.poll(() => fixture.ordersById[9300]?.reg_2 || null, { timeout: 10_000 }).toBe("MULTITRL");
});
test("desktop opens the booking chooser for a booked plate even without a vehicle match", async ({
page,
}, testInfo) => {
@@ -1136,7 +1765,7 @@ test.describe("POS flow", () => {
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-booking-only" });
const activeBookingSelector = page.locator('[data-testid="default-object-selector"].is-active');
const activeBookingSelector = getActiveDesktopModal(page);
await page.locator("#reg_1").fill("BOOKONLY1");
await expect(activeBookingSelector).toBeVisible({ timeout: 10_000 });
await activeBookingSelector.getByTestId("pos-desktop-order-booking-use-8111").click();
@@ -1187,8 +1816,8 @@ test.describe("POS flow", () => {
await setupDesktopPosPage(page, fixture, { token: "pos-desktop-skip-booking" });
const activeBookingSelector = page.locator('[data-testid="default-object-selector"].is-active');
await page.locator("#reg_1").fill("AB12345");
const activeBookingSelector = getActiveDesktopModal(page);
await commitDesktopReg1ByBlur(page, "AB12345");
await expect(activeBookingSelector).toBeVisible({ timeout: 10_000 });
await activeBookingSelector.getByTestId("pos-desktop-order-booking-skip").click();
+117 -5
View File
@@ -241,6 +241,24 @@ async function expectOrderBookingPopupIds(page, bookingIds) {
.toEqual(bookingIds.map((bookingId) => `pos-mobile-order-booking-option-${bookingId}`));
}
async function waitForMobileStepTwoReady(page) {
const stepTwoShell = page.getByTestId("pos-mobile-step-2");
const vehicleSelection = page.getByTestId("pos-mobile-vehicle-selection");
const resolvedView = await Promise.race([
stepTwoShell
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => "step-2")
.catch(() => null),
vehicleSelection
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => "vehicle-selection")
.catch(() => null),
]);
expect(resolvedView).not.toBeNull();
}
async function createOrderFromStep1(
page,
fixture,
@@ -285,12 +303,14 @@ async function createOrderFromStep1(
.catch(() => false);
if (!customerPopupAppeared) {
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await waitForMobileStepTwoReady(page);
return;
}
await selectCustomerFromPopup(page, customerNumber);
await waitForMobileNextStepCooldown(page);
await page.getByTestId("pos-mobile-next-step").click();
await waitForMobileStepTwoReady(page);
}
test.describe("POS mobile order flow", () => {
@@ -557,7 +577,8 @@ test.describe("POS mobile order flow", () => {
},
bookings: [
buildMobileOrderBooking(8201, {
reg_1: "MULTI123",
reg_1: "TRACT8201",
reg_2: "MULTI123",
datetime: "2026-01-01T07:00:00.000Z",
reference: "MULTI-BOOKING-A",
reference_number: "MULTI-BOOKING-A",
@@ -571,7 +592,8 @@ test.describe("POS mobile order flow", () => {
},
}),
buildMobileOrderBooking(8202, {
reg_1: "MULTI123",
reg_1: "TRACT8202",
reg_2: "MULTI123",
datetime: "2026-01-01T09:00:00.000Z",
reference: "MULTI-BOOKING-B",
reference_number: "MULTI-BOOKING-B",
@@ -613,6 +635,9 @@ test.describe("POS mobile order flow", () => {
return {
bookingId: snapshot?.metadata?.bookingId ?? null,
vehicleBookingId: snapshot?.vehicles?.vehicle_1?.booking_id ?? null,
reg1: snapshot?.vehicles?.vehicle_1?.reg ?? "",
reg2: snapshot?.vehicles?.vehicle_2?.reg ?? "",
vehicle2BookingId: snapshot?.vehicles?.vehicle_2?.booking_id ?? null,
reference: snapshot?.metadata?.reference ?? "",
};
},
@@ -621,6 +646,9 @@ test.describe("POS mobile order flow", () => {
.toEqual({
bookingId: 8202,
vehicleBookingId: 8202,
reg1: "TRACT8202",
reg2: "MULTI123",
vehicle2BookingId: 8202,
reference: "MULTI-BOOKING-B",
});
@@ -631,6 +659,18 @@ test.describe("POS mobile order flow", () => {
addonProductIds: [],
});
await expect(page.getByTestId("pos-mobile-reference-step-2-input")).toHaveValue("MULTI-BOOKING-B");
await expect
.poll(
() => ({
reg_1: fixture.ordersById[9300]?.reg_1 ?? "",
reg_2: fixture.ordersById[9300]?.reg_2 ?? "",
}),
{ timeout: 10_000 }
)
.toEqual({
reg_1: "TRACT8202",
reg_2: "MULTI123",
});
await page.getByTestId("pos-mobile-next-step").click();
@@ -654,6 +694,56 @@ test.describe("POS mobile order flow", () => {
await waitForStepReset(page);
});
test("booking popup keeps continue-without-booking visible and allows scrolling through long booking lists", async ({
page,
}) => {
const longBookingList = Array.from({ length: 8 }, (_, index) =>
buildMobileOrderBooking(8260 + index, {
reg_1: `TRAC${8260 + index}`,
reg_2: "MOBILELONG",
datetime: `2026-01-${String(index + 1).padStart(2, "0")}T09:00:00.000Z`,
reference: `LONG-${8260 + index}`,
reference_number: `LONG-${8260 + index}`,
})
);
const fixture = createMultiBookingFixture({
reg: "MOBILELONG",
bookings: longBookingList,
});
await setupMobilePosPage(page, fixture, {
token: "mobile-multi-booking-long-list-token",
seedState: {
customerId: null,
reg: "MOBILELONG",
reference: "",
includePrimaryItem: false,
vehicleType: null,
lastOrderId: null,
},
route: {
step: 1,
},
});
const popup = await waitForOrderBookingPopup(page);
const popupContent = page.locator('[data-testid="pos-mobile-popup"] .card-content');
await expect(page.getByTestId("pos-mobile-order-booking-skip")).toBeVisible({ timeout: 10_000 });
await expect
.poll(async () => popupContent.evaluate((element) => element.scrollHeight > element.clientHeight), {
timeout: 10_000,
})
.toBe(true);
await popupContent.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect(popup.getByTestId("pos-mobile-order-booking-option-8267")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-order-booking-skip").click();
await expect(page.getByTestId("pos-mobile-order-booking-popup")).toBeHidden({ timeout: 10_000 });
});
test("manual input can continue without booking and keeps the flow unbooked", async ({ page }) => {
const fixture = createMultiBookingFixture({
reg: "SKIP123",
@@ -734,13 +824,15 @@ test.describe("POS mobile order flow", () => {
reg: "RESTSEL1",
bookings: [
buildMobileOrderBooking(8401, {
reg_1: "RESTSEL1",
reg_1: "RESTTRAC1",
reg_2: "RESTSEL1",
datetime: "2026-01-01T07:00:00.000Z",
reference: "RESTORE-SELECT-A",
reference_number: "RESTORE-SELECT-A",
}),
buildMobileOrderBooking(8402, {
reg_1: "RESTSEL1",
reg_1: "RESTTRAC2",
reg_2: "RESTSEL1",
datetime: "2026-01-01T09:00:00.000Z",
reference: "RESTORE-SELECT-B",
reference_number: "RESTORE-SELECT-B",
@@ -769,6 +861,8 @@ test.describe("POS mobile order flow", () => {
return {
bookingId: snapshot?.metadata?.bookingId ?? null,
vehicleBookingId: snapshot?.vehicles?.vehicle_1?.booking_id ?? null,
reg1: snapshot?.vehicles?.vehicle_1?.reg ?? "",
reg2: snapshot?.vehicles?.vehicle_2?.reg ?? "",
};
},
{ timeout: 10_000 }
@@ -776,6 +870,8 @@ test.describe("POS mobile order flow", () => {
.toEqual({
bookingId: 8402,
vehicleBookingId: 8402,
reg1: "RESTTRAC2",
reg2: "RESTSEL1",
});
const orderBookingsGetBeforeReload = fixture.requestCounters.orderBookingsGet;
@@ -791,6 +887,8 @@ test.describe("POS mobile order flow", () => {
return {
bookingId: snapshot?.metadata?.bookingId ?? null,
vehicleBookingId: snapshot?.vehicles?.vehicle_1?.booking_id ?? null,
reg1: snapshot?.vehicles?.vehicle_1?.reg ?? "",
reg2: snapshot?.vehicles?.vehicle_2?.reg ?? "",
};
},
{ timeout: 10_000 }
@@ -798,6 +896,8 @@ test.describe("POS mobile order flow", () => {
.toEqual({
bookingId: 8402,
vehicleBookingId: 8402,
reg1: "RESTTRAC2",
reg2: "RESTSEL1",
});
await page.waitForTimeout(1_000);
await expect(page.getByTestId("pos-mobile-order-booking-popup")).toHaveCount(0);
@@ -1223,6 +1323,12 @@ test.describe("POS mobile order flow", () => {
await expect(page.getByTestId("pos-mobile-popup")).toBeHidden({ timeout: 10_000 });
await expect.poll(() => fixture.ordersById[orderId]?.reg_1 ?? "", { timeout: 10_000 }).toBe("CD1234");
await expect
.poll(
async () => (await getStoredPosSnapshot(page))?.vehicles?.vehicle_1?.reg ?? "",
{ timeout: 10_000 }
)
.toBe("CD1234");
await expect
.poll(
() =>
@@ -1236,6 +1342,12 @@ test.describe("POS mobile order flow", () => {
await expect(page.locator('[data-testid="pos-mobile-step-2"] .custom-button-secondary').first()).toContainText(
"CD1234"
);
await expect
.poll(
async () => (await getStoredPosSnapshot(page))?.vehicles?.vehicle_1?.reg ?? "",
{ timeout: 10_000 }
)
.toBe("CD1234");
});
test("manual step 2 selection supports addons and additional items", async ({ page }) => {
+6 -3
View File
@@ -255,10 +255,13 @@ test.describe("POS visuals", () => {
await primeSession(page, "pos-visual-desktop-step-1-token");
await page.goto("/admin/12/modules/pos");
await expect(page.getByTestId("pos-step-1")).toBeVisible();
await expectPosCardTabsDividerSpacing(page.getByTestId("pos-step-1"));
const stepOne = page.getByTestId("pos-step-1");
await expect(stepOne).toBeVisible();
await expectPosCardTabsDividerSpacing(stepOne);
await expect(page.getByTestId("pos-recent-scan-row-801")).toBeVisible();
await expect(page.getByTestId("pos-step-1")).toHaveScreenshot("pos-step-1-desktop.png", {
await expect(stepOne.getByText(/^Booket$/)).toBeVisible();
await expect(stepOne).not.toContainText("Booket (fremt. opdat.)");
await expect(stepOne).toHaveScreenshot("pos-step-1-desktop.png", {
maxDiffPixels: 300,
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

+21 -2
View File
@@ -904,7 +904,7 @@ function filterBookings(bookings, filters) {
return Number(booking.department) === Number(value);
}
if (key === "order_id") {
if (value === "null") {
if (value === "null" || value === "is null") {
return !booking.order_id;
}
return Number(booking.order_id) === Number(value);
@@ -920,6 +920,17 @@ function filterBookings(bookings, filters) {
});
}
function toOrderBookingListEntry(booking, stripDetails = false) {
if (!stripDetails || !booking || typeof booking !== "object") {
return booking;
}
const summaryBooking = { ...booking };
delete summaryBooking.items;
delete summaryBooking.parsed_services;
return summaryBooking;
}
function recordCounter(fixture, key) {
fixture.requestCounters[key] = Number(fixture.requestCounters[key] || 0) + 1;
}
@@ -1163,7 +1174,14 @@ export async function mockMobilePosApi(page, fixture) {
fixture.pendingBookings ?? Object.values(fixture.bookingsById),
parsedUrl.searchParams.get("filters")
);
await route.fulfill(json({ success: true, data: clone(bookings) }));
await route.fulfill(
json({
success: true,
data: clone(
bookings.map((booking) => toOrderBookingListEntry(booking, fixture.orderBookingListStripsDetails === true))
),
})
);
return;
}
@@ -1396,6 +1414,7 @@ export async function mockMobilePosApi(page, fixture) {
department_id: Number(body.department_id),
reference: body.reference || "",
notes: body.notes || "",
po: body.po || "",
reg_1: body.reg_1 || "",
reg_2: body.reg_2 || "",
reg_3: body.reg_3 || "",
+31 -2
View File
@@ -178,6 +178,17 @@ function parseFilterExpressions(filters) {
}, {});
}
function toOrderBookingListEntry(booking, stripDetails = false) {
if (!stripDetails || !booking || typeof booking !== "object") {
return booking;
}
const summaryBooking = { ...booking };
delete summaryBooking.items;
delete summaryBooking.parsed_services;
return summaryBooking;
}
function filterPosOrders(posFixture, filters) {
const filterMap = parseFilterExpressions(filters);
return Object.values(posFixture.ordersById || {}).filter((order) => {
@@ -1237,16 +1248,34 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
let bookings = Array.isArray(posFixture.orderBookings) ? posFixture.orderBookings : [];
if (id > 0) {
await route.fulfill(json({ success: true, data: bookings.find((booking) => booking.id === id) || null }));
await route.fulfill(json({ success: true, data: bookings.find((booking) => booking.id === id) || null }));
return true;
}
if (filters.includes("department:")) {
const department = filters.split("department:")[1]?.split(",")[0] || "";
bookings = bookings.filter(
(booking) => Number(booking.department ?? booking.department_id ?? 0) === Number(department)
);
}
if (filters.includes("order_id:null") || filters.includes("order_id:is null")) {
bookings = bookings.filter((booking) => !booking.order_id);
}
if (filters.includes("reg_1:")) {
const reg = filters.split("reg_1:")[1]?.split(",")[0] || "";
bookings = bookings.filter((booking) => String(booking.reg_1 || "").toUpperCase() === String(reg).toUpperCase());
}
await route.fulfill(json({ success: true, data: bookings }));
await route.fulfill(
json({
success: true,
data: bookings.map((booking) =>
toOrderBookingListEntry(booking, posFixture.orderBookingListStripsDetails === true)
),
})
);
return true;
}
+11
View File
@@ -23,6 +23,17 @@ describe("origin migration route guard", () => {
expect(target).toBeNull();
});
it("allows loopback origins on arbitrary ports", () => {
const target = resolveOriginMigrationRoute({
to: route,
currentOrigin: "http://localhost:5191",
allowedOrigins,
standalone: false,
});
expect(target).toBeNull();
});
it("routes to outdated installation for disallowed standalone context", () => {
const target = resolveOriginMigrationRoute({
to: route,
+308
View File
@@ -0,0 +1,308 @@
// @vitest-environment jsdom
import { nextTick, ref } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mountWithApp } from "./helpers/mountWithApp.js";
const posState = vi.hoisted(() => ({
customerId: null,
notes: null,
customerName: null,
reference: null,
reg1: null,
reg2: null,
reg3: null,
customerAttributes: null,
orderNotes: null,
orderPo: null,
orderId: null,
selectedOrderBookingId: null,
selectedOrderBookingPlate: null,
searchAndSelectCustomer: vi.fn(),
selectCustomer: vi.fn(),
setSelectedOrderBookingSelection: vi.fn(),
clearSelectedOrderBookingSelection: vi.fn(),
skipSelectedOrderBookingSelection: vi.fn(),
isSelectedOrderBookingSkippedForPlate: vi.fn(() => false),
}));
vi.mock("@/components/shop/POSDepartmentProcess.vue", async () => {
posState.customerId = ref(null);
posState.notes = ref([]);
posState.customerName = ref("");
posState.reference = ref("");
posState.reg1 = ref("AB12345");
posState.reg2 = ref("");
posState.reg3 = ref("");
posState.customerAttributes = ref([]);
posState.orderNotes = ref("");
posState.orderPo = ref("");
posState.orderId = ref(null);
posState.selectedOrderBookingId = ref(null);
posState.selectedOrderBookingPlate = ref("");
posState.selectCustomer.mockImplementation((customer) => {
posState.customerId.value = customer?.customerNumber ?? null;
posState.customerName.value = customer?.name ?? "";
});
return {
customer_id: posState.customerId,
notes: posState.notes,
isCustomerBarred: vi.fn(() => false),
customer_name: posState.customerName,
reference: posState.reference,
reg_1: posState.reg1,
reg_2: posState.reg2,
reg_3: posState.reg3,
order_notes: posState.orderNotes,
order_po: posState.orderPo,
order_id: posState.orderId,
searchAndSelectCustomer: posState.searchAndSelectCustomer,
selectCustomer: posState.selectCustomer,
isCustomerSelected: () => Boolean(posState.customerId.value),
customer_attributes: posState.customerAttributes,
selectedOrderBookingId: posState.selectedOrderBookingId,
selectedOrderBookingPlate: posState.selectedOrderBookingPlate,
setSelectedOrderBookingSelection: posState.setSelectedOrderBookingSelection,
clearSelectedOrderBookingSelection: posState.clearSelectedOrderBookingSelection,
skipSelectedOrderBookingSelection: posState.skipSelectedOrderBookingSelection,
isSelectedOrderBookingSkippedForPlate: posState.isSelectedOrderBookingSkippedForPlate,
};
});
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
adminUser: false,
objects: {
orders: {
columns: {
reg_1: { label: "Nummerplade" },
reg_2: { label: "Reg. 2" },
reg_3: { label: "Reg. 3" },
reference: { label: "Reference" },
},
},
global: {
language: {
customer: "Kunde",
select: "Vælg",
other: "Andet",
no_data: "Ingen data",
last_wash: "Sidste vask",
copy: "Kopier",
have: "Har",
have_not: "Har ikke",
quantity: "Antal",
show: "Vis",
hide: "Skjul",
},
},
vehicles: {
meta: {
labels: {
single: "Køretøj",
},
},
columns: {
wash_subscription: {
label: "Vaskeabonnement",
},
},
},
products: {
meta: {
labels: {
single: "Produkt",
},
},
functions: {
getProductName: vi.fn(() => "Forvogn"),
},
},
},
},
}));
import SelectVehicleFormPOS from "@/components/forms/department/pos/SelectVehicleFormPOS.vue";
const linkedVehicle = {
id: 7002,
reg: "EC21235",
customer_id: 12345679,
type: 53,
wash_subscription: false,
addons: { list: [] },
last_order_id: null,
};
const unlinkedVehicle = {
id: 7001,
reg: "AB12345",
customer_id: null,
type: 53,
wash_subscription: false,
addons: { list: [] },
last_order_id: null,
};
const LicensePlateReg1InputStub = {
name: "LicensePlateReg1Input",
emits: ["update:vehicleObject", "update:focus", "update:bookingObject", "update:bookingMatches"],
methods: {
emitUnlinkedVehicle() {
this.$emit("update:vehicleObject", { ...unlinkedVehicle });
},
emitLinkedVehicle() {
this.$emit("update:vehicleObject", { ...linkedVehicle });
},
},
template: `
<div>
<button
type="button"
data-testid="emit-unlinked-vehicle"
@click="emitUnlinkedVehicle"
>
Emit unlinked vehicle
</button>
<button
type="button"
data-testid="emit-linked-vehicle"
@click="emitLinkedVehicle"
>
Emit linked vehicle
</button>
</div>
`,
};
const ExpandableContentBoxStub = {
name: "ExpandableContentBox",
props: {
expanded: {
type: Boolean,
default: false,
},
},
emits: ["update:expanded"],
template: `
<div data-testid="expandable-content-box-stub">
<slot v-if="expanded" name="expandedContent" />
<button
type="button"
data-testid="expandable-toggle"
@click="$emit('update:expanded', !expanded)"
>
Toggle
</button>
</div>
`,
};
const CustomerSearchFieldPosStub = {
name: "CustomerSearchFieldPos",
props: {
showCardPaymentButton: {
type: Boolean,
default: true,
},
},
template: `
<div data-testid="customer-search-field-pos-stub">
<button
v-if="showCardPaymentButton"
type="button"
data-testid="customer-search-card-payment-stub"
>
Vælg direkte betaling med betalingskort
</button>
</div>
`,
};
const SimpleStub = {
template: "<div />",
};
function mountForm() {
return mountWithApp(SelectVehicleFormPOS, {
global: {
stubs: {
CustomerSearchFieldPos: CustomerSearchFieldPosStub,
customerSearchFieldPos: CustomerSearchFieldPosStub,
ExpandableContentBox: ExpandableContentBoxStub,
LicensePlateReg1Input: LicensePlateReg1InputStub,
LicensePlateInput: SimpleStub,
PosNotes: SimpleStub,
OrderContentTable: SimpleStub,
OrderItemsTable: SimpleStub,
VehicleCustomerSuggestionsPos: SimpleStub,
DefaultObjectSelector: SimpleStub,
},
},
});
}
describe("SelectVehicleFormPOS", () => {
beforeEach(() => {
posState.customerId.value = null;
posState.notes.value = [];
posState.customerName.value = "";
posState.reference.value = "";
posState.reg1.value = "AB12345";
posState.reg2.value = "";
posState.reg3.value = "";
posState.customerAttributes.value = [];
posState.orderNotes.value = "";
posState.orderPo.value = "";
posState.orderId.value = null;
posState.selectedOrderBookingId.value = null;
posState.selectedOrderBookingPlate.value = "";
posState.searchAndSelectCustomer.mockClear();
posState.selectCustomer.mockClear();
posState.setSelectedOrderBookingSelection.mockClear();
posState.clearSelectedOrderBookingSelection.mockClear();
posState.skipSelectedOrderBookingSelection.mockClear();
posState.isSelectedOrderBookingSkippedForPlate.mockReset();
posState.isSelectedOrderBookingSkippedForPlate.mockReturnValue(false);
});
it("shows a single inline customer selector before a vehicle is linked", () => {
const wrapper = mountForm();
expect(wrapper.findAll('[data-testid="customer-search-field-pos-stub"]')).toHaveLength(1);
expect(wrapper.findAll('[data-testid="customer-search-card-payment-stub"]')).toHaveLength(1);
expect(wrapper.find('[data-testid="expandable-content-box-stub"]').exists()).toBe(false);
});
it("keeps a single inline customer selector when an unlinked vehicle card is expanded", async () => {
const wrapper = mountForm();
await wrapper.get('[data-testid="emit-unlinked-vehicle"]').trigger("click");
await nextTick();
expect(wrapper.findAll('[data-testid="customer-search-field-pos-stub"]')).toHaveLength(1);
expect(wrapper.find('[data-testid="expandable-content-box-stub"]').exists()).toBe(true);
await wrapper.get('[data-testid="expandable-toggle"]').trigger("click");
await nextTick();
expect(wrapper.findAll('[data-testid="customer-search-field-pos-stub"]')).toHaveLength(1);
expect(wrapper.findAll('[data-testid="customer-search-card-payment-stub"]')).toHaveLength(1);
});
it("moves customer selection into the expanded other section for linked vehicles", async () => {
const wrapper = mountForm();
await wrapper.get('[data-testid="emit-linked-vehicle"]').trigger("click");
await nextTick();
expect(wrapper.findAll('[data-testid="customer-search-field-pos-stub"]')).toHaveLength(0);
await wrapper.get('[data-testid="expandable-toggle"]').trigger("click");
await nextTick();
expect(wrapper.findAll('[data-testid="customer-search-field-pos-stub"]')).toHaveLength(1);
expect(wrapper.findAll('[data-testid="customer-search-card-payment-stub"]')).toHaveLength(1);
});
});
+2 -1
View File
@@ -17,6 +17,7 @@ function getGitCommit() {
export default defineConfig(({ mode }) => {
const isProd = mode === 'production'
const isPlaywrightRuntime = process.env.PLAYWRIGHT === '1'
// Set COMMIT_HASH env var for use in the app
const version = process.env.npm_package_version || '0.0.0'
@@ -56,7 +57,7 @@ export default defineConfig(({ mode }) => {
plugins: [
vue(),
VueJsx(),
!isProd && vueDevTools(),
!isProd && !isPlaywrightRuntime && vueDevTools(),
enableSingleFile && viteSingleFile(),
VitePWA({
registerType: 'autoUpdate',