Files
vmall/apps/admin/pages/settlements.vue
T
james 9904696e76 feat: wave 2 migration (P3, P5, P7 openspec changes)
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.
2026-09-25 15:25:29 +00:00

719 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>