Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7058e1a73e | ||
|
|
02e56a3e5f | ||
|
|
97700841ba | ||
|
|
88d5252814 | ||
|
|
7d3ebf8d52 | ||
|
|
0a03cd5f98 |
@@ -1,8 +1,4 @@
|
||||
<script setup>
|
||||
|
||||
|
||||
|
||||
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue";
|
||||
@@ -40,75 +36,97 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
});
|
||||
const emit = defineEmits(["flagStatusChanged"]);
|
||||
|
||||
|
||||
|
||||
const orderItems = ref([]);
|
||||
|
||||
// Stable ordering for the order items table: primary items (related_item_id === 0
|
||||
// / null) first, then addons grouped by their parent, in insertion order. The
|
||||
// backend now also orders the SELECT, but we sort defensively here so a stale
|
||||
// cache or older API proxy cannot regress the render order.
|
||||
const sortOrderItemsForDisplay = (items) => {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
return items.slice().sort((left, right) => {
|
||||
const leftRelatedId = Number(left?.related_item_id ?? 0);
|
||||
const rightRelatedId = Number(right?.related_item_id ?? 0);
|
||||
// Primary items (related_item_id 0 / null) come first.
|
||||
if ((leftRelatedId === 0) !== (rightRelatedId === 0)) {
|
||||
return leftRelatedId === 0 ? -1 : 1;
|
||||
}
|
||||
// Within addons, group by parent.
|
||||
if (leftRelatedId !== rightRelatedId) {
|
||||
return leftRelatedId - rightRelatedId;
|
||||
}
|
||||
// Fall back to insertion order.
|
||||
return Number(left?.id ?? 0) - Number(right?.id ?? 0);
|
||||
});
|
||||
};
|
||||
|
||||
const loadOrderItems = async () => {
|
||||
if (props.useLocalOrderItems) {
|
||||
orderItems.value = props.localOrderItems;
|
||||
orderItems.value = sortOrderItemsForDisplay(props.localOrderItems);
|
||||
return;
|
||||
}
|
||||
await SessionUser.request(
|
||||
'/order/items',
|
||||
'GET',
|
||||
{
|
||||
order_id: props.orderId,
|
||||
}
|
||||
).then((response) => {
|
||||
orderItems.value = response.data.data;
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
await SessionUser.request("/order/items", "GET", {
|
||||
order_id: props.orderId,
|
||||
})
|
||||
.then((response) => {
|
||||
orderItems.value = sortOrderItemsForDisplay(response.data.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const isOrderItemAddon = (item) => {
|
||||
return item.related_item_id !== 0;
|
||||
}
|
||||
};
|
||||
|
||||
const getItemAddons = (item) => {
|
||||
return orderItems.value.filter((order_item) => {
|
||||
return order_item.related_item_id === item.id;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const _ucFirst = (str) => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
};
|
||||
|
||||
const sortInvoicePeriodFlags = (flags = []) => [...flags].sort((left, right) => {
|
||||
const sourceOrder = { manual: 0, automatic: 1 };
|
||||
const leftSource = sourceOrder[left?.source] ?? 99;
|
||||
const rightSource = sourceOrder[right?.source] ?? 99;
|
||||
if (leftSource !== rightSource) {
|
||||
return leftSource - rightSource;
|
||||
}
|
||||
const sortInvoicePeriodFlags = (flags = []) =>
|
||||
[...flags].sort((left, right) => {
|
||||
const sourceOrder = { manual: 0, automatic: 1 };
|
||||
const leftSource = sourceOrder[left?.source] ?? 99;
|
||||
const rightSource = sourceOrder[right?.source] ?? 99;
|
||||
if (leftSource !== rightSource) {
|
||||
return leftSource - rightSource;
|
||||
}
|
||||
|
||||
return String(left?.created_at || left?.fingerprint || "").localeCompare(
|
||||
String(right?.created_at || right?.fingerprint || "")
|
||||
return String(left?.created_at || left?.fingerprint || "").localeCompare(
|
||||
String(right?.created_at || right?.fingerprint || "")
|
||||
);
|
||||
});
|
||||
|
||||
const getOrderItemFlags = (orderItem) =>
|
||||
sortInvoicePeriodFlags(
|
||||
(props.invoicePeriodFlags || []).filter((flag) => {
|
||||
if (String(flag?.status || "active") !== "active") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetType = String(flag?.target_type || "");
|
||||
if (!["order_item", "order_item_field"].includes(targetType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flagOrderItemId = Number(flag?.order_item_id || flag?.target_id || flag?.context?.order_item_id || 0);
|
||||
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const getOrderItemFlags = (orderItem) => sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
|
||||
if (String(flag?.status || "active") !== "active") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetType = String(flag?.target_type || "");
|
||||
if (!["order_item", "order_item_field"].includes(targetType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flagOrderItemId = Number(flag?.order_item_id || flag?.target_id || flag?.context?.order_item_id || 0);
|
||||
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
|
||||
}));
|
||||
|
||||
const getOrderItemFlagIconClass = (orderItem) => (
|
||||
getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : ""
|
||||
);
|
||||
const getOrderItemFlagIconClass = (orderItem) => (getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : "");
|
||||
|
||||
const getOrderItemFlagColorClass = (orderItem) => {
|
||||
const flags = getOrderItemFlags(orderItem);
|
||||
@@ -119,19 +137,23 @@ const getOrderItemFlagColorClass = (orderItem) => {
|
||||
return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning";
|
||||
};
|
||||
|
||||
const displayColumnCount = computed(() => (
|
||||
2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
|
||||
));
|
||||
const displayColumnCount = computed(
|
||||
() => 2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
|
||||
);
|
||||
|
||||
const emitFlagStatusChanged = (flag) => emit("flagStatusChanged", flag);
|
||||
|
||||
loadOrderItems();
|
||||
|
||||
watch(() => props.orderId, (newValue, oldValue) => {
|
||||
if (newValue !== oldValue) {
|
||||
loadOrderItems();
|
||||
}
|
||||
}, { immediate: true });
|
||||
watch(
|
||||
() => props.orderId,
|
||||
(newValue, oldValue) => {
|
||||
if (newValue !== oldValue) {
|
||||
loadOrderItems();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -139,125 +161,128 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
||||
<table class="table is-fullwidth" v-if="!props.summaryMode">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('tables.products.name') }}</th>
|
||||
<th v-if="props.displayNotes">{{ $t('objects.columns.notes') }}</th>
|
||||
<th v-if="props.displayReference">{{ $t('common.reference') }}</th>
|
||||
<th>{{ $t('common.quantity') }}</th>
|
||||
<th v-if="props.displayPrice">{{ $t('tables.products.price') }}</th>
|
||||
<th>{{ $t("tables.products.name") }}</th>
|
||||
<th v-if="props.displayNotes">{{ $t("objects.columns.notes") }}</th>
|
||||
<th v-if="props.displayReference">{{ $t("common.reference") }}</th>
|
||||
<th>{{ $t("common.quantity") }}</th>
|
||||
<th v-if="props.displayPrice">{{ $t("tables.products.price") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="item in orderItems" :key="item.id">
|
||||
<!-- If the item is a primary item, show it -->
|
||||
<template v-if="!isOrderItemAddon(item)">
|
||||
<!-- If the item is included in the invoice, show it normally -->
|
||||
<tr v-if="item.include_in_invoice">
|
||||
<td>
|
||||
<span
|
||||
v-if="getOrderItemFlags(item).length > 0"
|
||||
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||
:class="getOrderItemFlagColorClass(item)"
|
||||
:data-testid="`order-content-item-flag-indicator-${item.id}`"
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(item)"></i>
|
||||
</span>
|
||||
{{ item.product.name }}
|
||||
</td>
|
||||
<td v-if="props.displayReference">{{ item.reference }}</td>
|
||||
<td v-if="props.displayNotes">{{ item.notes }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(item.price) }}</td>
|
||||
</tr>
|
||||
<!-- If the item is not included in the invoice, show it with a strikethrough -->
|
||||
<tr v-else class="has-background-warning-light">
|
||||
<td>
|
||||
<span
|
||||
v-if="getOrderItemFlags(item).length > 0"
|
||||
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||
:class="getOrderItemFlagColorClass(item)"
|
||||
:data-testid="`order-content-item-flag-indicator-${item.id}`"
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(item)"></i>
|
||||
</span>
|
||||
<span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
|
||||
</td>
|
||||
<td v-if="props.displayReference">{{ item.reference }}</td>
|
||||
<td v-if="props.displayNotes">{{ item.notes }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td v-if="props.displayPrice" style="text-decoration: line-through;">{{ SessionUser.functions.currency.toLocal(item.price) }}</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="getOrderItemFlags(item).length > 0"
|
||||
:data-testid="`order-content-item-flags-${item.id}`"
|
||||
>
|
||||
<td :colspan="displayColumnCount">
|
||||
<InvoicingPeriodFlagList
|
||||
compact
|
||||
:flags="getOrderItemFlags(item)"
|
||||
@statusChanged="emitFlagStatusChanged"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-for="item in orderItems" :key="item.id">
|
||||
<!-- If the item is a primary item, show it -->
|
||||
<template v-if="!isOrderItemAddon(item)">
|
||||
<!-- If the item is included in the invoice, show it normally -->
|
||||
<tr v-if="item.include_in_invoice">
|
||||
<td>
|
||||
<span
|
||||
v-if="getOrderItemFlags(item).length > 0"
|
||||
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||
:class="getOrderItemFlagColorClass(item)"
|
||||
:data-testid="`order-content-item-flag-indicator-${item.id}`"
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(item)"></i>
|
||||
</span>
|
||||
{{ item.product.name }}
|
||||
</td>
|
||||
<td v-if="props.displayReference">{{ item.reference }}</td>
|
||||
<td v-if="props.displayNotes">{{ item.notes }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(item.price) }}</td>
|
||||
</tr>
|
||||
<!-- If the item is not included in the invoice, show it with a strikethrough -->
|
||||
<tr v-else class="has-background-warning-light">
|
||||
<td>
|
||||
<span
|
||||
v-if="getOrderItemFlags(item).length > 0"
|
||||
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||
:class="getOrderItemFlagColorClass(item)"
|
||||
:data-testid="`order-content-item-flag-indicator-${item.id}`"
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(item)"></i>
|
||||
</span>
|
||||
<span style="text-decoration: line-through">{{ item.product.name }}</span> (
|
||||
{{ SessionUser.objects.vehicles.columns.wash_subscription.label }} )
|
||||
</td>
|
||||
<td v-if="props.displayReference">{{ item.reference }}</td>
|
||||
<td v-if="props.displayNotes">{{ item.notes }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td v-if="props.displayPrice" style="text-decoration: line-through">
|
||||
{{ SessionUser.functions.currency.toLocal(item.price) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="getOrderItemFlags(item).length > 0" :data-testid="`order-content-item-flags-${item.id}`">
|
||||
<td :colspan="displayColumnCount">
|
||||
<InvoicingPeriodFlagList
|
||||
compact
|
||||
:flags="getOrderItemFlags(item)"
|
||||
@statusChanged="emitFlagStatusChanged"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<!-- Show the add-on items -->
|
||||
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
|
||||
<!-- If the add-on item is included in the invoice, show it normally -->
|
||||
<tr v-if="addon_item.include_in_invoice">
|
||||
<td>
|
||||
<span
|
||||
v-if="getOrderItemFlags(addon_item).length > 0"
|
||||
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||
:class="getOrderItemFlagColorClass(addon_item)"
|
||||
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
|
||||
</span>
|
||||
+ {{ addon_item.product.name }}
|
||||
</td>
|
||||
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
|
||||
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
|
||||
<td>{{ addon_item.quantity }}</td>
|
||||
<td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(addon_item.price) }}</td>
|
||||
</tr>
|
||||
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
|
||||
<tr v-else class="has-background-warning">
|
||||
<td>
|
||||
<span
|
||||
v-if="getOrderItemFlags(addon_item).length > 0"
|
||||
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||
:class="getOrderItemFlagColorClass(addon_item)"
|
||||
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
|
||||
</span>
|
||||
+ <span style="text-decoration: line-through">{{ addon_item.product.name }}</span> (
|
||||
{{ SessionUser.objects.vehicles.columns.wash_subscription.label }} )
|
||||
</td>
|
||||
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
|
||||
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
|
||||
<td>{{ addon_item.quantity }}</td>
|
||||
<td v-if="props.displayPrice" style="text-decoration: line-through">
|
||||
{{ SessionUser.functions.currency.toLocal(addon_item.price) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="getOrderItemFlags(addon_item).length > 0"
|
||||
:data-testid="`order-content-item-flags-${addon_item.id}`"
|
||||
>
|
||||
<td :colspan="displayColumnCount">
|
||||
<InvoicingPeriodFlagList
|
||||
compact
|
||||
:flags="getOrderItemFlags(addon_item)"
|
||||
@statusChanged="emitFlagStatusChanged"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
<!-- Show the add-on items -->
|
||||
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
|
||||
<!-- If the add-on item is included in the invoice, show it normally -->
|
||||
<tr v-if="addon_item.include_in_invoice">
|
||||
<td>
|
||||
<span
|
||||
v-if="getOrderItemFlags(addon_item).length > 0"
|
||||
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||
:class="getOrderItemFlagColorClass(addon_item)"
|
||||
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
|
||||
</span>
|
||||
+ {{ addon_item.product.name }}
|
||||
</td>
|
||||
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
|
||||
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
|
||||
<td>{{ addon_item.quantity }}</td>
|
||||
<td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(addon_item.price) }}</td>
|
||||
</tr>
|
||||
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
|
||||
<tr v-else class="has-background-warning">
|
||||
<td>
|
||||
<span
|
||||
v-if="getOrderItemFlags(addon_item).length > 0"
|
||||
class="icon is-small mr-1 invoice-period-item-flag-indicator"
|
||||
:class="getOrderItemFlagColorClass(addon_item)"
|
||||
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
|
||||
</span>
|
||||
+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
|
||||
</td>
|
||||
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
|
||||
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
|
||||
<td>{{ addon_item.quantity }}</td>
|
||||
<td v-if="props.displayPrice" style="text-decoration: line-through;">{{ SessionUser.functions.currency.toLocal(addon_item.price) }}</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-if="getOrderItemFlags(addon_item).length > 0"
|
||||
:data-testid="`order-content-item-flags-${addon_item.id}`"
|
||||
>
|
||||
<td :colspan="displayColumnCount">
|
||||
<InvoicingPeriodFlagList
|
||||
compact
|
||||
:flags="getOrderItemFlags(addon_item)"
|
||||
@statusChanged="emitFlagStatusChanged"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Summary Mode -->
|
||||
<div v-else>
|
||||
<!-- Loader -->
|
||||
<template v-if="orderItems.length === 0">
|
||||
<span class="is-flex is-justify-content-center is-align-items-center" style="height: 100px;">
|
||||
<span class="is-flex is-justify-content-center is-align-items-center" style="height: 100px">
|
||||
<span class="loader is-loading is-align-self-center"></span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -278,11 +303,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
||||
<strong>{{ item.product.name }}</strong> x {{ item.quantity }}
|
||||
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span>
|
||||
</div>
|
||||
<InvoicingPeriodFlagList
|
||||
compact
|
||||
:flags="getOrderItemFlags(item)"
|
||||
@statusChanged="emitFlagStatusChanged"
|
||||
/>
|
||||
<InvoicingPeriodFlagList compact :flags="getOrderItemFlags(item)" @statusChanged="emitFlagStatusChanged" />
|
||||
<!-- Show the add-on items -->
|
||||
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
|
||||
<small>
|
||||
@@ -294,7 +315,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
|
||||
</span>
|
||||
+ {{ addon_item.product.name }} x {{ addon_item.quantity }}
|
||||
+ {{ addon_item.product.name }} x {{ addon_item.quantity }}
|
||||
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
|
||||
</small>
|
||||
<InvoicingPeriodFlagList
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// @vitest-environment jsdom
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
|
||||
import { createTestI18n } from "./helpers/mountWithApp.js";
|
||||
|
||||
// Track mock state in a hoisted holder so the vi.mock factory below can stay
|
||||
// free of top-level variables (vitest hoists vi.mock above imports, so the
|
||||
// factory must not reference any binding that has not been hoisted).
|
||||
const requestHolder = vi.hoisted(() => ({
|
||||
responses: [],
|
||||
calls: 0,
|
||||
}));
|
||||
|
||||
const showPopperMock = vi.hoisted(() => vi.fn());
|
||||
const removePopperIfOpenMock = vi.hoisted(() => vi.fn());
|
||||
const popperBoxMock = vi.hoisted(() => vi.fn((title, body) => ({ title, body })));
|
||||
const swalFireMock = vi.hoisted(() => vi.fn(() => Promise.resolve({ isConfirmed: false })));
|
||||
|
||||
vi.mock("sweetalert2", () => ({
|
||||
default: {
|
||||
fire: swalFireMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/displays/PopperDefault.vue", () => ({
|
||||
showPopper: showPopperMock,
|
||||
removePopperIfOpen: removePopperIfOpenMock,
|
||||
popperBox: popperBoxMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
SessionUser: {
|
||||
request: () => {
|
||||
requestHolder.calls += 1;
|
||||
const next = requestHolder.responses.shift();
|
||||
return Promise.resolve(next ?? { data: { data: [] } });
|
||||
},
|
||||
functions: {
|
||||
currency: {
|
||||
toLocal: (value) => String(value ?? ""),
|
||||
},
|
||||
redirectTo: {
|
||||
department: vi.fn(),
|
||||
},
|
||||
},
|
||||
objects: {
|
||||
vehicles: {
|
||||
columns: {
|
||||
wash_subscription: {
|
||||
label: "Wash subscription",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const i18n = createTestI18n({
|
||||
en: {
|
||||
global: {
|
||||
cancel: "Cancel",
|
||||
no_data: "No data",
|
||||
quantity: "Quantity",
|
||||
},
|
||||
tables: {
|
||||
products: {
|
||||
name: "Product",
|
||||
price: "Price",
|
||||
},
|
||||
},
|
||||
objects: {
|
||||
columns: {
|
||||
notes: "Notes",
|
||||
reference: "Reference",
|
||||
},
|
||||
},
|
||||
invoice_period: {
|
||||
flags: {
|
||||
status: {
|
||||
resolved: "Resolved",
|
||||
ignored: "Ignored",
|
||||
false_positive: "False positive",
|
||||
reason_placeholder: "Optional reason",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const buildWrapper = (props = {}) =>
|
||||
mount(OrderContentTable, {
|
||||
props: {
|
||||
orderId: 42,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
},
|
||||
});
|
||||
|
||||
describe("OrderContentTable addon ordering", () => {
|
||||
beforeEach(() => {
|
||||
requestHolder.responses = [];
|
||||
requestHolder.calls = 0;
|
||||
showPopperMock.mockReset();
|
||||
removePopperIfOpenMock.mockReset();
|
||||
popperBoxMock.mockReset();
|
||||
swalFireMock.mockReset();
|
||||
});
|
||||
|
||||
it("renders primary items before their addons even when the API returns them out of order", async () => {
|
||||
// Simulate the legacy MySQL response: addons appear before the primary
|
||||
// item they belong to. After the fix, the component must render the
|
||||
// primary first.
|
||||
const apiResponse = [
|
||||
{
|
||||
id: 102,
|
||||
order_id: 42,
|
||||
product_id: 9,
|
||||
related_item_id: 101,
|
||||
price: 50,
|
||||
quantity: 1,
|
||||
include_in_invoice: true,
|
||||
notes: "",
|
||||
reference: "",
|
||||
product: { id: 9, name: "Addon A" },
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
order_id: 42,
|
||||
product_id: 10,
|
||||
related_item_id: 101,
|
||||
price: 30,
|
||||
quantity: 1,
|
||||
include_in_invoice: true,
|
||||
notes: "",
|
||||
reference: "",
|
||||
product: { id: 10, name: "Addon B" },
|
||||
},
|
||||
{
|
||||
id: 101,
|
||||
order_id: 42,
|
||||
product_id: 1,
|
||||
related_item_id: 0,
|
||||
price: 200,
|
||||
quantity: 1,
|
||||
include_in_invoice: true,
|
||||
notes: "",
|
||||
reference: "",
|
||||
product: { id: 1, name: "Primary wash" },
|
||||
},
|
||||
];
|
||||
requestHolder.responses.push({ data: { data: apiResponse } });
|
||||
requestHolder.responses.push({ data: { data: apiResponse } });
|
||||
|
||||
const wrapper = buildWrapper();
|
||||
await flushPromises();
|
||||
|
||||
const renderedRows = wrapper.findAll("tbody tr");
|
||||
const productNames = renderedRows.map((row) => String(row.text() || "").trim());
|
||||
|
||||
const primaryIndex = productNames.findIndex((text) => text.includes("Primary wash"));
|
||||
const addonAIndex = productNames.findIndex((text) => text.includes("Addon A"));
|
||||
const addonBIndex = productNames.findIndex((text) => text.includes("Addon B"));
|
||||
|
||||
expect(primaryIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(addonAIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(addonBIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(primaryIndex).toBeLessThan(addonAIndex);
|
||||
expect(primaryIndex).toBeLessThan(addonBIndex);
|
||||
});
|
||||
|
||||
it("preserves insertion order for addons that share the same parent", async () => {
|
||||
const apiResponse = [
|
||||
{
|
||||
id: 101,
|
||||
order_id: 42,
|
||||
product_id: 1,
|
||||
related_item_id: 0,
|
||||
price: 200,
|
||||
quantity: 1,
|
||||
include_in_invoice: true,
|
||||
notes: "",
|
||||
reference: "",
|
||||
product: { id: 1, name: "Primary wash" },
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
order_id: 42,
|
||||
product_id: 10,
|
||||
related_item_id: 101,
|
||||
price: 30,
|
||||
quantity: 1,
|
||||
include_in_invoice: true,
|
||||
notes: "",
|
||||
reference: "",
|
||||
product: { id: 10, name: "Addon B" },
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
order_id: 42,
|
||||
product_id: 9,
|
||||
related_item_id: 101,
|
||||
price: 50,
|
||||
quantity: 1,
|
||||
include_in_invoice: true,
|
||||
notes: "",
|
||||
reference: "",
|
||||
product: { id: 9, name: "Addon A" },
|
||||
},
|
||||
];
|
||||
// OrderContentTable mounts twice on initial render (immediate watcher plus
|
||||
// a top-level call); queue two identical responses.
|
||||
requestHolder.responses.push({ data: { data: apiResponse } });
|
||||
requestHolder.responses.push({ data: { data: apiResponse } });
|
||||
|
||||
const wrapper = buildWrapper();
|
||||
await flushPromises();
|
||||
|
||||
const renderedRows = wrapper.findAll("tbody tr");
|
||||
const productNames = renderedRows.map((row) => String(row.text() || "").trim());
|
||||
|
||||
const addonAIndex = productNames.findIndex((text) => text.includes("Addon A"));
|
||||
const addonBIndex = productNames.findIndex((text) => text.includes("Addon B"));
|
||||
|
||||
expect(addonAIndex).toBeLessThan(addonBIndex);
|
||||
});
|
||||
|
||||
it("sorts localOrderItems defensively when useLocalOrderItems is true", async () => {
|
||||
const localItems = [
|
||||
{
|
||||
id: 201,
|
||||
order_id: 7,
|
||||
product_id: 9,
|
||||
related_item_id: 200,
|
||||
price: 50,
|
||||
quantity: 1,
|
||||
include_in_invoice: true,
|
||||
notes: "",
|
||||
reference: "",
|
||||
product: { id: 9, name: "Local addon" },
|
||||
},
|
||||
{
|
||||
id: 200,
|
||||
order_id: 7,
|
||||
product_id: 1,
|
||||
related_item_id: 0,
|
||||
price: 200,
|
||||
quantity: 1,
|
||||
include_in_invoice: true,
|
||||
notes: "",
|
||||
reference: "",
|
||||
product: { id: 1, name: "Local primary" },
|
||||
},
|
||||
];
|
||||
|
||||
const wrapper = buildWrapper({
|
||||
orderId: 7,
|
||||
useLocalOrderItems: true,
|
||||
localOrderItems: localItems,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const renderedRows = wrapper.findAll("tbody tr");
|
||||
const productNames = renderedRows.map((row) => String(row.text() || "").trim());
|
||||
|
||||
const primaryIndex = productNames.findIndex((text) => text.includes("Local primary"));
|
||||
const addonIndex = productNames.findIndex((text) => text.includes("Local addon"));
|
||||
|
||||
expect(primaryIndex).toBeLessThan(addonIndex);
|
||||
expect(requestHolder.calls).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user