diff --git a/src/components/displays/department/pos/PosLastScannedLicensePlatesV2.vue b/src/components/displays/department/pos/PosLastScannedLicensePlatesV2.vue
index 2dab15eb..7f4aa5b0 100644
--- a/src/components/displays/department/pos/PosLastScannedLicensePlatesV2.vue
+++ b/src/components/displays/department/pos/PosLastScannedLicensePlatesV2.vue
@@ -328,6 +328,47 @@ const getCustomerDetailItems = (scan) => {
return items;
};
+const getLastWashDetailItem = (scan) => {
+ // TRU-78 / DRIFT 17: surface the "last washed" timestamp for a scanned
+ // license plate on the landing page so the operator can see at a glance
+ // when a trailer was last washed (DHL pick-up use case).
+ const lastWash = formatLastWashTimestamp(scan?.last_wash);
+ if (!lastWash) {
+ return null;
+ }
+
+ return {
+ label: SessionUser.objects.global.language.last_wash || t('admin.pos.recent_washes'),
+ value: lastWash,
+ testKey: 'last-wash',
+ };
+};
+
+const getLastWashEmptyStateText = () => {
+ return t('admin.pos.never_washed');
+};
+
+const formatLastWashTimestamp = (value) => {
+ if (!isMeaningfulValue(value)) {
+ return '';
+ }
+
+ const parsedDate = new Date(String(value).replace(' ', 'T'));
+ if (Number.isNaN(parsedDate.getTime())) {
+ return String(value);
+ }
+
+ try {
+ return new Intl.DateTimeFormat(locale.value || undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(parsedDate);
+ } catch (error) {
+ console.error('Unable to format last_wash timestamp', error);
+ return String(value);
+ }
+};
+
const getVehicleDetailItems = (scan) => {
const detailState = getPlateDetailState(scan.plate);
if (detailState.status !== 'loaded') {
@@ -493,19 +534,29 @@ setTimeout(() => {
:data-testid="`pos-recent-scan-details-${scan.id}`"
>
{{ item.label }}
{{ item.value }}
+
+
+ {{ getLastWashEmptyStateText() }}
+
+
{{ $t('admin.pos.recent_scan_details_loading') }}
diff --git a/src/i18n/source/da/phrases/compat/pos/index.json b/src/i18n/source/da/phrases/compat/pos/index.json
index 0bc51ccf..9a61c393 100644
--- a/src/i18n/source/da/phrases/compat/pos/index.json
+++ b/src/i18n/source/da/phrases/compat/pos/index.json
@@ -44,6 +44,7 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.ydelse'}, @:{'terms.glossary.tilvalg'} @:{'terms.glossary.og'} @:{'terms.glossary.ekstra'} @:{'terms.glossary.varer'} @:{'terms.glossary.som'} @:{'terms.glossary.sidste'} @:{'terms.glossary.vask'}",
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
+ "never_washed": "Aldrig vasket",
"new_order": "@.capitalize:{'terms.glossary.ny'} order",
"new_sale": "@.capitalize:{'terms.glossary.nyt'} @:{'terms.glossary.salg'}",
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bookinger'} @:{'terms.glossary.fundet'}",
diff --git a/src/i18n/source/de/phrases/compat/pos/index.json b/src/i18n/source/de/phrases/compat/pos/index.json
index 4fb61c60..386d6496 100644
--- a/src/i18n/source/de/phrases/compat/pos/index.json
+++ b/src/i18n/source/de/phrases/compat/pos/index.json
@@ -44,6 +44,7 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.gleiche'} @:{'terms.glossary.leistung'}, @:{'terms.glossary.zusatzoptionen'} @:{'terms.glossary.und'} @:{'terms.glossary.zusatzartikel'} @:{'terms.glossary.wie'} @:{'terms.glossary.bei'} @:{'terms.glossary.der'} @:{'terms.glossary.letzten'} @:{'terms.glossary.wasche'}",
"manual_entry": "@.capitalize:{'terms.glossary.manuell'} registrering",
+ "never_washed": "Noch nie gewaschen",
"new_order": "@.capitalize:{'terms.glossary.neuer'} @:{'terms.glossary.auftrag'}",
"new_sale": "Nytt salg",
"no_bookings": "@.capitalize:{'terms.glossary.keine'} @:{'terms.glossary.buchungen'} @:{'terms.glossary.gefunden'}",
diff --git a/src/i18n/source/en/phrases/compat/pos/index.json b/src/i18n/source/en/phrases/compat/pos/index.json
index 7eab419e..ddcd46a2 100644
--- a/src/i18n/source/en/phrases/compat/pos/index.json
+++ b/src/i18n/source/en/phrases/compat/pos/index.json
@@ -44,6 +44,7 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.same'} @:{'terms.glossary.service'}, @:{'terms.glossary.add'}-@:{'terms.glossary.ons'}, @:{'terms.glossary.and'} @:{'terms.glossary.extra'} @:{'terms.glossary.items'} @:{'terms.glossary.as'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.previous'} @:{'terms.glossary.wash'}",
"manual_entry": "@:{'terms.glossary.manual'} @:{'terms.glossary.registration'}",
+ "never_washed": "Never washed",
"new_order": "@.capitalize:{'terms.glossary.new'} @:{'terms.glossary.order'}",
"new_sale": "@.capitalize:{'terms.glossary.new'} sale",
"no_bookings": "@.capitalize:{'terms.glossary.no'} @:{'terms.glossary.orders'} @:{'terms.glossary.found'}",
diff --git a/src/i18n/source/global/shared/pos/index.json b/src/i18n/source/global/shared/pos/index.json
index e6ffe0a1..f41b98ae 100644
--- a/src/i18n/source/global/shared/pos/index.json
+++ b/src/i18n/source/global/shared/pos/index.json
@@ -76,6 +76,8 @@
"last_wash_matches_current_selection": "@:{'phrases.compat.pos.last_wash_matches_current_selection'}",
"license_plate": "@:{'phrases.compat.tables.common.registration_number'}",
"manual_entry": "@:{'phrases.compat.pos.manual_entry'}",
+ "last_wash": "@:{'phrases.compat.global.last_wash'}",
+ "never_washed": "@:{'phrases.compat.pos.never_washed'}",
"new_order": "@:{'phrases.compat.pos.new_order'}",
"new_sale": "@:{'phrases.compat.pos.new_sale'}",
"no_bookings": "@:{'phrases.compat.pos.no_bookings'}",
diff --git a/src/i18n/source/no/phrases/compat/pos/index.json b/src/i18n/source/no/phrases/compat/pos/index.json
index cbf3b624..9a12f1c5 100644
--- a/src/i18n/source/no/phrases/compat/pos/index.json
+++ b/src/i18n/source/no/phrases/compat/pos/index.json
@@ -44,6 +44,7 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.tjeneste'}, @:{'terms.glossary.tillegg'} @:{'terms.glossary.og'} @:{'terms.glossary.ekstra'} @:{'terms.glossary.varer'} @:{'terms.glossary.som'} @:{'terms.glossary.forrige'} @:{'terms.glossary.vask'}",
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
+ "never_washed": "Aldri vasket",
"new_order": "@.capitalize:{'terms.glossary.bestillingen'}",
"new_sale": "@.capitalize:{'terms.glossary.nytt'} @:{'terms.glossary.salg'}",
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bestillinger'} @:{'terms.glossary.funnet'}",
diff --git a/src/i18n/source/sv/phrases/compat/pos/index.json b/src/i18n/source/sv/phrases/compat/pos/index.json
index 51d3ddea..2277bc91 100644
--- a/src/i18n/source/sv/phrases/compat/pos/index.json
+++ b/src/i18n/source/sv/phrases/compat/pos/index.json
@@ -44,6 +44,7 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samma'} @:{'terms.glossary.tjanst'}, @:{'terms.glossary.tillval'} @:{'terms.glossary.och'} @:{'terms.glossary.extra'} @:{'terms.glossary.artiklar'} @:{'terms.glossary.som'} @:{'terms.glossary.senaste'} @:{'terms.glossary.tvatten'}",
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
+ "never_washed": "Aldrig tvättad",
"new_order": "@.capitalize:{'terms.glossary.ny'} @:{'terms.glossary.order'}",
"new_sale": "@.capitalize:{'terms.glossary.nytt'} salg",
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bestillinger'} @:{'terms.glossary.funnet'}",
diff --git a/tests/unit/pos-last-scanned-license-plates.spec.js b/tests/unit/pos-last-scanned-license-plates.spec.js
index dd3f2009..e87259d9 100644
--- a/tests/unit/pos-last-scanned-license-plates.spec.js
+++ b/tests/unit/pos-last-scanned-license-plates.spec.js
@@ -83,6 +83,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
},
global: {
language: {
+ last_wash: "Last wash",
nothing_left_to_show: "Nothing left to show",
},
},
@@ -133,6 +134,7 @@ const messages = {
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.",
@@ -164,6 +166,9 @@ const scans = [
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,
@@ -174,6 +179,22 @@ const scans = [
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)
},
];
@@ -341,4 +362,59 @@ describe("PosLastScannedLicensePlatesV2", () => {
"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");
+ });
});