## 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>
850 lines
28 KiB
Vue
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>
|