feat(freight): shop freight templates, server-side checkout fees, company dictionary (add-freight-templates)
This commit is contained in:
@@ -42,6 +42,12 @@ watchEffect(() => {
|
||||
class="text-muted hover:bg-bg rounded-md px-3 py-2 text-sm font-medium"
|
||||
>{{ $t("nav.shopProfile") }}</NuxtLink
|
||||
>
|
||||
<NuxtLink
|
||||
to="/freight-templates"
|
||||
active-class="bg-primary-soft text-primary"
|
||||
class="text-muted hover:bg-bg rounded-md px-3 py-2 text-sm font-medium"
|
||||
>{{ $t("nav.freightTemplates") }}</NuxtLink
|
||||
>
|
||||
<NuxtLink
|
||||
to="/orders"
|
||||
active-class="bg-primary-soft text-primary"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { t as localized } from "@vmall/shared";
|
||||
import type { Category, Product, ProductUpsertBody } from "@vmall/shared";
|
||||
import type { Category, FreightTemplate, Product, ProductUpsertBody } from "@vmall/shared";
|
||||
|
||||
const props = defineProps<{
|
||||
product?: Product;
|
||||
categories: Category[];
|
||||
freightTemplates: FreightTemplate[];
|
||||
busy?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{ submit: [body: ProductUpsertBody] }>();
|
||||
@@ -16,6 +17,7 @@ const nameZh = ref("");
|
||||
const descriptionEn = ref("");
|
||||
const descriptionZh = ref("");
|
||||
const categoryId = ref("");
|
||||
const freightTemplateId = ref("");
|
||||
const images = ref("");
|
||||
const validationError = ref("");
|
||||
|
||||
@@ -26,6 +28,7 @@ function resetFromProduct(product: Product | undefined): void {
|
||||
descriptionEn.value = product?.description.en ?? "";
|
||||
descriptionZh.value = product?.description.zh ?? "";
|
||||
categoryId.value = product?.category_id ?? "";
|
||||
freightTemplateId.value = product?.freight_template_id ?? "";
|
||||
images.value = product?.images.join("\n") ?? "";
|
||||
validationError.value = "";
|
||||
}
|
||||
@@ -45,6 +48,7 @@ function submit(): void {
|
||||
validationError.value = "";
|
||||
const body: ProductUpsertBody = {
|
||||
category_id: categoryId.value || null,
|
||||
freight_template_id: freightTemplateId.value || null,
|
||||
slug: cleanSlug,
|
||||
name: { en: nameEn.value.trim(), zh: nameZh.value.trim() },
|
||||
description: { en: descriptionEn.value.trim(), zh: descriptionZh.value.trim() },
|
||||
@@ -100,6 +104,18 @@ function submit(): void {
|
||||
</option>
|
||||
</select>
|
||||
</VField>
|
||||
<VField :label="$t('shop.freightTemplate')">
|
||||
<select
|
||||
id="product-freight-template"
|
||||
v-model="freightTemplateId"
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2"
|
||||
>
|
||||
<option value="">{{ $t("shop.freightTemplateDefault") }}</option>
|
||||
<option v-for="template in freightTemplates" :key="template.id" :value="template.id">
|
||||
{{ template.name }}
|
||||
</option>
|
||||
</select>
|
||||
</VField>
|
||||
<VField :label="$t('product.images')">
|
||||
<textarea
|
||||
id="product-images"
|
||||
|
||||
@@ -16,10 +16,40 @@ export function useMoney() {
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(amountMinor: number, currency: string): string {
|
||||
const exponent = currencies.value.find((c) => c.code === currency)?.exponent ?? 2;
|
||||
return formatMoney(amountMinor, currency, exponent, locale.value);
|
||||
function exponentOf(currency: string): number {
|
||||
return currencies.value.find((c) => c.code === currency)?.exponent ?? 2;
|
||||
}
|
||||
|
||||
return { currencies, load, fmt };
|
||||
function fmt(amountMinor: number, currency: string): string {
|
||||
return formatMoney(amountMinor, currency, exponentOf(currency), locale.value);
|
||||
}
|
||||
|
||||
/** Currency for money fields without an explicit currency: the table's base. */
|
||||
const shopCurrency = computed(
|
||||
() => currencies.value.find((c) => c.is_base)?.code ?? currencies.value[0]?.code ?? "USD",
|
||||
);
|
||||
|
||||
/** Parse a major-unit decimal string into integer minor units; null when invalid. */
|
||||
function majorToMinor(value: string, currency: string): number | null {
|
||||
const clean = value.trim();
|
||||
if (!/^\d+(?:\.\d+)?$/.test(clean)) return null;
|
||||
const exponent = exponentOf(currency);
|
||||
const [whole, fraction = ""] = clean.split(".");
|
||||
if (fraction.length > exponent) return null;
|
||||
const minor = Number(whole) * 10 ** exponent + Number(fraction.padEnd(exponent, "0") || 0);
|
||||
return Number.isSafeInteger(minor) ? minor : null;
|
||||
}
|
||||
|
||||
/** Render integer minor units as a major-unit string for form inputs. */
|
||||
function minorToMajor(minor: number, currency: string): string {
|
||||
const exponent = exponentOf(currency);
|
||||
const sign = minor < 0 ? "-" : "";
|
||||
const abs = Math.abs(minor);
|
||||
const whole = Math.trunc(abs / 10 ** exponent);
|
||||
if (exponent === 0) return `${sign}${whole}`;
|
||||
const fraction = (abs % 10 ** exponent).toString().padStart(exponent, "0");
|
||||
return `${sign}${whole}.${fraction}`;
|
||||
}
|
||||
|
||||
return { currencies, load, fmt, shopCurrency, majorToMinor, minorToMajor };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export const enExtra = {
|
||||
groupBuying: "Group buying",
|
||||
shopProfile: "Shop profile",
|
||||
aftersales: "After-sales",
|
||||
freightTemplates: "Freight templates",
|
||||
},
|
||||
shop: {
|
||||
profileSaved: "Shop profile saved.",
|
||||
@@ -56,7 +57,13 @@ export const enExtra = {
|
||||
skuPriceMajor: "Price (major units)",
|
||||
stock: "Stock",
|
||||
active: "Active",
|
||||
addSku: "Add SKU",
|
||||
skuSaved: "SKU saved.",
|
||||
skuWeight: "Weight (g)",
|
||||
skuWeightGrams: "Weight (grams, optional)",
|
||||
skuWeightInvalid: "Weight must be a positive integer.",
|
||||
freightTemplate: "Freight template",
|
||||
freightTemplateDefault: "Shop default",
|
||||
actions: "Actions",
|
||||
edit: "Edit",
|
||||
invoiceNo: "Invoice no.",
|
||||
@@ -71,6 +78,9 @@ export const enExtra = {
|
||||
phone: "Phone",
|
||||
carrier: "Carrier",
|
||||
trackingNo: "Tracking number",
|
||||
shippingCompany: "Shipping company",
|
||||
selectShippingCompany: "Select a shipping company",
|
||||
shippingCompanyRequired: "Choose a shipping company.",
|
||||
shipmentQuantities: "Shipment quantities",
|
||||
unshipped: "Unshipped",
|
||||
createShipment: "Create shipment",
|
||||
@@ -160,6 +170,45 @@ export const enExtra = {
|
||||
groupLifetimeInvalid: "Group lifetime must be a positive number of hours.",
|
||||
groupWindowInvalid: "End must not precede start.",
|
||||
},
|
||||
freight: {
|
||||
title: "Freight templates",
|
||||
newTemplate: "New template",
|
||||
editTemplate: "Edit template",
|
||||
name: "Template name",
|
||||
pricingMethod: "Pricing method",
|
||||
methodByPiece: "By piece",
|
||||
methodByWeight: "By weight",
|
||||
unitPiece: "pcs",
|
||||
unitGram: "g",
|
||||
firstFeeShort: "First fee",
|
||||
additionalFeeShort: "Additional fee",
|
||||
firstFeeMajor: "First fee (major units)",
|
||||
additionalFeeMajor: "Additional fee (major units)",
|
||||
firstUnit: "First unit",
|
||||
additionalUnit: "Additional unit",
|
||||
freeThresholdMajor: "Free shipping over (major units, optional)",
|
||||
alwaysFree: "Always free",
|
||||
isDefault: "Default template",
|
||||
defaultTag: "Default",
|
||||
createTemplate: "Create template",
|
||||
updateTemplate: "Update template",
|
||||
saved: "Freight template saved.",
|
||||
deleted: "Freight template deleted.",
|
||||
setDefault: "Set as default",
|
||||
defaultSaved: "Default template updated.",
|
||||
nameRequired: "Template name is required.",
|
||||
feeInvalid: "Fees must be non-negative amounts.",
|
||||
unitInvalid: "Unit sizes must be positive integers.",
|
||||
thresholdInvalid: "Free-shipping threshold must be a non-negative amount.",
|
||||
regionRules: "Region rules",
|
||||
noRegionRules: "No region rules yet.",
|
||||
addRule: "Add rule",
|
||||
ruleRegions: "Regions (comma separated)",
|
||||
ruleRegionsRequired: "Each rule needs at least one region.",
|
||||
deleteRule: "Delete rule",
|
||||
deleteRuleConfirm: "Delete this region rule?",
|
||||
deleteConfirm: "Delete this freight template?",
|
||||
},
|
||||
aftersale: {
|
||||
title: "After-sales",
|
||||
detail: "After-sale detail",
|
||||
@@ -222,6 +271,7 @@ export const zhExtra = {
|
||||
groupBuying: "拼团",
|
||||
shopProfile: "店铺资料",
|
||||
aftersales: "售后",
|
||||
freightTemplates: "运费模板",
|
||||
},
|
||||
shop: {
|
||||
profileSaved: "店铺资料已保存。",
|
||||
@@ -269,6 +319,11 @@ export const zhExtra = {
|
||||
stock: "库存",
|
||||
active: "启用",
|
||||
skuSaved: "SKU 已保存。",
|
||||
skuWeight: "重量(克)",
|
||||
skuWeightGrams: "重量(克,可选)",
|
||||
skuWeightInvalid: "重量必须为正整数。",
|
||||
freightTemplate: "运费模板",
|
||||
freightTemplateDefault: "店铺默认",
|
||||
actions: "操作",
|
||||
edit: "编辑",
|
||||
addSku: "添加 SKU",
|
||||
@@ -283,6 +338,9 @@ export const zhExtra = {
|
||||
phone: "电话",
|
||||
carrier: "承运商",
|
||||
trackingNo: "物流单号",
|
||||
shippingCompany: "物流公司",
|
||||
selectShippingCompany: "请选择物流公司",
|
||||
shippingCompanyRequired: "请选择物流公司。",
|
||||
shipmentQuantities: "发货数量",
|
||||
unshipped: "待发货",
|
||||
createShipment: "创建发货单",
|
||||
@@ -372,6 +430,45 @@ export const zhExtra = {
|
||||
groupLifetimeInvalid: "团有效期必须为正数小时。",
|
||||
groupWindowInvalid: "结束时间不能早于开始时间。",
|
||||
},
|
||||
freight: {
|
||||
title: "运费模板",
|
||||
newTemplate: "新建运费模板",
|
||||
editTemplate: "编辑运费模板",
|
||||
name: "模板名称",
|
||||
pricingMethod: "计价方式",
|
||||
methodByPiece: "按件",
|
||||
methodByWeight: "按重量",
|
||||
unitPiece: "件",
|
||||
unitGram: "克",
|
||||
firstFeeShort: "首费",
|
||||
additionalFeeShort: "续费",
|
||||
firstFeeMajor: "首费(主单位)",
|
||||
additionalFeeMajor: "续费(主单位)",
|
||||
firstUnit: "首段单位量",
|
||||
additionalUnit: "续段单位量",
|
||||
freeThresholdMajor: "满额免运费(主单位,可留空)",
|
||||
alwaysFree: "全程包邮",
|
||||
isDefault: "默认模板",
|
||||
defaultTag: "默认",
|
||||
createTemplate: "创建模板",
|
||||
updateTemplate: "更新模板",
|
||||
saved: "运费模板已保存。",
|
||||
deleted: "运费模板已删除。",
|
||||
setDefault: "设为默认",
|
||||
defaultSaved: "默认模板已更新。",
|
||||
nameRequired: "模板名称为必填项。",
|
||||
feeInvalid: "费用必须为非负金额。",
|
||||
unitInvalid: "单位量必须为正整数。",
|
||||
thresholdInvalid: "免运费门槛必须为非负金额。",
|
||||
regionRules: "地区规则",
|
||||
noRegionRules: "暂无地区规则。",
|
||||
addRule: "添加规则",
|
||||
ruleRegions: "地区(逗号分隔)",
|
||||
ruleRegionsRequired: "每条规则至少填写一个地区。",
|
||||
deleteRule: "删除规则",
|
||||
deleteRuleConfirm: "确定删除该地区规则?",
|
||||
deleteConfirm: "确定删除该运费模板?",
|
||||
},
|
||||
aftersale: {
|
||||
title: "售后",
|
||||
detail: "售后详情",
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
FreightRegionRule,
|
||||
FreightRegionRuleInput,
|
||||
FreightTemplate,
|
||||
FreightTemplateInput,
|
||||
} from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { t: translate } = useI18n();
|
||||
const { load: loadMoney, fmt, shopCurrency, majorToMinor, minorToMajor } = useMoney();
|
||||
|
||||
interface RuleForm {
|
||||
regions: string;
|
||||
firstFeeMajor: string;
|
||||
firstUnit: string;
|
||||
additionalFeeMajor: string;
|
||||
additionalUnit: string;
|
||||
}
|
||||
|
||||
const templates = ref<FreightTemplate[]>([]);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const actionId = ref("");
|
||||
const error = ref("");
|
||||
const message = ref("");
|
||||
const editingId = ref("");
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
pricingMethod: "by_piece" as FreightTemplate["pricing_method"],
|
||||
firstFeeMajor: "0",
|
||||
firstUnit: "1",
|
||||
additionalFeeMajor: "0",
|
||||
additionalUnit: "1",
|
||||
freeThresholdMajor: "",
|
||||
alwaysFree: false,
|
||||
isDefault: false,
|
||||
});
|
||||
const rules = ref<RuleForm[]>([]);
|
||||
|
||||
/** Unit word (pcs / g) for the given pricing method; many table + form call sites. */
|
||||
function unitOf(method: FreightTemplate["pricing_method"]): string {
|
||||
return method === "by_weight" ? translate("freight.unitGram") : translate("freight.unitPiece");
|
||||
}
|
||||
|
||||
function parseUnit(value: string): number | null {
|
||||
const clean = value.trim();
|
||||
return /^[1-9]\d*$/.test(clean) ? Number(clean) : null;
|
||||
}
|
||||
|
||||
function toRuleInput(rule: FreightRegionRule): FreightRegionRuleInput {
|
||||
return {
|
||||
regions: rule.regions,
|
||||
first_fee_minor: rule.first_fee_minor,
|
||||
first_unit: rule.first_unit,
|
||||
additional_fee_minor: rule.additional_fee_minor,
|
||||
additional_unit: rule.additional_unit,
|
||||
};
|
||||
}
|
||||
|
||||
/** Validated payload, or the first validation error to show. */
|
||||
function payload(): FreightTemplateInput | string {
|
||||
const name = form.name.trim();
|
||||
if (!name) return translate("freight.nameRequired");
|
||||
const firstFee = majorToMinor(form.firstFeeMajor, shopCurrency.value);
|
||||
const additionalFee = majorToMinor(form.additionalFeeMajor, shopCurrency.value);
|
||||
if (firstFee === null || additionalFee === null) return translate("freight.feeInvalid");
|
||||
const firstUnit = parseUnit(form.firstUnit);
|
||||
const additionalUnit = parseUnit(form.additionalUnit);
|
||||
if (firstUnit === null || additionalUnit === null) return translate("freight.unitInvalid");
|
||||
let threshold: number | null = null;
|
||||
if (form.freeThresholdMajor.trim()) {
|
||||
const parsed = majorToMinor(form.freeThresholdMajor, shopCurrency.value);
|
||||
if (parsed === null) return translate("freight.thresholdInvalid");
|
||||
threshold = parsed;
|
||||
}
|
||||
const regionRules: FreightRegionRuleInput[] = [];
|
||||
for (const rule of rules.value) {
|
||||
const regions = rule.regions
|
||||
.split(",")
|
||||
.map((region) => region.trim())
|
||||
.filter((region) => region.length > 0);
|
||||
if (!regions.length) return translate("freight.ruleRegionsRequired");
|
||||
const ruleFirstFee = majorToMinor(rule.firstFeeMajor, shopCurrency.value);
|
||||
const ruleAdditionalFee = majorToMinor(rule.additionalFeeMajor, shopCurrency.value);
|
||||
if (ruleFirstFee === null || ruleAdditionalFee === null) return translate("freight.feeInvalid");
|
||||
const ruleFirstUnit = parseUnit(rule.firstUnit);
|
||||
const ruleAdditionalUnit = parseUnit(rule.additionalUnit);
|
||||
if (ruleFirstUnit === null || ruleAdditionalUnit === null)
|
||||
return translate("freight.unitInvalid");
|
||||
regionRules.push({
|
||||
regions,
|
||||
first_fee_minor: ruleFirstFee,
|
||||
first_unit: ruleFirstUnit,
|
||||
additional_fee_minor: ruleAdditionalFee,
|
||||
additional_unit: ruleAdditionalUnit,
|
||||
});
|
||||
}
|
||||
return {
|
||||
name,
|
||||
is_default: form.isDefault,
|
||||
always_free: form.alwaysFree,
|
||||
pricing_method: form.pricingMethod,
|
||||
first_fee_minor: firstFee,
|
||||
first_unit: firstUnit,
|
||||
additional_fee_minor: additionalFee,
|
||||
additional_unit: additionalUnit,
|
||||
free_threshold_minor: threshold,
|
||||
region_rules: regionRules,
|
||||
};
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
editingId.value = "";
|
||||
form.name = "";
|
||||
form.pricingMethod = "by_piece";
|
||||
form.firstFeeMajor = "0";
|
||||
form.firstUnit = "1";
|
||||
form.additionalFeeMajor = "0";
|
||||
form.additionalUnit = "1";
|
||||
form.freeThresholdMajor = "";
|
||||
form.alwaysFree = false;
|
||||
form.isDefault = false;
|
||||
rules.value = [];
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
function addRule(): void {
|
||||
rules.value.push({
|
||||
regions: "",
|
||||
firstFeeMajor: "0",
|
||||
firstUnit: "1",
|
||||
additionalFeeMajor: "0",
|
||||
additionalUnit: "1",
|
||||
});
|
||||
}
|
||||
|
||||
function removeRule(index: number): void {
|
||||
if (!confirm(translate("freight.deleteRuleConfirm"))) return;
|
||||
rules.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function edit(template: FreightTemplate): void {
|
||||
editingId.value = template.id;
|
||||
form.name = template.name;
|
||||
form.pricingMethod = template.pricing_method;
|
||||
form.firstFeeMajor = minorToMajor(template.first_fee_minor, shopCurrency.value);
|
||||
form.firstUnit = String(template.first_unit);
|
||||
form.additionalFeeMajor = minorToMajor(template.additional_fee_minor, shopCurrency.value);
|
||||
form.additionalUnit = String(template.additional_unit);
|
||||
form.freeThresholdMajor =
|
||||
template.free_threshold_minor === null
|
||||
? ""
|
||||
: minorToMajor(template.free_threshold_minor, shopCurrency.value);
|
||||
form.alwaysFree = template.always_free;
|
||||
form.isDefault = template.is_default;
|
||||
rules.value = template.region_rules.map((rule) => ({
|
||||
regions: rule.regions.join(", "),
|
||||
firstFeeMajor: minorToMajor(rule.first_fee_minor, shopCurrency.value),
|
||||
firstUnit: String(rule.first_unit),
|
||||
additionalFeeMajor: minorToMajor(rule.additional_fee_minor, shopCurrency.value),
|
||||
additionalUnit: String(rule.additional_unit),
|
||||
}));
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
}
|
||||
|
||||
async function loadTemplates(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
templates.value = await $api.shop.listFreightTemplates();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
const body = payload();
|
||||
if (typeof body === "string") {
|
||||
error.value = body;
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
if (editingId.value) await $api.shop.updateFreightTemplate(editingId.value, body);
|
||||
else await $api.shop.createFreightTemplate(body);
|
||||
message.value = translate("freight.saved");
|
||||
resetForm();
|
||||
await loadTemplates();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefault(template: FreightTemplate): Promise<void> {
|
||||
actionId.value = template.id;
|
||||
error.value = "";
|
||||
try {
|
||||
await $api.shop.updateFreightTemplate(template.id, {
|
||||
name: template.name,
|
||||
is_default: true,
|
||||
always_free: template.always_free,
|
||||
pricing_method: template.pricing_method,
|
||||
first_fee_minor: template.first_fee_minor,
|
||||
first_unit: template.first_unit,
|
||||
additional_fee_minor: template.additional_fee_minor,
|
||||
additional_unit: template.additional_unit,
|
||||
free_threshold_minor: template.free_threshold_minor,
|
||||
region_rules: template.region_rules.map(toRuleInput),
|
||||
});
|
||||
message.value = translate("freight.defaultSaved");
|
||||
await loadTemplates();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
actionId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(template: FreightTemplate): Promise<void> {
|
||||
if (!confirm(translate("freight.deleteConfirm"))) return;
|
||||
actionId.value = template.id;
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
try {
|
||||
await $api.shop.deleteFreightTemplate(template.id);
|
||||
message.value = translate("freight.deleted");
|
||||
if (editingId.value === template.id) resetForm();
|
||||
await loadTemplates();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
actionId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadMoney();
|
||||
resetForm();
|
||||
await loadTemplates();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPage :title="$t('freight.title')">
|
||||
<div v-if="error" class="text-danger my-2 text-sm" role="alert">{{ error }}</div>
|
||||
<div v-if="message" class="text-muted my-2 text-sm" role="status">{{ message }}</div>
|
||||
|
||||
<VCard class="mb-5">
|
||||
<h2 class="mb-4 text-base font-semibold">
|
||||
{{ editingId ? $t("freight.editTemplate") : $t("freight.newTemplate") }}
|
||||
</h2>
|
||||
<div class="my-3 grid [grid-template-columns:repeat(auto-fit,minmax(220px,1fr))] gap-3">
|
||||
<VField :label="$t('freight.name')">
|
||||
<VInput id="freight-name" v-model="form.name" type="text" />
|
||||
</VField>
|
||||
<VField :label="$t('freight.pricingMethod')">
|
||||
<select
|
||||
id="freight-pricing-method"
|
||||
v-model="form.pricingMethod"
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2"
|
||||
>
|
||||
<option value="by_piece">{{ $t("freight.methodByPiece") }}</option>
|
||||
<option value="by_weight">{{ $t("freight.methodByWeight") }}</option>
|
||||
</select>
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.firstFeeMajor')} · ${shopCurrency}`">
|
||||
<VInput id="freight-first-fee" v-model="form.firstFeeMajor" inputmode="decimal" />
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.firstUnit')} (${unitOf(form.pricingMethod)})`">
|
||||
<VInput id="freight-first-unit" v-model="form.firstUnit" inputmode="numeric" />
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.additionalFeeMajor')} · ${shopCurrency}`">
|
||||
<VInput
|
||||
id="freight-additional-fee"
|
||||
v-model="form.additionalFeeMajor"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.additionalUnit')} (${unitOf(form.pricingMethod)})`">
|
||||
<VInput id="freight-additional-unit" v-model="form.additionalUnit" inputmode="numeric" />
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.freeThresholdMajor')} · ${shopCurrency}`">
|
||||
<VInput
|
||||
id="freight-free-threshold"
|
||||
v-model="form.freeThresholdMajor"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</VField>
|
||||
<div class="grid content-start gap-2">
|
||||
<label class="text-text flex items-center gap-2 text-sm font-medium"
|
||||
><input v-model="form.alwaysFree" type="checkbox" class="accent-primary h-4 w-4" />
|
||||
{{ $t("freight.alwaysFree") }}</label
|
||||
>
|
||||
<label class="text-text flex items-center gap-2 text-sm font-medium"
|
||||
><input v-model="form.isDefault" type="checkbox" class="accent-primary h-4 w-4" />
|
||||
{{ $t("freight.isDefault") }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 text-base font-semibold">{{ $t("freight.regionRules") }}</h3>
|
||||
<div v-if="!rules.length" class="text-muted mb-3 text-sm">
|
||||
{{ $t("freight.noRegionRules") }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(rule, index) in rules"
|
||||
:key="index"
|
||||
class="border-border bg-bg mb-3 rounded-md border p-3"
|
||||
>
|
||||
<div class="grid [grid-template-columns:repeat(auto-fit,minmax(200px,1fr))] gap-3">
|
||||
<VField :label="$t('freight.ruleRegions')">
|
||||
<VInput
|
||||
:id="`rule-regions-${index}`"
|
||||
v-model="rule.regions"
|
||||
type="text"
|
||||
:placeholder="$t('freight.ruleRegions')"
|
||||
/>
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.firstFeeMajor')} · ${shopCurrency}`">
|
||||
<VInput
|
||||
:id="`rule-first-fee-${index}`"
|
||||
v-model="rule.firstFeeMajor"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.firstUnit')} (${unitOf(form.pricingMethod)})`">
|
||||
<VInput :id="`rule-first-unit-${index}`" v-model="rule.firstUnit" inputmode="numeric" />
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.additionalFeeMajor')} · ${shopCurrency}`">
|
||||
<VInput
|
||||
:id="`rule-additional-fee-${index}`"
|
||||
v-model="rule.additionalFeeMajor"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</VField>
|
||||
<VField :label="`${$t('freight.additionalUnit')} (${unitOf(form.pricingMethod)})`">
|
||||
<VInput
|
||||
:id="`rule-additional-unit-${index}`"
|
||||
v-model="rule.additionalUnit"
|
||||
inputmode="numeric"
|
||||
/>
|
||||
</VField>
|
||||
</div>
|
||||
<VBtn size="sm" variant="danger" @click="removeRule(index)">
|
||||
{{ $t("freight.deleteRule") }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<VBtn @click="addRule">{{ $t("freight.addRule") }}</VBtn>
|
||||
<VBtn variant="primary" :disabled="saving" @click="submit">
|
||||
{{ editingId ? $t("freight.updateTemplate") : $t("freight.createTemplate") }}
|
||||
</VBtn>
|
||||
<VBtn v-if="editingId" @click="resetForm">{{ $t("common.cancel") }}</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
|
||||
<VCard v-else-if="!templates.length" class="text-muted">{{ $t("common.empty") }}</VCard>
|
||||
<VTable v-else>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("freight.name") }}</th>
|
||||
<th>{{ $t("freight.pricingMethod") }}</th>
|
||||
<th>
|
||||
{{ $t("freight.firstFeeShort") }} / {{ $t("freight.additionalFeeShort") }}
|
||||
</th>
|
||||
<th>{{ $t("freight.freeThresholdMajor") }}</th>
|
||||
<th>{{ $t("freight.isDefault") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="template in templates" :key="template.id">
|
||||
<td>
|
||||
<div class="font-medium">{{ template.name }}</div>
|
||||
<ul v-if="template.region_rules.length" class="text-muted mt-1 grid gap-0.5 text-xs">
|
||||
<li v-for="rule in template.region_rules" :key="rule.id">
|
||||
{{ rule.regions.join(", ") }}:
|
||||
{{ fmt(rule.first_fee_minor, shopCurrency) }}/{{ rule.first_unit }}
|
||||
{{ unitOf(template.pricing_method) }}
|
||||
+ {{ fmt(rule.additional_fee_minor, shopCurrency) }}/{{ rule.additional_unit }}
|
||||
{{ unitOf(template.pricing_method) }}
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
{{
|
||||
template.pricing_method === "by_weight"
|
||||
? $t("freight.methodByWeight")
|
||||
: $t("freight.methodByPiece")
|
||||
}}
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
{{ fmt(template.first_fee_minor, shopCurrency) }} / {{ template.first_unit }}
|
||||
{{ unitOf(template.pricing_method) }}
|
||||
</div>
|
||||
<div>
|
||||
+ {{ fmt(template.additional_fee_minor, shopCurrency) }} /
|
||||
{{ template.additional_unit }} {{ unitOf(template.pricing_method) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<VBadge v-if="template.always_free" tone="green">{{ $t("freight.alwaysFree") }}</VBadge>
|
||||
<span v-else-if="template.free_threshold_minor !== null">
|
||||
{{ fmt(template.free_threshold_minor, shopCurrency) }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td>
|
||||
<VBadge v-if="template.is_default" tone="blue">{{ $t("freight.defaultTag") }}</VBadge>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td class="flex flex-wrap gap-1.5">
|
||||
<VBtn size="sm" :disabled="actionId === template.id" @click="edit(template)">{{
|
||||
$t("shop.edit")
|
||||
}}</VBtn>
|
||||
<VBtn
|
||||
v-if="!template.is_default"
|
||||
size="sm"
|
||||
:disabled="actionId === template.id"
|
||||
@click="setDefault(template)"
|
||||
>{{ $t("freight.setDefault") }}</VBtn
|
||||
>
|
||||
<VBtn
|
||||
size="sm"
|
||||
variant="danger"
|
||||
:disabled="actionId === template.id"
|
||||
@click="remove(template)"
|
||||
>{{ $t("common.delete") }}</VBtn
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</VPage>
|
||||
</template>
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { t as localized } from "@vmall/shared";
|
||||
import type { Order, OrderStatus, Shipment, ShipmentItemBody } from "@vmall/shared";
|
||||
import type {
|
||||
Order,
|
||||
OrderStatus,
|
||||
Shipment,
|
||||
ShipmentItemBody,
|
||||
ShippingCompany,
|
||||
} from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
@@ -11,9 +17,11 @@ const route = useRoute();
|
||||
const orderId = computed(() => String(route.params.id));
|
||||
const order = ref<Order | null>(null);
|
||||
const shipments = ref<Shipment[]>([]);
|
||||
const companies = ref<ShippingCompany[]>([]);
|
||||
const quantities = ref<Record<string, number>>({});
|
||||
const carrier = ref("");
|
||||
const trackingNo = ref("");
|
||||
const shippingCompanyCode = ref("");
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const actionId = ref("");
|
||||
@@ -34,10 +42,16 @@ function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||||
}
|
||||
|
||||
function companyName(code: string | null | undefined): string {
|
||||
if (!code) return "—";
|
||||
const company = companies.value.find((item) => item.code === code);
|
||||
return company ? localized(company.name, locale.value) : code;
|
||||
}
|
||||
|
||||
function shippedQuantity(itemId: string): number {
|
||||
return shipments.value
|
||||
.filter((shipment) => shipment.order_id === orderId.value)
|
||||
.flatMap((shipment) => shipment.items)
|
||||
.flatMap((shipment) => shipment.items ?? [])
|
||||
.filter((item) => item.order_item_id === itemId)
|
||||
.reduce((total, item) => total + item.qty, 0);
|
||||
}
|
||||
@@ -63,12 +77,14 @@ async function loadOrder(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const [loadedOrder, loadedShipments] = await Promise.all([
|
||||
const [loadedOrder, loadedShipments, loadedCompanies] = await Promise.all([
|
||||
findShopOrder(),
|
||||
$api.shop.listShipments(),
|
||||
$api.listShippingCompanies(),
|
||||
]);
|
||||
order.value = loadedOrder;
|
||||
shipments.value = loadedShipments.filter((shipment) => shipment.order_id === orderId.value);
|
||||
companies.value = loadedCompanies;
|
||||
const nextQuantities: Record<string, number> = {};
|
||||
loadedOrder.items.forEach((item) => {
|
||||
nextQuantities[item.id] = Math.max(
|
||||
@@ -76,7 +92,7 @@ async function loadOrder(): Promise<void> {
|
||||
item.qty -
|
||||
loadedShipments
|
||||
.filter((shipment) => shipment.order_id === orderId.value)
|
||||
.flatMap((shipment) => shipment.items)
|
||||
.flatMap((shipment) => shipment.items ?? [])
|
||||
.filter((shipmentItem) => shipmentItem.order_item_id === item.id)
|
||||
.reduce((total, shipmentItem) => total + shipmentItem.qty, 0),
|
||||
);
|
||||
@@ -94,6 +110,10 @@ async function createShipment(): Promise<void> {
|
||||
error.value = translate("common.required");
|
||||
return;
|
||||
}
|
||||
if (!shippingCompanyCode.value) {
|
||||
error.value = translate("shop.shippingCompanyRequired");
|
||||
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);
|
||||
@@ -109,9 +129,11 @@ async function createShipment(): Promise<void> {
|
||||
carrier.value.trim(),
|
||||
trackingNo.value.trim(),
|
||||
items,
|
||||
shippingCompanyCode.value,
|
||||
);
|
||||
carrier.value = "";
|
||||
trackingNo.value = "";
|
||||
shippingCompanyCode.value = "";
|
||||
await loadOrder();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
@@ -208,6 +230,19 @@ onMounted(() => {
|
||||
<VField :label="$t('shop.trackingNo')">
|
||||
<VInput id="tracking-no" v-model="trackingNo" required />
|
||||
</VField>
|
||||
<VField :label="$t('shop.shippingCompany')">
|
||||
<select
|
||||
id="shipping-company"
|
||||
v-model="shippingCompanyCode"
|
||||
required
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2"
|
||||
>
|
||||
<option value="" disabled>{{ $t("shop.selectShippingCompany") }}</option>
|
||||
<option v-for="company in companies" :key="company.code" :value="company.code">
|
||||
{{ localized(company.name, locale) }}
|
||||
</option>
|
||||
</select>
|
||||
</VField>
|
||||
</div>
|
||||
<h3 class="mb-3 text-base font-semibold">{{ $t("shop.shipmentQuantities") }}</h3>
|
||||
<div
|
||||
@@ -246,6 +281,7 @@ onMounted(() => {
|
||||
<tr>
|
||||
<th>{{ $t("shipment.shipmentNo") }}</th>
|
||||
<th>{{ $t("shop.carrier") }}</th>
|
||||
<th>{{ $t("shop.shippingCompany") }}</th>
|
||||
<th>{{ $t("shop.trackingNo") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("common.actions") }}</th>
|
||||
@@ -255,6 +291,7 @@ onMounted(() => {
|
||||
<tr v-for="shipment in shipments" :key="shipment.id">
|
||||
<td>{{ shipment.shipment_no }}</td>
|
||||
<td>{{ shipment.carrier }}</td>
|
||||
<td>{{ companyName(shipment.shipping_company_code) }}</td>
|
||||
<td>{{ shipment.tracking_no }}</td>
|
||||
<td>
|
||||
<VBadge :tone="shipmentStatusClass(shipment.status)">{{
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { Category, Product, ProductUpsertBody, Sku, SkuUpsertBody } from "@vmall/shared";
|
||||
import type {
|
||||
Category,
|
||||
FreightTemplate,
|
||||
Product,
|
||||
ProductUpsertBody,
|
||||
Sku,
|
||||
SkuUpsertBody,
|
||||
} from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
@@ -10,6 +17,7 @@ const route = useRoute();
|
||||
const productId = computed(() => String(route.params.id));
|
||||
const product = ref<Product | null>(null);
|
||||
const categories = ref<Category[]>([]);
|
||||
const freightTemplates = ref<FreightTemplate[]>([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const skuBusy = ref(false);
|
||||
@@ -18,6 +26,7 @@ const skuCode = ref("");
|
||||
const skuPrice = ref("");
|
||||
const skuCurrency = ref("USD");
|
||||
const skuStock = ref("0");
|
||||
const skuWeight = ref("");
|
||||
const skuActive = ref(true);
|
||||
|
||||
function formatDate(value: string): string {
|
||||
@@ -40,12 +49,14 @@ async function loadProduct(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const [loadedProduct, loadedCategories] = await Promise.all([
|
||||
const [loadedProduct, loadedCategories, loadedFreightTemplates] = await Promise.all([
|
||||
$api.shop.getProduct(productId.value),
|
||||
$api.listCategories(),
|
||||
$api.shop.listFreightTemplates(),
|
||||
]);
|
||||
product.value = loadedProduct;
|
||||
categories.value = loadedCategories;
|
||||
freightTemplates.value = loadedFreightTemplates;
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
@@ -69,10 +80,21 @@ async function updateProduct(body: ProductUpsertBody): Promise<void> {
|
||||
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) {
|
||||
const weightText = skuWeight.value.trim();
|
||||
const weight = weightText ? Number(weightText) : null;
|
||||
if (
|
||||
!skuCode.value.trim() ||
|
||||
priceMinor === null ||
|
||||
!Number.isInteger(stock) ||
|
||||
stock < 0
|
||||
) {
|
||||
error.value = translate("common.required");
|
||||
return;
|
||||
}
|
||||
if (weight !== null && (!Number.isInteger(weight) || weight <= 0)) {
|
||||
error.value = translate("shop.skuWeightInvalid");
|
||||
return;
|
||||
}
|
||||
skuBusy.value = true;
|
||||
error.value = "";
|
||||
const body: SkuUpsertBody = {
|
||||
@@ -81,6 +103,7 @@ async function saveSku(): Promise<void> {
|
||||
currency: skuCurrency.value,
|
||||
stock,
|
||||
active: skuActive.value,
|
||||
weight_grams: weight,
|
||||
};
|
||||
try {
|
||||
await $api.shop.upsertSku(productId.value, body);
|
||||
@@ -89,6 +112,7 @@ async function saveSku(): Promise<void> {
|
||||
skuPrice.value = "";
|
||||
skuCurrency.value = "USD";
|
||||
skuStock.value = "0";
|
||||
skuWeight.value = "";
|
||||
skuActive.value = true;
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
@@ -118,6 +142,7 @@ onMounted(() => {
|
||||
<ProductForm
|
||||
:product="product"
|
||||
:categories="categories"
|
||||
:freight-templates="freightTemplates"
|
||||
:busy="busy"
|
||||
@submit="updateProduct"
|
||||
/>
|
||||
@@ -131,6 +156,7 @@ onMounted(() => {
|
||||
<th>{{ $t("common.price") }}</th>
|
||||
<th>{{ $t("common.currency") }}</th>
|
||||
<th>{{ $t("shop.stock") }}</th>
|
||||
<th>{{ $t("shop.skuWeight") }}</th>
|
||||
<th>{{ $t("shop.active") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -140,6 +166,7 @@ onMounted(() => {
|
||||
<td>{{ formatSkuPrice(sku) }}</td>
|
||||
<td>{{ sku.currency }}</td>
|
||||
<td>{{ sku.stock }}</td>
|
||||
<td>{{ sku.weight_grams ?? "—" }}</td>
|
||||
<td>
|
||||
<VBadge :tone="sku.active ? 'green' : 'red'">{{
|
||||
sku.active ? $t("common.yes") : $t("common.no")
|
||||
@@ -171,6 +198,9 @@ onMounted(() => {
|
||||
<VField :label="$t('shop.stock')">
|
||||
<VInput id="sku-stock" v-model="skuStock" type="number" min="0" step="1" required />
|
||||
</VField>
|
||||
<VField :label="$t('shop.skuWeightGrams')">
|
||||
<VInput id="sku-weight" v-model="skuWeight" inputmode="numeric" />
|
||||
</VField>
|
||||
</div>
|
||||
<label class="text-text mb-3.5 flex items-center gap-2 text-sm font-medium"
|
||||
><input v-model="skuActive" type="checkbox" class="accent-primary h-4 w-4" />
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { Category, ProductUpsertBody } from "@vmall/shared";
|
||||
import type { Category, FreightTemplate, ProductUpsertBody } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { t: translate } = useI18n();
|
||||
const categories = ref<Category[]>([]);
|
||||
const freightTemplates = ref<FreightTemplate[]>([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function loadCategories(): Promise<void> {
|
||||
async function loadFormOptions(): Promise<void> {
|
||||
try {
|
||||
categories.value = await $api.listCategories();
|
||||
const [loadedCategories, loadedFreightTemplates] = await Promise.all([
|
||||
$api.listCategories(),
|
||||
$api.shop.listFreightTemplates(),
|
||||
]);
|
||||
categories.value = loadedCategories;
|
||||
freightTemplates.value = loadedFreightTemplates;
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
@@ -33,7 +39,7 @@ async function saveProduct(body: ProductUpsertBody): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCategories);
|
||||
onMounted(loadFormOptions);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,6 +53,12 @@ onMounted(loadCategories);
|
||||
</template>
|
||||
<div v-if="error" class="text-danger my-2 text-sm" role="alert">{{ error }}</div>
|
||||
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
|
||||
<ProductForm v-else :categories="categories" :busy="busy" @submit="saveProduct" />
|
||||
<ProductForm
|
||||
v-else
|
||||
:categories="categories"
|
||||
:freight-templates="freightTemplates"
|
||||
:busy="busy"
|
||||
@submit="saveProduct"
|
||||
/>
|
||||
</VPage>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { Shipment } from "@vmall/shared";
|
||||
import { t as localized } from "@vmall/shared";
|
||||
import type { Shipment, ShippingCompany } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { $api } = useNuxtApp();
|
||||
const { locale, t: translate } = useI18n();
|
||||
const shipments = ref<Shipment[]>([]);
|
||||
const companies = ref<ShippingCompany[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const actionId = ref("");
|
||||
|
||||
function companyName(code: string | null | undefined): string {
|
||||
if (!code) return "—";
|
||||
const company = companies.value.find((item) => item.code === code);
|
||||
return company ? localized(company.name, locale.value) : code;
|
||||
}
|
||||
|
||||
function statusClass(value: Shipment["status"]): "green" | "blue" | "orange" {
|
||||
return value === "delivered" ? "green" : value === "shipped" ? "blue" : "orange";
|
||||
}
|
||||
@@ -22,7 +30,12 @@ async function loadShipments(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
shipments.value = await $api.shop.listShipments();
|
||||
const [loadedShipments, loadedCompanies] = await Promise.all([
|
||||
$api.shop.listShipments(),
|
||||
$api.listShippingCompanies(),
|
||||
]);
|
||||
shipments.value = loadedShipments;
|
||||
companies.value = loadedCompanies;
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : translate("common.error");
|
||||
} finally {
|
||||
@@ -57,6 +70,7 @@ onMounted(loadShipments);
|
||||
<th>{{ $t("shipment.shipmentNo") }}</th>
|
||||
<th>{{ $t("shop.orderNo") }}</th>
|
||||
<th>{{ $t("shop.carrier") }}</th>
|
||||
<th>{{ $t("shop.shippingCompany") }}</th>
|
||||
<th>{{ $t("shop.trackingNo") }}</th>
|
||||
<th>{{ $t("common.status") }}</th>
|
||||
<th>{{ $t("shop.created") }}</th>
|
||||
@@ -72,6 +86,7 @@ onMounted(loadShipments);
|
||||
}}</NuxtLink>
|
||||
</td>
|
||||
<td>{{ shipment.carrier }}</td>
|
||||
<td>{{ companyName(shipment.shipping_company_code) }}</td>
|
||||
<td>{{ shipment.tracking_no }}</td>
|
||||
<td>
|
||||
<VBadge :tone="statusClass(shipment.status)">{{
|
||||
|
||||
Reference in New Issue
Block a user