From a80fa755d4768a1936b42ad93115bd0a83782b7c Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 24 Mar 2026 17:24:55 +0000 Subject: [PATCH] feat: implement pagination for payment history and enhance translation files --- public/locales/en/translation.json | 7 +- public/locales/vi/translation.json | 7 +- .../Billing/components/PaymentHistory.tsx | 57 ++++++- src/routes/settings/admin/AdTemplates.vue | 4 +- src/routes/settings/admin/Payments.vue | 19 +-- src/server/gen/proto/app/v1/payments.ts | 153 +++++++++++++++++- src/server/routes/rpc/me.ts | 9 +- 7 files changed, 222 insertions(+), 34 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 703a50b..b253f70 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -11,7 +11,12 @@ "status": "Status", "videos": "Videos", "selected": "{{count}} selected", - "copy": "Copy" + "copy": "Copy", + "rowsPerPage": "Rows per page", + "next": "Next", + "previous": "Previous", + "page": "Page {{current}} of {{total}}", + "records": "records" }, "app": { "name": "EcoStream" diff --git a/public/locales/vi/translation.json b/public/locales/vi/translation.json index 979df13..8d5c718 100644 --- a/public/locales/vi/translation.json +++ b/public/locales/vi/translation.json @@ -11,7 +11,12 @@ "status": "Trạng thái", "videos": "Video", "selected": "{{count}} mục đã chọn", - "copy": "Sao chép" + "copy": "Sao chép", + "rowsPerPage": "Số hàng mỗi trang", + "next": "Tiếp", + "previous": "Trước", + "page": "Trang {{current}} trên {{total}}", + "records": "bản ghi" }, "app": { "name": "EcoStream" diff --git a/src/routes/settings/Billing/components/PaymentHistory.tsx b/src/routes/settings/Billing/components/PaymentHistory.tsx index d573a6b..f560b85 100644 --- a/src/routes/settings/Billing/components/PaymentHistory.tsx +++ b/src/routes/settings/Billing/components/PaymentHistory.tsx @@ -9,6 +9,10 @@ import { useAuthStore } from "@/stores/auth"; import { useQuery } from "@pinia/colada"; import { useTranslation } from "i18next-vue"; import type { ColumnDef } from "@tanstack/vue-table"; +import { computed, defineComponent, ref } from "vue"; +import AppButton from "@/components/ui/AppButton.vue"; + +const pageSizeOptions = [5, 10, 20, 50] as const; const normalizeHistoryStatus = (status?: string) => { switch ((status || '').toLowerCase()) { @@ -35,6 +39,8 @@ const PaymentHistory = defineComponent({ const { t } = useTranslation(); const toast = useAppToast(); const downloadingInvoiceId = ref(null); + const page = ref(1); + const limit = ref(10); const formatTermLabel = (months: number) => t('settings.billing.termOption', { months }); const formatPaymentMethodLabel = (value?: string) => { @@ -74,9 +80,13 @@ const PaymentHistory = defineComponent({ }; }; const { data, isLoading } = useQuery({ - key: ['paymentHistory'], - query: () => client.listPaymentHistory().then(res => (res.payments || []).map(mapHistoryItem)), + key: () => ['paymentHistory', page.value, limit.value], + query: () => client.listPaymentHistory(page.value, limit.value).then(res => ({ + ...res, + items: (res.payments || []).map(mapHistoryItem) + })), }); + const totalPages = computed(() => Math.max(1, Math.ceil((data.value?.total || 0) / (data.value?.limit || limit.value || 1)))); const handleDownloadInvoice = async (item: PaymentHistoryItem) => { if (!item.id) return; @@ -193,11 +203,26 @@ const PaymentHistory = defineComponent({ ), meta: { headerClass: 'col-span-2 flex justify-center', - cellClass: 'col-span-2 justify-center', + cellClass: 'col-span-2 flex justify-center', }, }, ]; + const previousPage = () => { + if (!data.value?.hasPrev || isLoading.value) return; + page.value -= 1; + }; + const nextPage = () => { + if (!data.value?.hasNext || isLoading.value) return; + page.value += 1; + }; + const changePageSize = (event: Event) => { + const nextLimit = Number((event.target as HTMLSelectElement).value) || 10; + if (nextLimit === limit.value) return; + limit.value = nextLimit; + page.value = 1; + }; + return () => (
@@ -211,7 +236,7 @@ const PaymentHistory = defineComponent({
(
- {Array.from({ length: 3 }).map((_, index) => ( + {Array.from({ length: 10 }).map((_, index) => (
@@ -240,6 +265,28 @@ const PaymentHistory = defineComponent({ ) }} + +
+
{t('common.page', { current: data.value?.page || page.value, total: totalPages.value })} · {data.value?.total || 0} {t('common.records')}
+
+ +
+ {t('common.previous')} + {t('common.next')} +
+
+
); } diff --git a/src/routes/settings/admin/AdTemplates.vue b/src/routes/settings/admin/AdTemplates.vue index ea4516e..33bb06d 100644 --- a/src/routes/settings/admin/AdTemplates.vue +++ b/src/routes/settings/admin/AdTemplates.vue @@ -440,7 +440,7 @@ onMounted(loadTemplates);
- +
@@ -488,7 +488,7 @@ onMounted(loadTemplates);
- +
diff --git a/src/routes/settings/admin/Payments.vue b/src/routes/settings/admin/Payments.vue index d70b605..512e0f4 100644 --- a/src/routes/settings/admin/Payments.vue +++ b/src/routes/settings/admin/Payments.vue @@ -6,7 +6,7 @@ import AdminInput from "./components/AdminInput.vue"; import AdminSelect from "./components/AdminSelect.vue"; import AdminTable from "./components/AdminTable.vue"; import AdminSectionCard from "./components/AdminSectionCard.vue"; -import BillingPlansSection from "@/routes/settings/Billing/components/BillingPlansSection.vue"; +import PlanSelection from "@/routes/settings/Billing/components/PlanSelection.tsx"; import type { Plan as ModelPlan } from "@/server/gen/proto/app/v1/common"; import { type ColumnDef } from "@tanstack/vue-table"; import { computed, h, onMounted, reactive, ref, watch } from "vue"; @@ -470,21 +470,10 @@ onMounted(() => {
-
diff --git a/src/server/gen/proto/app/v1/payments.ts b/src/server/gen/proto/app/v1/payments.ts index 01e3ced..eaea6db 100644 --- a/src/server/gen/proto/app/v1/payments.ts +++ b/src/server/gen/proto/app/v1/payments.ts @@ -38,10 +38,17 @@ export interface CreatePaymentResponse { } export interface ListPaymentHistoryRequest { + page?: number | undefined; + limit?: number | undefined; } export interface ListPaymentHistoryResponse { payments?: PaymentHistoryItem[] | undefined; + total?: number | undefined; + page?: number | undefined; + limit?: number | undefined; + hasPrev?: boolean | undefined; + hasNext?: boolean | undefined; } export interface TopupWalletRequest { @@ -325,11 +332,17 @@ export const CreatePaymentResponse: MessageFns = { }; function createBaseListPaymentHistoryRequest(): ListPaymentHistoryRequest { - return {}; + return { page: 0, limit: 0 }; } export const ListPaymentHistoryRequest: MessageFns = { - encode(_: ListPaymentHistoryRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + encode(message: ListPaymentHistoryRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.page !== undefined && message.page !== 0) { + writer.uint32(8).int32(message.page); + } + if (message.limit !== undefined && message.limit !== 0) { + writer.uint32(16).int32(message.limit); + } return writer; }, @@ -340,6 +353,22 @@ export const ListPaymentHistoryRequest: MessageFns = while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.page = reader.int32(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.limit = reader.int32(); + continue; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -349,26 +378,37 @@ export const ListPaymentHistoryRequest: MessageFns = return message; }, - fromJSON(_: any): ListPaymentHistoryRequest { - return {}; + fromJSON(object: any): ListPaymentHistoryRequest { + return { + page: isSet(object.page) ? globalThis.Number(object.page) : 0, + limit: isSet(object.limit) ? globalThis.Number(object.limit) : 0, + }; }, - toJSON(_: ListPaymentHistoryRequest): unknown { + toJSON(message: ListPaymentHistoryRequest): unknown { const obj: any = {}; + if (message.page !== undefined && message.page !== 0) { + obj.page = Math.round(message.page); + } + if (message.limit !== undefined && message.limit !== 0) { + obj.limit = Math.round(message.limit); + } return obj; }, create, I>>(base?: I): ListPaymentHistoryRequest { return ListPaymentHistoryRequest.fromPartial(base ?? ({} as any)); }, - fromPartial, I>>(_: I): ListPaymentHistoryRequest { + fromPartial, I>>(object: I): ListPaymentHistoryRequest { const message = createBaseListPaymentHistoryRequest(); + message.page = object.page ?? 0; + message.limit = object.limit ?? 0; return message; }, }; function createBaseListPaymentHistoryResponse(): ListPaymentHistoryResponse { - return { payments: [] }; + return { payments: [], total: 0, page: 0, limit: 0, hasPrev: false, hasNext: false }; } export const ListPaymentHistoryResponse: MessageFns = { @@ -378,6 +418,21 @@ export const ListPaymentHistoryResponse: MessageFns PaymentHistoryItem.encode(v!, writer.uint32(10).fork()).join(); } } + if (message.total !== undefined && message.total !== 0) { + writer.uint32(16).int64(message.total); + } + if (message.page !== undefined && message.page !== 0) { + writer.uint32(24).int32(message.page); + } + if (message.limit !== undefined && message.limit !== 0) { + writer.uint32(32).int32(message.limit); + } + if (message.hasPrev !== undefined && message.hasPrev !== false) { + writer.uint32(40).bool(message.hasPrev); + } + if (message.hasNext !== undefined && message.hasNext !== false) { + writer.uint32(48).bool(message.hasNext); + } return writer; }, @@ -399,6 +454,46 @@ export const ListPaymentHistoryResponse: MessageFns } continue; } + case 2: { + if (tag !== 16) { + break; + } + + message.total = longToNumber(reader.int64()); + continue; + } + case 3: { + if (tag !== 24) { + break; + } + + message.page = reader.int32(); + continue; + } + case 4: { + if (tag !== 32) { + break; + } + + message.limit = reader.int32(); + continue; + } + case 5: { + if (tag !== 40) { + break; + } + + message.hasPrev = reader.bool(); + continue; + } + case 6: { + if (tag !== 48) { + break; + } + + message.hasNext = reader.bool(); + continue; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -413,6 +508,19 @@ export const ListPaymentHistoryResponse: MessageFns payments: globalThis.Array.isArray(object?.payments) ? object.payments.map((e: any) => PaymentHistoryItem.fromJSON(e)) : [], + total: isSet(object.total) ? globalThis.Number(object.total) : 0, + page: isSet(object.page) ? globalThis.Number(object.page) : 0, + limit: isSet(object.limit) ? globalThis.Number(object.limit) : 0, + hasPrev: isSet(object.hasPrev) + ? globalThis.Boolean(object.hasPrev) + : isSet(object.has_prev) + ? globalThis.Boolean(object.has_prev) + : false, + hasNext: isSet(object.hasNext) + ? globalThis.Boolean(object.hasNext) + : isSet(object.has_next) + ? globalThis.Boolean(object.has_next) + : false, }; }, @@ -421,6 +529,21 @@ export const ListPaymentHistoryResponse: MessageFns if (message.payments?.length) { obj.payments = message.payments.map((e) => PaymentHistoryItem.toJSON(e)); } + if (message.total !== undefined && message.total !== 0) { + obj.total = Math.round(message.total); + } + if (message.page !== undefined && message.page !== 0) { + obj.page = Math.round(message.page); + } + if (message.limit !== undefined && message.limit !== 0) { + obj.limit = Math.round(message.limit); + } + if (message.hasPrev !== undefined && message.hasPrev !== false) { + obj.hasPrev = message.hasPrev; + } + if (message.hasNext !== undefined && message.hasNext !== false) { + obj.hasNext = message.hasNext; + } return obj; }, @@ -430,6 +553,11 @@ export const ListPaymentHistoryResponse: MessageFns fromPartial, I>>(object: I): ListPaymentHistoryResponse { const message = createBaseListPaymentHistoryResponse(); message.payments = object.payments?.map((e) => PaymentHistoryItem.fromPartial(e)) || []; + message.total = object.total ?? 0; + message.page = object.page ?? 0; + message.limit = object.limit ?? 0; + message.hasPrev = object.hasPrev ?? false; + message.hasNext = object.hasNext ?? false; return message; }, }; @@ -888,6 +1016,17 @@ type KeysOfUnion = T extends T ? keyof T : never; export type Exact = P extends Builtin ? P : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; +function longToNumber(int64: { toString(): string }): number { + const num = globalThis.Number(int64.toString()); + if (num > globalThis.Number.MAX_SAFE_INTEGER) { + throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER"); + } + if (num < globalThis.Number.MIN_SAFE_INTEGER) { + throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER"); + } + return num; +} + function isSet(value: any): boolean { return value !== null && value !== undefined; } diff --git a/src/server/routes/rpc/me.ts b/src/server/routes/rpc/me.ts index 2147d07..8e43a44 100644 --- a/src/server/routes/rpc/me.ts +++ b/src/server/routes/rpc/me.ts @@ -322,12 +322,15 @@ export const meMethods = { const metadata = context.get("grpcMetadata"); return await plansClient.listPlans({}, metadata); }, - listPaymentHistory: async () => { + listPaymentHistory: validateFn( + z.number().int().min(1).optional(), + z.number().int().min(1).max(100).optional(), + )(async (page, limit) => { const context = getContext(); const paymentsClient = context.get("paymentsServiceClient"); const metadata = context.get("grpcMetadata"); - return await paymentsClient.listPaymentHistory({}, metadata); - }, + return await paymentsClient.listPaymentHistory({ page, limit }, metadata); + }), createPayment: validateFn( z.object({ planId: z.string().trim().min(1),