feat(aftersale): per-line refund/return flow with ledger-backed completion (add-aftersale-refunds)
This commit is contained in:
@@ -15,6 +15,7 @@ const navItems = [
|
||||
{ to: "/users", label: "nav.users" },
|
||||
{ to: "/shops", label: "nav.shops" },
|
||||
{ to: "/orders", label: "nav.orders" },
|
||||
{ to: "/aftersales", label: "nav.aftersales" },
|
||||
{ to: "/content", label: "nav.content" },
|
||||
{ to: "/brands", label: "nav.brands" },
|
||||
{ to: "/currencies", label: "nav.currencies" },
|
||||
|
||||
@@ -10,6 +10,7 @@ export const enExtra = {
|
||||
pointsOrders: "Point redemptions",
|
||||
content: "Content",
|
||||
brands: "Brands",
|
||||
aftersales: "After-sales",
|
||||
},
|
||||
admin: {
|
||||
dashboardTitle: "Platform overview",
|
||||
@@ -88,6 +89,62 @@ export const enExtra = {
|
||||
brandNameRequired: "Both language names are required.",
|
||||
brandSlugDuplicate: "Duplicate slug.",
|
||||
brandsSaved: "Brands saved.",
|
||||
aftersaleFilterShop: "Shop ID",
|
||||
aftersaleShopIdPlaceholder: "shop id",
|
||||
aftersaleBuyerIdPlaceholder: "buyer id",
|
||||
aftersaleFilterBuyer: "Buyer ID",
|
||||
aftersaleKind: "Type",
|
||||
aftersaleAmount: "Amount",
|
||||
aftersaleId: "Application",
|
||||
aftersaleShop: "Shop",
|
||||
aftersaleBuyer: "Buyer",
|
||||
aftersaleDetail: "After-sale detail",
|
||||
aftersaleView: "Detail",
|
||||
aftersaleClose: "Close",
|
||||
aftersaleCreated: "Created",
|
||||
aftersaleUpdated: "Updated",
|
||||
aftersaleReason: "Reason",
|
||||
aftersaleEvidence: "Evidence",
|
||||
aftersaleNoEvidence: "No evidence attached",
|
||||
aftersaleItem: "Order item",
|
||||
aftersaleOrder: "Order",
|
||||
aftersaleOrderTotal: "Order total",
|
||||
aftersaleOrderRefunded: "Order refunded total",
|
||||
aftersaleRemaining: "Remaining refundable",
|
||||
aftersaleReturnTracking: "Return tracking",
|
||||
aftersaleMessages: "Messages",
|
||||
aftersaleNoMessages: "No messages yet",
|
||||
aftersaleReopened: "Reopened once",
|
||||
aftersaleArbitrate: "Arbitration",
|
||||
aftersaleArbitrateRefund: "Arbitrate: refund",
|
||||
aftersaleArbitrateReject: "Arbitrate: reject",
|
||||
aftersaleConfirmRefund: "Grant a terminal refund to the buyer? This completes the refund and cannot be undone.",
|
||||
aftersaleConfirmReject: "Reject this dispute terminally? Only the buyer's one-time reopen can resume it.",
|
||||
aftersaleArbitratedRefund: "Arbitration complete: refunded.",
|
||||
aftersaleArbitratedReject: "Arbitration complete: rejected.",
|
||||
aftersaleOutcomeRefunded: "Refunded: the customer ledger is credited and the order refund total updated.",
|
||||
aftersaleOutcomeRejected: "Rejected: only the buyer's one-time reopen can resume it.",
|
||||
aftersaleOutcomeCancelled: "Cancelled by the buyer.",
|
||||
aftersaleConflict: "The application state changed; refresh and try again",
|
||||
aftersaleOrderNotFound: "Order not found in the order list.",
|
||||
aftersaleKinds: {
|
||||
refund_only: "Refund only",
|
||||
return_refund: "Return & refund",
|
||||
},
|
||||
aftersaleStatuses: {
|
||||
pending: "Pending review",
|
||||
approved: "Approved",
|
||||
rejected: "Rejected",
|
||||
buyer_shipping: "Buyer shipping",
|
||||
merchant_confirmed: "Return received",
|
||||
refunded: "Refunded",
|
||||
cancelled: "Cancelled",
|
||||
},
|
||||
aftersaleRoles: {
|
||||
buyer: "Buyer",
|
||||
merchant: "Merchant",
|
||||
platform: "Platform",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -101,6 +158,7 @@ export const zhExtra = {
|
||||
pointsOrders: "积分兑换单",
|
||||
content: "内容",
|
||||
brands: "品牌",
|
||||
aftersales: "售后仲裁",
|
||||
},
|
||||
admin: {
|
||||
dashboardTitle: "平台概览",
|
||||
@@ -179,5 +237,61 @@ export const zhExtra = {
|
||||
brandNameRequired: "中英文名称均为必填项。",
|
||||
brandSlugDuplicate: "别名重复。",
|
||||
brandsSaved: "品牌已保存。",
|
||||
aftersaleFilterShop: "店铺 ID",
|
||||
aftersaleShopIdPlaceholder: "店铺 ID",
|
||||
aftersaleBuyerIdPlaceholder: "买家 ID",
|
||||
aftersaleFilterBuyer: "买家 ID",
|
||||
aftersaleKind: "类型",
|
||||
aftersaleAmount: "金额",
|
||||
aftersaleId: "售后单",
|
||||
aftersaleShop: "店铺",
|
||||
aftersaleBuyer: "买家",
|
||||
aftersaleDetail: "售后详情",
|
||||
aftersaleView: "详情",
|
||||
aftersaleClose: "收起",
|
||||
aftersaleCreated: "创建时间",
|
||||
aftersaleUpdated: "更新时间",
|
||||
aftersaleReason: "原因",
|
||||
aftersaleEvidence: "凭证",
|
||||
aftersaleNoEvidence: "无凭证",
|
||||
aftersaleItem: "订单商品",
|
||||
aftersaleOrder: "订单",
|
||||
aftersaleOrderTotal: "订单总额",
|
||||
aftersaleOrderRefunded: "订单已退款总额",
|
||||
aftersaleRemaining: "剩余可退金额",
|
||||
aftersaleReturnTracking: "退货物流",
|
||||
aftersaleMessages: "留言记录",
|
||||
aftersaleNoMessages: "暂无留言",
|
||||
aftersaleReopened: "已重新申请",
|
||||
aftersaleArbitrate: "仲裁",
|
||||
aftersaleArbitrateRefund: "仲裁退款",
|
||||
aftersaleArbitrateReject: "仲裁驳回",
|
||||
aftersaleConfirmRefund: "确定仲裁退款给买家吗?退款将立即完成且不可撤销。",
|
||||
aftersaleConfirmReject: "确定仲裁驳回吗?仅买家一次重新申请可恢复。",
|
||||
aftersaleArbitratedRefund: "仲裁完成:已退款。",
|
||||
aftersaleArbitratedReject: "仲裁完成:已驳回。",
|
||||
aftersaleOutcomeRefunded: "已退款:买家账户已入账,订单退款总额已更新。",
|
||||
aftersaleOutcomeRejected: "已驳回:仅买家一次重新申请可恢复。",
|
||||
aftersaleOutcomeCancelled: "买家已取消。",
|
||||
aftersaleConflict: "售后单状态已变化,请刷新后重试",
|
||||
aftersaleOrderNotFound: "订单列表中未找到该订单。",
|
||||
aftersaleKinds: {
|
||||
refund_only: "仅退款",
|
||||
return_refund: "退货退款",
|
||||
},
|
||||
aftersaleStatuses: {
|
||||
pending: "待处理",
|
||||
approved: "已通过",
|
||||
rejected: "已驳回",
|
||||
buyer_shipping: "买家退货中",
|
||||
merchant_confirmed: "商家已收货",
|
||||
refunded: "已退款",
|
||||
cancelled: "已取消",
|
||||
},
|
||||
aftersaleRoles: {
|
||||
buyer: "买家",
|
||||
merchant: "商家",
|
||||
platform: "平台",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
<script setup lang="ts">
|
||||
import { ApiError, formatMoney, t as localizedText } from "@vmall/shared";
|
||||
import type {
|
||||
Aftersale,
|
||||
AftersaleArbitration,
|
||||
AftersaleDetail,
|
||||
AftersaleKind,
|
||||
AftersaleMessage,
|
||||
AftersaleStatus,
|
||||
Currency,
|
||||
Shop,
|
||||
} from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
interface OrderSummary {
|
||||
orderNo: string;
|
||||
totalMinor: number;
|
||||
refundTotalMinor: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
const aftersales = ref<Aftersale[]>([]);
|
||||
const currencies = ref<Currency[]>([]);
|
||||
const shops = ref<Shop[]>([]);
|
||||
const statusFilter = ref<"" | AftersaleStatus>("");
|
||||
const kindFilter = ref<"" | AftersaleKind>("");
|
||||
const shopFilter = ref("");
|
||||
const buyerFilter = ref("");
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref("");
|
||||
|
||||
const openId = ref<string | null>(null);
|
||||
const detail = ref<AftersaleDetail | null>(null);
|
||||
const orderSummary = ref<OrderSummary | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
const detailError = ref("");
|
||||
const notice = ref("");
|
||||
const acting = ref(false);
|
||||
const orderCache = new Map<string, OrderSummary>();
|
||||
|
||||
const statuses: AftersaleStatus[] = [
|
||||
"pending",
|
||||
"approved",
|
||||
"buyer_shipping",
|
||||
"merchant_confirmed",
|
||||
"refunded",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
];
|
||||
const kinds: AftersaleKind[] = ["refund_only", "return_refund"];
|
||||
const arbitrableStatuses: AftersaleStatus[] = [
|
||||
"pending",
|
||||
"approved",
|
||||
"buyer_shipping",
|
||||
"merchant_confirmed",
|
||||
];
|
||||
|
||||
const filteredAftersales = computed(() => {
|
||||
const shopQuery = shopFilter.value.trim().toLowerCase();
|
||||
const buyerQuery = buyerFilter.value.trim().toLowerCase();
|
||||
return aftersales.value.filter((aftersale) => {
|
||||
if (kindFilter.value && aftersale.kind !== kindFilter.value) return false;
|
||||
if (shopQuery && !aftersale.shop_id.toLowerCase().includes(shopQuery)) return false;
|
||||
if (buyerQuery && !aftersale.user_id.toLowerCase().includes(buyerQuery)) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
function currencyExponent(code: string): number | undefined {
|
||||
return currencies.value.find((currency) => currency.code === code)?.exponent;
|
||||
}
|
||||
|
||||
function money(amountMinor: number, currency: string): string {
|
||||
const exponent = currencyExponent(currency);
|
||||
return exponent === undefined
|
||||
? "—"
|
||||
: formatMoney(amountMinor, currency, exponent, locale.value);
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
function statusTone(status: AftersaleStatus): "green" | "red" | "orange" | "blue" | "gray" {
|
||||
if (status === "refunded") return "green";
|
||||
if (status === "rejected") return "red";
|
||||
if (status === "cancelled") return "gray";
|
||||
if (status === "pending") return "orange";
|
||||
return "blue";
|
||||
}
|
||||
|
||||
function roleTone(role: AftersaleMessage["author_role"]): "green" | "blue" | "gray" {
|
||||
if (role === "buyer") return "green";
|
||||
if (role === "merchant") return "blue";
|
||||
return "gray";
|
||||
}
|
||||
|
||||
function statusLabel(status: AftersaleStatus): string {
|
||||
return t(`admin.aftersaleStatuses.${status}`);
|
||||
}
|
||||
|
||||
function kindLabel(kind: AftersaleKind): string {
|
||||
return t(`admin.aftersaleKinds.${kind}`);
|
||||
}
|
||||
|
||||
function roleLabel(role: AftersaleMessage["author_role"]): string {
|
||||
return t(`admin.aftersaleRoles.${role}`);
|
||||
}
|
||||
|
||||
function shopName(shopId: string): string {
|
||||
const shop = shops.value.find((item) => item.id === shopId);
|
||||
return shop ? localizedText(shop.name, locale.value) : shopId;
|
||||
}
|
||||
|
||||
function shortId(value: string): string {
|
||||
return value.slice(0, 8);
|
||||
}
|
||||
|
||||
function canArbitrate(status: AftersaleStatus): boolean {
|
||||
return arbitrableStatuses.includes(status);
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : t("common.error");
|
||||
return error instanceof ApiError && error.status === 409
|
||||
? `${t("admin.aftersaleConflict")}: ${message}`
|
||||
: message;
|
||||
}
|
||||
|
||||
async function loadAftersales(): Promise<void> {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const [rows, currencyList, shopList] = await Promise.all([
|
||||
$api.admin.listAftersales(statusFilter.value || undefined),
|
||||
$api.admin.listCurrencies(),
|
||||
$api.admin.listShops(),
|
||||
]);
|
||||
aftersales.value = rows;
|
||||
currencies.value = currencyList;
|
||||
shops.value = shopList;
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = errorText(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrderSummary(orderId: string): Promise<void> {
|
||||
const cached = orderCache.get(orderId);
|
||||
if (cached) {
|
||||
orderSummary.value = cached;
|
||||
return;
|
||||
}
|
||||
|
||||
let page = 1;
|
||||
for (;;) {
|
||||
const paged = await $api.admin.listOrders(page);
|
||||
const found = paged.items.find((order) => order.id === orderId);
|
||||
if (found) {
|
||||
const summary: OrderSummary = {
|
||||
orderNo: found.order_no,
|
||||
totalMinor: found.total_minor,
|
||||
refundTotalMinor: found.refund_total_minor,
|
||||
currency: found.currency,
|
||||
};
|
||||
orderCache.set(orderId, summary);
|
||||
orderSummary.value = summary;
|
||||
return;
|
||||
}
|
||||
if (page * paged.per_page >= paged.total) break;
|
||||
page += 1;
|
||||
}
|
||||
orderSummary.value = null;
|
||||
}
|
||||
|
||||
async function openDetail(id: string): Promise<void> {
|
||||
openId.value = id;
|
||||
detail.value = null;
|
||||
orderSummary.value = null;
|
||||
detailError.value = "";
|
||||
notice.value = "";
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
const loaded = await $api.admin.getAftersale(id);
|
||||
detail.value = loaded;
|
||||
await loadOrderSummary(loaded.order_id);
|
||||
} catch (error: unknown) {
|
||||
detailError.value = errorText(error);
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDetail(id: string): Promise<void> {
|
||||
if (openId.value === id) {
|
||||
openId.value = null;
|
||||
detail.value = null;
|
||||
orderSummary.value = null;
|
||||
detailError.value = "";
|
||||
return;
|
||||
}
|
||||
await openDetail(id);
|
||||
}
|
||||
|
||||
async function arbitrate(row: Aftersale, outcome: AftersaleArbitration): Promise<void> {
|
||||
const confirmation = outcome === "refund"
|
||||
? t("admin.aftersaleConfirmRefund")
|
||||
: t("admin.aftersaleConfirmReject");
|
||||
if (!confirm(confirmation)) return;
|
||||
|
||||
acting.value = true;
|
||||
detailError.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
await $api.admin.arbitrateAftersale(row.id, outcome);
|
||||
await loadAftersales();
|
||||
orderCache.delete(row.order_id);
|
||||
await openDetail(row.id);
|
||||
notice.value = outcome === "refund"
|
||||
? t("admin.aftersaleArbitratedRefund")
|
||||
: t("admin.aftersaleArbitratedReject");
|
||||
} catch (error: unknown) {
|
||||
detailError.value = errorText(error);
|
||||
} finally {
|
||||
acting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadAftersales();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPage :title="$t('nav.aftersales')">
|
||||
<VCard class="mb-5">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="grid gap-1 text-sm font-medium" for="aftersale-status">
|
||||
{{ $t("common.status") }}
|
||||
<select
|
||||
id="aftersale-status"
|
||||
v-model="statusFilter"
|
||||
class="rounded-md border border-border bg-surface px-3 py-2 font-normal text-text focus:border-primary focus:outline-2 focus:outline-primary/30"
|
||||
@change="loadAftersales"
|
||||
>
|
||||
<option value="">{{ $t("common.all") }}</option>
|
||||
<option v-for="status in statuses" :key="status" :value="status">
|
||||
{{ statusLabel(status) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium" for="aftersale-kind">
|
||||
{{ $t("admin.aftersaleKind") }}
|
||||
<select
|
||||
id="aftersale-kind"
|
||||
v-model="kindFilter"
|
||||
class="rounded-md border border-border bg-surface px-3 py-2 font-normal text-text focus:border-primary focus:outline-2 focus:outline-primary/30"
|
||||
>
|
||||
<option value="">{{ $t("common.all") }}</option>
|
||||
<option v-for="kind in kinds" :key="kind" :value="kind">{{ kindLabel(kind) }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<VField :label="$t('admin.aftersaleFilterShop')">
|
||||
<VInput v-model="shopFilter" :placeholder="$t('admin.aftersaleShopIdPlaceholder')" />
|
||||
</VField>
|
||||
<VField :label="$t('admin.aftersaleFilterBuyer')">
|
||||
<VInput v-model="buyerFilter" :placeholder="$t('admin.aftersaleBuyerIdPlaceholder')" />
|
||||
</VField>
|
||||
<VBtn size="sm" :disabled="loading" @click="loadAftersales">
|
||||
{{ $t("admin.refresh") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<p v-if="loading" class="text-sm text-muted">{{ $t("common.loading") }}</p>
|
||||
<p v-else-if="errorMessage" class="my-2 text-sm text-danger" role="alert">{{ errorMessage }}</p>
|
||||
<VCard v-else-if="filteredAftersales.length === 0" class="text-muted">
|
||||
{{ $t("common.empty") }}
|
||||
</VCard>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<div class="min-w-[1080px]"><VTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("admin.aftersaleId") }}</th>
|
||||
<th>{{ $t("admin.aftersaleShop") }}</th>
|
||||
<th>{{ $t("admin.aftersaleBuyer") }}</th>
|
||||
<th>{{ $t("admin.aftersaleKind") }}</th>
|
||||
<th>{{ $t("admin.aftersaleAmount") }}</th>
|
||||
<th>{{ $t("admin.created") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="row in filteredAftersales" :key="row.id">
|
||||
<tr :class="openId === row.id ? 'bg-primary-soft/30' : ''">
|
||||
<td><code class="rounded bg-bg px-1.5 py-0.5" :title="row.id">{{ shortId(row.id) }}</code></td>
|
||||
<td>
|
||||
<span class="block">{{ shopName(row.shop_id) }}</span>
|
||||
<code class="text-xs text-muted" :title="row.shop_id">{{ shortId(row.shop_id) }}</code>
|
||||
</td>
|
||||
<td><code class="rounded bg-bg px-1.5 py-0.5" :title="row.user_id">{{ shortId(row.user_id) }}</code></td>
|
||||
<td>{{ kindLabel(row.kind) }}</td>
|
||||
<td>{{ money(row.amount_minor, row.currency) }}</td>
|
||||
<td>{{ formatDate(row.created_at) }}</td>
|
||||
<td><VBadge :tone="statusTone(row.status)">{{ statusLabel(row.status) }}</VBadge></td>
|
||||
<td>
|
||||
<VBtn size="sm" @click="toggleDetail(row.id)">
|
||||
{{ openId === row.id ? $t("admin.aftersaleClose") : $t("admin.aftersaleView") }}
|
||||
</VBtn>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="openId === row.id" class="bg-bg">
|
||||
<td colspan="8" class="p-0">
|
||||
<div class="p-4">
|
||||
<p v-if="detailLoading" class="text-sm text-muted">{{ $t("common.loading") }}</p>
|
||||
<p v-else-if="detailError" class="text-sm text-danger" role="alert">{{ detailError }}</p>
|
||||
<VPanel v-else-if="detail" :title="$t('admin.aftersaleDetail')">
|
||||
<template #actions>
|
||||
<VBadge :tone="statusTone(detail.status)">{{ statusLabel(detail.status) }}</VBadge>
|
||||
</template>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div><span class="text-muted">{{ $t("admin.aftersaleId") }}</span><code class="ml-2">{{ detail.id }}</code></div>
|
||||
<div><span class="text-muted">{{ $t("admin.aftersaleOrder") }}</span><code class="ml-2">{{ detail.order_id }}</code></div>
|
||||
<div><span class="text-muted">{{ $t("admin.aftersaleFilterShop") }}</span><code class="ml-2">{{ detail.shop_id }}</code></div>
|
||||
<div><span class="text-muted">{{ $t("admin.aftersaleFilterBuyer") }}</span><code class="ml-2">{{ detail.user_id }}</code></div>
|
||||
<div><span class="text-muted">{{ $t("admin.aftersaleKind") }}</span><span class="ml-2">{{ kindLabel(detail.kind) }}</span></div>
|
||||
<div><span class="text-muted">{{ $t("admin.aftersaleCreated") }}</span><span class="ml-2">{{ formatDate(detail.created_at) }}</span></div>
|
||||
<div><span class="text-muted">{{ $t("admin.aftersaleUpdated") }}</span><span class="ml-2">{{ formatDate(detail.updated_at) }}</span></div>
|
||||
<div v-if="detail.reopened"><VBadge tone="orange">{{ $t("admin.aftersaleReopened") }}</VBadge></div>
|
||||
</div>
|
||||
<VCard class="bg-bg" :padded="true">
|
||||
<h4 class="mb-3 text-sm font-semibold">{{ $t("admin.aftersaleOrder") }}</h4>
|
||||
<p v-if="!orderSummary" class="text-sm text-muted">{{ $t("admin.aftersaleOrderNotFound") }}</p>
|
||||
<dl v-else class="grid gap-2 text-sm">
|
||||
<div class="flex justify-between gap-3"><dt class="text-muted">{{ $t("order.orderNo") }}</dt><dd>{{ orderSummary.orderNo }}</dd></div>
|
||||
<div class="flex justify-between gap-3"><dt class="text-muted">{{ $t("admin.aftersaleOrderTotal") }}</dt><dd>{{ money(orderSummary.totalMinor, orderSummary.currency) }}</dd></div>
|
||||
<div class="flex justify-between gap-3 font-semibold"><dt class="text-muted">{{ $t("admin.aftersaleOrderRefunded") }}</dt><dd>{{ money(orderSummary.refundTotalMinor, orderSummary.currency) }}</dd></div>
|
||||
</dl>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<VPanel class="mt-4" :title="$t('admin.aftersaleItem')">
|
||||
<div class="flex gap-3">
|
||||
<img v-if="detail.item.image" :src="detail.item.image" :alt="localizedText(detail.item.product_name, locale)" class="h-16 w-16 rounded object-cover" />
|
||||
<div class="min-w-0 text-sm">
|
||||
<p class="font-medium">{{ localizedText(detail.item.product_name, locale) }}</p>
|
||||
<p class="text-muted">{{ detail.item.sku_code }}</p>
|
||||
<p class="mt-1">{{ money(detail.item.unit_price_minor, detail.currency) }} × {{ detail.item.qty }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</VPanel>
|
||||
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<VCard :padded="true">
|
||||
<h4 class="mb-2 text-sm font-semibold">{{ $t("admin.aftersaleReason") }}</h4>
|
||||
<p class="text-sm">{{ localizedText(detail.reason, locale) }}</p>
|
||||
<dl class="mt-3 grid gap-1 text-sm">
|
||||
<div class="flex justify-between gap-3"><dt class="text-muted">{{ $t("admin.aftersaleAmount") }}</dt><dd>{{ money(detail.amount_minor, detail.currency) }}</dd></div>
|
||||
<div class="flex justify-between gap-3"><dt class="text-muted">{{ $t("admin.aftersaleRemaining") }}</dt><dd>{{ money(detail.remaining_refundable_minor, detail.currency) }}</dd></div>
|
||||
</dl>
|
||||
</VCard>
|
||||
<VCard :padded="true">
|
||||
<h4 class="mb-2 text-sm font-semibold">{{ $t("admin.aftersaleEvidence") }}</h4>
|
||||
<ul v-if="detail.evidence.length" class="grid gap-1 text-sm">
|
||||
<li v-for="url in detail.evidence" :key="url"><a :href="url" target="_blank" rel="noopener noreferrer" class="break-all text-primary underline">{{ url }}</a></li>
|
||||
</ul>
|
||||
<p v-else class="text-sm text-muted">{{ $t("admin.aftersaleNoEvidence") }}</p>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<VCard v-if="detail.return_carrier || detail.return_tracking_no" class="mt-4" :padded="true">
|
||||
<h4 class="mb-2 text-sm font-semibold">{{ $t("admin.aftersaleReturnTracking") }}</h4>
|
||||
<p class="text-sm">{{ detail.return_carrier || "—" }} · {{ detail.return_tracking_no || "—" }}</p>
|
||||
</VCard>
|
||||
|
||||
<VPanel class="mt-4" :title="$t('admin.aftersaleMessages')">
|
||||
<div v-if="detail.messages.length" class="grid gap-3">
|
||||
<article v-for="message in [...detail.messages].sort((a, b) => a.created_at.localeCompare(b.created_at))" :key="message.id" class="rounded-md border border-border bg-bg p-3">
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<VBadge :tone="roleTone(message.author_role)">{{ roleLabel(message.author_role) }}</VBadge>
|
||||
<code :title="message.author_id">{{ shortId(message.author_id) }}</code>
|
||||
<time class="text-muted">{{ formatDate(message.created_at) }}</time>
|
||||
</div>
|
||||
<p class="text-sm">{{ localizedText(message.content, locale) }}</p>
|
||||
<ul v-if="message.evidence.length" class="mt-2 grid gap-1 text-xs">
|
||||
<li v-for="url in message.evidence" :key="url"><a :href="url" target="_blank" rel="noopener noreferrer" class="break-all text-primary underline">{{ url }}</a></li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="text-sm text-muted">{{ $t("admin.aftersaleNoMessages") }}</p>
|
||||
</VPanel>
|
||||
|
||||
<div class="mt-4 border-t border-border pt-4">
|
||||
<p v-if="notice" class="mb-3 text-sm text-success" role="status">{{ notice }}</p>
|
||||
<p v-if="canArbitrate(detail.status)" class="mb-3 text-sm text-muted">{{ $t("admin.aftersaleArbitrate") }}</p>
|
||||
<div v-if="canArbitrate(detail.status)" class="flex flex-wrap gap-2">
|
||||
<VBtn variant="primary" :disabled="acting" @click="arbitrate(detail, 'refund')">{{ $t("admin.aftersaleArbitrateRefund") }}</VBtn>
|
||||
<VBtn variant="danger" :disabled="acting" @click="arbitrate(detail, 'reject')">{{ $t("admin.aftersaleArbitrateReject") }}</VBtn>
|
||||
</div>
|
||||
<p v-else-if="detail.status === 'refunded'" class="text-sm text-success">{{ $t("admin.aftersaleOutcomeRefunded") }}</p>
|
||||
<p v-else-if="detail.status === 'rejected'" class="text-sm text-danger">{{ $t("admin.aftersaleOutcomeRejected") }}</p>
|
||||
<p v-else class="text-sm text-muted">{{ $t("admin.aftersaleOutcomeCancelled") }}</p>
|
||||
</div>
|
||||
</VPanel>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</VTable></div>
|
||||
</div>
|
||||
</VPage>
|
||||
</template>
|
||||
Reference in New Issue
Block a user