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"); + }); });