When the operator expands a row in the recent license plate scans panel on the POS landing page, the inline details now include a 'Last wash' field with the timestamp of the most recent wash for that plate (as returned by the enriched /numberplatescans endpoint). The label falls back to the existing 'recent_washes' / global.last_wash translation. If the API reports last_wash = null, an inline 'Never washed' / 'Aldrig vasket' note is shown instead (TRU-78 / DRIFT 17 — DHL trailer pick-up use case: see at a glance whether the trailer needs another wash). - New pos/never_washed i18n leaf in all 5 locales. - 3 new vitest cases in pos-last-scanned-license-plates.spec.js: - renders the last washed timestamp when present - shows 'never washed' when the API returns null - falls back to 'never washed' when the field is missing entirely
421 lines
13 KiB
JavaScript
421 lines
13 KiB
JavaScript
// @vitest-environment jsdom
|
|
import { nextTick, ref } from "vue";
|
|
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
|
import { mountWithApp } from "./helpers/mountWithApp.js";
|
|
|
|
const paginationState = vi.hoisted(() => ({
|
|
list: null,
|
|
metaCurrentPage: null,
|
|
metaItemsPerPage: null,
|
|
metaTotalItems: null,
|
|
metaSearch: null,
|
|
loadList: vi.fn(),
|
|
setEndpoint: vi.fn(),
|
|
setMetaItemsPerPage: vi.fn(),
|
|
setPage: vi.fn(),
|
|
search: vi.fn(),
|
|
setFilter: vi.fn(),
|
|
setOrder: vi.fn(),
|
|
}));
|
|
|
|
const posState = vi.hoisted(() => ({
|
|
departmentId: null,
|
|
getDepartment: vi.fn(() => 12),
|
|
selectScan: vi.fn(),
|
|
}));
|
|
|
|
const requestMock = vi.hoisted(() => vi.fn());
|
|
const lookupMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@/components/pagination/paginatedList.vue", async () => {
|
|
paginationState.list = ref([]);
|
|
paginationState.metaCurrentPage = ref(1);
|
|
paginationState.metaItemsPerPage = ref(10);
|
|
paginationState.metaTotalItems = ref(0);
|
|
paginationState.metaSearch = ref("");
|
|
|
|
return {
|
|
list: paginationState.list,
|
|
metaCurrentPage: paginationState.metaCurrentPage,
|
|
metaItemsPerPage: paginationState.metaItemsPerPage,
|
|
metaTotalItems: paginationState.metaTotalItems,
|
|
metaSearch: paginationState.metaSearch,
|
|
loadList: paginationState.loadList,
|
|
setEndpoint: paginationState.setEndpoint,
|
|
setMetaItemsPerPage: paginationState.setMetaItemsPerPage,
|
|
setPage: paginationState.setPage,
|
|
search: paginationState.search,
|
|
setFilter: paginationState.setFilter,
|
|
setOrder: paginationState.setOrder,
|
|
};
|
|
});
|
|
|
|
vi.mock("@/components/shop/POSDepartmentProcess.vue", async () => {
|
|
posState.departmentId = ref(12);
|
|
|
|
return {
|
|
department_id: posState.departmentId,
|
|
getDepartment: posState.getDepartment,
|
|
selectScan: posState.selectScan,
|
|
};
|
|
});
|
|
|
|
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
|
SessionUser: {
|
|
request: requestMock,
|
|
functions: {
|
|
date: {
|
|
toLocalTimeHours: vi.fn((value) => String(value).slice(11, 16)),
|
|
},
|
|
},
|
|
objects: {
|
|
order_bookings: {
|
|
meta: {
|
|
endpoint: "/order-bookings",
|
|
},
|
|
},
|
|
orders: {
|
|
columns: {
|
|
reg_1: {
|
|
label: "License plate",
|
|
},
|
|
},
|
|
},
|
|
global: {
|
|
language: {
|
|
last_wash: "Last wash",
|
|
nothing_left_to_show: "Nothing left to show",
|
|
},
|
|
},
|
|
},
|
|
superUser: {
|
|
modules: {
|
|
motorapi: {
|
|
functions: {
|
|
lookup: lookupMock,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/ThemeConfig.vue", () => ({
|
|
Colors: {
|
|
inputs: {
|
|
default: {
|
|
backgroundColor: "#ffffff",
|
|
textColor: "#22364d",
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("@vueuse/core", async () => {
|
|
const { ref } = await import("vue");
|
|
return {
|
|
useElementVisibility: vi.fn(() => ref(false)),
|
|
};
|
|
});
|
|
|
|
import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/PosLastScannedLicensePlatesV2.vue";
|
|
|
|
let consoleErrorSpy;
|
|
|
|
const messages = {
|
|
en: {
|
|
admin: {
|
|
pos: {
|
|
customer: "Customer",
|
|
customer_number: "Customer number",
|
|
booking: "Booking",
|
|
license_plate: "License plate",
|
|
loading: "Loading...",
|
|
make: "Make",
|
|
model: "Model",
|
|
no_plates_found: "No license plates found",
|
|
never_washed: "Never washed",
|
|
recent_scans: "Recent scans",
|
|
recent_scans_helper: "Select a license plate to view vehicle details.",
|
|
recent_scan_details_empty: "No vehicle details are available for this scan.",
|
|
recent_scan_details_error: "Vehicle details could not be loaded. Please try another scan.",
|
|
recent_scan_details_loading: "Loading vehicle details...",
|
|
scanner: "Scanner",
|
|
status_barred: "Barred",
|
|
status_booking: "Booking",
|
|
status_unknown: "New",
|
|
try_again: "Try again",
|
|
type: "Type",
|
|
use: "Use",
|
|
variant: "Variant",
|
|
},
|
|
},
|
|
global: {
|
|
search_license_plate: "Search for license plate",
|
|
},
|
|
},
|
|
};
|
|
|
|
const scans = [
|
|
{
|
|
id: 801,
|
|
plate: "AB12345",
|
|
plate_scanner_id: 1,
|
|
created_at: "2026-04-08 08:44:07",
|
|
customer_number: 12345679,
|
|
customer_name: "Pleno Logistics",
|
|
seen_before: true,
|
|
barred: false,
|
|
// TRU-78: enriched by the API so the landing page can show when
|
|
// the plate was last washed (DHL pick-up use case).
|
|
last_wash: "2026-04-07 17:12:33",
|
|
},
|
|
{
|
|
id: 802,
|
|
plate: "CD67890",
|
|
plate_scanner_id: 2,
|
|
created_at: "2026-04-08 08:31:05",
|
|
customer_number: null,
|
|
customer_name: "",
|
|
seen_before: false,
|
|
barred: false,
|
|
// TRU-78: explicit null means the API confirmed the plate has
|
|
// never been washed.
|
|
last_wash: null,
|
|
},
|
|
{
|
|
id: 803,
|
|
plate: "EF11111",
|
|
plate_scanner_id: 3,
|
|
created_at: "2026-04-08 08:30:00",
|
|
customer_number: null,
|
|
customer_name: "",
|
|
seen_before: false,
|
|
barred: false,
|
|
// TRU-78: missing field should be treated as "never washed" too,
|
|
// so older backends don't break the new UI.
|
|
// (no `last_wash` key on purpose)
|
|
},
|
|
];
|
|
|
|
const flushUi = async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await nextTick();
|
|
};
|
|
|
|
function buildLookupResponse(overrides = {}) {
|
|
return {
|
|
data: {
|
|
data: {
|
|
make: "RENAULT",
|
|
model: "Captur",
|
|
variant: "dCi 90",
|
|
type: "Personbil",
|
|
use: "Privat personkørsel",
|
|
...overrides,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("PosLastScannedLicensePlatesV2", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
paginationState.list.value = [];
|
|
paginationState.metaCurrentPage.value = 1;
|
|
paginationState.metaItemsPerPage.value = 10;
|
|
paginationState.metaTotalItems.value = scans.length;
|
|
paginationState.metaSearch.value = "";
|
|
|
|
paginationState.loadList.mockClear();
|
|
paginationState.setEndpoint.mockClear();
|
|
paginationState.setMetaItemsPerPage.mockClear();
|
|
paginationState.setPage.mockClear();
|
|
paginationState.search.mockClear();
|
|
paginationState.setFilter.mockClear();
|
|
paginationState.setOrder.mockClear();
|
|
|
|
posState.selectScan.mockClear();
|
|
posState.getDepartment.mockReturnValue(12);
|
|
posState.departmentId.value = 12;
|
|
|
|
requestMock.mockReset();
|
|
requestMock.mockImplementation((url) => {
|
|
if (String(url).startsWith("/department/numberplatescanners")) {
|
|
return Promise.resolve({
|
|
data: {
|
|
data: [
|
|
{ id: 1, name: "North scanner" },
|
|
{ id: 2, name: "South scanner" },
|
|
],
|
|
},
|
|
});
|
|
}
|
|
|
|
return Promise.resolve({
|
|
data: {
|
|
data: [{ id: 501, reg_1: "AB12345", reg_2: "" }],
|
|
},
|
|
});
|
|
});
|
|
|
|
lookupMock.mockReset();
|
|
lookupMock.mockImplementation((plate) => {
|
|
if (plate === "AB12345") {
|
|
return Promise.resolve(buildLookupResponse());
|
|
}
|
|
|
|
return Promise.resolve(
|
|
buildLookupResponse({
|
|
make: "SCANIA",
|
|
model: "R500",
|
|
variant: "Highline",
|
|
type: "Lastbil",
|
|
use: "Godstransport",
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.runOnlyPendingTimers();
|
|
consoleErrorSpy.mockRestore();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("clicking a row selects the scan and reveals inline details", async () => {
|
|
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
|
|
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
paginationState.list.value = scans;
|
|
await flushUi();
|
|
|
|
await wrapper.get('[data-testid="pos-recent-scan-row-801"]').trigger("click");
|
|
await flushUi();
|
|
|
|
expect(posState.selectScan).toHaveBeenCalledWith(expect.objectContaining({ id: 801, plate: "AB12345" }));
|
|
expect(lookupMock).toHaveBeenCalledWith("AB12345");
|
|
expect(wrapper.get('[data-testid="pos-recent-scan-details-801"]').text()).toContain("RENAULT");
|
|
expect(wrapper.get('[data-testid="pos-recent-scan-details-801"]').text()).toContain("Customer number");
|
|
});
|
|
|
|
it("keeps only one inline detail panel expanded at a time", async () => {
|
|
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
|
|
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
paginationState.list.value = scans;
|
|
await flushUi();
|
|
|
|
await wrapper.get('[data-testid="pos-recent-scan-row-801"]').trigger("click");
|
|
await flushUi();
|
|
expect(wrapper.find('[data-testid="pos-recent-scan-details-801"]').exists()).toBe(true);
|
|
|
|
await wrapper.get('[data-testid="pos-recent-scan-row-802"]').trigger("click");
|
|
await flushUi();
|
|
|
|
expect(wrapper.find('[data-testid="pos-recent-scan-details-801"]').exists()).toBe(false);
|
|
expect(wrapper.find('[data-testid="pos-recent-scan-details-802"]').exists()).toBe(true);
|
|
});
|
|
|
|
it("caches MotorAPI lookups by plate when the user revisits a scan", async () => {
|
|
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
|
|
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
paginationState.list.value = scans;
|
|
await flushUi();
|
|
|
|
await wrapper.get('[data-testid="pos-recent-scan-row-801"]').trigger("click");
|
|
await flushUi();
|
|
|
|
await wrapper.get('[data-testid="pos-recent-scan-row-802"]').trigger("click");
|
|
await flushUi();
|
|
|
|
await wrapper.get('[data-testid="pos-recent-scan-row-801"]').trigger("click");
|
|
await flushUi();
|
|
|
|
expect(lookupMock.mock.calls.filter(([plate]) => plate === "AB12345")).toHaveLength(1);
|
|
expect(lookupMock.mock.calls.filter(([plate]) => plate === "CD67890")).toHaveLength(1);
|
|
});
|
|
|
|
it("shows an inline error message when MotorAPI lookup fails", async () => {
|
|
lookupMock.mockImplementation((plate) => {
|
|
if (plate === "CD67890") {
|
|
return Promise.reject(new Error("Lookup failed"));
|
|
}
|
|
|
|
return Promise.resolve(buildLookupResponse());
|
|
});
|
|
|
|
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
|
|
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
paginationState.list.value = scans;
|
|
await flushUi();
|
|
|
|
await wrapper.get('[data-testid="pos-recent-scan-row-802"]').trigger("click");
|
|
await flushUi();
|
|
|
|
expect(wrapper.get('[data-testid="pos-recent-scan-details-802"]').text()).toContain(
|
|
"Vehicle details could not be loaded"
|
|
);
|
|
});
|
|
|
|
// TRU-78 / DRIFT 17: license plate scan should show "last washed" on the
|
|
// landing page so the operator can see at a glance when a trailer was
|
|
// last washed (DHL pick-up use case).
|
|
it("renders the last washed timestamp when the scan has a last_wash value", async () => {
|
|
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
|
|
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
paginationState.list.value = scans;
|
|
await flushUi();
|
|
|
|
await wrapper.get('[data-testid="pos-recent-scan-row-801"]').trigger("click");
|
|
await flushUi();
|
|
|
|
const lastWashField = wrapper.get('[data-testid="pos-recent-scan-detail-801-last-wash"]');
|
|
expect(lastWashField.exists()).toBe(true);
|
|
expect(lastWashField.text()).toContain("Last wash");
|
|
// The MySQL DATETIME "2026-04-07 17:12:33" is rendered in the user's
|
|
// locale. We accept both the raw DATETIME string (jsdom without full
|
|
// Intl support) and the locale-formatted output (e.g. 04/07/2026 or
|
|
// 2026-04-07 in browsers with Intl.DateTimeFormat).
|
|
expect(lastWashField.text()).toMatch(/2026/);
|
|
expect(lastWashField.text()).toMatch(/04[\/.\- ]07|07[\/.\- ]04|7[\/.\- ]4|2026-04-07/);
|
|
expect(lastWashField.text()).toMatch(/17:12/);
|
|
});
|
|
|
|
it("shows the 'never washed' message when the API confirms the plate has no last_wash", async () => {
|
|
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
|
|
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
paginationState.list.value = scans;
|
|
await flushUi();
|
|
|
|
await wrapper.get('[data-testid="pos-recent-scan-row-802"]').trigger("click");
|
|
await flushUi();
|
|
|
|
const neverWashed = wrapper.get('[data-testid="pos-recent-scan-never-washed-802"]');
|
|
expect(neverWashed.exists()).toBe(true);
|
|
expect(neverWashed.text()).toContain("Never washed");
|
|
});
|
|
|
|
it("falls back to 'never washed' when the scan has no last_wash key at all", async () => {
|
|
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
|
|
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
paginationState.list.value = scans;
|
|
await flushUi();
|
|
|
|
await wrapper.get('[data-testid="pos-recent-scan-row-803"]').trigger("click");
|
|
await flushUi();
|
|
|
|
const neverWashed = wrapper.get('[data-testid="pos-recent-scan-never-washed-803"]');
|
|
expect(neverWashed.exists()).toBe(true);
|
|
expect(neverWashed.text()).toContain("Never washed");
|
|
});
|
|
});
|