265 lines
9.2 KiB
Vue
265 lines
9.2 KiB
Vue
<script setup lang="ts">
|
|
import type { Aftersale, InvoiceKind, Order, Shipment } from "@vmall/shared";
|
|
import { t as pick } from "@vmall/shared";
|
|
|
|
definePageMeta({ middleware: "auth" });
|
|
|
|
const { locale, t } = useI18n();
|
|
const { $api } = useNuxtApp();
|
|
const route = useRoute();
|
|
|
|
const order = ref<Order | null>(null);
|
|
const shipments = ref<Shipment[]>([]);
|
|
const aftersales = ref<Aftersale[]>([]);
|
|
const loading = ref(true);
|
|
const confirmingShipmentId = ref("");
|
|
const invoiceWorking = ref(false);
|
|
const invoiceSuccess = ref(false);
|
|
const invoiceErrorKey = ref("");
|
|
|
|
const invoiceTitle = ref("");
|
|
const invoiceTaxNo = ref("");
|
|
const invoiceKind = ref<InvoiceKind>("personal");
|
|
|
|
const orderId = computed(() => String(route.params.id ?? ""));
|
|
|
|
const orderShipments = computed(() =>
|
|
shipments.value.filter((shipment) => shipment.order_id === order.value?.id),
|
|
);
|
|
const activeAftersaleItemIds = computed(
|
|
() =>
|
|
new Set(
|
|
aftersales.value
|
|
.filter((row) =>
|
|
["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status),
|
|
)
|
|
.map((row) => row.order_item_id),
|
|
),
|
|
);
|
|
|
|
function canApply(itemId: string): boolean {
|
|
return Boolean(
|
|
order.value &&
|
|
["paid", "fulfilling", "shipped", "completed"].includes(order.value.status) &&
|
|
!activeAftersaleItemIds.value.has(itemId),
|
|
);
|
|
}
|
|
|
|
async function load(): Promise<void> {
|
|
loading.value = true;
|
|
try {
|
|
const [o, allShipments, mine] = await Promise.all([
|
|
$api.getOrder(orderId.value),
|
|
$api.listMyShipments(),
|
|
$api.listMyAftersales(),
|
|
]);
|
|
order.value = o;
|
|
shipments.value = allShipments;
|
|
aftersales.value = mine;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function confirmDelivery(shipment: Shipment): Promise<void> {
|
|
confirmingShipmentId.value = shipment.id;
|
|
try {
|
|
await $api.confirmDelivered(shipment.id);
|
|
await load();
|
|
} finally {
|
|
confirmingShipmentId.value = "";
|
|
}
|
|
}
|
|
|
|
async function submitInvoice(): Promise<void> {
|
|
invoiceErrorKey.value = "";
|
|
if (!order.value || !invoiceTitle.value.trim()) {
|
|
invoiceErrorKey.value = "user.validationRequired";
|
|
return;
|
|
}
|
|
invoiceWorking.value = true;
|
|
try {
|
|
await $api.requestInvoice(
|
|
order.value.id,
|
|
invoiceTitle.value.trim(),
|
|
invoiceTaxNo.value.trim() || null,
|
|
invoiceKind.value,
|
|
);
|
|
invoiceSuccess.value = true;
|
|
} catch {
|
|
invoiceErrorKey.value = "user.loadFailed";
|
|
} finally {
|
|
invoiceWorking.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
void load();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<VCard class="min-h-[560px]">
|
|
<h1 class="text-text mb-4 text-xl font-bold">{{ t("user.orderDetail") }}</h1>
|
|
<div v-if="loading" class="text-muted py-5">{{ t("common.loading") }}</div>
|
|
<UiEmptyState v-else-if="!order" :text="t('user.noOrders')" />
|
|
<div v-else class="space-y-4">
|
|
<header
|
|
class="border-border bg-bg flex items-center justify-between border p-3.5 max-sm:flex-col max-sm:items-start max-sm:gap-2.5"
|
|
>
|
|
<div>
|
|
<strong class="text-text text-[15px]"
|
|
>{{ t("user.orderNo") }} {{ order.order_no }}</strong
|
|
>
|
|
<p class="text-muted mt-1 text-xs">
|
|
{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}
|
|
</p>
|
|
</div>
|
|
<StatusBadge :status="order.status" kind="order" />
|
|
</header>
|
|
|
|
<VCard :padded="false" class="p-5">
|
|
<h2 class="text-text mb-3.5 text-[15px] font-semibold">{{ t("user.shippingAddress") }}</h2>
|
|
<p class="text-text m-0 text-sm">
|
|
{{ order.shipping_address.recipient }} · {{ order.shipping_address.phone }}
|
|
</p>
|
|
<p class="text-text mt-1 text-sm">
|
|
{{ order.shipping_address.country }} {{ order.shipping_address.region }}
|
|
{{ order.shipping_address.city }} {{ order.shipping_address.line1 }}
|
|
{{ order.shipping_address.postal_code }}
|
|
</p>
|
|
</VCard>
|
|
|
|
<VCard :padded="false" class="p-5">
|
|
<h2 class="text-text mb-3.5 text-[15px] font-semibold">{{ t("user.orderItems") }}</h2>
|
|
<VTable>
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t("user.product") }}</th>
|
|
<th>{{ t("common.price") }}</th>
|
|
<th>{{ t("common.qty") }}</th>
|
|
<th>{{ t("common.total") }}</th>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="item in order.items" :key="item.id">
|
|
<td>
|
|
<div class="flex items-center gap-2.5">
|
|
<img
|
|
class="border-border h-11 w-11 border object-contain"
|
|
:src="item.image || '/mock/product-1.svg'"
|
|
:alt="pick(item.product_name, locale)"
|
|
/><span>{{ pick(item.product_name, locale) }}</span
|
|
><VBadge v-if="item.flash_sale_item_id" tone="red">{{
|
|
t("marketing.flashTag")
|
|
}}</VBadge>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<PriceText :amount-minor="item.unit_price_minor" :currency="order.currency" />
|
|
</td>
|
|
<td>{{ item.qty }}</td>
|
|
<td>
|
|
<PriceText
|
|
:amount-minor="item.unit_price_minor * item.qty"
|
|
:currency="order.currency"
|
|
/>
|
|
</td>
|
|
<td>
|
|
<NuxtLink
|
|
v-if="canApply(item.id)"
|
|
class="text-primary no-underline"
|
|
:to="{
|
|
path: '/user/aftersales/apply',
|
|
query: { order_id: order.id, item_id: item.id },
|
|
}"
|
|
>{{ t("user.applyAftersaleForItem") }}</NuxtLink
|
|
>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</VTable>
|
|
<p v-if="order.discount_minor > 0" class="text-primary mt-4 text-right text-[13px]">
|
|
{{ t("checkout.couponDiscount") }}:
|
|
<PriceText :amount-minor="order.discount_minor" :currency="order.currency" />
|
|
</p>
|
|
<p class="text-muted mt-4 text-right text-[13px]">
|
|
{{ t("user.total") }}:
|
|
<PriceText
|
|
class="text-primary text-lg font-semibold"
|
|
:amount-minor="order.total_minor"
|
|
:currency="order.currency"
|
|
/>
|
|
</p>
|
|
</VCard>
|
|
|
|
<VCard :padded="false" class="p-5">
|
|
<h2 class="text-text mb-3.5 text-[15px] font-semibold">{{ t("user.shipments") }}</h2>
|
|
<UiEmptyState v-if="orderShipments.length === 0" :text="t('user.noShipments')" />
|
|
<div v-else class="space-y-2.5">
|
|
<div
|
|
v-for="shipment in orderShipments"
|
|
:key="shipment.id"
|
|
class="border-border flex items-center justify-between border p-3 max-sm:flex-col max-sm:items-start max-sm:gap-2.5"
|
|
>
|
|
<div>
|
|
<strong class="text-text text-sm"
|
|
>{{ t("user.shipmentNo") }} {{ shipment.shipment_no }}</strong
|
|
>
|
|
<p class="text-muted mt-1 text-xs">{{ t("user.carrier") }}: {{ shipment.carrier }}</p>
|
|
<p class="text-muted mt-1 text-xs">
|
|
{{ t("user.trackingNo") }}: {{ shipment.tracking_no }}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<StatusBadge :status="shipment.status" kind="shipment" /><VBtn
|
|
v-if="shipment.status === 'shipped'"
|
|
variant="primary"
|
|
size="sm"
|
|
type="button"
|
|
:disabled="confirmingShipmentId === shipment.id"
|
|
@click="confirmDelivery(shipment)"
|
|
>{{ t("user.confirmDelivery") }}</VBtn
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</VCard>
|
|
|
|
<VCard :padded="false" class="p-5">
|
|
<h2 class="text-text mb-3.5 text-[15px] font-semibold">{{ t("user.invoiceRequest") }}</h2>
|
|
<div v-if="invoiceSuccess" class="text-success text-sm">{{ t("user.invoiceSuccess") }}</div>
|
|
<form v-else class="grid max-w-[520px] gap-3" @submit.prevent="submitInvoice">
|
|
<VField :label="t('user.invoiceTitle')"><VInput v-model="invoiceTitle" /></VField>
|
|
<VField :label="t('user.taxNo')"><VInput v-model="invoiceTaxNo" /></VField>
|
|
<fieldset class="flex items-center gap-4 border-0 p-0">
|
|
<legend class="text-muted mb-1 block text-[13px]">{{ t("user.invoiceKind") }}</legend>
|
|
<label class="text-text text-[13px]"
|
|
><input
|
|
v-model="invoiceKind"
|
|
class="accent-primary mr-1"
|
|
type="radio"
|
|
value="personal"
|
|
/>
|
|
{{ t("user.personal") }}</label
|
|
><label class="text-text text-[13px]"
|
|
><input
|
|
v-model="invoiceKind"
|
|
class="accent-primary mr-1"
|
|
type="radio"
|
|
value="company"
|
|
/>
|
|
{{ t("user.company") }}</label
|
|
>
|
|
</fieldset>
|
|
<p v-if="invoiceErrorKey" class="text-danger m-0 text-xs">{{ t(invoiceErrorKey) }}</p>
|
|
<VBtn class="w-fit" variant="primary" type="submit" :disabled="invoiceWorking">{{
|
|
t("user.submitInvoice")
|
|
}}</VBtn>
|
|
</form>
|
|
</VCard>
|
|
</div>
|
|
</VCard>
|
|
</template>
|