feat: three nuxt frontends, demo seed, rounding + money-exponent + rate-cast fixes, archived specs
This commit is contained in:
@@ -1,6 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { t as localized } from "@vmall/shared";
|
||||
import type { OrderStatus, Shop } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t: translate } = useI18n();
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const shop = ref<Shop | null>(null);
|
||||
const totalProducts = ref(0);
|
||||
const publishedProducts = ref(0);
|
||||
const orderCounts = ref<Record<OrderStatus, number>>({
|
||||
pending_payment: 0,
|
||||
paid: 0,
|
||||
fulfilling: 0,
|
||||
shipped: 0,
|
||||
completed: 0,
|
||||
cancelled: 0,
|
||||
});
|
||||
const pendingInvoices = ref(0);
|
||||
const orderStatuses: OrderStatus[] = ["pending_payment", "paid", "fulfilling", "shipped"];
|
||||
|
||||
async function loadDashboard(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const [products, published, shopData, invoices, ...orders] = await Promise.all([
|
||||
$api.shop.listMyProducts({ per_page: 100 }),
|
||||
$api.shop.listMyProducts({ per_page: 100, status: "published" }),
|
||||
$api.shop.getMyShop(),
|
||||
$api.shop.listInvoices(),
|
||||
...orderStatuses.map((status) => $api.shop.listOrders({ status, per_page: 100 })),
|
||||
]);
|
||||
totalProducts.value = products.total;
|
||||
publishedProducts.value = published.total;
|
||||
shop.value = shopData;
|
||||
pendingInvoices.value = invoices.filter((invoice) => invoice.status === "requested").length;
|
||||
orderStatuses.forEach((status, index) => {
|
||||
orderCounts.value[status] = orders[index]?.total ?? 0;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDashboard);
|
||||
|
||||
const stats = computed(() => [
|
||||
{ key: "totalProducts", label: "shop.totalProducts", value: totalProducts.value },
|
||||
{ key: "publishedProducts", label: "shop.publishedProducts", value: publishedProducts.value },
|
||||
{ key: "pendingPayment", label: "shop.pendingPaymentOrders", value: orderCounts.value.pending_payment },
|
||||
{ key: "paid", label: "shop.paidOrders", value: orderCounts.value.paid },
|
||||
{ key: "fulfilling", label: "shop.fulfillingOrders", value: orderCounts.value.fulfilling },
|
||||
{ key: "shipped", label: "shop.shippedOrders", value: orderCounts.value.shipped },
|
||||
{ key: "invoices", label: "shop.pendingInvoices", value: pendingInvoices.value },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="page-title">{{ $t("common.appName") }}</h1>
|
||||
<p class="muted">{{ $t("common.loading") }}</p>
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">{{ $t("shop.dashboardTitle") }}</h1>
|
||||
<button class="btn sm" :disabled="loading" @click="loadDashboard">{{ $t("common.search") }}</button>
|
||||
</div>
|
||||
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<template v-else>
|
||||
<div class="stats-grid">
|
||||
<div v-for="stat in stats" :key="stat.key" class="card stat-card">
|
||||
<span class="muted">{{ $t(stat.label) }}</span>
|
||||
<strong>{{ stat.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<section v-if="shop" class="card shop-profile mt">
|
||||
<h2>{{ $t("shop.profile") }}</h2>
|
||||
<div class="profile-grid">
|
||||
<div>
|
||||
<span class="muted">{{ $t("product.shop") }}</span>
|
||||
<strong>{{ localized(shop.name, locale) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="muted">{{ $t("shop.slug") }}</span>
|
||||
<strong>{{ shop.slug }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="muted">{{ $t("shop.shopStatus") }}</span>
|
||||
<span class="badge" :class="shop.status === 'active' ? 'green' : 'red'">
|
||||
{{ $t(`admin.${shop.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.stat-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.stat-card strong {
|
||||
font-size: 28px;
|
||||
}
|
||||
.shop-profile h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.profile-grid > div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import type { Invoice } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t: translate } = useI18n();
|
||||
const { load: loadMoney, fmt } = useMoney();
|
||||
const invoices = ref<Invoice[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const actionId = ref("");
|
||||
|
||||
function statusClass(value: Invoice["status"]): string {
|
||||
return value === "issued" ? "green" : value === "cancelled" ? "red" : "orange";
|
||||
}
|
||||
|
||||
async function loadInvoices(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
invoices.value = await $api.shop.listInvoices();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function issueInvoice(invoice: Invoice): Promise<void> {
|
||||
actionId.value = invoice.id;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.shop.issueInvoice(invoice.id);
|
||||
await loadInvoices();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
actionId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMoney();
|
||||
loadInvoices();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1 class="page-title">{{ $t("shop.invoiceList") }}</h1>
|
||||
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<div v-else-if="!invoices.length" class="card muted">{{ $t("common.empty") }}</div>
|
||||
<div v-else class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("shop.invoiceNo") }}</th>
|
||||
<th>{{ $t("shop.orderNo") }}</th>
|
||||
<th>{{ $t("invoice.invoiceTitle") }}</th>
|
||||
<th>{{ $t("shop.kind") }}</th>
|
||||
<th>{{ $t("shop.taxNo") }}</th>
|
||||
<th>{{ $t("invoice.amount") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="invoice in invoices" :key="invoice.id">
|
||||
<td>{{ invoice.invoice_no || $t("shop.noInvoice") }}</td>
|
||||
<td><NuxtLink :to="`/orders/${invoice.order_id}`">{{ invoice.order_no ?? invoice.order_id }}</NuxtLink></td>
|
||||
<td>{{ invoice.title }}</td>
|
||||
<td>{{ $t(`invoice.${invoice.kind}`) }}</td>
|
||||
<td>{{ invoice.tax_no || $t("shop.noInvoice") }}</td>
|
||||
<td>{{ fmt(invoice.amount_minor, invoice.currency) }}</td>
|
||||
<td><span class="badge" :class="statusClass(invoice.status)">{{ $t(`invoice.status.${invoice.status}`) }}</span></td>
|
||||
<td><button v-if="invoice.status === 'requested'" class="btn sm primary" :disabled="actionId === invoice.id" @click="issueInvoice(invoice)">{{ $t("shop.issueInvoice") }}</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
const { $api } = useNuxtApp();
|
||||
const session = useSessionStore();
|
||||
const { t: translate } = useI18n();
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const busy = ref(false);
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
error.value = "";
|
||||
busy.value = true;
|
||||
try {
|
||||
const auth = await $api.login(email.value.trim(), password.value);
|
||||
if (auth.user.role !== "shop_owner" && auth.user.role !== "shop_staff") {
|
||||
error.value = translate("auth.shopRoleError");
|
||||
return;
|
||||
}
|
||||
session.setAuth(auth);
|
||||
await navigateTo("/");
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page">
|
||||
<form class="card form-narrow" @submit.prevent="submit">
|
||||
<h1 class="page-title">{{ $t("auth.welcome") }}</h1>
|
||||
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
|
||||
<div class="field">
|
||||
<label for="email">{{ $t("common.email") }}</label>
|
||||
<input id="email" v-model="email" type="email" autocomplete="username" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">{{ $t("common.password") }}</label>
|
||||
<input id="password" v-model="password" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
<button class="btn primary" type="submit" :disabled="busy">
|
||||
{{ busy ? $t("common.loading") : $t("common.login") }}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { t as localized } from "@vmall/shared";
|
||||
import type { Order, OrderStatus, Shipment, ShipmentItemBody } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t: translate } = useI18n();
|
||||
const { load: loadMoney, fmt } = useMoney();
|
||||
const route = useRoute();
|
||||
const orderId = computed(() => String(route.params.id));
|
||||
const order = ref<Order | null>(null);
|
||||
const shipments = ref<Shipment[]>([]);
|
||||
const quantities = ref<Record<string, number>>({});
|
||||
const carrier = ref("");
|
||||
const trackingNo = ref("");
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const actionId = ref("");
|
||||
const error = ref("");
|
||||
|
||||
function statusClass(value: OrderStatus): string {
|
||||
if (value === "completed") return "green";
|
||||
if (value === "cancelled") return "red";
|
||||
if (value === "pending_payment") return "orange";
|
||||
return "blue";
|
||||
}
|
||||
|
||||
function shipmentStatusClass(value: Shipment["status"]): string {
|
||||
return value === "delivered" ? "green" : value === "shipped" ? "blue" : "orange";
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
function shippedQuantity(itemId: string): number {
|
||||
return shipments.value
|
||||
.filter((shipment) => shipment.order_id === orderId.value)
|
||||
.flatMap((shipment) => shipment.items)
|
||||
.filter((item) => item.order_item_id === itemId)
|
||||
.reduce((total, item) => total + item.qty, 0);
|
||||
}
|
||||
|
||||
function unshippedQuantity(itemId: string, total: number): number {
|
||||
return Math.max(0, total - shippedQuantity(itemId));
|
||||
}
|
||||
|
||||
async function findShopOrder(): Promise<Order> {
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
while (currentPage <= totalPages) {
|
||||
const result = await $api.shop.listOrders({ page: currentPage });
|
||||
const found = result.items.find((item) => item.id === orderId.value);
|
||||
if (found) return found;
|
||||
totalPages = Math.max(1, Math.ceil(result.total / result.per_page));
|
||||
currentPage += 1;
|
||||
}
|
||||
throw new Error(translate("common.empty"));
|
||||
}
|
||||
|
||||
async function loadOrder(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const [loadedOrder, loadedShipments] = await Promise.all([
|
||||
findShopOrder(),
|
||||
$api.shop.listShipments(),
|
||||
]);
|
||||
order.value = loadedOrder;
|
||||
shipments.value = loadedShipments.filter((shipment) => shipment.order_id === orderId.value);
|
||||
const nextQuantities: Record<string, number> = {};
|
||||
loadedOrder.items.forEach((item) => {
|
||||
nextQuantities[item.id] = Math.max(
|
||||
0,
|
||||
item.qty - loadedShipments
|
||||
.filter((shipment) => shipment.order_id === orderId.value)
|
||||
.flatMap((shipment) => shipment.items)
|
||||
.filter((shipmentItem) => shipmentItem.order_item_id === item.id)
|
||||
.reduce((total, shipmentItem) => total + shipmentItem.qty, 0),
|
||||
);
|
||||
});
|
||||
quantities.value = nextQuantities;
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createShipment(): Promise<void> {
|
||||
if (!order.value || !carrier.value.trim() || !trackingNo.value.trim()) {
|
||||
error.value = translate("common.required");
|
||||
return;
|
||||
}
|
||||
const items: ShipmentItemBody[] = order.value.items
|
||||
.map((item) => ({ order_item_id: item.id, qty: quantities.value[item.id] ?? 0 }))
|
||||
.filter((item) => item.qty > 0);
|
||||
if (!items.length) {
|
||||
error.value = translate("common.required");
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.shop.createShipment(order.value.id, carrier.value.trim(), trackingNo.value.trim(), items);
|
||||
carrier.value = "";
|
||||
trackingNo.value = "";
|
||||
await loadOrder();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markShipped(shipment: Shipment): Promise<void> {
|
||||
actionId.value = shipment.id;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.shop.markShipped(shipment.id);
|
||||
await loadOrder();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
actionId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMoney();
|
||||
loadOrder();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">{{ order ? order.order_no : $t("order.title") }}</h1>
|
||||
<NuxtLink class="btn" to="/orders">{{ $t("common.back") }}</NuxtLink>
|
||||
</div>
|
||||
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<template v-else-if="order">
|
||||
<section class="card">
|
||||
<div class="row between detail-heading">
|
||||
<h2>{{ $t("shop.details") }}</h2>
|
||||
<span class="badge" :class="statusClass(order.status)">{{ $t(`order.status.${order.status}`) }}</span>
|
||||
</div>
|
||||
<div class="order-meta">
|
||||
<div><span class="muted">{{ $t("shop.created") }}</span><strong>{{ formatDate(order.created_at) }}</strong></div>
|
||||
<div><span class="muted">{{ $t("shop.total") }}</span><strong>{{ fmt(order.total_minor, order.currency) }}</strong></div>
|
||||
</div>
|
||||
<h3>{{ $t("shop.items") }}</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>{{ $t("product.detail") }}</th><th>{{ $t("product.skuCode") }}</th><th>{{ $t("common.qty") }}</th><th>{{ $t("common.price") }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in order.items" :key="item.id">
|
||||
<td>{{ localized(item.product_name, locale) }}</td>
|
||||
<td>{{ item.sku_code }}</td>
|
||||
<td>{{ item.qty }}</td>
|
||||
<td>{{ fmt(item.unit_price_minor, order.currency) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h3>{{ $t("shop.address") }}</h3>
|
||||
<address class="address">
|
||||
<div>{{ order.shipping_address.recipient }} · {{ order.shipping_address.phone }}</div>
|
||||
<div>{{ order.shipping_address.country }}, {{ order.shipping_address.region }}, {{ order.shipping_address.city }}</div>
|
||||
<div>{{ order.shipping_address.line1 }}, {{ order.shipping_address.postal_code }}</div>
|
||||
</address>
|
||||
</section>
|
||||
|
||||
<section v-if="order.status === 'paid' || order.status === 'fulfilling'" class="card mt">
|
||||
<h2>{{ $t("shop.createShipment") }}</h2>
|
||||
<form @submit.prevent="createShipment">
|
||||
<div class="field-row">
|
||||
<div class="field"><label for="carrier">{{ $t("shop.carrier") }}</label><input id="carrier" v-model="carrier" required /></div>
|
||||
<div class="field"><label for="tracking-no">{{ $t("shop.trackingNo") }}</label><input id="tracking-no" v-model="trackingNo" required /></div>
|
||||
</div>
|
||||
<h3>{{ $t("shop.shipmentQuantities") }}</h3>
|
||||
<div v-for="item in order.items" :key="`quantity-${item.id}`" class="field-row quantity-row">
|
||||
<div class="quantity-name">{{ localized(item.product_name, locale) }} ({{ item.sku_code }})</div>
|
||||
<div class="field"><label :for="`quantity-${item.id}`">{{ $t("shop.unshipped") }}: {{ unshippedQuantity(item.id, item.qty) }}</label><input :id="`quantity-${item.id}`" v-model.number="quantities[item.id]" type="number" min="0" :max="unshippedQuantity(item.id, item.qty)" step="1" /></div>
|
||||
</div>
|
||||
<button class="btn primary" type="submit" :disabled="busy">{{ $t("shop.createShipment") }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card mt">
|
||||
<h2>{{ $t("shipment.title") }}</h2>
|
||||
<div v-if="!shipments.length" class="muted">{{ $t("common.empty") }}</div>
|
||||
<div v-else class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>{{ $t("shipment.shipmentNo") }}</th><th>{{ $t("shop.carrier") }}</th><th>{{ $t("shop.trackingNo") }}</th><th>{{ $t("common.status") }}</th><th>{{ $t("common.actions") }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="shipment in shipments" :key="shipment.id">
|
||||
<td>{{ shipment.shipment_no }}</td><td>{{ shipment.carrier }}</td><td>{{ shipment.tracking_no }}</td>
|
||||
<td><span class="badge" :class="shipmentStatusClass(shipment.status)">{{ $t(`shipment.status.${shipment.status}`) }}</span></td>
|
||||
<td><button v-if="shipment.status === 'pending'" class="btn sm" :disabled="actionId === shipment.id" @click="markShipped(shipment)">{{ $t("shipment.markShipped") }}</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.detail-heading h2,
|
||||
.card h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.order-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.order-meta > div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.address {
|
||||
font-style: normal;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.quantity-row {
|
||||
align-items: end;
|
||||
}
|
||||
.quantity-name {
|
||||
flex: 2;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.quantity-row .field {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import type { Order, OrderStatus, Paged } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t: translate } = useI18n();
|
||||
const { load: loadMoney, fmt } = useMoney();
|
||||
const orders = ref<Paged<Order> | null>(null);
|
||||
const page = ref(1);
|
||||
const status = ref<"" | OrderStatus>("");
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
|
||||
function statusClass(value: OrderStatus): string {
|
||||
if (value === "completed") return "green";
|
||||
if (value === "cancelled") return "red";
|
||||
if (value === "pending_payment") return "orange";
|
||||
return "blue";
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
async function loadOrders(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
orders.value = await $api.shop.listOrders({ page: page.value, status: status.value || undefined });
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function filterChanged(): Promise<void> {
|
||||
page.value = 1;
|
||||
await loadOrders();
|
||||
}
|
||||
|
||||
async function changePage(nextPage: number): Promise<void> {
|
||||
if (!orders.value || nextPage < 1 || nextPage > Math.ceil(orders.value.total / orders.value.per_page)) return;
|
||||
page.value = nextPage;
|
||||
await loadOrders();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMoney();
|
||||
loadOrders();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-head"><h1 class="page-title">{{ $t("shop.orderList") }}</h1></div>
|
||||
<div class="row mb">
|
||||
<label for="order-status">{{ $t("shop.filterOrderStatus") }}</label>
|
||||
<select id="order-status" v-model="status" @change="filterChanged">
|
||||
<option value="">{{ $t("common.all") }}</option>
|
||||
<option value="pending_payment">{{ $t("order.status.pending_payment") }}</option>
|
||||
<option value="paid">{{ $t("order.status.paid") }}</option>
|
||||
<option value="fulfilling">{{ $t("order.status.fulfilling") }}</option>
|
||||
<option value="shipped">{{ $t("order.status.shipped") }}</option>
|
||||
<option value="completed">{{ $t("order.status.completed") }}</option>
|
||||
<option value="cancelled">{{ $t("order.status.cancelled") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<div v-else-if="!orders?.items.length" class="card muted">{{ $t("common.empty") }}</div>
|
||||
<template v-else>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("shop.orderNo") }}</th>
|
||||
<th>{{ $t("shop.created") }}</th>
|
||||
<th>{{ $t("shop.items") }}</th>
|
||||
<th>{{ $t("shop.total") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="order in orders.items" :key="order.id">
|
||||
<td><NuxtLink :to="`/orders/${order.id}`">{{ order.order_no }}</NuxtLink></td>
|
||||
<td>{{ formatDate(order.created_at) }}</td>
|
||||
<td>{{ order.items.length }}</td>
|
||||
<td>{{ fmt(order.total_minor, order.currency) }}</td>
|
||||
<td><span class="badge" :class="statusClass(order.status)">{{ $t(`order.status.${order.status}`) }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="orders.total > orders.per_page" class="row between mt">
|
||||
<button class="btn sm" :disabled="page <= 1 || loading" @click="changePage(page - 1)">{{ $t("common.prev") }}</button>
|
||||
<span class="muted">{{ $t("common.page") }} {{ page }} / {{ Math.ceil(orders.total / orders.per_page) }}</span>
|
||||
<button class="btn sm" :disabled="page >= Math.ceil(orders.total / orders.per_page) || loading" @click="changePage(page + 1)">{{ $t("common.next") }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import type { Category, Product, ProductUpsertBody, Sku, SkuUpsertBody } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t: translate } = useI18n();
|
||||
const { load: loadMoney, fmt } = useMoney();
|
||||
const route = useRoute();
|
||||
const productId = computed(() => String(route.params.id));
|
||||
const product = ref<Product | null>(null);
|
||||
const categories = ref<Category[]>([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const skuBusy = ref(false);
|
||||
const error = ref("");
|
||||
const skuCode = ref("");
|
||||
const skuPrice = ref("");
|
||||
const skuCurrency = ref("USD");
|
||||
const skuStock = ref("0");
|
||||
const skuActive = ref(true);
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
function formatSkuPrice(sku: Sku): string {
|
||||
return fmt(sku.price_minor, sku.currency);
|
||||
}
|
||||
|
||||
function majorToMinor(value: string): number | null {
|
||||
const clean = value.trim();
|
||||
if (!/^\d+(?:\.\d{1,2})?$/.test(clean)) return null;
|
||||
const [whole, fraction = ""] = clean.split(".");
|
||||
const minor = Number(whole) * 100 + Number(fraction.padEnd(2, "0"));
|
||||
return Number.isSafeInteger(minor) ? minor : null;
|
||||
}
|
||||
|
||||
async function loadProduct(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const [loadedProduct, loadedCategories] = await Promise.all([
|
||||
$api.shop.getProduct(productId.value),
|
||||
$api.listCategories(),
|
||||
]);
|
||||
product.value = loadedProduct;
|
||||
categories.value = loadedCategories;
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProduct(body: ProductUpsertBody): Promise<void> {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.shop.updateProduct(productId.value, body);
|
||||
product.value = await $api.shop.getProduct(productId.value);
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSku(): Promise<void> {
|
||||
const priceMinor = majorToMinor(skuPrice.value);
|
||||
const stock = Number(skuStock.value);
|
||||
if (!skuCode.value.trim() || priceMinor === null || !Number.isInteger(stock) || stock < 0) {
|
||||
error.value = translate("common.required");
|
||||
return;
|
||||
}
|
||||
skuBusy.value = true;
|
||||
error.value = "";
|
||||
const body: SkuUpsertBody = {
|
||||
sku_code: skuCode.value.trim(),
|
||||
price_minor: priceMinor,
|
||||
currency: skuCurrency.value,
|
||||
stock,
|
||||
active: skuActive.value,
|
||||
};
|
||||
try {
|
||||
await $api.shop.upsertSku(productId.value, body);
|
||||
product.value = await $api.shop.getProduct(productId.value);
|
||||
skuCode.value = "";
|
||||
skuPrice.value = "";
|
||||
skuCurrency.value = "USD";
|
||||
skuStock.value = "0";
|
||||
skuActive.value = true;
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
skuBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMoney();
|
||||
loadProduct();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">{{ $t("product.editProduct") }}</h1>
|
||||
<NuxtLink class="btn" to="/products">{{ $t("common.back") }}</NuxtLink>
|
||||
</div>
|
||||
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<template v-else-if="product">
|
||||
<ProductForm :product="product" :categories="categories" :busy="busy" @submit="updateProduct" />
|
||||
<section class="card mt sku-section">
|
||||
<h2>{{ $t("shop.skuManager") }}</h2>
|
||||
<div v-if="!product.skus?.length" class="muted mb">{{ $t("shop.noSkus") }}</div>
|
||||
<div v-else class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("product.skuCode") }}</th>
|
||||
<th>{{ $t("common.price") }}</th>
|
||||
<th>{{ $t("common.currency") }}</th>
|
||||
<th>{{ $t("shop.stock") }}</th>
|
||||
<th>{{ $t("shop.active") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="sku in product.skus" :key="sku.id">
|
||||
<td>{{ sku.sku_code }}</td>
|
||||
<td>{{ formatSkuPrice(sku) }}</td>
|
||||
<td>{{ sku.currency }}</td>
|
||||
<td>{{ sku.stock }}</td>
|
||||
<td><span class="badge" :class="sku.active ? 'green' : 'red'">{{ sku.active ? $t("common.yes") : $t("common.no") }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form class="sku-form mt" @submit.prevent="saveSku">
|
||||
<h3>{{ $t("shop.addSku") }}</h3>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="sku-code">{{ $t("product.skuCode") }}</label>
|
||||
<input id="sku-code" v-model="skuCode" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sku-price">{{ $t("shop.skuPriceMajor") }}</label>
|
||||
<input id="sku-price" v-model="skuPrice" inputmode="decimal" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="sku-currency">{{ $t("common.currency") }}</label>
|
||||
<select id="sku-currency" v-model="skuCurrency">
|
||||
<option value="USD">USD</option>
|
||||
<option value="CNY">CNY</option>
|
||||
<option value="EUR">EUR</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sku-stock">{{ $t("shop.stock") }}</label>
|
||||
<input id="sku-stock" v-model="skuStock" type="number" min="0" step="1" required />
|
||||
</div>
|
||||
</div>
|
||||
<label class="checkbox-field"><input v-model="skuActive" type="checkbox" /> {{ $t("shop.active") }}</label>
|
||||
<button class="btn primary" type="submit" :disabled="skuBusy">{{ $t("shop.addSku") }}</button>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sku-section {
|
||||
max-width: 960px;
|
||||
}
|
||||
.sku-section h2,
|
||||
.sku-form h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.sku-form {
|
||||
max-width: 720px;
|
||||
}
|
||||
.checkbox-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.checkbox-field input {
|
||||
width: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { t as localized } from "@vmall/shared";
|
||||
import type { Paged, Product, ProductStatus } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t: translate } = useI18n();
|
||||
const products = ref<Paged<Product> | null>(null);
|
||||
const page = ref(1);
|
||||
const status = ref<"" | ProductStatus>("");
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const actionId = ref("");
|
||||
|
||||
function statusClass(value: ProductStatus): string {
|
||||
return value === "published" ? "green" : value === "unpublished" ? "orange" : "blue";
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
async function loadProducts(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
products.value = await $api.shop.listMyProducts({ page: page.value, status: status.value || undefined });
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function filterChanged(): Promise<void> {
|
||||
page.value = 1;
|
||||
await loadProducts();
|
||||
}
|
||||
|
||||
async function togglePublished(product: Product): Promise<void> {
|
||||
actionId.value = product.id;
|
||||
error.value = "";
|
||||
try {
|
||||
if (product.status === "published") await $api.shop.unpublish(product.id);
|
||||
else await $api.shop.publish(product.id);
|
||||
await loadProducts();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
actionId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function changePage(nextPage: number): Promise<void> {
|
||||
if (!products.value || nextPage < 1 || nextPage > Math.ceil(products.value.total / products.value.per_page)) return;
|
||||
page.value = nextPage;
|
||||
await loadProducts();
|
||||
}
|
||||
|
||||
onMounted(loadProducts);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">{{ $t("shop.productList") }}</h1>
|
||||
<NuxtLink class="btn primary" to="/products/new">{{ $t("shop.newProduct") }}</NuxtLink>
|
||||
</div>
|
||||
<div class="row mb">
|
||||
<label for="product-status">{{ $t("shop.filterStatus") }}</label>
|
||||
<select id="product-status" v-model="status" @change="filterChanged">
|
||||
<option value="">{{ $t("common.all") }}</option>
|
||||
<option value="draft">{{ $t("product.draft") }}</option>
|
||||
<option value="published">{{ $t("product.published") }}</option>
|
||||
<option value="unpublished">{{ $t("product.unpublished") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<div v-else-if="!products?.items.length" class="card muted">{{ $t("common.empty") }}</div>
|
||||
<template v-else>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("product.detail") }}</th>
|
||||
<th>{{ $t("product.slug") }}</th>
|
||||
<th>{{ $t("shop.skuCount") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("shop.created") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="product in products.items" :key="product.id">
|
||||
<td>{{ localized(product.name, locale) }}</td>
|
||||
<td>{{ product.slug }}</td>
|
||||
<td>{{ product.skus?.length ?? 0 }}</td>
|
||||
<td><span class="badge" :class="statusClass(product.status)">{{ $t(`product.${product.status}`) }}</span></td>
|
||||
<td>{{ formatDate(product.created_at) }}</td>
|
||||
<td class="row actions-cell">
|
||||
<NuxtLink class="btn sm" :to="`/products/${product.id}`">{{ $t("shop.edit") }}</NuxtLink>
|
||||
<button
|
||||
v-if="product.status === 'published'"
|
||||
class="btn sm"
|
||||
:disabled="actionId === product.id"
|
||||
@click="togglePublished(product)"
|
||||
>{{ $t("product.unpublish") }}</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn sm primary"
|
||||
:disabled="actionId === product.id"
|
||||
@click="togglePublished(product)"
|
||||
>{{ $t("product.publish") }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="products.total > products.per_page" class="row between mt">
|
||||
<button class="btn sm" :disabled="page <= 1 || loading" @click="changePage(page - 1)">{{ $t("common.prev") }}</button>
|
||||
<span class="muted">{{ $t("common.page") }} {{ page }} / {{ Math.ceil(products.total / products.per_page) }}</span>
|
||||
<button class="btn sm" :disabled="page >= Math.ceil(products.total / products.per_page) || loading" @click="changePage(page + 1)">{{ $t("common.next") }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.actions-cell {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { Category, ProductUpsertBody } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { t: translate } = useI18n();
|
||||
const categories = ref<Category[]>([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function loadCategories(): Promise<void> {
|
||||
try {
|
||||
categories.value = await $api.listCategories();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProduct(body: ProductUpsertBody): Promise<void> {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const product = await $api.shop.createProduct(body);
|
||||
await navigateTo(`/products/${product.id}`);
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCategories);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">{{ $t("product.newProduct") }}</h1>
|
||||
<NuxtLink class="btn" to="/products">{{ $t("common.back") }}</NuxtLink>
|
||||
</div>
|
||||
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<ProductForm v-else :categories="categories" :busy="busy" @submit="saveProduct" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import type { Shipment } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t: translate } = useI18n();
|
||||
const shipments = ref<Shipment[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const actionId = ref("");
|
||||
|
||||
function statusClass(value: Shipment["status"]): string {
|
||||
return value === "delivered" ? "green" : value === "shipped" ? "blue" : "orange";
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
async function loadShipments(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
shipments.value = await $api.shop.listShipments();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markShipped(shipment: Shipment): Promise<void> {
|
||||
actionId.value = shipment.id;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.shop.markShipped(shipment.id);
|
||||
await loadShipments();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
actionId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadShipments);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h1 class="page-title">{{ $t("shop.shipmentList") }}</h1>
|
||||
<div v-if="error" class="error-text" role="alert">{{ error }}</div>
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<div v-else-if="!shipments.length" class="card muted">{{ $t("common.empty") }}</div>
|
||||
<div v-else class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("shipment.shipmentNo") }}</th>
|
||||
<th>{{ $t("shop.orderNo") }}</th>
|
||||
<th>{{ $t("shop.carrier") }}</th>
|
||||
<th>{{ $t("shop.trackingNo") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("shop.created") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="shipment in shipments" :key="shipment.id">
|
||||
<td>{{ shipment.shipment_no }}</td>
|
||||
<td><NuxtLink :to="`/orders/${shipment.order_id}`">{{ shipment.order_no ?? shipment.order_id }}</NuxtLink></td>
|
||||
<td>{{ shipment.carrier }}</td>
|
||||
<td>{{ shipment.tracking_no }}</td>
|
||||
<td><span class="badge" :class="statusClass(shipment.status)">{{ $t(`shipment.status.${shipment.status}`) }}</span></td>
|
||||
<td>{{ formatDate(shipment.created_at) }}</td>
|
||||
<td><button v-if="shipment.status === 'pending'" class="btn sm" :disabled="actionId === shipment.id" @click="markShipped(shipment)">{{ $t("shipment.markShipped") }}</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user