Implements, verifies, and archives the three remaining Wave 2 changes from openspec/MIGRATION-PLAN.md. - add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and one-time admin review, paginated own fund entries, idempotent per-shop weekly/monthly settlement statements with commission rate and one-time payout confirmation. - add-merchant-onboarding (P5): personal/enterprise applications with one live application per user, guarded review with mandatory rejection reason, and transactional shop + owner provisioning returning one-time credentials; mall onboarding/status pages and an admin review console. - add-membership-messaging (P7): platform member levels, append-only growth accrual on order completion with guarded one-way leveling, order/shipment/ refund system messages with unread/read state and soft deletion, plus the mall header unread badge. Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding, membership and messaging modules, event hooks in order/fulfillment/aftersale, and integration suites for each. Shared contract extended and all three frontends updated; code indexes, domain docs, backend guidelines and the migration tracker synced. Verification: cargo test -p vmall-api green twice consecutively; mall, admin and shop-admin builds pass; browser smoke on every new surface; openspec validate --all --strict green (33 passed). The three changes share the @vmall/shared contract, the mall mock adapter and per-app locale/nav files, so they are committed together to keep every commit buildable.
719 lines
27 KiB
Vue
719 lines
27 KiB
Vue
<script setup lang="ts">
|
||
import { ApiError, formatMoney, t as localizedText } from "@vmall/shared";
|
||
import type {
|
||
Currency,
|
||
SettlementPeriodKind,
|
||
SettlementStatement,
|
||
SettlementStatementDetail,
|
||
SettlementStatus,
|
||
Shop,
|
||
} from "@vmall/shared";
|
||
|
||
definePageMeta({ middleware: "auth" });
|
||
|
||
const { $api } = useNuxtApp();
|
||
const { locale, t } = useI18n();
|
||
|
||
const currencies = ref<Currency[]>([]);
|
||
const shops = ref<Shop[]>([]);
|
||
|
||
// ---- commission rate ----
|
||
const commissionRate = ref<number | null>(null);
|
||
const rateDraft = ref<string | number>("");
|
||
const rateSaving = ref(false);
|
||
const rateError = ref("");
|
||
|
||
// ---- statement list ----
|
||
const statements = ref<SettlementStatement[]>([]);
|
||
const shopFilter = ref("");
|
||
const statusFilter = ref<"" | SettlementStatus>("");
|
||
const page = ref(1);
|
||
const perPage = ref(20);
|
||
const total = ref(0);
|
||
const loading = ref(true);
|
||
const listError = ref("");
|
||
|
||
// ---- manual generation ----
|
||
const generateShop = ref("");
|
||
const generateKind = ref<SettlementPeriodKind>("month");
|
||
const generateDate = ref(defaultPeriodDate());
|
||
const generating = ref(false);
|
||
const generateError = ref("");
|
||
const generatedStatement = ref<SettlementStatement | null>(null);
|
||
const generatedWasExisting = ref(false);
|
||
|
||
// ---- detail ----
|
||
const openId = ref<string | null>(null);
|
||
const detail = ref<SettlementStatementDetail | null>(null);
|
||
const detailLoading = ref(false);
|
||
const detailError = ref("");
|
||
const actingId = ref<string | null>(null);
|
||
|
||
const notice = ref("");
|
||
const conflictMessage = ref("");
|
||
|
||
const periodKinds: SettlementPeriodKind[] = ["week", "month"];
|
||
const settlementStatuses: SettlementStatus[] = ["pending", "confirmed"];
|
||
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)));
|
||
const currentRateLabel = computed(() =>
|
||
commissionRate.value === null
|
||
? "—"
|
||
: t("admin.commissionRateDisplay", {
|
||
bps: commissionRate.value,
|
||
percent: ratePercent(commissionRate.value),
|
||
}),
|
||
);
|
||
|
||
/** First day of the previous month: always inside a closed week/month period. */
|
||
function defaultPeriodDate(): string {
|
||
const now = new Date();
|
||
const firstOfPreviousMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||
const month = String(firstOfPreviousMonth.getMonth() + 1).padStart(2, "0");
|
||
return `${firstOfPreviousMonth.getFullYear()}-${month}-01`;
|
||
}
|
||
|
||
function currencyExponent(code: string): number | undefined {
|
||
return currencies.value.find((currency) => currency.code === code)?.exponent;
|
||
}
|
||
|
||
function money(amountMinor: number, currency: string): string {
|
||
const exponent = currencyExponent(currency);
|
||
return exponent === undefined ? "—" : formatMoney(amountMinor, currency, exponent, locale.value);
|
||
}
|
||
|
||
function formatDate(value: string | null): string {
|
||
if (!value) return "—";
|
||
return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US");
|
||
}
|
||
|
||
function shortId(value: string): string {
|
||
return value.slice(0, 8);
|
||
}
|
||
|
||
function shopLabel(statement: SettlementStatement): string {
|
||
const name = localizedText(statement.shop_name, locale.value);
|
||
return name === "" ? shortId(statement.shop_id) : name;
|
||
}
|
||
|
||
function periodKindLabel(kind: SettlementPeriodKind): string {
|
||
return t(`admin.settlementPeriodKinds.${kind}`);
|
||
}
|
||
|
||
function periodLabel(row: SettlementStatement): string {
|
||
return `${periodKindLabel(row.period_kind)} · ${row.period_start} – ${row.period_end}`;
|
||
}
|
||
|
||
function settlementStatusLabel(status: SettlementStatus): string {
|
||
return t(`admin.settlementStatuses.${status}`);
|
||
}
|
||
|
||
function settlementStatusTone(status: SettlementStatus): "green" | "orange" {
|
||
return status === "confirmed" ? "green" : "orange";
|
||
}
|
||
|
||
/** Display-only basis-point → percent conversion; never used for arithmetic. */
|
||
function ratePercent(bps: number): string {
|
||
return String(Number((bps / 100).toFixed(2)));
|
||
}
|
||
|
||
function shopOptionLabel(shop: Shop): string {
|
||
const name = localizedText(shop.name, locale.value);
|
||
return name === "" ? shop.slug : name;
|
||
}
|
||
|
||
/** Parse the draft as an integer basis-point value in 0..10000, or null when invalid. */
|
||
function parseBps(raw: string | number): number | null {
|
||
const text = typeof raw === "number" ? String(raw) : raw.trim();
|
||
if (text === "" || !/^\d+$/.test(text)) return null;
|
||
const value = Number(text);
|
||
return Number.isInteger(value) && value >= 0 && value <= 10000 ? value : null;
|
||
}
|
||
|
||
function isIsoDate(value: string): boolean {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||
const parsed = new Date(`${value}T00:00:00Z`);
|
||
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
||
}
|
||
|
||
function errorText(error: unknown): string {
|
||
return error instanceof Error ? error.message : t("common.error");
|
||
}
|
||
|
||
async function loadReference(): Promise<void> {
|
||
try {
|
||
const [currencyList, shopList] = await Promise.all([
|
||
$api.admin.listCurrencies(),
|
||
$api.admin.listShops(),
|
||
]);
|
||
currencies.value = currencyList;
|
||
shops.value = shopList;
|
||
} catch (error: unknown) {
|
||
listError.value = errorText(error);
|
||
}
|
||
}
|
||
|
||
async function loadCommissionRate(): Promise<void> {
|
||
try {
|
||
const rate = await $api.admin.getCommissionRate();
|
||
commissionRate.value = rate.commission_rate_bps;
|
||
rateDraft.value = rate.commission_rate_bps;
|
||
} catch (error: unknown) {
|
||
rateError.value = errorText(error);
|
||
}
|
||
}
|
||
|
||
async function loadStatements(): Promise<void> {
|
||
loading.value = true;
|
||
listError.value = "";
|
||
try {
|
||
const paged = await $api.admin.listSettlementStatements({
|
||
page: page.value,
|
||
shop_id: shopFilter.value || undefined,
|
||
status: statusFilter.value || undefined,
|
||
});
|
||
statements.value = paged.items;
|
||
total.value = paged.total;
|
||
perPage.value = paged.per_page;
|
||
} catch (error: unknown) {
|
||
listError.value = errorText(error);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function changePage(nextPage: number): Promise<void> {
|
||
if (nextPage < 1 || nextPage > totalPages.value || nextPage === page.value) return;
|
||
page.value = nextPage;
|
||
await loadStatements();
|
||
}
|
||
|
||
async function applyFilters(): Promise<void> {
|
||
page.value = 1;
|
||
openId.value = null;
|
||
detail.value = null;
|
||
await loadStatements();
|
||
}
|
||
|
||
async function saveRate(): Promise<void> {
|
||
rateError.value = "";
|
||
notice.value = "";
|
||
conflictMessage.value = "";
|
||
const bps = parseBps(rateDraft.value);
|
||
if (bps === null) {
|
||
rateError.value = t("admin.commissionRateInvalid");
|
||
return;
|
||
}
|
||
rateSaving.value = true;
|
||
try {
|
||
const saved = await $api.admin.setCommissionRate(bps);
|
||
commissionRate.value = saved.commission_rate_bps;
|
||
rateDraft.value = saved.commission_rate_bps;
|
||
notice.value = t("admin.commissionRateSaved");
|
||
} catch (error: unknown) {
|
||
rateError.value = errorText(error);
|
||
} finally {
|
||
rateSaving.value = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* All statement ids this shop already has, so a repeat generation call can be
|
||
* reported as "the existing statement was returned" (generation is idempotent).
|
||
*/
|
||
async function knownStatementIds(shopId: string): Promise<Set<string>> {
|
||
const ids = new Set<string>();
|
||
for (let current = 1; current <= 20; current += 1) {
|
||
const paged = await $api.admin.listSettlementStatements({
|
||
shop_id: shopId,
|
||
page: current,
|
||
per_page: 100,
|
||
});
|
||
for (const row of paged.items) ids.add(row.id);
|
||
if (paged.items.length === 0 || current * paged.per_page >= paged.total) break;
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
async function generate(): Promise<void> {
|
||
generateError.value = "";
|
||
notice.value = "";
|
||
conflictMessage.value = "";
|
||
generatedStatement.value = null;
|
||
generatedWasExisting.value = false;
|
||
|
||
if (!generateShop.value) {
|
||
generateError.value = t("admin.settlementPickShop");
|
||
return;
|
||
}
|
||
if (!isIsoDate(generateDate.value)) {
|
||
generateError.value = t("admin.settlementPeriodDateInvalid");
|
||
return;
|
||
}
|
||
|
||
generating.value = true;
|
||
try {
|
||
const known = await knownStatementIds(generateShop.value);
|
||
const statement = await $api.admin.generateSettlementStatement({
|
||
shop_id: generateShop.value,
|
||
period_kind: generateKind.value,
|
||
period_start: generateDate.value,
|
||
});
|
||
generatedWasExisting.value = known.has(statement.id);
|
||
generatedStatement.value = statement;
|
||
notice.value = generatedWasExisting.value
|
||
? t("admin.settlementGeneratedExisting")
|
||
: t("admin.settlementGenerated");
|
||
await loadStatements();
|
||
} catch (error: unknown) {
|
||
// A period that is not closed yet is refused with a conflict.
|
||
if (error instanceof ApiError && error.status === 409) {
|
||
conflictMessage.value = `${t("admin.settlementPeriodNotClosed")}: ${error.message}`;
|
||
} else {
|
||
generateError.value = errorText(error);
|
||
}
|
||
} finally {
|
||
generating.value = false;
|
||
}
|
||
}
|
||
|
||
async function openDetail(id: string): Promise<void> {
|
||
openId.value = id;
|
||
detail.value = null;
|
||
detailError.value = "";
|
||
detailLoading.value = true;
|
||
try {
|
||
detail.value = await $api.admin.getSettlementStatement(id);
|
||
} catch (error: unknown) {
|
||
detailError.value = errorText(error);
|
||
} finally {
|
||
detailLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function toggleDetail(id: string): Promise<void> {
|
||
if (openId.value === id) {
|
||
openId.value = null;
|
||
detail.value = null;
|
||
detailError.value = "";
|
||
return;
|
||
}
|
||
await openDetail(id);
|
||
}
|
||
|
||
async function confirmPayout(row: SettlementStatement): Promise<void> {
|
||
if (!confirm(t("admin.settlementConfirmDialog"))) return;
|
||
|
||
actingId.value = row.id;
|
||
notice.value = "";
|
||
conflictMessage.value = "";
|
||
detailError.value = "";
|
||
try {
|
||
await $api.admin.confirmSettlementStatement(row.id);
|
||
notice.value = t("admin.settlementPayoutConfirmed");
|
||
await loadStatements();
|
||
if (openId.value === row.id) await openDetail(row.id);
|
||
} catch (error: unknown) {
|
||
// A repeat confirmation is a conflict: refresh so the UI converges to truth.
|
||
if (error instanceof ApiError && error.status === 409) {
|
||
conflictMessage.value = `${t("admin.settlementConflict")}: ${error.message}`;
|
||
await loadStatements();
|
||
if (openId.value === row.id) await openDetail(row.id);
|
||
} else {
|
||
detailError.value = errorText(error);
|
||
}
|
||
} finally {
|
||
actingId.value = null;
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
void Promise.all([loadReference(), loadCommissionRate(), loadStatements()]);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<VPage :title="$t('nav.settlements')">
|
||
<template #actions>
|
||
<span v-if="!loading" class="text-muted text-sm">{{
|
||
$t("admin.pageOf", { page, total: totalPages })
|
||
}}</span>
|
||
</template>
|
||
|
||
<p v-if="notice" class="text-success my-2 text-sm" role="status">{{ notice }}</p>
|
||
<p
|
||
v-if="conflictMessage"
|
||
class="bg-warning/10 text-warning my-2 rounded-md px-3 py-2 text-sm"
|
||
role="alert"
|
||
>
|
||
{{ conflictMessage }}
|
||
</p>
|
||
|
||
<VCard class="mb-5">
|
||
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.commissionRateTitle") }}</h2>
|
||
<p class="text-muted mb-3 text-sm">
|
||
{{ $t("admin.commissionRateCurrent") }}: {{ currentRateLabel }}
|
||
</p>
|
||
<div class="flex flex-wrap items-start gap-3">
|
||
<VField
|
||
class="min-w-[240px] flex-1"
|
||
:label="$t('admin.commissionRate')"
|
||
:error="rateError"
|
||
>
|
||
<VInput
|
||
id="commission-rate-bps"
|
||
v-model="rateDraft"
|
||
type="number"
|
||
min="0"
|
||
max="10000"
|
||
step="1"
|
||
:disabled="rateSaving"
|
||
/>
|
||
</VField>
|
||
<VBtn variant="primary" :disabled="rateSaving" @click="saveRate">
|
||
{{ rateSaving ? $t("common.loading") : $t("admin.commissionRateSave") }}
|
||
</VBtn>
|
||
</div>
|
||
<p class="text-muted text-sm">{{ $t("admin.commissionRateHint") }}</p>
|
||
</VCard>
|
||
|
||
<VCard class="mb-5">
|
||
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.settlementGenerateTitle") }}</h2>
|
||
<div class="grid gap-3 md:grid-cols-3">
|
||
<label class="grid gap-1 text-sm font-medium" for="settlement-generate-shop">
|
||
{{ $t("admin.settlementShop") }}
|
||
<select
|
||
id="settlement-generate-shop"
|
||
v-model="generateShop"
|
||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 font-normal focus:outline-2"
|
||
>
|
||
<option value="">—</option>
|
||
<option v-for="shop in shops" :key="shop.id" :value="shop.id">
|
||
{{ shopOptionLabel(shop) }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label class="grid gap-1 text-sm font-medium" for="settlement-generate-kind">
|
||
{{ $t("admin.settlementPeriodKind") }}
|
||
<select
|
||
id="settlement-generate-kind"
|
||
v-model="generateKind"
|
||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 font-normal focus:outline-2"
|
||
>
|
||
<option v-for="kind in periodKinds" :key="kind" :value="kind">
|
||
{{ periodKindLabel(kind) }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<VField :label="$t('admin.settlementPeriodDate')">
|
||
<VInput
|
||
id="settlement-generate-date"
|
||
v-model="generateDate"
|
||
type="date"
|
||
:disabled="generating"
|
||
/>
|
||
</VField>
|
||
</div>
|
||
<p class="text-muted mb-3 text-sm">{{ $t("admin.settlementPeriodDateHint") }}</p>
|
||
<p v-if="generateError" class="text-danger my-2 text-sm" role="alert">{{ generateError }}</p>
|
||
<VBtn variant="primary" :disabled="generating" @click="generate">
|
||
{{ generating ? $t("common.loading") : $t("admin.settlementGenerate") }}
|
||
</VBtn>
|
||
<p class="text-muted mt-3 text-sm">{{ $t("admin.settlementGenerateHint") }}</p>
|
||
|
||
<VPanel v-if="generatedStatement" class="mt-4" :title="$t('admin.settlementGeneratedResult')">
|
||
<template #actions>
|
||
<VBadge :tone="settlementStatusTone(generatedStatement.status)">
|
||
{{ settlementStatusLabel(generatedStatement.status) }}
|
||
</VBadge>
|
||
</template>
|
||
<dl class="grid gap-2 text-sm sm:grid-cols-3">
|
||
<div>
|
||
<dt class="text-muted">{{ $t("admin.settlementShop") }}</dt>
|
||
<dd>{{ shopLabel(generatedStatement) }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="text-muted">{{ $t("admin.settlementPeriod") }}</dt>
|
||
<dd>{{ periodLabel(generatedStatement) }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="text-muted">{{ $t("admin.settlementOrderCount") }}</dt>
|
||
<dd>{{ generatedStatement.order_count }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="text-muted">{{ $t("admin.settlementGross") }}</dt>
|
||
<dd>{{ money(generatedStatement.gross_minor, generatedStatement.currency) }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="text-muted">{{ $t("admin.settlementRefunds") }}</dt>
|
||
<dd>{{ money(generatedStatement.refund_minor, generatedStatement.currency) }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="text-muted">{{ $t("admin.settlementCommission") }}</dt>
|
||
<dd>
|
||
{{
|
||
$t("admin.commissionRateDisplay", {
|
||
bps: generatedStatement.commission_rate_bps,
|
||
percent: ratePercent(generatedStatement.commission_rate_bps),
|
||
})
|
||
}}
|
||
·
|
||
{{ money(generatedStatement.commission_minor, generatedStatement.currency) }}
|
||
</dd>
|
||
</div>
|
||
<div class="font-semibold">
|
||
<dt class="text-muted">{{ $t("admin.settlementPayable") }}</dt>
|
||
<dd>{{ money(generatedStatement.payable_minor, generatedStatement.currency) }}</dd>
|
||
</div>
|
||
</dl>
|
||
</VPanel>
|
||
</VCard>
|
||
|
||
<VCard class="mb-5">
|
||
<h2 class="mb-4 text-base font-semibold">{{ $t("admin.settlementListTitle") }}</h2>
|
||
<div class="flex flex-wrap items-end gap-3">
|
||
<label class="grid gap-1 text-sm font-medium" for="settlement-filter-shop">
|
||
{{ $t("admin.settlementShop") }}
|
||
<select
|
||
id="settlement-filter-shop"
|
||
v-model="shopFilter"
|
||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 font-normal focus:outline-2"
|
||
@change="applyFilters"
|
||
>
|
||
<option value="">{{ $t("common.all") }}</option>
|
||
<option v-for="shop in shops" :key="shop.id" :value="shop.id">
|
||
{{ shopOptionLabel(shop) }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label class="grid gap-1 text-sm font-medium" for="settlement-filter-status">
|
||
{{ $t("common.status") }}
|
||
<select
|
||
id="settlement-filter-status"
|
||
v-model="statusFilter"
|
||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 font-normal focus:outline-2"
|
||
@change="applyFilters"
|
||
>
|
||
<option value="">{{ $t("common.all") }}</option>
|
||
<option v-for="status in settlementStatuses" :key="status" :value="status">
|
||
{{ settlementStatusLabel(status) }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<VBtn size="sm" :disabled="loading" @click="loadStatements">
|
||
{{ $t("admin.refresh") }}
|
||
</VBtn>
|
||
</div>
|
||
</VCard>
|
||
|
||
<p v-if="loading" class="text-muted text-sm">{{ $t("common.loading") }}</p>
|
||
<p v-else-if="listError" class="text-danger my-2 text-sm" role="alert">{{ listError }}</p>
|
||
<VCard v-else-if="statements.length === 0" class="text-muted">{{ $t("common.empty") }}</VCard>
|
||
<div v-else class="overflow-x-auto">
|
||
<div class="min-w-[1180px]">
|
||
<VTable>
|
||
<thead>
|
||
<tr>
|
||
<th>{{ $t("admin.settlementPeriod") }}</th>
|
||
<th>{{ $t("admin.settlementShop") }}</th>
|
||
<th>{{ $t("admin.settlementOrderCount") }}</th>
|
||
<th>{{ $t("admin.settlementGross") }}</th>
|
||
<th>{{ $t("admin.settlementRefunds") }}</th>
|
||
<th>{{ $t("admin.settlementCommission") }}</th>
|
||
<th>{{ $t("admin.settlementPayable") }}</th>
|
||
<th>{{ $t("common.status") }}</th>
|
||
<th>{{ $t("common.actions") }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template v-for="row in statements" :key="row.id">
|
||
<tr :class="openId === row.id ? 'bg-primary-soft/30' : ''">
|
||
<td>
|
||
<span class="block text-sm">{{ periodLabel(row) }}</span>
|
||
<code class="text-muted text-xs" :title="row.id">{{ shortId(row.id) }}</code>
|
||
</td>
|
||
<td>
|
||
<span class="block">{{ shopLabel(row) }}</span>
|
||
<code class="text-muted text-xs" :title="row.shop_id">{{
|
||
shortId(row.shop_id)
|
||
}}</code>
|
||
</td>
|
||
<td>{{ row.order_count }}</td>
|
||
<td>{{ money(row.gross_minor, row.currency) }}</td>
|
||
<td>{{ money(row.refund_minor, row.currency) }}</td>
|
||
<td>
|
||
<span class="block text-sm">{{
|
||
$t("admin.commissionRateDisplay", {
|
||
bps: row.commission_rate_bps,
|
||
percent: ratePercent(row.commission_rate_bps),
|
||
})
|
||
}}</span>
|
||
<span class="text-muted block text-xs">{{
|
||
money(row.commission_minor, row.currency)
|
||
}}</span>
|
||
</td>
|
||
<td class="font-semibold">{{ money(row.payable_minor, row.currency) }}</td>
|
||
<td>
|
||
<VBadge :tone="settlementStatusTone(row.status)">{{
|
||
settlementStatusLabel(row.status)
|
||
}}</VBadge>
|
||
</td>
|
||
<td>
|
||
<VBtn size="sm" @click="toggleDetail(row.id)">
|
||
{{
|
||
openId === row.id ? $t("admin.settlementClose") : $t("admin.settlementDetail")
|
||
}}
|
||
</VBtn>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="openId === row.id" class="bg-bg">
|
||
<td colspan="9" class="p-0">
|
||
<div class="p-4">
|
||
<p v-if="detailLoading" class="text-muted text-sm">
|
||
{{ $t("common.loading") }}
|
||
</p>
|
||
<p v-else-if="detailError" class="text-danger text-sm" role="alert">
|
||
{{ detailError }}
|
||
</p>
|
||
<VPanel v-else-if="detail" :title="$t('admin.settlementDetailTitle')">
|
||
<template #actions>
|
||
<VBadge :tone="settlementStatusTone(detail.status)">
|
||
{{ settlementStatusLabel(detail.status) }}
|
||
</VBadge>
|
||
</template>
|
||
|
||
<h4 class="mb-2 text-sm font-semibold">
|
||
{{ $t("admin.settlementSnapshot") }}
|
||
</h4>
|
||
<dl class="grid gap-2 text-sm sm:grid-cols-3">
|
||
<div>
|
||
<dt class="text-muted">{{ $t("admin.settlementShop") }}</dt>
|
||
<dd>{{ shopLabel(detail) }} · {{ detail.shop_id }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="text-muted">{{ $t("admin.settlementPeriod") }}</dt>
|
||
<dd>{{ periodLabel(detail) }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="text-muted">{{ $t("common.currency") }}</dt>
|
||
<dd>{{ detail.currency }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="text-muted">{{ $t("admin.settlementOrderCount") }}</dt>
|
||
<dd>{{ detail.order_count }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="text-muted">{{ $t("admin.settlementGross") }}</dt>
|
||
<dd>{{ money(detail.gross_minor, detail.currency) }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="text-muted">{{ $t("admin.settlementRefunds") }}</dt>
|
||
<dd>{{ money(detail.refund_minor, detail.currency) }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="text-muted">{{ $t("admin.settlementCommission") }}</dt>
|
||
<dd>
|
||
{{
|
||
$t("admin.commissionRateDisplay", {
|
||
bps: detail.commission_rate_bps,
|
||
percent: ratePercent(detail.commission_rate_bps),
|
||
})
|
||
}}
|
||
· {{ money(detail.commission_minor, detail.currency) }}
|
||
</dd>
|
||
</div>
|
||
<div class="font-semibold">
|
||
<dt class="text-muted">{{ $t("admin.settlementPayable") }}</dt>
|
||
<dd>{{ money(detail.payable_minor, detail.currency) }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="text-muted">{{ $t("admin.settlementConfirmedAt") }}</dt>
|
||
<dd>{{ formatDate(detail.confirmed_at) }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="text-muted">{{ $t("admin.created") }}</dt>
|
||
<dd>{{ formatDate(detail.created_at) }}</dd>
|
||
</div>
|
||
<div>
|
||
<dt class="text-muted">{{ $t("admin.settlementUpdated") }}</dt>
|
||
<dd>{{ formatDate(detail.updated_at) }}</dd>
|
||
</div>
|
||
</dl>
|
||
|
||
<h4 class="mt-5 mb-2 text-sm font-semibold">
|
||
{{ $t("admin.settlementOrders") }}
|
||
</h4>
|
||
<p v-if="detail.orders.length === 0" class="text-muted text-sm">
|
||
{{ $t("admin.settlementNoOrders") }}
|
||
</p>
|
||
<div v-else class="overflow-x-auto">
|
||
<div class="min-w-[720px]">
|
||
<VTable>
|
||
<thead>
|
||
<tr>
|
||
<th>{{ $t("admin.settlementOrderNo") }}</th>
|
||
<th>{{ $t("admin.settlementOrderCurrency") }}</th>
|
||
<th>{{ $t("admin.settlementGross") }}</th>
|
||
<th>{{ $t("admin.settlementRefunds") }}</th>
|
||
<th>{{ $t("admin.created") }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="line in detail.orders" :key="line.order_id">
|
||
<td>{{ line.order_no }}</td>
|
||
<td>{{ line.order_currency }}</td>
|
||
<td>{{ money(line.gross_minor, detail.currency) }}</td>
|
||
<td>{{ money(line.refund_minor, detail.currency) }}</td>
|
||
<td>{{ formatDate(line.created_at) }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</VTable>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="border-border mt-4 border-t pt-4">
|
||
<template v-if="detail.status === 'pending'">
|
||
<p class="text-muted mb-3 text-sm">
|
||
{{ $t("admin.settlementConfirmPayoutHint") }}
|
||
</p>
|
||
<VBtn
|
||
variant="primary"
|
||
:disabled="actingId === detail.id"
|
||
@click="confirmPayout(detail)"
|
||
>
|
||
{{
|
||
actingId === detail.id
|
||
? $t("common.loading")
|
||
: $t("admin.settlementConfirmPayout")
|
||
}}
|
||
</VBtn>
|
||
</template>
|
||
<p v-else class="text-success text-sm">
|
||
{{ $t("admin.settlementPayoutConfirmed") }}
|
||
</p>
|
||
</div>
|
||
</VPanel>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</VTable>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="!loading && !listError && statements.length > 0"
|
||
class="mt-4 flex items-center justify-center gap-3"
|
||
>
|
||
<VBtn size="sm" :disabled="page <= 1" @click="changePage(page - 1)">{{
|
||
$t("common.prev")
|
||
}}</VBtn>
|
||
<span class="text-muted text-sm">{{ page }} / {{ totalPages }}</span>
|
||
<VBtn size="sm" :disabled="page >= totalPages" @click="changePage(page + 1)">{{
|
||
$t("common.next")
|
||
}}</VBtn>
|
||
</div>
|
||
</VPage>
|
||
</template>
|