- 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.
245 lines
7.8 KiB
Vue
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>
|