Files
pleno-vue/src/views/dashboards/superUserDashboard/vehicle/displays/XLVaskUsageLog.vue
T
6b44835347 Surface XL vask accept/compare/link/deny actions and i18n labels (#263)
## Why

In the superuser fakturaer-periode selvvask view, XL vask rows were
missing usable controls. Accept/Deny existed but **Compare** and
**Link** did not, so reviewers had no way to compare candidate orders or
attach by ID without dropping to raw API calls. Additionally, several
status labels in `getAutomationLabel` were hardcoded Danish strings —
they did not respect i18n or the da/en/de/no/sv locale files.

A legacy stub in `XLVaskUsageLog.vue` (`<template v-if="usage.WashItems
&& 1 === 2">`) permanently disabled the per-row wash items display.

## What changed


`src/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue`:
- New **Compare** button — `<b-modal>` side-by-side price view using
existing `duplicates` + `doesObjectHaveExactMatch`. Disabled when no
duplicates. Test IDs `xlvask-compare-{id}` and `xlvask-compare-modal`.
- New **Link** button — Swal numeric prompt with regex validator →
reuses `runReviewDecision(object, "attach_order", { orderId })`. Test ID
`xlvask-automation-link-{id}`.
- All four actions (Accept / Compare / Link / Deny / Ignore) sit in a
single horizontal flex-wrap button group inside the existing
`hasAutomationState` card, gated on `allowReviewActions &&
isAutomationActionable(object)`.
- Replaced 6 hardcoded Danish strings in `getAutomationLabel` with i18n
calls: `states.suggested_*`, `states.auto_accepted_*`,
`states.accepted_*`.

`src/i18n/source/global/shared/invoicing_period/xlvask_autopilot.json`
(and the 5 locale overrides) — added:
- `actions.compare`, `actions.link`
- `actions.compare_modal_title`, `actions.compare_modal_close`
- `actions.link_prompt_title`, `actions.link_prompt_label`,
`actions.link_prompt_invalid`
- `states.suggested_create_order`, `states.suggested_attach_order`,
`states.auto_accepted_create`, `states.auto_accepted_attach`,
`states.accepted_create`, `states.accepted_attach`

Regenerated the i18n bundle (`src/i18n/generated/*-v2.json`).


`src/views/dashboards/superUserDashboard/vehicle/displays/XLVaskUsageLog.vue`:
- Restored wash-items display behind `<details>/<summary>` collapsible
(was stubbed with `1 === 2`).

## Verification

- `npx eslint` — clean.
- `npm run i18n:v2:check` — all 4 sub-checks green.

Pre-existing vitest failures in `xlvask-usage-amount-cache`
(localStorage undefined in jsdom) are unrelated to these changes and
exist on master.

## Risk

- Surface-only changes inside existing automation card; no new
endpoints, no new permissions, no data shape changes. Backwards
compatible.

🤖 Generated with [OpenClaw](https://openclaw.ai)

---------

Co-authored-by: XL Vask Subagent <agent@truckwash.dk>
Co-authored-by: Truck Wash Agent <agent@copenhagentruckwash.local>
2026-08-09 01:14:26 +02:00

850 lines
28 KiB
Vue

<script setup>
import { onMounted, ref, watch } from 'vue';
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import { createOrderItem } from "@/components/shop/OrdersItems.vue";
import Swal from "sweetalert2";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
reg: {
type: String,
default: null,
},
dateFrom: {
type: Date,
default: () => null,
},
});
/** Dynamic variables */
const usageLog = ref(null);
const usageLogLoading = ref(false);
const xlvask_vehicle_types = ref(null);
const product_options = ref(null);
const departments = ref(null);
const related_orders = ref(null);
const getUsageLog = async () => {
usageLogLoading.value = true;
usageLog.value = null;
SessionUser.superUser.modules.xlvask.functions.getUsageLog(
(props.dateFrom === null ? null : SessionUser.superUser.modules.xlvask.functions.convertDateTimeToISO(props.dateFrom)),
props.reg,
).then(
(response) => {
if (response.status === 200) {
usageLog.value = response.data.data;
} else {
console.error("XL-Vask: usage log returned non-OK status.", { status: response?.status, reg: props.reg });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.fetch_usage_log'),
});
}
}
).catch(
(error) => {
console.error("XL-Vask: failed to load usage log.", { reg: props.reg, error });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.fetch_usage_log'),
});
}
).finally(() => {
usageLogLoading.value = false;
});
};
const getVehicleTypes = async () => {
xlvask_vehicle_types.value = null;
SessionUser.superUser.modules.xlvask.functions.getVehicleTypes().then(
(response) => {
if (response.status === 200) {
xlvask_vehicle_types.value = response.data.data;
} else {
console.error("XL-Vask: vehicle types returned non-OK status.", { status: response?.status });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.fetch_vehicle_types'),
});
}
}
).catch(
(error) => {
console.error("XL-Vask: failed to load vehicle types.", { error });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.fetch_vehicle_types'),
});
}
);
};
const getProductOptions = async () => {
if (product_options.value === null) {
SessionUser.objects.product_options.get.all().then((result) => {
product_options.value = result;
});
}
}
const getDepartments = async () => {
SessionUser.objects.departments.get.all().then((result) => {
departments.value = result;
});
}
const getRelatedOrders = async () => {
related_orders.value = null;
SessionUser.superUser.modules.xlvask.functions.getRelatedOrders(listWashIds()).then(
(response) => {
if (response.status === 200) {
related_orders.value = response.data.data;
} else {
console.error("XL-Vask: related orders returned non-OK status.", { status: response?.status });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.fetch_related_orders'),
});
}
}
).catch(
(error) => {
console.error("XL-Vask: failed to load related orders.", { error });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.fetch_related_orders'),
});
}
);
};
// Watch for changes in the usage log and update the related orders
watch(usageLog, (newValue) => {
if (newValue) {
getRelatedOrders();
}
});
const listWashIds = () => {
let washIds = [];
if (usageLog.value) {
for (const usage of usageLog.value) {
washIds.push(usage.WashId);
}
}
return washIds;
}
const _example = {
"WashId": "892ae789-aeea-4bda-9374-cf931290aefd",
"CustomerId": "59440200",
"Customer": "DITOBUS EXCURSIONS A/S",
"VatNumber": "31171520",
"Location": "Hvidovre",
"Hall": "Hvidovre_1",
"HallId": "845d29a1-a7d2-4e3b-bbc3-2b13242d744a",
"StartTime": "2024-01-26T14:46:53.067",
"FinishTime": "2024-01-26T14:54:08.653",
"RegistrationNumber": "BJ22227",
"VehicleType": "Bus/autocamper, M",
"IdentificationType": "LPR",
"IdentificationId": "BJ22227",
"Info": "BJ22227",
"Updated": "",
"Prepaid": false,
"FinishStatus": 1,
"CustomerGuid": "21ba156a-b2d2-44be-8398-4b67d66003d6",
"VehicleId": "0584ef66-3deb-491e-9b82-0a28cfc20e9e",
"WashItems": [
{
"WashItemId": "399b25c1-5c03-4f25-b5e9-00731e9b94c4",
"ExternalProductId": null,
"ExternalProductName": null,
"OriginalProductName": "Ikke HT dysebom bag",
"Unit": "stk",
"UnitPrice": 0,
"Count": 1,
"Discount": 65,
"PriceExVat": 0,
"Vat": 0,
"PriceIncVat": 0
},
{
"WashItemId": "ef3ca812-bbe5-4cf3-916f-06fd17c04225",
"ExternalProductId": null,
"ExternalProductName": "Spot Free",
"OriginalProductName": "Skylning med RO",
"Unit": "stk",
"UnitPrice": 35,
"Count": 1,
"Discount": 65,
"PriceExVat": 35,
"Vat": 3.06,
"PriceIncVat": 15.31
},
{
"WashItemId": "5c4ec0d9-cffc-45eb-9213-406dbcb8c975",
"ExternalProductId": null,
"ExternalProductName": null,
"OriginalProductName": "2-børstevask",
"Unit": "stk",
"UnitPrice": 0,
"Count": 1,
"Discount": 65,
"PriceExVat": 0,
"Vat": 0,
"PriceIncVat": 0
},
{
"WashItemId": "f7bd7404-f5a3-4a59-8e85-53b284df3260",
"ExternalProductId": null,
"ExternalProductName": null,
"OriginalProductName": "Halleje",
"Unit": "min",
"UnitPrice": 0,
"Count": 1,
"Discount": 65,
"PriceExVat": 0,
"Vat": 0,
"PriceIncVat": 0
},
{
"WashItemId": "6dba6c24-8191-4c2b-913c-7366518fb41d",
"ExternalProductId": null,
"ExternalProductName": null,
"OriginalProductName": "Stor bil",
"Unit": "stk",
"UnitPrice": 559,
"Count": 1,
"Discount": 65,
"PriceExVat": 559,
"Vat": 48.91,
"PriceIncVat": 244.56
},
{
"WashItemId": "61d11d87-2ee4-4a4d-890b-a0870ae1a924",
"ExternalProductId": null,
"ExternalProductName": null,
"OriginalProductName": "HT sider",
"Unit": "stk",
"UnitPrice": 0,
"Count": 1,
"Discount": 65,
"PriceExVat": 0,
"Vat": 0,
"PriceIncVat": 0
},
{
"WashItemId": "ec6eb1c4-b58c-457f-8224-b674cf41dc29",
"ExternalProductId": null,
"ExternalProductName": null,
"OriginalProductName": "EU spejl",
"Unit": "stk",
"UnitPrice": 0,
"Count": 1,
"Discount": 65,
"PriceExVat": 0,
"Vat": 0,
"PriceIncVat": 0
},
{
"WashItemId": "54b4deec-be3e-4cb2-b68d-b9af9cff6fcf",
"ExternalProductId": null,
"ExternalProductName": null,
"OriginalProductName": "HT chassis",
"Unit": "stk",
"UnitPrice": 0,
"Count": 1,
"Discount": 65,
"PriceExVat": 0,
"Vat": 0,
"PriceIncVat": 0
}
]
}
const _exampleVehicleTypes = [
{
"id": 1,
"vehicleTypeId": "0f915576-587c-4494-bcce-388b3b3fe55a",
"product": 17,
"name": "Bus/autocamper, M",
"created_at": "2025-05-19 09:41:20",
"updated_at": "2025-05-19 09:41:20"
},
{
"id": 2,
"vehicleTypeId": "e2638c21-366d-4b7f-b0af-eb3634ae2c8c",
"product": 15,
"name": "Kassevogn/Varevogn, L",
"created_at": "2025-05-19 09:44:39",
"updated_at": "2025-05-19 09:44:39"
},
{
"id": 3,
"vehicleTypeId": "5e6cfa13-df14-4a11-8d3d-603a41d37f68",
"product": 17,
"name": "Bus/autocamper, L",
"created_at": "2025-05-19 10:12:54",
"updated_at": "2025-05-19 10:12:54"
}
];
onMounted(() => {
getVehicleTypes();
getUsageLog();
getProductOptions();
getDepartments();
});
const getProductName = (item) => {
if (item.ExternalProductName) {
return item.ExternalProductName;
} else if (item.OriginalProductName) {
return item.OriginalProductName;
} else {
return t('invoicing_period.xlvask_autopilot.labels.unknown_product_with_id', { id: item.WashItemId });
}
};
const formatDate = (value) => {
if (!value) return '';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
};
const getOrderFromUsageLogEntry = (usageLogEntry) => {
return {
customer_id: usageLogEntry.CustomerId,
reg_1: usageLogEntry.RegistrationNumber,
reg_2: "",
reg_3: "",
wash_id: usageLogEntry.WashId,
}
}
const getPrimaryServiceProductId = (usageLogEntry) => {
// Check if the vehicle type is in the list
const vehicleType = xlvask_vehicle_types.value.find(type => type.name === usageLogEntry.VehicleType);
if (vehicleType) {
// If the vehicle type is found, return the product ID
return vehicleType.product;
} else {
// If the vehicle type is not found, return null
return null;
}
}
const recognizedItems = {
"Undervognskylning": {
getProductId: (usageLogEntry) => {
let primaryProductId = getPrimaryServiceProductId(usageLogEntry);
// Get all the matching options where the product id is the same as the primary product id
let options = product_options.value.filter(option => option.product_id === primaryProductId);
// Check if any options have the option_id 21
let option = options.find(option => option.option_id === 21);
if (option) {
// If the option is found, return the option_id (21)
return option.option_id;
} else {
// If the option is not found, return null
return null;
}
}
},
// Spot Free
"Skylning med RO": {
getProductId: (usageLogEntry) => {
let primaryProductId = getPrimaryServiceProductId(usageLogEntry);
// Get all the matching options where the product id is the same as the primary product id
let options = product_options.value.filter(option => option.product_id === primaryProductId);
// Check if any options have the option_id 23, or 24
let option = options.find(option => option.option_id === 23 || option.option_id === 24);
if (option) {
// If the option is found, return the option_id (23 or 24)
return option.option_id;
} else {
// If the option is not found, return null
return null;
}
}
},
// Primary services
"Stor bil": {
getProductId: (usageLogEntry) => {
// Check if the vehicle type is in the list
return getPrimaryServiceProductId(usageLogEntry);
}
},
"Lille bil": {
getProductId: (usageLogEntry) => {
// Check if the vehicle type is in the list
return getPrimaryServiceProductId(usageLogEntry);
}
},
}
const round_price_down = (price) => {
// Round the price down to the nearest whole number
return Math.floor(price);
}
const getOrderItemsFromUsageLogEntry = (usageLogEntry) => {
let items = [];
let unrecognizedItems = [];
// Filter out all the free items
const filteredItems = usageLogEntry.WashItems.filter(item => item.PriceIncVat > 0);
// If the "Stor bil" item is present, set it to be the first item
const storBilIndex = filteredItems.findIndex(item => item.OriginalProductName === "Stor bil" || item.OriginalProductName === "Lille bil");
if (storBilIndex > -1) {
const storBilItem = filteredItems.splice(storBilIndex, 1)[0];
filteredItems.unshift(storBilItem);
}
// Loop through the filtered items
for (const item of filteredItems) {
// Check if the item is recognized
if (recognizedItems[item.OriginalProductName]) {
items.push({
product_id: recognizedItems[item.OriginalProductName].getProductId(usageLogEntry),
quantity: item.Count,
discount_percentage: item.Discount,
price: {
unit: round_price_down(item.UnitPrice), // Before discount
each: round_price_down( item.UnitPrice - (item.UnitPrice * item.Discount / 100) ), // One item after discount
total: round_price_down( (item.UnitPrice * item.Count) - (item.UnitPrice * item.Count * item.Discount / 100) ), // Total price after discount x quantity
}
});
} else {
// If the item is not recognized, add it to the unrecognized items
unrecognizedItems.push(item);
}
}
// Calculate the total price
let price = {
total: 0,
}
for (const item of items) {
price.total += item.price.total;
}
return { items, unrecognizedItems, price };
}
const onClickCreateOrder = async (usageLogEntry) => {
// Check if the order can be created
if (!canCreateOrder(usageLogEntry)) {
console.warn("XL-Vask: cannot create order from usage log entry.", {
washId: usageLogEntry?.WashId,
hasUnrecognizedItems: hasUnrecognizedItems(usageLogEntry),
hasUnrecognizedDepartment: hasUnrecognizedDepartment(usageLogEntry),
hasRelatedOrder: hasRelatedOrder(usageLogEntry),
});
Swal.fire({
icon: 'warning',
title: t('invoicing_period.xlvask_autopilot.errors.create_order_unrecognized_items'),
});
return;
}
let order = getOrderFromUsageLogEntry(usageLogEntry);
let orderItems = getOrderItemsFromUsageLogEntry(usageLogEntry);
// Create the order
let order_props = {
customer_id: parseInt(order.customer_id),
cashier_id: null,
department_id: getDepartmentId(usageLogEntry),
reference: null,
reg_1: order.reg_1,
reg_2: order.reg_2,
reg_3: order.reg_3,
notes: null,
invoice_collection_id: null,
}
await SessionUser.objects.orders
.add(
order_props.customer_id,
order_props.cashier_id,
order_props.department_id,
order_props.reference,
order_props.reg_1,
order_props.reg_2,
order_props.reg_3,
order_props.notes,
order_props.invoice_collection_id,
).then(
async (response) => {
if (response.status === 200) {
let orderId = parseInt(response.data.data.id);
// Add the wash id to the order
await SessionUser.objects.orders.set.wash_id(orderId, usageLogEntry.WashId);
// Set the time created to the start time
await SessionUser.objects.orders.set.created_at(orderId, SessionUser.functions.date.format(SessionUser.superUser.modules.xlvask.functions.convertISODateTimeToDate(usageLogEntry.StartTime)));
let relational_id = null;
let itemFailure = false;
// Add the order items to the order
for (const item of orderItems.items) {
await createOrderItem(
orderId,
item.product_id,
item.quantity,
relational_id,
null,
item.price.each
).then(
(response) => {
if (response.status === 200) {
// If the relational_id is null, set it to the order item id
if (relational_id === null) {
relational_id = parseInt(response.data.data.id);
}
} else {
itemFailure = true;
console.error("XL-Vask: order item returned non-OK status.", {
orderId,
productId: item?.product_id,
status: response?.status,
});
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.create_order_item_failed'),
});
}
}
).catch(
(error) => {
itemFailure = true;
console.error("XL-Vask: failed to create order item.", { orderId, error });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.create_order_item_failed'),
});
}
)
}
if (!itemFailure) {
onOrderCreated(orderId);
}
} else {
console.error("XL-Vask: order creation returned non-OK status.", { status: response?.status });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.create_order_failed'),
});
}
}
).catch(
(error) => {
console.error("XL-Vask: failed to create order.", { washId: usageLogEntry?.WashId, error });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.create_order_failed'),
});
}
);
}
const onOrderCreated = (orderId) => {
Swal.fire({
title: t('tables.xlvask.order_created'),
text: t('tables.xlvask.order_id', { id: orderId }),
icon: "success",
confirmButtonText: t('invoicing_period.xlvask_autopilot.labels.ok')
});
// Load the related orders
getRelatedOrders();
}
const hasUnrecognizedItems = (usageLogEntry) => {
let orderItems = getOrderItemsFromUsageLogEntry(usageLogEntry);
return orderItems.unrecognizedItems.length > 0;
}
const hasUnrecognizedDepartment = (usageLogEntry) => {
// Check if the department is in the list
const department = getDepartmentId(usageLogEntry);
return ( department === null || department === undefined );
}
const listRelatedOrders = (usageLogEntry) => {
// Check if the order is already created (If the wash id key is present in the related orders)
if (related_orders.value) {
let keys = Object.keys(related_orders.value);
if (keys.includes(usageLogEntry.WashId)) {
return related_orders.value[usageLogEntry.WashId];
}
}
}
const hasRelatedOrder = (usageLogEntry) => {
// Check if the order is already created (If the wash id key is present in the related orders)
/**
* {
* "892ae789-aeea-4bda-9374-cf931290aefd": [
* 9997, // Order ID
* 9998, // Order ID #2 (If multiple orders are related)
* ]
* }
*/
return listRelatedOrders(usageLogEntry) !== undefined && listRelatedOrders(usageLogEntry).length > 0;
}
const canCreateOrder = (usageLogEntry) => {
// Check if there are any unrecognized items, and if the department is recognized
return (
!hasUnrecognizedItems(usageLogEntry) &&
!hasUnrecognizedDepartment(usageLogEntry) &&
!hasRelatedOrder(usageLogEntry)
);
}
const getDepartmentId = (usageLogEntry) => {
// Check if the department is in the list
const department = departments.value.find(department => department.name === usageLogEntry.Location);
if (department) {
// If the department is found, return the department id
return parseInt(department.id);
} else {
// If the department is not found, return null
return null;
}
}
const redirectDepartmentOrderPage = async (orderId) => {
// Send the user to the order page (In a new tab)
// Get the department id from the order
await SessionUser.objects.orders.functions.get_department_id(orderId).then((response) => {
// Get the department id from the response
// Send the user to the order page (In a new tab)
window.open(`/admin/${response}/modules/pos/orders/${orderId}`, '_blank');
}).catch((error) => {
console.error("XL-Vask: failed to resolve department id for related order.", { orderId, error });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.redirect_order_failed'),
});
});
};
const getUsageStatus = (usageLogEntry) => {
let result = {
color_class: "has-text-grey",
price: {
total: 0,
}
}
if (hasRelatedOrder(usageLogEntry)) {
result.color_class = "has-text-success";
}
if (hasUnrecognizedItems(usageLogEntry)) {
result.color_class = "has-text-danger";
}
if (hasUnrecognizedDepartment(usageLogEntry)) {
result.color_class = "has-text-warning";
}
// Add the price to the result
result.price = getOrderItemsFromUsageLogEntry(usageLogEntry).price;
return result;
}
const onClickCreateOrderAllApplicable = async () => {
// Loop through all the usage log entries
for (const usageLogEntry of usageLog.value) {
// Check if the order can be created
if (canCreateOrder(usageLogEntry)) {
await onClickCreateOrder(usageLogEntry);
}
}
}
</script>
<template>
<h1>{{ t('tables.xlvask.usage_log_title') }}</h1>
<div class="buttons">
<button class="button is-primary" @click="onClickCreateOrderAllApplicable">{{ t('tables.xlvask.create_order_all') }}</button>
<button class="button is-info" :class="{ 'is-loading': usageLogLoading }" :disabled="usageLogLoading" @click="getUsageLog">{{ t('tables.xlvask.refresh') }}</button>
</div>
<table class="table mb-6">
<!-- Table header -->
<thead>
<tr>
<th class="is-narrow"><!-- Status --></th>
<th>{{ t('tables.common.customer') }}</th>
<th>{{ t('tables.common.registration_number') }}</th>
<th>{{ t('tables.common.price') }}</th>
<th>{{ t('tables.common.start_time') }}</th>
<th>{{ t('tables.common.end_time') }}</th>
<th>{{ t('tables.common.location') }}</th>
<th><!-- Actions --></th>
</tr>
</thead>
<!-- Table body -->
<tbody>
<tr v-if="!usageLog || usageLog.length === 0">
<td colspan="8" class="has-text-centered has-text-grey">
{{ t('tables.xlvask.usage_log_empty') }}
</td>
</tr>
<template v-for="(usage, index) in usageLog" :key="index">
<tr>
<td>
<ColorIndicator
v-bind:color_class="getUsageStatus(usage).color_class"
v-bind:visibility="{
icon: true,
dropdown: false
}"
@onClick="() => { /* status indicator clicked */ }"
/>
</td>
<td>{{ usage.CustomerId }}</td>
<td>{{ usage.RegistrationNumber }}</td>
<td>
<ColorIndicator
v-bind:color_class="getUsageStatus(usage).color_class"
v-bind:visibility="{
icon: false,
dropdown: true
}"
v-bind:label="{
text: SessionUser.functions.currency.toLocal(getUsageStatus(usage).price.total),
classes: []
}"
v-bind:dropdown_content="{
content: [
{
text: t('common.services'),
button: false,
action: () => {},
},
...(getOrderItemsFromUsageLogEntry(usage).items.map(item => {
return {
text: `${SessionUser.objects.products.functions.getProductName(item.product_id)} - ${item.quantity} x ${SessionUser.functions.currency.toLocal(item.price.each)}`,
button: true,
action: () => {},
button_text: SessionUser.functions.currency.toLocal(item.price.total),
v_centered: true,
}
})),
...(getOrderItemsFromUsageLogEntry(usage).unrecognizedItems.map(item => {
return {
text: `${item.OriginalProductName} - ${item.Count} x ${SessionUser.functions.currency.toLocal(item.UnitPrice)}`,
button: true,
action: () => {},
icon: 'fas fa-exclamation-triangle',
button_text: SessionUser.functions.currency.toLocal(item.PriceIncVat),
v_centered: true,
}
}))
]
}"
/>
</td>
<td>{{ formatDate(usage.StartTime) }}</td>
<td>{{ formatDate(usage.FinishTime) }}</td>
<td>
<ColorIndicator
v-bind:color_class="getUsageStatus(usage).color_class"
v-bind:visibility="{
icon: false,
dropdown: false
}"
v-bind:label="{
text: usage.Location,
classes: []
}"
v-bind:dropdown_content="{
content: [
{
text: t('self_wash.lane'),
button: true,
button_text: usage.Hall,
action: () => {},
v_centered: true,
}
]
}"
/>
</td>
<td>
<ActionSettingsWheelButton>
<template #actions>
<!-- Create order based on usage log entry -->
<ActionSettingsWheelItem
:label="t('tables.xlvask.create_order')"
icon="fas fa-plus"
v-bind:disabled="!canCreateOrder(usage)"
v-bind:click-action="() => onClickCreateOrder(usage)"
/>
<!-- If there's related orders, add them as buttons -->
<template v-if="hasRelatedOrder(usage)">
<ActionSettingsWheelItemLabel
:label="SessionUser.objects.orders.meta.labels.multiple"
/>
<ActionSettingsWheelItem
v-for="(order, orderIndex) in listRelatedOrders(usage)"
:key="orderIndex"
:label="`${SessionUser.functions.ucFirst(SessionUser.objects.orders.meta.labels.single)} #${order}`"
icon="fas fa-file-invoice"
v-bind:click-action="() => redirectDepartmentOrderPage(order)"
/>
</template>
</template>
</ActionSettingsWheelButton>
</td>
</tr>
<template v-if="usage.WashItems && usage.WashItems.length > 0">
<!-- Nested table for wash items -->
<tr class="xlvask-usage-log-wash-items-row">
<td colspan="100%">
<details class="xlvask-usage-log-wash-items" data-testid="xlvask-usage-log-wash-items">
<summary class="is-size-7">
{{ t('tables.common.wash_item') }} ({{ usage.WashItems.length }})
</summary>
<table class="table is-fullwidth is-striped is-hoverable is-bordered mt-2">
<thead>
<tr>
<th>{{ t('tables.common.wash_item') }}</th>
<th>{{ t('tables.common.count') }}</th>
<th>{{ t('tables.common.price') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, itemIndex) in usage.WashItems" :key="itemIndex">
<td>{{ getProductName(item) }}</td>
<td>{{ item.Count }}</td>
<td>{{ SessionUser.functions.currency.toLocal(Number(item.PriceIncVat ?? 0).toFixed(2)) }}</td>
</tr>
</tbody>
</table>
</details>
</td>
</tr>
</template>
</template>
</tbody>
</table>
</template>
<style scoped>
.xlvask-usage-log-wash-items-row > td {
background: rgba(10, 10, 10, 0.03);
padding: 0.5rem 0.85rem;
}
.xlvask-usage-log-wash-items > summary {
cursor: pointer;
font-weight: 600;
outline: none;
}
.xlvask-usage-log-wash-items[open] > summary {
margin-bottom: 0.5rem;
}
</style>