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:
+2
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+628
@@ -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);
|
||||
|
||||
+45
-4
@@ -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>
|
||||
|
||||
+57
-57
@@ -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
|
||||
@@ -27,4 +28,4 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
||||
+1
-1
@@ -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),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
BIN
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 |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -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 || "",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user