feat(aftersale): per-line refund/return flow with ledger-backed completion (add-aftersale-refunds)
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
import type { AftersaleDetail, AftersaleStatus } from "@vmall/shared";
|
||||
import { t as pick } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const route = useRoute();
|
||||
const detail = ref<AftersaleDetail | null>(null);
|
||||
const loading = ref(true);
|
||||
const working = ref(false);
|
||||
const failed = ref(false);
|
||||
const errorKey = ref("");
|
||||
const carrier = ref("");
|
||||
const trackingNo = ref("");
|
||||
const message = ref("");
|
||||
const evidence = ref("");
|
||||
const progress: AftersaleStatus[] = ["pending", "approved", "buyer_shipping", "merchant_confirmed", "refunded"];
|
||||
|
||||
const id = computed(() => String(route.params.id ?? ""));
|
||||
|
||||
function tone(status: AftersaleStatus): "green" | "blue" | "red" | "orange" {
|
||||
if (status === "refunded") return "green";
|
||||
if (["approved", "buyer_shipping", "merchant_confirmed"].includes(status)) return "blue";
|
||||
if (["rejected", "cancelled"].includes(status)) return "red";
|
||||
return "orange";
|
||||
}
|
||||
|
||||
function statusLabel(status: AftersaleStatus): string {
|
||||
return t(`user.aftersaleStatus_${status}`);
|
||||
}
|
||||
|
||||
function progressTone(status: AftersaleStatus): string {
|
||||
if (!detail.value) return "border-border bg-surface text-muted";
|
||||
const current = progress.indexOf(detail.value.status);
|
||||
const step = progress.indexOf(status);
|
||||
return step >= 0 && current >= step ? "border-primary bg-primary text-white" : "border-border bg-surface text-muted";
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
failed.value = false;
|
||||
try {
|
||||
detail.value = await $api.getAftersale(id.value);
|
||||
carrier.value = detail.value.return_carrier ?? "";
|
||||
trackingNo.value = detail.value.return_tracking_no ?? "";
|
||||
} catch {
|
||||
failed.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(): Promise<void> {
|
||||
if (!detail.value) return;
|
||||
working.value = true;
|
||||
errorKey.value = "";
|
||||
try {
|
||||
await $api.cancelAftersale(detail.value.id);
|
||||
await load();
|
||||
} catch {
|
||||
errorKey.value = "user.aftersaleActionFailed";
|
||||
} finally {
|
||||
working.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reopen(): Promise<void> {
|
||||
if (!detail.value) return;
|
||||
working.value = true;
|
||||
errorKey.value = "";
|
||||
try {
|
||||
await $api.reopenAftersale(detail.value.id);
|
||||
await load();
|
||||
} catch {
|
||||
errorKey.value = "user.aftersaleActionFailed";
|
||||
} finally {
|
||||
working.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTracking(): Promise<void> {
|
||||
if (!detail.value || !carrier.value.trim() || !trackingNo.value.trim()) {
|
||||
errorKey.value = "user.validationRequired";
|
||||
return;
|
||||
}
|
||||
working.value = true;
|
||||
errorKey.value = "";
|
||||
try {
|
||||
await $api.submitAftersaleReturnTracking(detail.value.id, {
|
||||
carrier: carrier.value.trim(),
|
||||
tracking_no: trackingNo.value.trim(),
|
||||
});
|
||||
await load();
|
||||
} catch {
|
||||
errorKey.value = "user.aftersaleActionFailed";
|
||||
} finally {
|
||||
working.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(): Promise<void> {
|
||||
const content = message.value.trim();
|
||||
if (!detail.value || !content) {
|
||||
errorKey.value = "user.validationRequired";
|
||||
return;
|
||||
}
|
||||
working.value = true;
|
||||
errorKey.value = "";
|
||||
try {
|
||||
await $api.addAftersaleMessage(detail.value.id, {
|
||||
content: { en: content, zh: content },
|
||||
evidence: evidence.value.split("\n").map((url) => url.trim()).filter(Boolean),
|
||||
});
|
||||
message.value = "";
|
||||
evidence.value = "";
|
||||
await load();
|
||||
} catch {
|
||||
errorKey.value = "user.aftersaleActionFailed";
|
||||
} finally {
|
||||
working.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="min-h-[560px]">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<h1 class="m-0 text-xl font-bold text-text">{{ t("user.aftersaleDetail") }}</h1>
|
||||
<NuxtLink class="text-sm text-primary no-underline" to="/user/aftersales">{{ t("user.aftersalesTitle") }}</NuxtLink>
|
||||
</div>
|
||||
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
|
||||
<p v-else-if="failed || !detail" class="py-5 text-sm text-danger">{{ t("user.aftersaleLoadFailed") }}</p>
|
||||
<div v-else class="space-y-4">
|
||||
<header class="flex items-center justify-between gap-3 border border-border bg-bg p-4 max-sm:flex-col max-sm:items-start">
|
||||
<div>
|
||||
<p class="m-0 text-sm text-muted">{{ t("user.aftersaleOrder") }} {{ detail.order_id }}</p>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("user.aftersaleCreatedAt") }} {{ detail.created_at.slice(0, 10) }}</p>
|
||||
</div>
|
||||
<VBadge :tone="tone(detail.status)">{{ statusLabel(detail.status) }}</VBadge>
|
||||
</header>
|
||||
|
||||
<VCard :padded="false" class="p-5">
|
||||
<h2 class="mb-3 text-[15px] font-semibold text-text">{{ t("user.aftersaleItem") }}</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<img class="h-14 w-14 border border-border object-contain" :src="detail.item.image || '/mock/product-1.svg'" :alt="pick(detail.item.product_name, locale)" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="m-0 truncate text-sm text-text">{{ pick(detail.item.product_name, locale) }}</p>
|
||||
<p class="mt-1 text-xs text-muted">{{ detail.item.sku_code }} × {{ detail.item.qty }}</p>
|
||||
</div>
|
||||
<PriceText :amount-minor="detail.item.unit_price_minor * detail.item.qty" :currency="detail.currency" />
|
||||
</div>
|
||||
<div class="mt-4 grid gap-2 text-sm text-muted sm:grid-cols-2">
|
||||
<span>{{ t("user.aftersaleType") }}: {{ t(detail.kind === "refund_only" ? "user.refundOnly" : "user.returnRefund") }}</span>
|
||||
<span>{{ t("user.aftersaleAmount") }}: <PriceText :amount-minor="detail.amount_minor" :currency="detail.currency" /></span>
|
||||
<span>{{ t("user.aftersaleRemaining") }}: <PriceText :amount-minor="detail.remaining_refundable_minor" :currency="detail.currency" /></span>
|
||||
<span>{{ t("user.aftersaleReason") }}: {{ pick(detail.reason, locale) }}</span>
|
||||
</div>
|
||||
<div v-if="detail.evidence.length" class="mt-4 flex flex-wrap gap-2 text-sm">
|
||||
<a v-for="url in detail.evidence" :key="url" class="text-primary" :href="url" target="_blank" rel="noreferrer">{{ t("user.aftersaleViewEvidence") }}</a>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<VCard :padded="false" class="p-5">
|
||||
<h2 class="mb-4 text-[15px] font-semibold text-text">{{ t("user.aftersaleStatus") }}</h2>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<template v-for="(step, index) in progress" :key="step">
|
||||
<span class="rounded-full border px-3 py-1 text-xs" :class="progressTone(step)">{{ statusLabel(step) }}</span>
|
||||
<span v-if="index < progress.length - 1" class="text-muted">→</span>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="detail.status === 'rejected' || detail.status === 'cancelled'" class="mt-3 text-sm text-danger">{{ statusLabel(detail.status) }}</p>
|
||||
<div v-if="detail.return_carrier || detail.return_tracking_no" class="mt-3 text-sm text-muted">
|
||||
{{ t("user.returnShipping") }}: {{ detail.return_carrier }} {{ detail.return_tracking_no }}
|
||||
</div>
|
||||
<div v-if="detail.status === 'refunded'" class="mt-3 text-sm text-success">
|
||||
{{ t("user.aftersaleRefunded") }} · {{ t("user.aftersaleRefundResult") }}:
|
||||
<PriceText :amount-minor="detail.amount_minor" :currency="detail.currency" />
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<VBtn v-if="['pending', 'approved', 'buyer_shipping', 'merchant_confirmed'].includes(detail.status)" variant="danger" size="sm" type="button" :disabled="working" @click="cancel">{{ t("user.cancelAftersale") }}</VBtn>
|
||||
<VBtn v-if="detail.status === 'rejected'" variant="primary" size="sm" type="button" :disabled="working" @click="reopen">{{ t("user.reopenAftersale") }}</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<VCard v-if="detail.status === 'approved' && detail.kind === 'return_refund'" :padded="false" class="p-5">
|
||||
<h2 class="mb-3 text-[15px] font-semibold text-text">{{ t("user.returnShipping") }}</h2>
|
||||
<form class="grid max-w-[520px] gap-3" @submit.prevent="submitTracking">
|
||||
<VField :label="t('user.aftersaleCarrier')"><VInput v-model="carrier" /></VField>
|
||||
<VField :label="t('user.aftersaleTrackingNo')"><VInput v-model="trackingNo" /></VField>
|
||||
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{ t("user.submitReturnTracking") }}</VBtn>
|
||||
</form>
|
||||
</VCard>
|
||||
|
||||
<VCard :padded="false" class="p-5">
|
||||
<h2 class="mb-3 text-[15px] font-semibold text-text">{{ t("user.aftersaleMessages") }}</h2>
|
||||
<div v-if="detail.messages.length === 0" class="mb-4 text-sm text-muted">{{ t("user.noAftersales") }}</div>
|
||||
<div v-else class="mb-4 space-y-2">
|
||||
<div v-for="entry in detail.messages" :key="entry.id" class="rounded border border-border p-3" :class="entry.author_role === 'buyer' ? 'bg-bg' : 'bg-surface'">
|
||||
<div class="flex items-center justify-between gap-2 text-xs text-muted"><span>{{ t(`user.${entry.author_role}`) }}</span><time>{{ entry.created_at.slice(0, 16).replace('T', ' ') }}</time></div>
|
||||
<p class="m-0 mt-2 whitespace-pre-wrap text-sm text-text">{{ pick(entry.content, locale) }}</p>
|
||||
<div v-if="entry.evidence.length" class="mt-2 flex flex-wrap gap-2 text-xs"><a v-for="url in entry.evidence" :key="url" class="text-primary" :href="url" target="_blank" rel="noreferrer">{{ t("user.aftersaleViewEvidence") }}</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<form class="grid max-w-[640px] gap-3" @submit.prevent="sendMessage">
|
||||
<VField :label="t('user.aftersaleMessagePlaceholder')"><textarea v-model="message" class="min-h-24 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleMessagePlaceholder')" /></VField>
|
||||
<VField :label="t('user.aftersaleEvidence')"><textarea v-model="evidence" class="min-h-16 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleEvidenceHint')" /></VField>
|
||||
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{ t("user.sendMessage") }}</VBtn>
|
||||
</form>
|
||||
</VCard>
|
||||
<p v-if="errorKey" class="m-0 text-xs text-danger">{{ t(errorKey) }}</p>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import type { Aftersale, AftersaleKind, Order } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const route = useRoute();
|
||||
const { ensureCurrencies, exponentFor } = usePrice();
|
||||
const order = ref<Order | null>(null);
|
||||
const aftersales = ref<Aftersale[]>([]);
|
||||
const loading = ref(true);
|
||||
const working = ref(false);
|
||||
const errorKey = ref("");
|
||||
const kind = ref<AftersaleKind>("refund_only");
|
||||
const reason = ref("");
|
||||
const amountMajor = ref("");
|
||||
const evidence = ref("");
|
||||
|
||||
const orderId = computed(() => String(route.query.order_id ?? ""));
|
||||
const itemId = computed(() => String(route.query.item_id ?? ""));
|
||||
const item = computed(() => order.value?.items.find((entry) => entry.id === itemId.value) ?? null);
|
||||
const remainingMinor = computed(() => {
|
||||
const line = item.value;
|
||||
if (!line || !order.value) return 0;
|
||||
const refunded = aftersales.value
|
||||
.filter((row) => row.order_item_id === line.id && row.status === "refunded")
|
||||
.reduce((sum, row) => sum + row.amount_minor, 0);
|
||||
return Math.max(0, line.unit_price_minor * line.qty - refunded);
|
||||
});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
await ensureCurrencies();
|
||||
const [loadedOrder, loadedAftersales] = await Promise.all([$api.getOrder(orderId.value), $api.listMyAftersales()]);
|
||||
order.value = loadedOrder;
|
||||
aftersales.value = loadedAftersales;
|
||||
} catch {
|
||||
errorKey.value = "user.aftersaleLoadFailed";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function amountMinor(): number | null {
|
||||
const raw = Number(amountMajor.value);
|
||||
if (!Number.isFinite(raw) || raw <= 0 || !order.value) return null;
|
||||
const exponent = exponentFor(order.value.currency);
|
||||
const minor = Math.round(raw * 10 ** exponent);
|
||||
return Number.isSafeInteger(minor) && minor > 0 ? minor : null;
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
errorKey.value = "";
|
||||
const content = reason.value.trim();
|
||||
const amount = amountMinor();
|
||||
if (!item.value || !order.value || !content || amount === null) {
|
||||
errorKey.value = "user.validationRequired";
|
||||
return;
|
||||
}
|
||||
if (amount > remainingMinor.value) {
|
||||
errorKey.value = "user.aftersaleAmountHint";
|
||||
return;
|
||||
}
|
||||
working.value = true;
|
||||
try {
|
||||
const created = await $api.applyForAftersale({
|
||||
order_item_id: item.value.id,
|
||||
kind: kind.value,
|
||||
reason: { en: content, zh: content },
|
||||
amount_minor: amount,
|
||||
evidence: evidence.value.split("\n").map((url) => url.trim()).filter(Boolean),
|
||||
});
|
||||
await navigateTo(`/user/aftersales/${created.id}`);
|
||||
} catch {
|
||||
errorKey.value = "user.aftersaleSubmitFailed";
|
||||
} finally {
|
||||
working.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="min-h-[560px]">
|
||||
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.aftersaleApply") }}</h1>
|
||||
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
|
||||
<form v-else class="grid max-w-[640px] gap-4" @submit.prevent="submit">
|
||||
<VCard :padded="false" class="flex items-center gap-3 border border-border p-4">
|
||||
<img class="h-14 w-14 border border-border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="item.product_name.en" />
|
||||
<div class="min-w-0 flex-1"><p class="m-0 truncate text-sm text-text">{{ item.product_name.en }}</p><p class="mt-1 text-xs text-muted">{{ item.sku_code }} × {{ item.qty }}</p></div>
|
||||
</VCard>
|
||||
<fieldset class="grid gap-2 border-0 p-0">
|
||||
<legend class="mb-1 block text-[13px] text-muted">{{ t("user.aftersaleType") }}</legend>
|
||||
<label class="text-sm text-text"><input v-model="kind" class="mr-1 accent-primary" type="radio" value="refund_only" /> {{ t("user.refundOnly") }}</label>
|
||||
<label class="text-sm text-text"><input v-model="kind" class="mr-1 accent-primary" type="radio" value="return_refund" /> {{ t("user.returnRefund") }}</label>
|
||||
</fieldset>
|
||||
<VField :label="t('user.aftersaleReason')"><textarea v-model="reason" class="min-h-24 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleReasonHint')" /></VField>
|
||||
<VField :label="t('user.aftersaleAmount')">
|
||||
<VInput v-model="amountMajor" />
|
||||
<p class="m-0 mt-1 text-xs text-muted">{{ t("user.aftersaleAmountHint") }} {{ t("user.aftersaleRemaining") }}: <PriceText :amount-minor="remainingMinor" :currency="order.currency" /></p>
|
||||
</VField>
|
||||
<VField :label="t('user.aftersaleEvidence')"><textarea v-model="evidence" class="min-h-20 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleEvidenceHint')" /></VField>
|
||||
<p v-if="errorKey" class="m-0 text-xs text-danger">{{ t(errorKey) }}</p>
|
||||
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{ t("user.submitAftersale") }}</VBtn>
|
||||
</form>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import type { Aftersale } from "@vmall/shared";
|
||||
import { t as pick } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const rows = ref<Aftersale[]>([]);
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
|
||||
function tone(status: Aftersale["status"]): "green" | "blue" | "red" | "orange" {
|
||||
if (status === "refunded") return "green";
|
||||
if (["approved", "buyer_shipping", "merchant_confirmed"].includes(status)) return "blue";
|
||||
if (["rejected", "cancelled"].includes(status)) return "red";
|
||||
return "orange";
|
||||
}
|
||||
|
||||
function statusLabel(status: Aftersale["status"]): string {
|
||||
return t(`user.aftersaleStatus_${status}`);
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
failed.value = false;
|
||||
try {
|
||||
rows.value = await $api.listMyAftersales();
|
||||
} catch {
|
||||
failed.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="min-h-[560px]">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<h1 class="m-0 text-xl font-bold text-text">{{ t("user.aftersalesTitle") }}</h1>
|
||||
<NuxtLink class="text-sm text-primary no-underline" to="/user/orders">{{ t("user.myOrders") }}</NuxtLink>
|
||||
</div>
|
||||
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
|
||||
<p v-else-if="failed" class="py-5 text-sm text-danger">{{ t("user.aftersaleLoadFailed") }}</p>
|
||||
<UiEmptyState v-else-if="rows.length === 0" :text="t('user.noAftersales')" />
|
||||
<VTable v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("user.aftersaleOrder") }}</th>
|
||||
<th>{{ t("user.aftersaleType") }}</th>
|
||||
<th>{{ t("user.aftersaleAmount") }}</th>
|
||||
<th>{{ t("user.aftersaleStatus") }}</th>
|
||||
<th>{{ t("user.aftersaleCreatedAt") }}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>
|
||||
<NuxtLink class="text-primary no-underline" :to="`/user/aftersales/${row.id}`">{{ row.order_id }}</NuxtLink>
|
||||
</td>
|
||||
<td>{{ t(row.kind === "refund_only" ? "user.refundOnly" : "user.returnRefund") }}</td>
|
||||
<td><PriceText :amount-minor="row.amount_minor" :currency="row.currency" /></td>
|
||||
<td><VBadge :tone="tone(row.status)">{{ statusLabel(row.status) }}</VBadge></td>
|
||||
<td class="text-muted">{{ row.created_at.slice(0, 10) }}</td>
|
||||
<td><NuxtLink class="text-primary no-underline" :to="`/user/aftersales/${row.id}`">{{ t("user.viewDetails") }}</NuxtLink></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VCard>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { InvoiceKind, Order, Shipment } from "@vmall/shared";
|
||||
import type { Aftersale, InvoiceKind, Order, Shipment } from "@vmall/shared";
|
||||
import { t as pick } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
@@ -10,6 +10,7 @@ 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);
|
||||
@@ -23,13 +24,23 @@ 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] = await Promise.all([$api.getOrder(orderId.value), $api.listMyShipments()]);
|
||||
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;
|
||||
}
|
||||
@@ -92,13 +103,14 @@ onMounted(() => {
|
||||
<VCard :padded="false" class="p-5">
|
||||
<h2 class="mb-3.5 text-[15px] font-semibold text-text">{{ 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></tr></thead>
|
||||
<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="h-11 w-11 border border-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>
|
||||
|
||||
Reference in New Issue
Block a user