Files
pleno-vue/src/components/displays/department/pos/PosOrderItemsCurrent.vue
T
Jeppe Bundgaard d7ff64bf4e Add encoding repair utilities and tests:
- Introduced `repairText` and `containsSuspiciousEncoding` utilities for handling corrupted text encodings.
- Added unit tests (`encoding-repair.spec.js`) to validate encoding repair and encoding marker detection.
- Integrated encoding check into unit test workflows (`text:check-encoding` and `text:fix-encoding` scripts).
- Replaced corrupted strings across multiple components and locales with proper UTF-8 encodings.
2026-04-21 10:24:35 +02:00

1315 lines
44 KiB
Vue

<script setup>
import { useI18n } from "vue-i18n";
import { showEditOrderItemForm } from "@/components/forms/superUser/editOrderItemForm.vue";
import { removeOrderItem } from "@/components/shop/OrdersItems.vue";
import { computed, ref, watch, defineProps, useSlots, defineEmits } from "vue";
import PosOrderDetailItemsTable from "@/components/displays/department/pos/order/PosOrderDetailItemsTable.vue";
import PosOrderItemsDefaultTable from "@/components/displays/department/pos/order/PosOrderItemsDefaultTable.vue";
import PosOrderItemEditModal from "@/components/displays/department/pos/order/PosOrderItemEditModal.vue";
const props = defineProps({
orderItems: Array,
orderId: [Number, String],
loadOrderItems: Function,
editPossible: Boolean,
isLarge: Boolean,
stackMetadata: Boolean,
reg_1: String,
reg_2: String,
reg_3: String,
customer_name: String,
emitEventsOnly: Boolean,
disallowItemEdit: Boolean,
variant: {
type: String,
default: "default",
validator: (value) => ["default", "order-detail", "sidebar"].includes(value),
},
});
const emits = defineEmits(["update:orderItems", "onDestroyItem"]);
import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays/PopperDefault.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import {
customer_name,
customerRequiresReferenceNumber,
fetchAttachments,
customerUsesPONumbers,
getOrderDetails,
setOrderId,
order_id,
reference,
order_notes,
order_po,
order_safety_seal,
} from "@/components/shop/POSDepartmentProcess.vue";
import SpanSkeleton from "@/components/displays/skeletons/SpanSkeleton.vue";
import PosOrderLicensePlates from "@/components/displays/department/pos/order/PosOrderLicensePlates.vue";
import POSOrderCustomerWishes from "@/components/displays/department/pos/order/POSOrderCustomerWishes.vue";
import POSOrderNote from "@/components/displays/department/pos/order/POSOrderNote.vue";
import { doesOrderContainWashCertificateProduct } from "@/components/displays/department/pos/utils/washCertificate.js";
const { t } = useI18n();
const slots = useSlots();
// Tabs
const panel_tabs = ref([
{
id: "cart",
icon: "fas fa-shopping-cart",
name: t("admin.pos.cart"),
active: true,
},
]);
const handleCustomerWishesFieldSaved = async (fieldKey) => {
if (fieldKey !== "safety_seal") {
return;
}
await loadOrder();
};
// Check if the size is large
const isLarge = props.isLarge !== undefined ? props.isLarge : false;
const isOrderDetailVariant = computed(() => props.variant === "order-detail");
const isSidebarVariant = computed(() => props.variant === "sidebar");
const showInlineActionButtons = computed(() => isOrderDetailVariant.value);
const showSidebarTabsInHeader = computed(() => isSidebarVariant.value && panel_tabs.value.length > 1);
const rootClasses = computed(() => ({
"pos-order-items": true,
"pos-order-items--large": isLarge,
"pos-order-items--order-detail": isOrderDetailVariant.value,
"pos-order-items--sidebar": isSidebarVariant.value,
"pos-order-items--stacked-metadata": props.stackMetadata,
}));
const titleClasses = computed(() => ({
title: true,
"is-5": !isSidebarVariant.value,
"is-6": isSidebarVariant.value,
"pos-order-items__title": true,
}));
const displayCustomerName = computed(() => props.customer_name || customer_name.value || "");
const showHeaderActions = computed(() => slots.headerActions !== undefined);
const pendingRemovedOrderItemIds = ref([]);
const selectedOrderItem = ref(null);
const isOrderItemEditModalOpen = ref(false);
const referenceRequired = computed(() => customerRequiresReferenceNumber());
const poRequired = computed(() => customerUsesPONumbers());
const showSafetySealField = computed(() => doesOrderContainWashCertificateProduct(visibleOrderItems.value));
const buildVisibleOrderItems = (sourceOrderItems = []) => {
const pendingIds = new Set(pendingRemovedOrderItemIds.value);
const orderItems = (sourceOrderItems || []).filter((orderItem) => !pendingIds.has(orderItem.id));
const visibleItems = [];
for (const orderItem of orderItems) {
if (orderItem.related_item_id) {
continue;
}
visibleItems.push(orderItem);
visibleItems.push(...orderItems.filter((candidate) => candidate.related_item_id === orderItem.id));
}
return visibleItems;
};
const visibleOrderItems = computed(() => buildVisibleOrderItems(props.orderItems ?? []));
const totalPrice = computed(() => {
return visibleOrderItems.value.reduce((accumulator, orderItem) => {
return accumulator + Number(orderItem?.price ?? 0) * Number(orderItem?.quantity ?? 0);
}, 0);
});
const canEditOrderItems = computed(
() => Boolean(props.editPossible) && SessionUser.hasPermission("edit_order_items") && !props.disallowItemEdit
);
const orderDetailRows = computed(() => {
return visibleOrderItems.value.map((orderItem) => {
const siblingItems = orderItem.related_item_id
? visibleOrderItems.value.filter((candidate) => candidate.related_item_id === orderItem.related_item_id)
: [];
return {
rowKey: `order-item-${orderItem.id}`,
orderItem,
isRelated: Boolean(orderItem.related_item_id),
hasRelatedItems: visibleOrderItems.value.some((candidate) => candidate.related_item_id === orderItem.id),
isLastRelatedItem: !orderItem.related_item_id || siblingItems[siblingItems.length - 1]?.id === orderItem.id,
};
});
});
// Check if the other slot is present
const hasOtherSlot = slots.other !== undefined;
const hasAttachmentsSlot = slots.attachments !== undefined;
if (hasAttachmentsSlot) {
panel_tabs.value.push({
id: "attachments",
icon: "fas fa-paperclip",
name: t("admin.pos.attachments"),
active: false,
});
}
// If so, add the other tab
if (hasOtherSlot) {
panel_tabs.value.push({
id: "settings",
name: t("admin.pos.settings"),
active: false,
icon: "fas fa-cog",
});
}
const hasDetailsSlot = slots.details !== undefined;
// If so, add the other tab
if (hasDetailsSlot) {
panel_tabs.value.push({
id: "details",
name: t("admin.pos.details"),
active: false,
icon: "fas fa-info",
});
}
const getOrderItemProductName = (orderItem) => {
return orderItem?.product?.name || t("global.no_data");
};
const getItemRelatedItems = (orderItems, orderItemId) => {
return (orderItems || []).filter((orderItem) => orderItem.related_item_id === orderItemId);
};
const getOrderItemBasePrice = (orderItem) => {
return Number(orderItem?.product?.price ?? orderItem?.price ?? 0);
};
const isLastRelatedItem = (orderItems, orderItem) => {
const relatedItems = getItemRelatedItems(orderItems, orderItem.related_item_id);
if (relatedItems.length === 0) {
return true;
}
return relatedItems[relatedItems.length - 1].id === orderItem.id;
};
const createEmptyRegistrationNumbers = () => ({
1: "",
2: "",
3: "",
});
const registration_numbers = ref(createEmptyRegistrationNumbers());
const registrationNumbersLoading = ref(false);
const loadedRegistrationNumbersForOrderId = ref(null);
const normalizeRegistrationValue = (value) => (value == null ? "" : String(value));
const parseOrderId = (candidateOrderId) => {
const normalizedOrderId = Number.parseInt(candidateOrderId, 10);
if (!Number.isInteger(normalizedOrderId) || normalizedOrderId <= 0) {
return null;
}
return normalizedOrderId;
};
const activeOrderId = computed(() => {
return parseOrderId(props.orderId) ?? parseOrderId(order_id.value);
});
const syncRegistrationNumbers = (orderDetails = null) => {
const reg_numbers = registration_numbers.value;
reg_numbers[1] = normalizeRegistrationValue(orderDetails?.reg_1);
reg_numbers[2] = normalizeRegistrationValue(orderDetails?.reg_2);
reg_numbers[3] = normalizeRegistrationValue(orderDetails?.reg_3);
};
const loadOrder = (candidateOrderId = activeOrderId.value ?? props.orderId) => {
const normalizedOrderId = parseOrderId(candidateOrderId);
if (!normalizedOrderId) {
registrationNumbersLoading.value = false;
loadedRegistrationNumbersForOrderId.value = null;
syncRegistrationNumbers();
return Promise.resolve();
}
const shouldShowRegistrationSkeleton = loadedRegistrationNumbersForOrderId.value !== normalizedOrderId;
if (shouldShowRegistrationSkeleton) {
registrationNumbersLoading.value = true;
}
// Wait 100ms before loading the order.
// This is to ensure that the order_id is set before loading the order.
return new Promise((resolve) => {
setTimeout(() => {
setOrderId(normalizedOrderId);
getOrderDetails()
.then((orderDetails) => {
syncRegistrationNumbers(orderDetails);
loadedRegistrationNumbersForOrderId.value = normalizedOrderId;
return fetchAttachments(normalizedOrderId);
})
.catch((error) => {
console.error(error);
syncRegistrationNumbers();
})
.finally(() => {
registrationNumbersLoading.value = false;
resolve();
});
}, 100);
});
};
const isTabZeroExpanded = ref(isOrderDetailVariant.value);
const toggleIsSelected = () => {
isTabZeroExpanded.value = !isTabZeroExpanded.value;
};
const expandIcon = ref(null);
watch(
isOrderDetailVariant,
(isOrderDetail) => {
if (isOrderDetail) {
isTabZeroExpanded.value = true;
}
},
{ immediate: true }
);
if (props.orderId === -1) {
registration_numbers.value = {
1: normalizeRegistrationValue(props.reg_1),
2: normalizeRegistrationValue(props.reg_2),
3: normalizeRegistrationValue(props.reg_3),
};
registrationNumbersLoading.value = false;
loadedRegistrationNumbersForOrderId.value = null;
customer_name.value = props.customer_name;
watch(
() => props.orderId,
(newOrderId) => {
if (newOrderId !== -1) {
loadOrder(newOrderId);
}
}
);
} else {
watch(
() => props.orderId,
(newOrderId) => {
loadOrder(newOrderId);
},
{ immediate: true }
);
}
watch(
() => props.orderItems,
() => {
pendingRemovedOrderItemIds.value = [];
},
{ deep: true }
);
const removeItem = async (id) => {
// If the order id is -1, emit the updated order items
if (props.orderId === -1 || props.emitEventsOnly) {
const orderItemsExcludingRemovedItem = props.orderItems.filter(
(orderItem) => orderItem.id !== id && orderItem.related_item_id !== id
);
emits("onDestroyItem", id);
//console.warn('Emitting onDestroyItem event, new order items:', orderItemsExcludingRemovedItem);
emits(
"update:orderItems",
orderItemsExcludingRemovedItem.map((orderItem) => orderItem.product)
);
} else if (props.orderId > 0) {
// Otherwise remove the order item
await removeOrderItem(id);
}
};
const reloadOrderItems = async () => {
if (typeof props.loadOrderItems === "function") {
await props.loadOrderItems();
}
};
const closeOrderItemEditor = () => {
isOrderItemEditModalOpen.value = false;
selectedOrderItem.value = null;
};
const activateTab = (selectedTab) => {
panel_tabs.value.forEach((tab) => {
tab.active = tab === selectedTab;
});
};
const openOrderItemEditor = (orderItem) => {
if (isOrderDetailVariant.value) {
selectedOrderItem.value = {
...orderItem,
product: orderItem?.product ? { ...orderItem.product } : null,
};
isOrderItemEditModalOpen.value = true;
return;
}
return showEditOrderItemForm(
orderItem.id,
getOrderItemProductName(orderItem),
orderItem.price,
orderItem.notes,
orderItem.reference,
orderItem.quantity,
canEditOrderItems.value,
() => reloadOrderItems()
);
};
const handleOrderItemSaved = async () => {
await reloadOrderItems();
closeOrderItemEditor();
};
const getRemovalIds = (orderItemId) => {
const relatedIds = (props.orderItems ?? [])
.filter((orderItem) => orderItem.related_item_id === orderItemId)
.map((orderItem) => orderItem.id);
return [...new Set([orderItemId, ...relatedIds])];
};
const deleteOrderItem = async (orderItemId) => {
pendingRemovedOrderItemIds.value = [...new Set([...pendingRemovedOrderItemIds.value, ...getRemovalIds(orderItemId)])];
try {
await removeItem(orderItemId);
await reloadOrderItems();
} finally {
pendingRemovedOrderItemIds.value = [];
}
};
</script>
<template>
<div
:class="rootClasses"
:data-auto-excel-export-scope="isOrderDetailVariant ? null : '1'"
:data-disable-auto-excel-export="isOrderDetailVariant ? '1' : null"
>
<article class="pos-order-items__surface">
<header class="pos-order-items__header">
<div class="pos-order-items__header-main">
<div class="pos-order-items__heading">
<SpanSkeleton
:class="titleClasses"
:isSkeleton="!displayCustomerName"
width="100%"
height="1.5rem"
skeleton-class="is-size-4"
>
<span data-testid="pos-order-customer-name">{{ displayCustomerName }}</span>
</SpanSkeleton>
<div class="pos-order-items__subheading">
<p class="is-7 subtitle mb-0 pos-order-items__transaction" data-testid="pos-order-transaction-label">
{{ $t("admin.pos.transaction") }} #{{ orderId }}
</p>
<div class="pos-order-items__headingcenter">
<slot name="headingcenter"></slot>
</div>
</div>
</div>
<div class="pos-order-items__header-aside">
<div v-if="$slots.default" class="pos-order-items__status">
<slot></slot>
</div>
<div
v-if="showHeaderActions && panel_tabs[0].active"
class="pos-order-items__utility-actions"
data-auto-excel-export-actions="1"
data-testid="pos-order-header-export-actions"
>
<slot name="headerActions"></slot>
</div>
</div>
</div>
<div
v-if="showSidebarTabsInHeader"
class="tabs is-small is-centered pos-order-items__sidebar-tabs pos-sidebar-panel-tabs is-hidden-touch"
data-testid="pos-order-sidebar-tabs"
>
<ul>
<li v-for="(tab, index) in panel_tabs" :key="index" :class="{ 'is-active': tab.active }">
<a @click="activateTab(tab)">
<span v-if="tab.icon" class="icon is-small">
<i :class="tab.icon"></i>
</span>
<span>{{ tab.name }}</span>
</a>
</li>
</ul>
</div>
</header>
<nav class="pos-order-items__tabs-row is-hidden-touch" v-if="!showSidebarTabsInHeader && panel_tabs.length > 1">
<p class="panel-tabs pos-order-items__tabs">
<a
v-for="(tab, index) in panel_tabs"
:key="index"
:class="{ 'is-active': tab.active }"
:data-testid="isOrderDetailVariant ? `pos-order-tab-${tab.id}` : undefined"
@click="activateTab(tab)"
>
<span v-if="tab.icon" class="icon is-small">
<i :class="tab.icon"></i>
</span>
{{ tab.name }}
</a>
</p>
</nav>
<!-- Mobile nav -->
<div class="is-hidden-desktop mt-2">
<div class="buttons is-centered is-mobile pos-order-items__mobile-tabs" data-testid="pos-order-mobile-tabs">
<button
v-for="(tab, index) in panel_tabs"
:key="index"
:class="['button', 'is-fullwidth', tab.active ? 'is-link' : 'is-light']"
:data-testid="isOrderDetailVariant ? `pos-order-tab-${tab.id}` : undefined"
@click="activateTab(tab)"
>
<!-- If the tab has an icon, show it -->
<span v-if="tab.icon" class="icon is-small">
<i :class="tab.icon"></i>
</span>
<span>{{ tab.name }}</span>
</button>
</div>
</div>
<!-- Cart -->
<div
v-if="panel_tabs[0].active"
class="pos-order-items__tab-panel"
:data-testid="isOrderDetailVariant ? 'pos-order-panel-cart' : undefined"
>
<div class="panel-block pos-order-items__panel-block pos-order-items__panel-block--cart">
<div v-if="isOrderDetailVariant" class="pos-order-items__order-detail-cart">
<div v-if="$slots.beforeItems" class="pos-order-items__table-banner">
<slot name="beforeItems"></slot>
</div>
<PosOrderDetailItemsTable
:rows="orderDetailRows"
:total-price="totalPrice"
:show-actions="Boolean(editPossible)"
:can-edit-order-items="canEditOrderItems"
@edit="openOrderItemEditor"
@delete="deleteOrderItem"
/>
<div v-if="$slots['bottom-order-items']" class="pos-order-items__table-actions">
<slot name="bottom-order-items"></slot>
</div>
</div>
<div v-else class="pos-order-items__default-cart">
<div v-if="$slots.beforeItems" class="pos-order-items__table-banner">
<slot name="beforeItems"></slot>
</div>
<PosOrderItemsDefaultTable
:rows="orderDetailRows"
:total-price="totalPrice"
:show-actions="Boolean(editPossible)"
:can-edit-order-items="canEditOrderItems"
@edit="openOrderItemEditor"
@delete="deleteOrderItem"
/>
<div v-if="$slots['bottom-order-items']" class="pos-order-items__table-actions">
<slot name="bottom-order-items"></slot>
</div>
</div>
<table v-if="false" class="table is-fullwidth pos-order-items__table">
<thead>
<tr>
<th>{{ $t("admin.pos.product") }}</th>
<th style="width: 20%; text-align: right">{{ $t("admin.pos.price_dkk") }}</th>
<th
v-if="editPossible"
class="pos-order-items__actions-header"
:style="{ width: isOrderDetailVariant ? '18%' : '15%' }"
>
{{ isOrderDetailVariant ? $t("global.actions") : "" }}
</th>
</tr>
</thead>
<tbody>
<!-- Slot -->
<slot name="beforeItems"></slot>
<template v-for="orderItem in visibleOrderItems" :key="orderItem.id">
<tr
:id="'order-item-' + orderItem.id"
v-if="!orderItem.related_item_id"
:style="{
'border-bottom-style':
getItemRelatedItems(visibleOrderItems, orderItem.id).length > 0 ? 'hidden' : '',
}"
style="border-top: 1px solid #f5f5f5"
>
<td>
<span>{{ getOrderItemProductName(orderItem) }}</span>
<!-- Notes (If there are any) -->
<span
v-if="orderItem.notes"
@mouseover="showPopper(popperBox('Note', orderItem.notes), $event.target)"
@mouseleave="removePopperIfOpen()"
@click="openOrderItemEditor(orderItem)"
class="ml-2 is-size-7 has-text-grey is-clickable"
>
<i class="fas fa-comment"></i>
{{ $t("admin.pos.see_note") }}
</span>
<!-- Reference (If there are any) -->
<span
v-if="orderItem.reference"
@mouseover="showPopper(popperBox('Reference', orderItem.reference), $event.target)"
@mouseleave="removePopperIfOpen()"
@click="openOrderItemEditor(orderItem)"
class="ml-2 is-size-7 has-text-grey is-clickable"
>
<i class="fas fa-link"></i>
{{ $t("admin.pos.see_reference") }}
</span>
<!-- Quantity (If it is more than 1) -->
<span
v-if="orderItem.quantity > 1"
@mouseover="showPopper(popperBox('Antal', orderItem.quantity), $event.target)"
@mouseleave="removePopperIfOpen()"
@click="openOrderItemEditor(orderItem)"
class="ml-2 is-size-7 has-text-grey is-clickable"
>
<i class="fas fa-box"></i>
{{ $t("admin.pos.quantity") }}: {{ orderItem.quantity }}
</span>
</td>
<!-- If the price has been changed, show the new price (With a strike-through) and the old price -->
<td style="width: 20%; text-align: right" v-if="orderItem.price !== getOrderItemBasePrice(orderItem)">
<span
@mouseover="
showPopper(
popperBox(
'Pris',
'Pris ændret fra ' +
getOrderItemBasePrice(orderItem) +
' DKK til ' +
orderItem.price +
' DKK / Enhed (' +
orderItem.price * orderItem.quantity +
' DKK i alt)'
),
$event.target
)
"
@mouseleave="removePopperIfOpen()"
>
<span style="text-decoration: line-through" class="has-text-dark">{{
getOrderItemBasePrice(orderItem) * orderItem.quantity
}}</span>
<span>{{ orderItem.price * orderItem.quantity }}</span>
</span>
</td>
<td style="width: 20%; text-align: right" v-else>
<span
@mouseover="
showPopper(
popperBox(
'Pris',
orderItem.price + ' DKK / Enhed (' + orderItem.price * orderItem.quantity + ' DKK i alt)'
),
$event.target
)
"
@mouseleave="removePopperIfOpen()"
>
{{ orderItem.price * orderItem.quantity }}
</span>
</td>
<td
v-if="editPossible"
class="pos-order-items__action-cell"
:style="{ width: isOrderDetailVariant ? '18%' : '15%' }"
>
<div v-if="showInlineActionButtons" class="pos-order-items__item-actions">
<button
class="button is-small pos-order-items__item-action pos-order-items__item-action--edit"
type="button"
:data-testid="`pos-order-item-edit-${orderItem.id}`"
:disabled="!canEditOrderItems"
:aria-label="$t('admin.pos.edit')"
:title="$t('admin.pos.edit')"
@click="openOrderItemEditor(orderItem)"
>
<span class="icon is-small"><i class="fas fa-pen"></i></span>
</button>
<button
class="button is-small pos-order-items__item-action pos-order-items__item-action--delete"
type="button"
:data-testid="`pos-order-item-delete-${orderItem.id}`"
:aria-label="$t('admin.pos.delete')"
:title="$t('admin.pos.delete')"
@click="deleteOrderItem(orderItem.id)"
>
<span class="icon is-small"><i class="fas fa-trash"></i></span>
</button>
</div>
<div v-else class="buttons is-narrow">
<ActionSettingsWheelButton>
<template #actions>
<ActionSettingsWheelItem
:label="$t('admin.pos.delete')"
icon="fas fa-trash"
template="danger"
@click="deleteOrderItem(orderItem.id)"
/>
<ActionSettingsWheelItem
:label="$t('admin.pos.edit')"
icon="fas fa-edit"
template="warning"
:disabled="!SessionUser.hasPermission('edit_order_items') || props.disallowItemEdit"
@click="openOrderItemEditor(orderItem)"
/>
</template>
</ActionSettingsWheelButton>
</div>
</td>
</tr>
<!-- Related items -->
<template
v-for="relatedItem in getItemRelatedItems(visibleOrderItems, orderItem.id)"
:key="relatedItem.id"
>
<tr
:id="'order-item-' + relatedItem.id"
:style="{
'border-bottom-style': isLastRelatedItem(visibleOrderItems, relatedItem) ? '' : 'hidden',
}"
>
<td>
<span class="related-item-text is-one-line-hide-overflow">
<span
>+ <template v-if="relatedItem.quantity > 1">{{ relatedItem.quantity }} x</template
>{{ getOrderItemProductName(relatedItem) }}</span
>
</span>
<span class="is-size-7 has-text-grey">
<!-- Notes (If there are any) -->
<span
v-if="relatedItem.notes"
@mouseover="showPopper(popperBox('Note', relatedItem.notes), $event.target)"
@mouseleave="removePopperIfOpen()"
@click="openOrderItemEditor(relatedItem)"
class="ml-2 is-size-7 has-text-grey is-clickable"
>
<i class="fas fa-comment"></i>
{{ $t("admin.pos.see_note") }}
</span>
<!-- Reference (If there are any) -->
<span
v-if="relatedItem.reference"
@mouseover="showPopper(popperBox('Reference', relatedItem.reference), $event.target)"
@mouseleave="removePopperIfOpen()"
@click="openOrderItemEditor(relatedItem)"
class="ml-2 is-size-7 has-text-grey is-clickable"
>
<i class="fas fa-link"></i>
{{ $t("admin.pos.see_reference") }}
</span>
</span>
</td>
<!-- If the price has been changed, show the new price (With a strike-through) and the old price -->
<td
style="width: 20%; text-align: right"
v-if="relatedItem.price !== getOrderItemBasePrice(relatedItem)"
>
<span
@mouseover="
showPopper(
popperBox(
'Pris',
'Pris ændret fra ' +
getOrderItemBasePrice(relatedItem) +
' DKK til ' +
relatedItem.price +
' DKK / Enhed (' +
relatedItem.price * relatedItem.quantity +
' DKK i alt)'
),
$event.target
)
"
@mouseleave="removePopperIfOpen()"
>
<span style="text-decoration: line-through" class="has-text-dark related-item-text">{{
getOrderItemBasePrice(relatedItem) * relatedItem.quantity
}}</span>
<span>{{ relatedItem.price * relatedItem.quantity }} DKK</span>
</span>
</td>
<td style="width: 20%; text-align: right" v-else>
<span
class="related-item-text"
@mouseover="
showPopper(
popperBox(
'Pris',
relatedItem.price +
' DKK / Enhed (' +
relatedItem.price * relatedItem.quantity +
' DKK i alt)'
),
$event.target
)
"
@mouseleave="removePopperIfOpen()"
>
{{ relatedItem.price * relatedItem.quantity }}
</span>
</td>
<td
v-if="editPossible"
class="pos-order-items__action-cell"
:style="{ width: isOrderDetailVariant ? '18%' : '15%' }"
>
<div v-if="showInlineActionButtons" class="pos-order-items__item-actions">
<button
class="button is-small pos-order-items__item-action pos-order-items__item-action--edit"
type="button"
:data-testid="`pos-order-item-edit-${relatedItem.id}`"
:disabled="!canEditOrderItems"
:aria-label="$t('admin.pos.edit')"
:title="$t('admin.pos.edit')"
@click="openOrderItemEditor(relatedItem)"
>
<span class="icon is-small"><i class="fas fa-pen"></i></span>
</button>
<button
class="button is-small pos-order-items__item-action pos-order-items__item-action--delete"
type="button"
:data-testid="`pos-order-item-delete-${relatedItem.id}`"
:aria-label="$t('admin.pos.delete')"
:title="$t('admin.pos.delete')"
@click="deleteOrderItem(relatedItem.id)"
>
<span class="icon is-small"><i class="fas fa-trash"></i></span>
</button>
</div>
<div v-else class="buttons is-narrow">
<ActionSettingsWheelButton>
<template #actions>
<ActionSettingsWheelItem
:label="$t('admin.pos.delete')"
icon="fas fa-trash"
template="danger"
@click="deleteOrderItem(relatedItem.id)"
/>
<ActionSettingsWheelItem
:label="$t('admin.pos.edit')"
icon="fas fa-edit"
template="warning"
:disabled="!SessionUser.hasPermission('edit_order_items') || props.disallowItemEdit"
@click="openOrderItemEditor(relatedItem)"
/>
</template>
</ActionSettingsWheelButton>
</div>
</td>
</tr>
</template>
</template>
<slot name="bottom-order-items"></slot>
</tbody>
<tfoot>
<tr>
<td>{{ $t("admin.pos.total") }}</td>
<td colspan="1" style="width: 20%; text-align: right">
<span
@mouseover="
showPopper(popperBox('Total', 'Total pris for ordren: ' + totalPrice + ' DKK'), $event.target)
"
@mouseleave="removePopperIfOpen()"
>
{{ totalPrice }} DKK
</span>
</td>
<td v-if="editPossible" style="width: 15%"></td>
</tr>
</tfoot>
</table>
</div>
<div
class="panel-block pos-order-items__panel-block pos-order-items__panel-block--secondary"
v-if="$slots.bottom"
>
<!-- Bottom slot -->
<slot name="bottom"></slot>
</div>
</div>
<!-- Attachments -->
<div
v-if="panel_tabs[1] && panel_tabs[1].active"
:data-testid="isOrderDetailVariant ? 'pos-order-panel-attachments' : undefined"
>
<div class="panel-block is-fullwidth pos-order-items__panel-block pos-order-items__panel-block--secondary">
<!-- Attachments slot -->
<slot name="attachments"></slot>
</div>
</div>
<!-- Advanced -->
<div
v-if="panel_tabs[2] && panel_tabs[2].active"
:data-testid="isOrderDetailVariant ? 'pos-order-panel-settings' : undefined"
>
<div class="panel-block is-fullwidth pos-order-items__panel-block pos-order-items__panel-block--secondary">
<!-- Other slot -->
<slot name="other"></slot>
</div>
</div>
<!-- Details -->
<div
v-if="panel_tabs[3] && panel_tabs[3].active"
:data-testid="isOrderDetailVariant ? 'pos-order-panel-details' : undefined"
>
<div class="panel-block is-fullwidth pos-order-items__panel-block pos-order-items__panel-block--secondary">
<!-- Details slot -->
<slot name="details"></slot>
</div>
</div>
<div v-if="panel_tabs[0].active" class="pos-order-items__footer">
<div
class="pos-order-items__metadata"
:data-testid="isOrderDetailVariant ? 'pos-order-metadata' : undefined"
v-if="isTabZeroExpanded"
>
<div class="pos-order-metadata-grid" data-testid="pos-order-metadata-grid">
<section
class="pos-order-metadata-card pos-order-metadata-card--license-plates"
data-testid="pos-order-metadata-license-plates"
>
<p class="pos-order-metadata-label">{{ $t("admin.pos.license_plates") }}</p>
<PosOrderLicensePlates
v-bind:registration_numbers="registration_numbers"
v-bind:is-loading="registrationNumbersLoading"
v-bind:loadOrder="loadOrder"
v-bind:order_id="activeOrderId"
/>
</section>
<section
class="pos-order-metadata-card pos-order-metadata-card--customer-wishes"
data-testid="pos-order-metadata-customer-wishes"
>
<p class="pos-order-metadata-label">{{ $t("admin.pos.customer_wishes") }}</p>
<POSOrderCustomerWishes
v-bind:order_id="activeOrderId"
v-bind:reference="reference"
v-bind:po="order_po"
v-bind:safetySeal="order_safety_seal"
v-bind:showSafetySeal="showSafetySealField"
v-bind:referenceRequired="referenceRequired"
v-bind:poRequired="poRequired"
v-bind:onFieldSaved="handleCustomerWishesFieldSaved"
/>
</section>
<section
class="pos-order-metadata-card pos-order-metadata-card--note"
data-testid="pos-order-metadata-note"
>
<p class="pos-order-metadata-label">{{ $t("admin.pos.order_note") }}</p>
<POSOrderNote v-bind:order_id="activeOrderId" v-bind:loadOrder="loadOrder" v-bind:note="order_notes" />
</section>
</div>
</div>
<!-- Expander -->
<div class="pos-order-items__expander">
<p
class="has-text-centered pos-order-items__expander-trigger"
@click="toggleIsSelected"
@mouseover="expandIcon?.classList.add('has-text-link')"
@mouseleave="expandIcon?.classList.remove('has-text-link')"
>
<span class="icon is-small" style="color: #c7c7c7">
<i
class="fas fa-angle-down fa-lg"
:class="{ 'fa-rotate-180': isTabZeroExpanded }"
aria-hidden="true"
ref="expandIcon"
></i>
</span>
</p>
</div>
</div>
</article>
<PosOrderItemEditModal
v-if="isOrderDetailVariant"
v-model="isOrderItemEditModalOpen"
:order-item="selectedOrderItem"
:can-edit="canEditOrderItems"
@saved="handleOrderItemSaved"
@update:modelValue="
(value) => {
if (!value) {
closeOrderItemEditor();
}
}
"
/>
</div>
</template>
<style scoped>
.pos-order-items {
display: flex;
flex-direction: column;
gap: 0;
}
.pos-order-items__header {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.1rem 1.25rem 0;
}
.pos-order-items__header-main {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem 1.25rem;
align-items: start;
}
.pos-order-items__heading {
min-width: 0;
}
.pos-order-items__title {
margin: 0;
line-height: 1.15;
}
.pos-order-items--sidebar .pos-order-items__header {
gap: 0.75rem;
padding: 0.95rem 1rem 0.75rem;
border-bottom: 1px solid #e6edf7;
}
.pos-order-items--sidebar .pos-order-items__title {
font-size: 1.05rem;
line-height: 1.2;
color: #26384c;
}
.pos-order-items--sidebar .pos-order-items__subheading {
margin-top: 0.35rem;
}
.pos-order-items__sidebar-tabs {
width: 100%;
}
.pos-order-items--sidebar .pos-order-items__tabs-row {
padding-left: 1rem;
padding-right: 1rem;
}
.pos-order-items--sidebar .pos-order-items__panel-block,
.pos-order-items--sidebar .pos-order-items__footer {
padding-left: 1rem;
padding-right: 1rem;
}
.pos-order-items__subheading {
--pos-order-subheading-font-size: 0.75rem;
--pos-order-subheading-line-height: 1.5;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem 1rem;
margin-top: 0.45rem;
}
.pos-order-items__transaction {
margin: 0;
color: #6b7a90;
font-size: var(--pos-order-subheading-font-size);
font-weight: 600;
line-height: var(--pos-order-subheading-line-height);
}
.pos-order-items__headingcenter {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
min-width: min(100%, 34rem);
}
.pos-order-items__header-aside {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: flex-start;
gap: 0.75rem;
min-width: 0;
}
.pos-order-items__status {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.pos-order-items__status :deep(.tag) {
margin: 0;
}
.pos-order-items__utility-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.pos-order-items__utility-actions:empty {
display: none;
}
.pos-order-items__tabs-row {
padding: 0 1.25rem;
border-top: 1px solid #e6edf7;
}
.pos-order-items__tabs {
margin: 0;
border-bottom: 0;
}
.pos-order-items__tabs a {
min-height: 2.4rem;
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: none;
border-radius: 999px;
padding: 0.45rem 0.9rem;
color: #56657a;
font-weight: 600;
}
.pos-order-items__tabs a.is-active,
.pos-order-items__tabs .is-active {
border-bottom: none;
}
.pos-order-items__tabs a.is-active,
.pos-order-items__tabs .is-active a {
background: #eef4ff;
color: #21446d;
}
.pos-order-items--order-detail .pos-order-items__tabs-row {
padding-top: 0.15rem;
padding-bottom: 0.2rem;
}
.pos-order-items--order-detail .pos-order-items__tabs ul {
justify-content: flex-start;
gap: 0.35rem;
flex-wrap: wrap;
border-bottom: none;
}
.pos-order-items--order-detail .pos-order-items__tabs a {
min-height: 2.1rem;
padding: 0.45rem 0.85rem;
border: none;
background: transparent;
color: #5a6980;
box-shadow: none;
}
.pos-order-items--order-detail .pos-order-items__tabs a:hover {
background: #f6f9ff;
color: #21446d;
}
.pos-order-items--order-detail .pos-order-items__tabs a.is-active,
.pos-order-items--order-detail .pos-order-items__tabs .is-active a {
background: #eef4ff;
color: #21446d;
}
.pos-order-items__panel-block {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.pos-order-items__panel-block--cart {
padding-top: 0.85rem;
padding-bottom: 1.25rem;
}
.pos-order-items__panel-block--secondary {
padding-top: 1rem;
padding-bottom: 1.25rem;
}
.pos-order-items__table {
table-layout: fixed;
margin-bottom: 0;
}
.pos-order-items__order-detail-cart {
width: 100%;
display: grid;
gap: 0.9rem;
}
.pos-order-items__default-cart {
width: 100%;
display: grid;
gap: 0.9rem;
}
.pos-order-items__table-banner:empty,
.pos-order-items__table-actions:empty {
display: none;
}
.pos-order-items__metadata {
margin: 0;
}
.pos-order-items--order-detail.pos-order-items--stacked-metadata .pos-order-metadata-grid {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"license-plates"
"customer-wishes"
"note";
}
.pos-order-items__footer {
padding: 0 1.25rem 0.4rem;
}
.pos-order-items__expander {
padding-top: 0.35rem;
}
.pos-order-items__expander-trigger {
margin: 0;
color: #8fa0b7;
cursor: pointer;
}
.pos-order-items__actions-header {
text-align: right;
color: #6b7a90;
font-size: 0.8rem;
font-weight: 700;
}
.pos-order-items__action-cell {
white-space: nowrap;
text-align: right;
}
.pos-order-items__item-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0.45rem;
}
.pos-order-items__item-action {
min-width: 2.2rem;
min-height: 2.2rem;
border-radius: 0.75rem;
}
.pos-order-items__item-action--edit {
border-color: #d8e2ee;
background: #f7f9fc;
color: #22446d;
}
.pos-order-items__item-action--edit:hover,
.pos-order-items__item-action--edit:focus-visible {
border-color: #9bb3cf;
background: #eef4fb;
color: #173558;
}
.pos-order-items__item-action--delete {
border-color: #f0d2d2;
background: #fff6f6;
color: #b24141;
}
.pos-order-items__item-action--delete:hover,
.pos-order-items__item-action--delete:focus-visible {
border-color: #e4b5b5;
background: #ffecec;
color: #9a2d2d;
}
.related-item-text {
color: #4a4a4a;
font-size: 0.9rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
@media screen and (max-width: 768px) {
.pos-order-items__header {
padding: 1rem 1rem 0;
}
.pos-order-items__mobile-tabs {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.pos-order-items__header-main {
grid-template-columns: minmax(0, 1fr);
}
.pos-order-items__headingcenter {
min-width: 0;
}
.pos-order-items__header-aside,
.pos-order-items__status,
.pos-order-items__utility-actions {
justify-content: flex-start;
}
.pos-order-items__tabs-row {
padding: 0 1rem;
}
.pos-order-items__panel-block {
padding-left: 1rem;
padding-right: 1rem;
}
.pos-order-items__footer {
padding-left: 1rem;
padding-right: 1rem;
}
.pos-order-items__action-cell {
text-align: left;
}
.pos-order-items__item-actions {
justify-content: flex-start;
}
}
</style>