Compare commits

..
Author SHA1 Message Date
openhands e7ce3fbb56 test(invoicing): install vue-i18n plugin for InvoicingBillingPeriodViewAll mount
The new ProductAggregatesCard child component calls useI18n() during
setup. The existing invoicing-period-queue-state.behavior.spec.js
mounts the parent InvoicingBillingPeriodViewAll view without providing
a vue-i18n instance, which causes the Serial unit suite to fail with:

  SyntaxError: Need to install with `app.use` function

Wire createTestI18n() into the mount() global plugins so the new card
and any other future i18n-using children render correctly in this
serial spec.
2026-08-17 16:59:15 +00:00
Frontend Agent 61a27a0e8e feat(invoicing): add ProductAggregatesCard for superuser fakturaer view (TRU-185) 2026-08-17 16:40:03 +00:00
Frontend Agent 4637cb867b fix(ios): require arm64 instead of armv7 (TRU-92)
The Info.plist listed armv7 as a UIRequiredDeviceCapability, which
filtered the app out of all iOS 11+ devices (iPhone 5s and later) -
they are 64-bit only and report arm64, not armv7. With IPHONEOS_DEPLOYMENT_TARGET
= 15.0 the app is already arm64-only, so listing armv7 is both wrong
and the reason the build did not install on Mads's iPhone.

Replace armv7 with arm64 so the requirement matches the actual
deployment target and the iPhone App Store can match the device.
2026-08-17 16:38:42 +00:00
openclaw bugfix 3873849de1 fix(ci): regenerate i18n v2 runtime files for last_wash/never_washed (TRU-78)
The i18n:v2:source-check step in the Quality-i18n CI job was failing
because the new pos.last_wash and pos.never_washed leaves added in
f22ca71 were not yet reflected in the generated runtime bundles.

Running 'npm run i18n:v2:compile' regenerates global-v2.json and the
five locale bundles (da, de, en, no, sv) to expose the new keys.
No source phrases changed in this commit.
2026-08-17 14:29:35 +00:00
openclaw bugfix f22ca71558 feat(pos): show 'last washed' timestamp on license plate scan (TRU-78)
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
2026-08-17 14:20:12 +00:00
26 changed files with 630 additions and 465 deletions
+1 -1
View File
@@ -38,7 +38,7 @@
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
@@ -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}`"
>
<div
v-if="getCustomerDetailItems(scan).length > 0"
v-if="getCustomerDetailItems(scan).length > 0 || getLastWashDetailItem(scan)"
class="pos-scan-details__grid pos-scan-details__grid--customer"
>
<div
v-for="item in getCustomerDetailItems(scan)"
v-for="item in [...getCustomerDetailItems(scan), getLastWashDetailItem(scan)].filter(Boolean)"
:key="item.label"
class="pos-scan-detail-field"
:data-testid="`pos-recent-scan-detail-${scan.id}-${item.testKey || item.label}`"
>
<span class="pos-scan-detail-field__label">{{ item.label }}</span>
<span class="pos-scan-detail-field__value">{{ item.value }}</span>
</div>
</div>
<p
v-if="!getLastWashDetailItem(scan) && scan.last_wash == null"
class="pos-scan-details__state"
:data-testid="`pos-recent-scan-never-washed-${scan.id}`"
>
<i class="fas fa-info-circle" aria-hidden="true"></i>
<span>{{ getLastWashEmptyStateText() }}</span>
</p>
<div v-if="getPlateDetailState(scan.plate).status === 'loading'" class="pos-scan-details__state">
<i class="fas fa-spinner fa-spin"></i>
<span>{{ $t('admin.pos.recent_scan_details_loading') }}</span>
@@ -60,16 +60,6 @@ const menu_items = computed(() => [
'user'
]
},
{
name: t('user_menu.my_permissions'),
route: '/user/permissions',
icon: 'fas fa-user-shield',
children: [],
permissions: [
'user',
'SUBUSERS_LIST'
]
},
]);
</script>
+1
View File
@@ -5448,6 +5448,7 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samme'} @:{'words.generated.ydelse'}, @:{'words.generated.tilvalg'} @:{'words.generated.og'} @:{'words.generated.ekstra'} @:{'words.generated.varer'} @:{'words.generated.som'} @:{'words.generated.sidste'} @:{'words.generated.vask'}",
"manual_entry": "Manuell @:{'words.generated.registrering'}",
"never_washed": "Aldrig vasket",
"new_order": "@.capitalize:{'words.generated.ny'} order",
"new_sale": "@.capitalize:{'words.generated.nyt'} @:{'words.generated.salg'}",
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bookinger'} @:{'words.generated.fundet'}",
+1
View File
@@ -5558,6 +5558,7 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.gleiche'} @:{'words.generated.leistung'}, @:{'words.generated.zusatzoptionen'} @:{'words.generated.und'} @:{'words.generated.zusatzartikel'} @:{'words.generated.wie'} @:{'words.generated.bei'} @:{'words.generated.der'} @:{'words.generated.letzten'} @:{'words.generated.wasche'}",
"manual_entry": "@.capitalize:{'words.generated.manuell'} registrering",
"never_washed": "Noch nie gewaschen",
"new_order": "@.capitalize:{'words.generated.neuer'} @:{'words.generated.auftrag'}",
"new_sale": "Nytt salg",
"no_bookings": "@.capitalize:{'words.generated.keine'} @:{'words.generated.buchungen'} @:{'words.generated.gefunden'}",
+1
View File
@@ -5279,6 +5279,7 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.same'} @:{'words.generated.service'}, @:{'words.generated.add'}-@:{'words.generated.ons'}, @:{'words.generated.and'} @:{'words.generated.extra'} @:{'words.generated.items'} @:{'words.generated.as'} @:{'words.replication.article.host_mention'} @:{'words.generated.previous'} @:{'words.generated.wash'}",
"manual_entry": "@:{'words.generated.manual'} @:{'words.generated.registration'}",
"never_washed": "Never washed",
"new_order": "@.capitalize:{'words.generated.new'} @:{'words.generated.order'}",
"new_sale": "@.capitalize:{'words.generated.new'} sale",
"no_bookings": "@.capitalize:{'words.generated.no'} @:{'words.generated.orders'} @:{'words.generated.found'}",
+2
View File
@@ -4852,6 +4852,8 @@
"last_wash_matches_current_selection": "@:{'templates.generated.compat.pos.last_wash_matches_current_selection'}",
"license_plate": "@:{'templates.generated.compat.tables.common.registration_number'}",
"manual_entry": "@:{'templates.generated.compat.pos.manual_entry'}",
"last_wash": "@:{'templates.generated.compat.global.last_wash'}",
"never_washed": "@:{'templates.generated.compat.pos.never_washed'}",
"new_order": "@:{'templates.generated.compat.pos.new_order'}",
"new_sale": "@:{'templates.generated.compat.pos.new_sale'}",
"no_bookings": "@:{'templates.generated.compat.pos.no_bookings'}",
+1
View File
@@ -5561,6 +5561,7 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samme'} @:{'words.generated.tjeneste'}, @:{'words.generated.tillegg'} @:{'words.generated.og'} @:{'words.generated.ekstra'} @:{'words.generated.varer'} @:{'words.generated.som'} @:{'words.generated.forrige'} @:{'words.generated.vask'}",
"manual_entry": "Manuell @:{'words.generated.registrering'}",
"never_washed": "Aldri vasket",
"new_order": "@.capitalize:{'words.generated.bestillingen'}",
"new_sale": "@.capitalize:{'words.generated.nytt'} @:{'words.generated.salg'}",
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bestillinger'} @:{'words.generated.funnet'}",
+1
View File
@@ -5611,6 +5611,7 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samma'} @:{'words.generated.tjanst'}, @:{'words.generated.tillval'} @:{'words.generated.och'} @:{'words.generated.extra'} @:{'words.generated.artiklar'} @:{'words.generated.som'} @:{'words.generated.senaste'} @:{'words.generated.tvatten'}",
"manual_entry": "Manuell @:{'words.generated.registrering'}",
"never_washed": "Aldrig tvättad",
"new_order": "@.capitalize:{'words.generated.ny'} @:{'words.generated.order'}",
"new_sale": "@.capitalize:{'words.generated.nytt'} salg",
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bestillinger'} @:{'words.generated.funnet'}",
@@ -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'}",
@@ -53,30 +53,6 @@
"menu": {
"statistics": "@:{'phrases.compat.global.statistics'}"
},
"permissions": {
"card_chauffor_label": "Chauffør (standard)",
"card_chauffor_description": "Kan bruge døgnvask, oprette bookinger, se køretøjer og ordrer.",
"card_booking_label": "Bookingkoodinator",
"card_booking_description": "Kan oprette og redigere bookinger samt se køretøjer og ordrer.",
"card_intro": "Vælg den rolle, som nye chauffører som standard skal have, når de oprettes via QR-kode.",
"groups": {
"vehicles": "Køretøjer",
"selfserve": "Døgnvask",
"bookings": "Bookinger",
"orders": "Ordrer",
"driver_management": "Chaufførstyring"
},
"loading": "Henter tilladelser...",
"save": "Gem standardrolle",
"saved": "Standardrollen er gemt.",
"save_error": "Standardrollen kunne ikke gemmes.",
"selected_template": "Valgt rolle: {label}",
"subtitle": "Vælg standardtilladelser for nye chauffører.",
"toggle_booking_coordinator": "Bookingkoodinator",
"toggle_chauffor": "Chauffør",
"toggle_description": "De 5 kategorier nedenfor viser, hvad rollen giver adgang til.",
"title": "Mine tilladelser"
},
"orders": {
"single_subtitle": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.detaljer'} @:{'terms.glossary.om'} transaktionen",
"subtitle": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.dine'} @:{'terms.glossary.vaske'}",
@@ -3,7 +3,6 @@
"user_menu": {
"my_bookings": "@:{'terms.glossary.mine'} @:{'terms.glossary.bookinger'}",
"my_invoices": "@:{'terms.glossary.mine'} @:{'terms.glossary.fakturaer'}",
"my_permissions": "@:{'terms.glossary.mine'} @:{'terms.glossary.tilladelser'}",
"my_vehicles": "@:{'terms.glossary.mine'} @:{'terms.glossary.køretøjer'}",
"my_washes": "@:{'terms.glossary.mine'} @:{'terms.glossary.vaske'}"
}
@@ -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'}",
@@ -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'}",
@@ -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'}",
@@ -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'}",
@@ -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'}",
-7
View File
@@ -146,7 +146,6 @@ const DepartmentTimeBookingsTypes = lazyView('@/views/dashboards/departmentDashb
const DepartmentModulesSetup = lazyView('@/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue');
const Vehicle = lazyView('@/views/dashboards/superUserDashboard/vehicle/Vehicle.vue');
const MyInvoices = lazyView('@/views/dashboards/userDashboard/invoices/MyInvoices.vue');
const MyPermissions = lazyView('@/views/dashboards/userDashboard/permissions/MyPermissions.vue');
const MicrosoftCallbackToken = lazyView('@/views/callback/microsoft/MicrosoftCallbackToken.vue');
const ConfigurationEntra = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEntra.vue');
const DepartmentTimeBookingsNew = lazyView('@/views/dashboards/departmentDashboard/modules/time-bookings/book/DepartmentTimeBookingsNew.vue');
@@ -370,12 +369,6 @@ export const router = createRouter({
component: MyInvoices,
meta: { middleware: authMiddleware }
},
{
name: 'mypermissions',
path: '/user/permissions',
component: MyPermissions,
meta: { middleware: authMiddleware }
},
{
name: 'myorder',
path: '/user/orders/:orderId',
@@ -1264,19 +1264,14 @@ const createPathEditorQuestion = async () => {
await loadPathOutcomes({ force: true });
};
const pathEditorProgramButtonOptions = computed(() => {
if (!pathEditorMachineIsAvailable.value) {
return [{ id: 0, value: "0", label: "0 = OFF" }];
}
return [
{ id: 0, value: "0", label: "0 = OFF" },
...Array.from({ length: 12 }, (_, index) => ({
id: index + 1,
value: String(index + 1),
label: `Wheel position #${index + 1}`,
})),
];
});
const pathEditorProgramButtonOptions = computed(() => [
{ id: 0, value: "0", label: "0 = OFF" },
...Array.from({ length: 12 }, (_, index) => ({
id: index + 1,
value: String(index + 1),
label: `Wheel position #${index + 1}`,
})),
]);
@@ -1397,56 +1392,16 @@ const pathVerificationSelectedCaseHasRun = computed(() =>
)
);
// TRU-91: Filter path editor machine options to only show enabled/available
// machine programs for the current scope. A machine is considered "available"
// when the current lane has a machine_type_id that resolves to a non-deleted
// selfserve_machine_types row in the lookups.
const pathEditorCurrentLaneRow = computed(() => {
const laneId = parseIntOrZero(selectedSimulatorLaneId.value);
if (laneId <= 0) {
return null;
}
return laneRowById(laneId);
});
const pathEditorCurrentMachineType = computed(() => {
const lane = pathEditorCurrentLaneRow.value;
if (!lane) {
return null;
}
const machineTypeId = parseNullableInt(lane.machine_type_id);
if (!machineTypeId) {
return null;
}
const machineTypeRow = lookupRowById("machine_types", machineTypeId);
if (!machineTypeRow) {
return null;
}
if (
machineTypeRow.deleted_at !== null &&
machineTypeRow.deleted_at !== undefined &&
machineTypeRow.deleted_at !== ""
) {
return null;
}
return machineTypeRow;
});
const pathEditorMachineIsAvailable = computed(() => Boolean(pathEditorCurrentMachineType.value));
const pathEditorMachineButtonOptions = computed(() => {
if (!pathEditorMachineIsAvailable.value) {
return [];
}
return Array.from({ length: 12 }, (_, index) => {
const pathEditorMachineButtonOptions = computed(() =>
Array.from({ length: 12 }, (_, index) => {
const option = taskButtonOptions.find((entry) => entry.id === index);
return {
id: index,
label: option?.description || `Machine button ${index}`,
description: option?.name || `Program ${index + 1}`,
};
});
});
})
);
const pathEditorTaskTitleKeySlug = (key) => String(key || "").replace(/[^a-zA-Z0-9_-]/g, "-");
@@ -0,0 +1,439 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
type ProductAggregate = {
product_id?: number | string;
product_name: string;
quantity: number;
total_amount?: number | string | null;
};
type AggregatesPayload = {
customer_number?: number | string | null;
date_from?: string | null;
date_to?: string | null;
primary_products: ProductAggregate[];
addons: ProductAggregate[];
};
const props = withDefaults(
defineProps<{
customer: any;
dateFrom?: string | null;
dateTo?: string | null;
useMockData?: boolean;
}>(),
{
dateFrom: null,
dateTo: null,
useMockData: false,
}
);
const { t, locale } = useI18n();
const AGGREGATES_ENDPOINT = "/superuser/invoicing/period/customer-aggregates";
const translate = (key: string, fallback: string, params: Record<string, any> = {}) => {
const translated = t(key, params);
return translated === key ? fallback : translated;
};
const localeValue = () => (typeof locale === "string" ? locale : locale.value);
const aggregates = ref<AggregatesPayload | null>(null);
const isLoading = ref(false);
const errorMessage = ref<string | null>(null);
let pendingRequest = 0;
const buildMockAggregates = (): AggregatesPayload => {
const primaryProducts: ProductAggregate[] = [
{ product_id: "bilvask", product_name: "Bilvask", quantity: 5, total_amount: 750 },
{ product_id: "storvask", product_name: "Storvask", quantity: 2, total_amount: 480 },
];
const addons: ProductAggregate[] = [
{ product_id: "traekker", product_name: "Trækker", quantity: 1, total_amount: 25 },
{ product_id: "spot_free", product_name: "Spot Free", quantity: 2, total_amount: 60 },
];
return {
customer_number: props.customer?.customer_number ?? null,
date_from: props.dateFrom ?? null,
date_to: props.dateTo ?? null,
primary_products: primaryProducts,
addons,
};
};
const fetchAggregates = async () => {
const customerNumber = Number(props.customer?.customer_number || 0);
if (customerNumber < 1) {
aggregates.value = null;
return;
}
const requestId = ++pendingRequest;
isLoading.value = true;
errorMessage.value = null;
try {
if (props.useMockData) {
await new Promise((resolve) => setTimeout(resolve, 150));
if (requestId !== pendingRequest) {
return;
}
aggregates.value = buildMockAggregates();
return;
}
const params: Record<string, string> = { customer_number: String(customerNumber) };
if (props.dateFrom) {
params.date_from = props.dateFrom;
}
if (props.dateTo) {
params.date_to = props.dateTo;
}
const response: any = await SessionUser.request(AGGREGATES_ENDPOINT, "GET", params);
if (requestId !== pendingRequest) {
return;
}
const data = response?.data?.data ?? response?.data ?? null;
if (data && Array.isArray(data.primary_products) && Array.isArray(data.addons)) {
aggregates.value = data as AggregatesPayload;
} else {
aggregates.value = { primary_products: [], addons: [] };
}
} catch (error: any) {
if (requestId !== pendingRequest) {
return;
}
const status = Number(error?.response?.status || 0);
if (status === 404 || status === 501) {
aggregates.value = buildMockAggregates();
errorMessage.value = translate(
"invoicing_period.aggregates.using_mock_notice",
"Backend endpoint not available — showing sample aggregates."
);
} else {
errorMessage.value =
error?.response?.data?.message ||
error?.message ||
translate("invoicing_period.aggregates.error", "Kunne ikke hente produkt- og tilvalgsoversigt.");
aggregates.value = null;
}
} finally {
if (requestId === pendingRequest) {
isLoading.value = false;
}
}
};
const refresh = () => {
fetchAggregates();
};
defineExpose({ refresh });
watch(
() => [
Number(props.customer?.customer_number || 0),
String(props.dateFrom || ""),
String(props.dateTo || ""),
Boolean(props.useMockData),
],
() => {
fetchAggregates();
},
{ immediate: true }
);
onBeforeUnmount(() => {
pendingRequest += 1;
});
const hasPrimaryProducts = computed(
() => Array.isArray(aggregates.value?.primary_products) && (aggregates.value?.primary_products?.length ?? 0) > 0
);
const hasAddons = computed(
() => Array.isArray(aggregates.value?.addons) && (aggregates.value?.addons?.length ?? 0) > 0
);
const hasAnyAggregates = computed(() => hasPrimaryProducts.value || hasAddons.value);
const sortedPrimaryProducts = computed(() => {
const list = Array.isArray(aggregates.value?.primary_products) ? [...aggregates.value!.primary_products] : [];
return list.sort((a, b) => Number(b.quantity || 0) - Number(a.quantity || 0));
});
const sortedAddons = computed(() => {
const list = Array.isArray(aggregates.value?.addons) ? [...aggregates.value!.addons] : [];
return list.sort((a, b) => Number(b.quantity || 0) - Number(a.quantity || 0));
});
const formatQuantity = (value: any) => {
const parsed = Number.parseInt(String(value ?? "0"), 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
};
const formatCurrency = (value: any) => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return "";
}
return SessionUser.functions.currency.toLocal(numeric);
};
const heading = translate("invoicing_period.aggregates.heading", "Produktoversigt");
const primaryHeading = translate("invoicing_period.aggregates.primary_products", "Primære produkter");
const addonsHeading = translate("invoicing_period.aggregates.addons", "Tilvalg");
const emptyLabel = translate("invoicing_period.aggregates.empty", "Ingen produkter");
const loadingLabel = translate("invoicing_period.aggregates.loading", "Indlæser produktoversigt…");
const totalLabel = translate("invoicing_period.aggregates.total", "i alt");
const customerLabel = translate("invoicing_period.aggregates.for_customer", "for {name}", {
name: String(props.customer?.customer_name || "").trim() || `#${props.customer?.customer_number ?? ""}`,
});
</script>
<template>
<section
class="product-aggregates-card"
data-testid="invoicing-period-product-aggregates"
:aria-label="heading"
>
<header class="product-aggregates-card__header">
<div>
<h3 class="product-aggregates-card__title">{{ heading }}</h3>
<p class="product-aggregates-card__subtitle">{{ customerLabel }}</p>
</div>
<button
type="button"
class="button is-small is-light product-aggregates-card__refresh"
:aria-label="translate('invoicing_period.aggregates.refresh', 'Opdater produktoversigt')"
:disabled="isLoading"
data-testid="invoicing-period-product-aggregates-refresh"
@click="refresh"
>
<span class="icon is-small">
<i class="fas fa-sync-alt" :class="{ 'fa-spin': isLoading }"></i>
</span>
</button>
</header>
<p
v-if="errorMessage"
class="product-aggregates-card__notice"
data-testid="invoicing-period-product-aggregates-notice"
>
{{ errorMessage }}
</p>
<div v-if="isLoading && !hasAnyAggregates" class="product-aggregates-card__loading" role="status" data-testid="invoicing-period-product-aggregates-loading">
<span class="icon is-small"><i class="fas fa-circle-notch fa-spin"></i></span>
<span>{{ loadingLabel }}</span>
</div>
<div v-else-if="!hasAnyAggregates" class="product-aggregates-card__empty" data-testid="invoicing-period-product-aggregates-empty">
<span class="icon is-small has-text-grey"><i class="fas fa-box-open"></i></span>
<span>{{ emptyLabel }}</span>
</div>
<div v-else class="product-aggregates-card__sections">
<div class="product-aggregates-card__section" data-testid="invoicing-period-product-aggregates-primary">
<h4 class="product-aggregates-card__section-title">
<span class="icon is-small"><i class="fas fa-soap"></i></span>
<span>{{ primaryHeading }}</span>
<span class="tag is-light is-rounded">{{ sortedPrimaryProducts.length }}</span>
</h4>
<ul v-if="hasPrimaryProducts" class="product-aggregates-card__list">
<li
v-for="item in sortedPrimaryProducts"
:key="`primary-${item.product_id ?? item.product_name}`"
class="product-aggregates-card__item"
:data-testid="`invoicing-period-product-aggregates-primary-item-${item.product_id ?? item.product_name}`"
>
<span class="product-aggregates-card__item-name">{{ item.product_name }}</span>
<span class="tag is-info is-light product-aggregates-card__qty">{{ formatQuantity(item.quantity) }}</span>
<span
v-if="formatCurrency(item.total_amount) !== ''"
class="product-aggregates-card__amount has-text-grey"
>
{{ formatCurrency(item.total_amount) }}
</span>
</li>
</ul>
<p v-else class="product-aggregates-card__section-empty">{{ emptyLabel }}</p>
</div>
<div class="product-aggregates-card__section" data-testid="invoicing-period-product-aggregates-addons">
<h4 class="product-aggregates-card__section-title">
<span class="icon is-small"><i class="fas fa-puzzle-piece"></i></span>
<span>{{ addonsHeading }}</span>
<span class="tag is-light is-rounded">{{ sortedAddons.length }}</span>
</h4>
<ul v-if="hasAddons" class="product-aggregates-card__list">
<li
v-for="item in sortedAddons"
:key="`addon-${item.product_id ?? item.product_name}`"
class="product-aggregates-card__item"
:data-testid="`invoicing-period-product-aggregates-addon-item-${item.product_id ?? item.product_name}`"
>
<span class="product-aggregates-card__item-name">{{ item.product_name }}</span>
<span class="tag is-warning is-light product-aggregates-card__qty">{{ formatQuantity(item.quantity) }}</span>
<span
v-if="formatCurrency(item.total_amount) !== ''"
class="product-aggregates-card__amount has-text-grey"
>
{{ formatCurrency(item.total_amount) }}
</span>
</li>
</ul>
<p v-else class="product-aggregates-card__section-empty">{{ emptyLabel }}</p>
</div>
</div>
<p v-if="hasAnyAggregates" class="product-aggregates-card__hint has-text-grey">
<span class="icon is-small"><i class="fas fa-info-circle"></i></span>
<span>{{ totalLabel }}: {{ sortedPrimaryProducts.length + sortedAddons.length }}</span>
</p>
</section>
</template>
<style scoped>
.product-aggregates-card {
background: #f7f9fb;
border: 1px solid #dfe3e8;
border-radius: 8px;
display: grid;
gap: 0.6rem;
margin-top: 0.75rem;
padding: 0.85rem 1rem;
}
.product-aggregates-card__header {
align-items: start;
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.product-aggregates-card__title {
color: #263142;
font-size: 0.95rem;
font-weight: 700;
margin: 0;
}
.product-aggregates-card__subtitle {
color: #6b7280;
font-size: 0.75rem;
margin: 0.1rem 0 0;
}
.product-aggregates-card__refresh {
flex: 0 0 auto;
}
.product-aggregates-card__notice {
background: #fff7e0;
border: 1px solid #f1d27b;
border-radius: 6px;
color: #7a5b00;
font-size: 0.78rem;
margin: 0;
padding: 0.35rem 0.6rem;
}
.product-aggregates-card__loading,
.product-aggregates-card__empty {
align-items: center;
color: #536174;
display: flex;
gap: 0.4rem;
padding: 0.35rem 0;
font-size: 0.85rem;
}
.product-aggregates-card__sections {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.product-aggregates-card__section {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 0.55rem 0.7rem;
}
.product-aggregates-card__section-title {
align-items: center;
color: #263142;
display: flex;
font-size: 0.82rem;
font-weight: 700;
gap: 0.35rem;
margin: 0 0 0.4rem;
}
.product-aggregates-card__section-title .tag {
font-size: 0.7rem;
margin-left: auto;
}
.product-aggregates-card__list {
display: grid;
gap: 0.25rem;
list-style: none;
margin: 0;
padding: 0;
}
.product-aggregates-card__item {
align-items: center;
display: grid;
font-size: 0.82rem;
gap: 0.4rem;
grid-template-columns: minmax(0, 1fr) auto auto;
}
.product-aggregates-card__item-name {
color: #1f2937;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-aggregates-card__qty {
border-radius: 999px;
font-weight: 700;
min-width: 1.8rem;
text-align: center;
}
.product-aggregates-card__amount {
font-size: 0.75rem;
}
.product-aggregates-card__section-empty {
color: #6b7280;
font-size: 0.8rem;
margin: 0;
}
.product-aggregates-card__hint {
align-items: center;
display: flex;
font-size: 0.72rem;
gap: 0.3rem;
margin: 0;
}
@media screen and (max-width: 768px) {
.product-aggregates-card__sections {
grid-template-columns: 1fr;
}
}
</style>
@@ -109,6 +109,26 @@ const REVIEW_WORKSPACE_TRANSLATORS = {
composer.t("invoicing_period.review_workspace.states.refreshing", params),
"states.retained_error": (composer, params) =>
composer.t("invoicing_period.review_workspace.states.retained_error", params),
"aggregates.heading": (composer, params) =>
composer.t("invoicing_period.review_workspace.aggregates.heading", params),
"aggregates.primary_products": (composer, params) =>
composer.t("invoicing_period.review_workspace.aggregates.primary_products", params),
"aggregates.addons": (composer, params) =>
composer.t("invoicing_period.review_workspace.aggregates.addons", params),
"aggregates.empty": (composer, params) =>
composer.t("invoicing_period.review_workspace.aggregates.empty", params),
"aggregates.loading": (composer, params) =>
composer.t("invoicing_period.review_workspace.aggregates.loading", params),
"aggregates.error": (composer, params) =>
composer.t("invoicing_period.review_workspace.aggregates.error", params),
"aggregates.refresh": (composer, params) =>
composer.t("invoicing_period.review_workspace.aggregates.refresh", params),
"aggregates.total": (composer, params) =>
composer.t("invoicing_period.review_workspace.aggregates.total", params),
"aggregates.for_customer": (composer, params) =>
composer.t("invoicing_period.review_workspace.aggregates.for_customer", params),
"aggregates.using_mock_notice": (composer, params) =>
composer.t("invoicing_period.review_workspace.aggregates.using_mock_notice", params),
"toolbar.all_departments": (composer, params) =>
composer.t("invoicing_period.review_workspace.toolbar.all_departments", params),
"toolbar.all_invoice_states": (composer, params) =>
@@ -23,6 +23,7 @@ import InvoicingBillingPeriodFilters from "@/views/dashboards/superUserDashboard
import InvoicingBillingPeriodCustomerAttributes from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue";
import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue";
import InvoicingPeriodObjectTree from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue";
import ProductAggregatesCard from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/ProductAggregatesCard.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import {
buildMultiMonthInvoiceContext,
@@ -977,6 +978,10 @@ const getTransactionQueryParameters = () => {
show_fixed_pricing: true,
};
};
// TRU-185: Backend product aggregate endpoint (TRU-182) is not yet merged.
// Set to true once `/superuser/invoicing/period/customer-aggregates` is live.
const isProductAggregatesEndpointAvailable = false;
</script>
<template>
@@ -1608,6 +1613,12 @@ const getTransactionQueryParameters = () => {
<span v-if="Number(reason.count || 0) > 1" class="tag is-light is-rounded">{{ reason.count }}</span>
</li>
</ul>
<ProductAggregatesCard
:customer="selectedCustomer"
:date-from="dates.computed.formattedStartDate.value"
:date-to="dates.computed.formattedEndDate.value"
:use-mock-data="!isProductAggregatesEndpointAvailable"
/>
<div
v-if="selectedCustomer.expanded"
class="period-review-detail__tree"
@@ -1,330 +0,0 @@
<script setup>
import { onMounted, ref, computed } from "vue";
import Swal from "sweetalert2";
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
/**
* Permission groups (5 toggles) that mirror the existing 5 capability groups
* in modules/subusers/classes/subuser_permission_templates_service.php.
* They cover the 5 main areas a customer wants to grant their drivers:
* - vehicles (Køretøjer)
* - selfserve (Døgnvask)
* - bookings (Bookinger)
* - orders (Ordrer)
* - driver_management (Chaufførstyring)
*/
const PERMISSION_GROUPS = [
{ key: "vehicles", capabilities: ["VEHICLES_LIST", "VEHICLES_EDIT", "VEHICLES_DELETE", "VEHICLES_ADD"] },
{ key: "selfserve", capabilities: ["SELFSERVE_LIST", "SELFSERVE_EDIT", "SELFSERVE_DELETE", "SELFSERVE_ADD"] },
{ key: "bookings", capabilities: ["BOOKINGS_LIST", "BOOKINGS_EDIT", "BOOKINGS_DELETE", "BOOKINGS_ADD"] },
{ key: "orders", capabilities: ["ORDERS_LIST", "ORDERS_EDIT"] },
{ key: "driver_management", capabilities: ["SUBUSERS_LIST", "SUBUSERS_EDIT", "SUBUSERS_DELETE", "SUBUSERS_ADD"] },
];
/**
* The two primary roles (templates) shown to the customer in the kunde portal
* "tilladelser" tab. The customer picks one as the default for new drivers
* created through the QR flow (see TRU-88 / TRU-90).
*
* - chauffeur (driver) : standard self-serve + bookings
* - booking_coordinator : extended bookings/orders (no self-serve)
*/
const PRIMARY_ROLE_KEYS = ["driver", "booking_coordinator"];
const isLoading = ref(true);
const isSaving = ref(false);
const accessModel = ref({ templates: [], groups: [] });
const selectedTemplateKey = ref(null);
const selectedTemplate = computed(() => {
if (!selectedTemplateKey.value) {
return null;
}
return accessModel.value.templates.find((template) => template.key === selectedTemplateKey.value) || null;
});
const isGroupActive = (groupKey) => {
if (!selectedTemplate.value) {
return false;
}
const groups = selectedTemplate.value.permission_groups || [];
return groups.some((group) => group.key === groupKey);
};
const isGroupFullyActive = (groupKey) => {
if (!selectedTemplate.value) {
return false;
}
const groups = selectedTemplate.value.permission_groups || [];
const group = groups.find((g) => g.key === groupKey);
if (!group) {
return false;
}
// "Fully active" means all 4 capabilities in the group are granted
const groupDefinition = PERMISSION_GROUPS.find((g) => g.key === groupKey);
if (!groupDefinition) {
return false;
}
return groupDefinition.capabilities.every((capability) => group.capabilities.includes(capability));
};
const isGroupPartiallyActive = (groupKey) => {
if (!selectedTemplate.value) {
return false;
}
return isGroupActive(groupKey) && !isGroupFullyActive(groupKey);
};
const isPrimaryRole = (key) => PRIMARY_ROLE_KEYS.includes(key);
const loadAccessModel = async () => {
isLoading.value = true;
try {
const model = await SessionUser.objects.subuser_grants.functions.getPermissionTemplates();
const payload = model?.data?.data || model?.data || model;
accessModel.value = {
templates: Array.isArray(payload?.templates) ? payload.templates : [],
groups: Array.isArray(payload?.groups) ? payload.groups : [],
};
// Read the customer's stored default template via SessionUser / API
const stored = await SessionUser.request("/customer/default-driver-template", "GET")
.then((response) => response?.data?.data?.template_key || response?.data?.template_key || null)
.catch(() => null);
if (stored && accessModel.value.templates.some((template) => template.key === stored)) {
selectedTemplateKey.value = stored;
} else {
const chauffeur = accessModel.value.templates.find((template) => template.key === "driver");
selectedTemplateKey.value = chauffeur?.key || accessModel.value.templates[0]?.key || null;
}
} catch (error) {
console.error("Failed to load permission templates:", error);
accessModel.value = { templates: [], groups: [] };
await Swal.fire({
title: t("common.error"),
text: error?.message || t("user_dashboard.permissions.save_error"),
icon: "error",
});
} finally {
isLoading.value = false;
}
};
const onSelectTemplate = (key) => {
if (!accessModel.value.templates.some((template) => template.key === key)) {
return;
}
selectedTemplateKey.value = key;
};
const onSaveDefault = async () => {
if (!selectedTemplateKey.value || isSaving.value) {
return;
}
isSaving.value = true;
try {
await SessionUser.request("/customer/default-driver-template", "PUT", {
template_key: selectedTemplateKey.value,
});
await Swal.fire({
title: t("user_dashboard.permissions.saved"),
icon: "success",
timer: 1500,
showConfirmButton: false,
});
} catch (error) {
await Swal.fire({
title: t("common.error"),
text: SessionUser.functions.parseErrorMessage(error) || t("user_dashboard.permissions.save_error"),
icon: "error",
});
} finally {
isSaving.value = false;
}
};
onMounted(loadAccessModel);
</script>
<template>
<UserDashboardPageWrapper
:title="$t('user_dashboard.permissions.title')"
:subtitle="$t('user_dashboard.permissions.subtitle')"
>
<div v-if="isLoading" class="notification is-light">
{{ $t("user_dashboard.permissions.loading") }}
</div>
<template v-else>
<div class="permissions-tab" data-testid="user-permissions-tab">
<p class="block has-text-grey">
{{ $t("user_dashboard.permissions.card_intro") }}
</p>
<div class="columns is-multiline" data-testid="user-permissions-roles">
<div
v-for="template in accessModel.templates.filter((tpl) => isPrimaryRole(tpl.key))"
:key="template.key"
class="column is-half"
>
<button
type="button"
class="permission-role-card"
:class="{ 'permission-role-card--selected': selectedTemplateKey === template.key }"
:data-testid="`user-permission-role-${template.key}`"
@click="onSelectTemplate(template.key)"
>
<span class="permission-role-card__title">
{{
template.key === "driver"
? $t("user_dashboard.permissions.card_chauffor_label")
: $t("user_dashboard.permissions.card_booking_label")
}}
</span>
<span class="permission-role-card__description">
{{
template.key === "driver"
? $t("user_dashboard.permissions.card_chauffor_description")
: $t("user_dashboard.permissions.card_booking_description")
}}
</span>
<span
v-if="selectedTemplateKey === template.key"
class="tag is-success is-light mt-2"
:data-testid="`user-permission-role-selected-${template.key}`"
>
{{ $t("user_dashboard.permissions.selected_template", { label: template.label }) }}
</span>
</button>
</div>
</div>
<div class="permissions-tab__toggles" data-testid="user-permissions-toggles">
<h3 class="title is-6 mb-2">
<span class="icon"><i class="fas fa-sliders-h"></i></span>
<span>{{ $t("user_dashboard.permissions.toggle_description") }}</span>
</h3>
<div class="columns is-multiline">
<div
v-for="group in PERMISSION_GROUPS"
:key="group.key"
class="column is-half"
>
<div
class="permission-toggle box"
:class="{
'permission-toggle--active': isGroupFullyActive(group.key),
'permission-toggle--partial': isGroupPartiallyActive(group.key),
}"
:data-testid="`user-permission-toggle-${group.key}`"
>
<div class="level is-mobile mb-0">
<div class="level-left">
<div class="level-item">
<div>
<p class="permission-toggle__label">
{{ $t(`user_dashboard.permissions.groups.${group.key}`) }}
</p>
<p class="help has-text-grey">
{{ group.capabilities.length }} rettigheder
</p>
</div>
</div>
</div>
<div class="level-right">
<div class="level-item">
<b-switch
:model-value="isGroupFullyActive(group.key) || isGroupPartiallyActive(group.key)"
disabled
size="is-small"
type="is-success"
:data-testid="`user-permission-toggle-switch-${group.key}`"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="permissions-tab__actions mt-4">
<button
class="button is-link"
:class="{ 'is-loading': isSaving }"
:disabled="!selectedTemplateKey || isSaving"
data-testid="user-permissions-save"
@click="onSaveDefault"
>
<span class="icon"><i class="fas fa-save"></i></span>
<span>{{ $t("user_dashboard.permissions.save") }}</span>
</button>
</div>
</div>
</template>
</UserDashboardPageWrapper>
</template>
<style scoped>
.permissions-tab .permission-role-card {
align-items: flex-start;
background: #fff;
border: 1px solid #dbdbdb;
border-radius: 6px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 150px;
padding: 1.25rem;
text-align: left;
width: 100%;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.permissions-tab .permission-role-card:hover,
.permissions-tab .permission-role-card--selected {
border-color: #3273dc;
box-shadow: 0 0 0 1px #3273dc;
}
.permissions-tab .permission-role-card__title {
color: #1f2933;
font-weight: 700;
font-size: 1.1rem;
}
.permissions-tab .permission-role-card__description {
color: #4a4a4a;
font-size: 0.9rem;
line-height: 1.4;
}
.permissions-tab .permission-toggle {
border: 1px solid #e6ebf1;
border-radius: 6px;
padding: 0.85rem 1rem;
background: #fafbfc;
}
.permissions-tab .permission-toggle--active {
background: #f1faf3;
border-color: #48c78e;
}
.permissions-tab .permission-toggle--partial {
background: #fff8e1;
border-color: #ffe08a;
}
.permissions-tab .permission-toggle__label {
font-weight: 600;
color: #1f2933;
margin: 0;
}
</style>
@@ -161,10 +161,14 @@ import {
periodPaging,
resetPeriodPagingState,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js";
import { createTestI18n } from "./helpers/mountWithApp.js";
const viewI18n = createTestI18n();
const mountView = () =>
mount(InvoicingBillingPeriodViewAll, {
global: {
plugins: [viewI18n],
stubs: {
"b-tabs": {
template: "<div data-testid='b-tabs-stub'><slot /></div>",
@@ -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");
});
});
@@ -1,33 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const readSource = (relativePath) => readFileSync(join(process.cwd(), relativePath), "utf8");
describe("self-serve studio path editor machine filter (TRU-91)", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
it("defines a path editor machine-availability check tied to the current lane and machine type", () => {
expect(studioSource).toContain("pathEditorCurrentLaneRow");
expect(studioSource).toContain("pathEditorCurrentMachineType");
expect(studioSource).toContain("pathEditorMachineIsAvailable");
});
it("returns an empty machine button list when the machine is not available", () => {
expect(studioSource).toMatch(
/pathEditorMachineButtonOptions[\s\S]{0,400}if\s*\(\s*!pathEditorMachineIsAvailable\.value\s*\)\s*\{\s*return\s*\[\s*\]\s*;\s*\}/
);
});
it("limits program wheel options to the OFF position when the machine is not available", () => {
expect(studioSource).toMatch(
/pathEditorProgramButtonOptions[\s\S]{0,600}if\s*\(\s*!pathEditorMachineIsAvailable\.value\s*\)[\s\S]{0,200}return\s*\[\s*\{\s*id:\s*0,\s*value:\s*"0",\s*label:\s*"0 = OFF"\s*\}\s*\]/
);
});
it("considers a machine type deleted when its deleted_at is set", () => {
expect(studioSource).toMatch(/machineTypeRow\.deleted_at[\s\S]{0,80}return\s+null/);
});
});