wip(freight): migration 0017 + model/column-list groundwork for add-freight-templates
This commit is contained in:
@@ -23,21 +23,36 @@ 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),
|
||||
));
|
||||
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));
|
||||
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()]);
|
||||
const [o, allShipments, mine] = await Promise.all([
|
||||
$api.getOrder(orderId.value),
|
||||
$api.listMyShipments(),
|
||||
$api.listMyAftersales(),
|
||||
]);
|
||||
order.value = o;
|
||||
shipments.value = allShipments;
|
||||
aftersales.value = mine;
|
||||
@@ -85,62 +100,165 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<VCard class="min-h-[560px]">
|
||||
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.orderDetail") }}</h1>
|
||||
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
|
||||
<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="flex items-center justify-between border border-border bg-bg p-3.5 max-sm:flex-col max-sm:items-start max-sm:gap-2.5">
|
||||
<div><strong class="text-[15px] text-text">{{ t("user.orderNo") }} {{ order.order_no }}</strong><p class="mt-1 text-xs text-muted">{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</p></div>
|
||||
<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="mb-3.5 text-[15px] font-semibold text-text">{{ t("user.shippingAddress") }}</h2>
|
||||
<p class="m-0 text-sm text-text">{{ order.shipping_address.recipient }} · {{ order.shipping_address.phone }}</p>
|
||||
<p class="mt-1 text-sm text-text">{{ order.shipping_address.country }} {{ order.shipping_address.region }} {{ order.shipping_address.city }} {{ order.shipping_address.line1 }} {{ order.shipping_address.postal_code }}</p>
|
||||
<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="mb-3.5 text-[15px] font-semibold text-text">{{ t("user.orderItems") }}</h2>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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="mt-4 text-right text-[13px] text-primary">{{ t("checkout.couponDiscount") }}: <PriceText :amount-minor="order.discount_minor" :currency="order.currency" /></p>
|
||||
<p class="mt-4 text-right text-[13px] text-muted">{{ t("user.total") }}: <PriceText class="text-lg font-semibold text-primary" :amount-minor="order.total_minor" :currency="order.currency" /></p>
|
||||
<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="mb-3.5 text-[15px] font-semibold text-text">{{ t("user.shipments") }}</h2>
|
||||
<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="flex items-center justify-between border border-border p-3 max-sm:flex-col max-sm:items-start max-sm:gap-2.5">
|
||||
<div><strong class="text-sm text-text">{{ t("user.shipmentNo") }} {{ shipment.shipment_no }}</strong><p class="mt-1 text-xs text-muted">{{ t("user.carrier") }}: {{ shipment.carrier }}</p><p class="mt-1 text-xs text-muted">{{ 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
|
||||
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="mb-3.5 text-[15px] font-semibold text-text">{{ t("user.invoiceRequest") }}</h2>
|
||||
<div v-if="invoiceSuccess" class="text-sm text-success">{{ t("user.invoiceSuccess") }}</div>
|
||||
<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="mb-1 block text-[13px] text-muted">{{ t("user.invoiceKind") }}</legend><label class="text-[13px] text-text"><input v-model="invoiceKind" class="mr-1 accent-primary" type="radio" value="personal" /> {{ t("user.personal") }}</label><label class="text-[13px] text-text"><input v-model="invoiceKind" class="mr-1 accent-primary" type="radio" value="company" /> {{ t("user.company") }}</label></fieldset>
|
||||
<p v-if="invoiceErrorKey" class="m-0 text-xs text-danger">{{ t(invoiceErrorKey) }}</p>
|
||||
<VBtn class="w-fit" variant="primary" type="submit" :disabled="invoiceWorking">{{ t("user.submitInvoice") }}</VBtn>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ function shopName(shopId: string): string {
|
||||
return shop ? pick(shop.name, locale.value) : t("user.shop");
|
||||
}
|
||||
|
||||
const activeFilter = ref("all");const orders = ref<Order[]>([]);
|
||||
const activeFilter = ref("all");
|
||||
const orders = ref<Order[]>([]);
|
||||
const page = ref(1);
|
||||
const total = ref(0);
|
||||
const perPage = ref(10);
|
||||
@@ -43,18 +44,23 @@ const tabs = computed(() => [
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
const statuses = filterStatuses[activeFilter.value] ?? [];
|
||||
return statuses.length === 0 ? orders.value : orders.value.filter((order) => statuses.includes(order.status));
|
||||
return statuses.length === 0
|
||||
? orders.value
|
||||
: orders.value.filter((order) => statuses.includes(order.status));
|
||||
});
|
||||
|
||||
function setInitialFilter(): void {
|
||||
const queryStatus = String(route.query.status ?? "");
|
||||
const mapped = queryStatus === "paid" || queryStatus === "fulfilling"
|
||||
? "pending_shipment"
|
||||
: queryStatus === "shipped"
|
||||
? "pending_receipt"
|
||||
: queryStatus === "pending_payment" || queryStatus === "completed" || queryStatus === "cancelled"
|
||||
? queryStatus
|
||||
: "all";
|
||||
const mapped =
|
||||
queryStatus === "paid" || queryStatus === "fulfilling"
|
||||
? "pending_shipment"
|
||||
: queryStatus === "shipped"
|
||||
? "pending_receipt"
|
||||
: queryStatus === "pending_payment" ||
|
||||
queryStatus === "completed" ||
|
||||
queryStatus === "cancelled"
|
||||
? queryStatus
|
||||
: "all";
|
||||
activeFilter.value = mapped;
|
||||
}
|
||||
|
||||
@@ -103,30 +109,89 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<VCard class="min-h-[560px]">
|
||||
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.ordersTitle") }}</h1>
|
||||
<h1 class="text-text mb-4 text-xl font-bold">{{ t("user.ordersTitle") }}</h1>
|
||||
<UiTabs :model-value="activeFilter" :tabs="tabs" @update:model-value="changeFilter">
|
||||
<template #default>
|
||||
<div v-if="loading" class="py-[30px] text-muted">{{ t("common.loading") }}</div>
|
||||
<div v-if="loading" class="text-muted py-[30px]">{{ t("common.loading") }}</div>
|
||||
<UiEmptyState v-else-if="filteredOrders.length === 0" :text="t('user.noOrders')" />
|
||||
<div v-else class="space-y-3.5">
|
||||
<VCard v-for="order in filteredOrders" :key="order.id" :padded="false" class="overflow-hidden">
|
||||
<header class="flex items-center justify-between gap-2.5 border-b border-border bg-bg px-3.5 py-3 text-xs text-muted max-sm:items-start">
|
||||
<div class="flex min-w-0 items-center gap-3.5 max-sm:flex-col max-sm:items-start max-sm:gap-1"><strong class="text-[13px] font-semibold text-text">{{ shopName(order.shop_id) }}</strong><span>{{ t("user.orderNo") }} {{ order.order_no }}</span><span>{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</span></div>
|
||||
<VCard
|
||||
v-for="order in filteredOrders"
|
||||
:key="order.id"
|
||||
:padded="false"
|
||||
class="overflow-hidden"
|
||||
>
|
||||
<header
|
||||
class="border-border bg-bg text-muted flex items-center justify-between gap-2.5 border-b px-3.5 py-3 text-xs max-sm:items-start"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-3.5 max-sm:flex-col max-sm:items-start max-sm:gap-1"
|
||||
>
|
||||
<strong class="text-text text-[13px] font-semibold">{{
|
||||
shopName(order.shop_id)
|
||||
}}</strong
|
||||
><span>{{ t("user.orderNo") }} {{ order.order_no }}</span
|
||||
><span>{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</span>
|
||||
</div>
|
||||
<StatusBadge :status="order.status" kind="order" />
|
||||
</header>
|
||||
<div class="px-3.5 py-1">
|
||||
<div v-for="item in order.items" :key="item.id" class="flex items-center gap-3 border-b border-border py-2.5 last:border-b-0">
|
||||
<img class="h-[54px] w-[54px] border border-border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 text-[13px]"><span class="truncate">{{ pick(item.product_name, locale) }}</span><small class="text-[11px] text-muted">{{ item.sku_code }} × {{ item.qty }}</small></div>
|
||||
<PriceText class="text-[13px] text-muted" :amount-minor="item.unit_price_minor" :currency="order.currency" />
|
||||
<div
|
||||
v-for="item in order.items"
|
||||
:key="item.id"
|
||||
class="border-border flex items-center gap-3 border-b py-2.5 last:border-b-0"
|
||||
>
|
||||
<img
|
||||
class="border-border h-[54px] w-[54px] border object-contain"
|
||||
:src="item.image || '/mock/product-1.svg'"
|
||||
:alt="pick(item.product_name, locale)"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1 text-[13px]">
|
||||
<span class="truncate">{{ pick(item.product_name, locale) }}</span
|
||||
><small class="text-muted text-[11px]"
|
||||
>{{ item.sku_code }} × {{ item.qty }}</small
|
||||
>
|
||||
</div>
|
||||
<PriceText
|
||||
class="text-muted text-[13px]"
|
||||
:amount-minor="item.unit_price_minor"
|
||||
:currency="order.currency"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="flex items-center justify-between gap-3 border-t border-border px-3.5 py-3 text-xs text-muted max-sm:flex-col max-sm:items-start">
|
||||
<span>{{ t("user.orderTotal") }} <strong class="text-primary"><PriceText :amount-minor="order.total_minor" :currency="order.currency" /></strong></span>
|
||||
<footer
|
||||
class="border-border text-muted flex items-center justify-between gap-3 border-t px-3.5 py-3 text-xs max-sm:flex-col max-sm:items-start"
|
||||
>
|
||||
<span
|
||||
>{{ t("user.orderTotal") }}
|
||||
<strong class="text-primary"
|
||||
><PriceText
|
||||
:amount-minor="order.total_minor"
|
||||
:currency="order.currency" /></strong
|
||||
></span>
|
||||
<div class="flex gap-2">
|
||||
<VBtn v-if="order.status === 'pending_payment'" variant="primary" size="sm" type="button" :disabled="workingId === order.id" @click="pay(order)">{{ t("user.payNow") }}</VBtn>
|
||||
<VBtn v-if="order.status === 'pending_payment'" size="sm" type="button" :disabled="workingId === order.id" @click="cancel(order)">{{ t("user.cancelOrder") }}</VBtn>
|
||||
<NuxtLink class="inline-flex items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[13px] font-medium text-text no-underline" :to="`/user/orders/${order.id}`">{{ t("user.viewDetails") }}</NuxtLink>
|
||||
<VBtn
|
||||
v-if="order.status === 'pending_payment'"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
type="button"
|
||||
:disabled="workingId === order.id"
|
||||
@click="pay(order)"
|
||||
>{{ t("user.payNow") }}</VBtn
|
||||
>
|
||||
<VBtn
|
||||
v-if="order.status === 'pending_payment'"
|
||||
size="sm"
|
||||
type="button"
|
||||
:disabled="workingId === order.id"
|
||||
@click="cancel(order)"
|
||||
>{{ t("user.cancelOrder") }}</VBtn
|
||||
>
|
||||
<NuxtLink
|
||||
class="border-border bg-surface text-text inline-flex items-center rounded-md border px-2.5 py-1 text-[13px] font-medium no-underline"
|
||||
:to="`/user/orders/${order.id}`"
|
||||
>{{ t("user.viewDetails") }}</NuxtLink
|
||||
>
|
||||
</div>
|
||||
</footer>
|
||||
</VCard>
|
||||
@@ -136,4 +201,3 @@ onMounted(() => {
|
||||
</UiTabs>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user