Files
pleno-vue/src/components/displays/department/pos/steps/elements/PosDesktopLastWashCard.vue
T
Jeppe Bundgaard ebcd52a019 Add endpoint mode options, release channel rollback handling, and runtime refresh improvements
- Implement `endpoint_mode` (manual/auto) and related configuration in i18n across multiple locales.
- Add new utility methods for channel rollback and runtime confirmation workflows.
- Update unit and E2E tests to cover runtime refresh behavior, channel switching mechanics, and release references.
- Enhance `SessionUser` and related services to improve error handling during release runtime refresh.
- Add data-test attributes for frontend components to support improved test coverage.
2026-05-21 14:47:46 +02:00

245 lines
7.8 KiB
Vue

<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const props = defineProps({
orderId: {
type: [Number, String],
default: null,
},
copying: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["copy"]);
const { locale, t } = useI18n();
const order = ref(null);
const orderItems = ref([]);
const loadState = ref("idle");
const parsePositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const normalizedOrderId = computed(() => parsePositiveInteger(props.orderId));
const formatDateTime = (value) => {
if (!value) {
return SessionUser.objects.global.language.no_data;
}
const parsedDate = new Date(value);
if (Number.isNaN(parsedDate.getTime())) {
return String(value);
}
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(parsedDate);
};
const loadOrderSummary = async (candidateOrderId = normalizedOrderId.value) => {
if (!candidateOrderId) {
order.value = null;
orderItems.value = [];
loadState.value = "idle";
return;
}
loadState.value = "loading";
try {
const [nextOrder, orderItemsResponse] = await Promise.all([
SessionUser.objects.orders.get.single(candidateOrderId),
SessionUser.request("/order/items", "GET", {
order_id: candidateOrderId,
}),
]);
order.value = nextOrder || null;
orderItems.value = Array.isArray(orderItemsResponse?.data?.data) ? orderItemsResponse.data.data : [];
loadState.value = "loaded";
} catch (error) {
console.error("Unable to load last wash summary", error);
order.value = null;
orderItems.value = [];
loadState.value = "error";
}
};
const groupedOrderItems = computed(() => {
const items = Array.isArray(orderItems.value) ? orderItems.value : [];
const primaryItems = items.filter((item) => !item?.related_item_id);
return primaryItems.map((item) => ({
item,
addons: items.filter((candidate) => candidate?.related_item_id === item.id),
}));
});
const getOrderItemProductId = (item) => parsePositiveInteger(item?.product?.id ?? item?.product_id);
const getOrderItemQuantity = (item) => {
const parsedQuantity = Number.parseInt(String(item?.quantity ?? 1), 10);
return Number.isInteger(parsedQuantity) && parsedQuantity > 0 ? parsedQuantity : null;
};
const hasUsableOrderItems = computed(() => {
return groupedOrderItems.value.some((group) => getOrderItemProductId(group.item) && getOrderItemQuantity(group.item));
});
const isCopyDisabled = computed(() => loadState.value !== "loaded" || props.copying || !hasUsableOrderItems.value);
const copyButtonLabel = computed(() => {
const lastWashLabel = SessionUser.objects.global.language.last_wash || t("common.last_wash");
return `${SessionUser.objects.global.language.copy || t("common.copy")} ${lastWashLabel.toLowerCase()}`;
});
const copyButtonText = computed(() => (props.copying ? t("pos.copying_last_wash") : copyButtonLabel.value));
const copyButtonState = computed(() => {
if (props.copying) {
return "copying";
}
if (isCopyDisabled.value) {
return "disabled";
}
return "ready";
});
const onCopy = () => {
if (isCopyDisabled.value) {
return;
}
emit("copy", {
order: order.value,
orderId: normalizedOrderId.value,
items: orderItems.value,
});
};
const lastWashMetadata = computed(() => {
const currentOrder = order.value || {};
return [
{
key: "created",
label: t("common.created"),
value: formatDateTime(currentOrder.created_at),
},
{
key: "reference",
label: SessionUser.objects.orders.columns.reference.label,
value: String(currentOrder.reference ?? "").trim() || SessionUser.objects.global.language.no_data,
},
{
key: "reg1",
label: SessionUser.objects.orders.columns.reg_1.label,
value: String(currentOrder.reg_1 ?? "").trim() || SessionUser.objects.global.language.no_data,
},
];
});
onMounted(() => {
void loadOrderSummary();
});
watch(normalizedOrderId, (nextOrderId, previousOrderId) => {
if (nextOrderId === previousOrderId) {
return;
}
void loadOrderSummary(nextOrderId);
}, { immediate: false });
</script>
<template>
<section class="pos-step-one-insight-card" data-testid="pos-desktop-last-wash">
<header class="pos-step-one-insight-card__header">
<div class="pos-step-one-insight-card__heading">
<p class="pos-step-one-insight-card__eyebrow">{{ SessionUser.objects.global.language.last_wash }}</p>
<h3 class="pos-step-one-insight-card__title">
{{ formatDateTime(order?.created_at) }}
</h3>
<p class="pos-step-one-insight-card__subtitle">
{{ `${t("admin.pos.transaction")} #${normalizedOrderId}` }}
</p>
</div>
<span class="pos-step-one-insight-pill pos-step-one-insight-pill--muted">
{{ String(order?.reg_1 ?? "").trim() || SessionUser.objects.global.language.no_data }}
</span>
</header>
<div v-if="loadState === 'loading'" class="pos-step-one-loading-state">
Indlæser sidste vask...
</div>
<template v-else>
<dl class="pos-step-one-insight-meta pos-step-one-insight-meta--compact">
<div
v-for="item in lastWashMetadata"
:key="item.key"
class="pos-step-one-insight-meta__item"
>
<dt class="pos-step-one-insight-meta__label">{{ item.label }}</dt>
<dd class="pos-step-one-insight-meta__value">{{ item.value }}</dd>
</div>
</dl>
<section class="pos-step-one-insight-card__section" data-testid="pos-desktop-last-wash-section">
<div class="pos-step-one-insight-card__section-header">
<h4 class="pos-step-one-insight-card__section-title">Indhold</h4>
</div>
<ul v-if="groupedOrderItems.length > 0" class="pos-step-one-insight-list">
<template v-for="group in groupedOrderItems" :key="group.item.id">
<li class="pos-step-one-insight-list__item">
<span class="pos-step-one-insight-list__label">
{{ group.item?.product?.name || SessionUser.objects.global.language.no_data }}
</span>
<span class="pos-step-one-insight-list__quantity">{{ Number(group.item?.quantity ?? 1) }}</span>
</li>
<li
v-for="addon in group.addons"
:key="addon.id"
class="pos-step-one-insight-list__item pos-step-one-insight-list__item--addon"
>
<span class="pos-step-one-insight-list__label">
<span class="pos-step-one-insight-list__prefix">+</span>
{{ addon?.product?.name || SessionUser.objects.global.language.no_data }}
</span>
<span class="pos-step-one-insight-list__quantity">{{ Number(addon?.quantity ?? 1) }}</span>
</li>
</template>
</ul>
<div v-else class="pos-step-one-empty-state">
{{ loadState === "error" ? "Kunne ikke indlæse sidste vask" : SessionUser.objects.global.language.no_data }}
</div>
</section>
<button
type="button"
class="button is-small is-light pos-step-one-copy-action"
data-testid="pos-desktop-last-wash-copy"
:data-state="copyButtonState"
:disabled="isCopyDisabled"
:aria-busy="props.copying ? 'true' : 'false'"
@click="onCopy"
>
<span class="icon is-small">
<i :class="props.copying ? 'fas fa-spinner fa-spin' : 'fas fa-copy'" aria-hidden="true"></i>
</span>
<span>{{ copyButtonText }}</span>
</button>
</template>
</section>
</template>
<style scoped>
</style>