Defensive FE sort in OrderContentTable.vue so primary items render before their addons (related_item_id === 0 first, then grouped by parent, then id ASC). The backend ORDER BY in api#364 is the primary fix; this sort is belt-and-suspenders for stale caches / older API proxies. Pinned with tests/unit/order-content-table-addon-ordering.spec.js (318 lines, covers primary-first ordering, addon grouping, insertion-order tiebreak). Note: superseded #283 (same fix without tests, plus unrelated reformatting). E2E-pr-smoke-{desktop,mobile} Playwright containers hung >90 min — same known flake as PR #280. Admin override used; all other Required CI (format, lint, i18n, build, unit-fast, unit-serial, E2E-pr-changed/ct/pr both browsers) passed.
344 lines
13 KiB
Vue
344 lines
13 KiB
Vue
<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";
|
|
|
|
const props = defineProps({
|
|
orderId: {
|
|
type: Number,
|
|
required: true,
|
|
},
|
|
displayPrice: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
displayReference: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
displayNotes: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
useLocalOrderItems: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
localOrderItems: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
summaryMode: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
invoicePeriodFlags: {
|
|
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 (api PR), but we sort defensively here so a
|
|
// stale cache or older API proxy cannot regress the render order (which previously
|
|
// made it look like only Trailer/Dolly were attached to a Trækker order because
|
|
// Spot-free and Undervognsskyld were listed above the primary and visually buried).
|
|
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 = sortOrderItemsForDisplay(props.localOrderItems);
|
|
return;
|
|
}
|
|
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;
|
|
}
|
|
|
|
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 getOrderItemFlagIconClass = (orderItem) => (
|
|
getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : ""
|
|
);
|
|
|
|
const getOrderItemFlagColorClass = (orderItem) => {
|
|
const flags = getOrderItemFlags(orderItem);
|
|
if (flags.length === 0) {
|
|
return "";
|
|
}
|
|
|
|
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 emitFlagStatusChanged = (flag) => emit("flagStatusChanged", flag);
|
|
|
|
loadOrderItems();
|
|
|
|
watch(() => props.orderId, (newValue, oldValue) => {
|
|
if (newValue !== oldValue) {
|
|
loadOrderItems();
|
|
}
|
|
}, { immediate: true });
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<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>
|
|
</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>
|
|
<!-- 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="loader is-loading is-align-self-center"></span>
|
|
</span>
|
|
</template>
|
|
<!-- Order Items -->
|
|
<template v-else>
|
|
<div v-for="item in orderItems" :key="item.id" class="mb-2">
|
|
<!-- If the item is a primary item, show it -->
|
|
<template v-if="!isOrderItemAddon(item)">
|
|
<div>
|
|
<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>
|
|
<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"
|
|
/>
|
|
<!-- Show the add-on items -->
|
|
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
|
|
<small>
|
|
<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 }} x {{ addon_item.quantity }}
|
|
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
|
|
</small>
|
|
<InvoicingPeriodFlagList
|
|
compact
|
|
:flags="getOrderItemFlags(addon_item)"
|
|
@statusChanged="emitFlagStatusChanged"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.invoice-period-item-flag-indicator {
|
|
vertical-align: middle;
|
|
}
|
|
</style>
|