refactor(reports): 优化报表导出和数据处理逻辑
- 异步动态引入 XLSX 库以优化加载性能 - 抽取导出表格为工作簿的异步函数,提高代码复用性 - 使用构建函数封装报表数据行和汇总的生成逻辑 - 替换硬编码数据聚合为调用构建函数,简化代码结构 - 在导出功能中支持异步操作,增强用户体验 - 统一格式化和统计报表数据,提升代码可维护性
This commit is contained in:
@@ -3,7 +3,6 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import * as XLSX from "xlsx";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
@@ -60,6 +59,17 @@ function stripOmittedColumns(table: HTMLTableElement): HTMLTableElement {
|
|||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportTableToWorkbook(table: HTMLTableElement, sheetName: string, filename: string): Promise<void> {
|
||||||
|
const XLSX = await import("xlsx");
|
||||||
|
const exportTable = stripOmittedColumns(table);
|
||||||
|
const workbook = XLSX.utils.table_to_book(exportTable, {
|
||||||
|
sheet: sheetName,
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
const safeName = filename.endsWith(".xlsx") ? filename : `${filename}.xlsx`;
|
||||||
|
XLSX.writeFile(workbook, safeName);
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminTableExportButton({
|
export function AdminTableExportButton({
|
||||||
tableId,
|
tableId,
|
||||||
filename,
|
filename,
|
||||||
@@ -73,7 +83,7 @@ export function AdminTableExportButton({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
|
|
||||||
const onExport = () => {
|
const onExport = async () => {
|
||||||
const table = document.getElementById(tableId);
|
const table = document.getElementById(tableId);
|
||||||
if (!(table instanceof HTMLTableElement)) {
|
if (!(table instanceof HTMLTableElement)) {
|
||||||
toast.error(t("toast.exportFailed"));
|
toast.error(t("toast.exportFailed"));
|
||||||
@@ -81,13 +91,7 @@ export function AdminTableExportButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const exportTable = stripOmittedColumns(table);
|
await exportTableToWorkbook(table, sheetName, filename);
|
||||||
const workbook = XLSX.utils.table_to_book(exportTable, {
|
|
||||||
sheet: sheetName,
|
|
||||||
raw: true,
|
|
||||||
});
|
|
||||||
const safeName = filename.endsWith(".xlsx") ? filename : `${filename}.xlsx`;
|
|
||||||
XLSX.writeFile(workbook, safeName);
|
|
||||||
toast.success(t("toast.exportDone"));
|
toast.success(t("toast.exportDone"));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("toast.exportFailed"));
|
toast.error(t("toast.exportFailed"));
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import * as XLSX from "xlsx";
|
|
||||||
import {
|
import {
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
@@ -279,7 +278,7 @@ function toCsvValue(value: ExportCell): string {
|
|||||||
return stringValue;
|
return stringValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportRows(rows: ExportRow[], filename: string, sheetName: string, format: ExportFormat): void {
|
async function exportRows(rows: ExportRow[], filename: string, sheetName: string, format: ExportFormat): Promise<void> {
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
throw new LotteryApiBizError("no_data", -1, null);
|
throw new LotteryApiBizError("no_data", -1, null);
|
||||||
}
|
}
|
||||||
@@ -295,12 +294,160 @@ function exportRows(rows: ExportRow[], filename: string, sheetName: string, form
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const XLSX = await import("xlsx");
|
||||||
const worksheet = XLSX.utils.json_to_sheet(rows);
|
const worksheet = XLSX.utils.json_to_sheet(rows);
|
||||||
const workbook = XLSX.utils.book_new();
|
const workbook = XLSX.utils.book_new();
|
||||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||||
XLSX.writeFile(workbook, `${filename}.xlsx`);
|
XLSX.writeFile(workbook, `${filename}.xlsx`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDailyProfitRowsAndSummary(
|
||||||
|
items: AdminReportDailyProfitRow[],
|
||||||
|
total: number,
|
||||||
|
t: (key: string) => string,
|
||||||
|
pageScopedLabel: (statKey: string) => string,
|
||||||
|
): Pick<Extract<ReportResult, { key: "daily_profit" }>, "rows" | "summary"> {
|
||||||
|
let totalBet = 0;
|
||||||
|
let totalPayout = 0;
|
||||||
|
let totalGross = 0;
|
||||||
|
|
||||||
|
const rows = items.map((item) => {
|
||||||
|
totalBet += item.total_bet_minor;
|
||||||
|
totalPayout += item.total_payout_minor;
|
||||||
|
totalGross += item.approx_house_gross_minor;
|
||||||
|
return {
|
||||||
|
business_date: item.business_date,
|
||||||
|
total_bet_minor: item.total_bet_minor,
|
||||||
|
total_payout_minor: item.total_payout_minor,
|
||||||
|
approx_house_gross_minor: item.approx_house_gross_minor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
summary: [
|
||||||
|
{ label: t("preview.stats.records"), value: String(total) },
|
||||||
|
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, "NPR") },
|
||||||
|
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, "NPR") },
|
||||||
|
{
|
||||||
|
label: pageScopedLabel("houseGross"),
|
||||||
|
value: formatPlainMoney(totalGross, "NPR"),
|
||||||
|
tone: totalGross >= 0 ? "good" : "bad",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlayerTransferRowsAndSummary(
|
||||||
|
items: AdminTransferOrderItem[],
|
||||||
|
total: number,
|
||||||
|
page: number,
|
||||||
|
perPage: number,
|
||||||
|
t: (key: string) => string,
|
||||||
|
pageScopedLabel: (statKey: string) => string,
|
||||||
|
): Pick<Extract<ReportResult, { key: "player_transfer" }>, "rows" | "summary" | "meta"> {
|
||||||
|
let transferInCount = 0;
|
||||||
|
let transferOutCount = 0;
|
||||||
|
|
||||||
|
const rows = items.map((item) => {
|
||||||
|
if (item.direction === "in") {
|
||||||
|
transferInCount += 1;
|
||||||
|
} else if (item.direction === "out") {
|
||||||
|
transferOutCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
transfer_no: item.transfer_no,
|
||||||
|
player_id: item.player_id,
|
||||||
|
username: item.username,
|
||||||
|
nickname: item.nickname,
|
||||||
|
direction: item.direction,
|
||||||
|
currency_code: item.currency_code,
|
||||||
|
amount: item.amount,
|
||||||
|
status: item.status,
|
||||||
|
external_ref_no: item.external_ref_no,
|
||||||
|
fail_reason: item.fail_reason,
|
||||||
|
created_at: formatExportInstant(item.created_at),
|
||||||
|
finished_at: formatExportInstant(item.finished_at),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
meta: { total, page, perPage, lastPage: Math.max(1, Math.ceil(total / perPage)) },
|
||||||
|
summary: [
|
||||||
|
{ label: t("preview.stats.records"), value: String(total) },
|
||||||
|
{ label: t("preview.stats.currentPage"), value: String(items.length) },
|
||||||
|
{ label: pageScopedLabel("transferIn"), value: String(transferInCount), tone: "good" },
|
||||||
|
{ label: pageScopedLabel("transferOut"), value: String(transferOutCount), tone: "warn" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlayDimensionRowsAndSummary(
|
||||||
|
items: AdminReportPlayDimensionRow[],
|
||||||
|
total: number,
|
||||||
|
t: (key: string) => string,
|
||||||
|
pageScopedLabel: (statKey: string) => string,
|
||||||
|
): Pick<Extract<ReportResult, { key: "play_dimension" }>, "rows" | "summary"> {
|
||||||
|
let totalBet = 0;
|
||||||
|
let totalPayout = 0;
|
||||||
|
|
||||||
|
const rows = items.map((item) => {
|
||||||
|
totalBet += item.total_bet_minor;
|
||||||
|
totalPayout += item.total_payout_minor;
|
||||||
|
return {
|
||||||
|
play_code: item.play_code,
|
||||||
|
dimension: item.dimension,
|
||||||
|
total_bet_minor: item.total_bet_minor,
|
||||||
|
total_payout_minor: item.total_payout_minor,
|
||||||
|
approx_house_gross_minor: item.approx_house_gross_minor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
summary: [
|
||||||
|
{ label: t("preview.stats.records"), value: String(total) },
|
||||||
|
{ label: t("preview.stats.currentPage"), value: String(items.length) },
|
||||||
|
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, "NPR") },
|
||||||
|
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, "NPR") },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRebateCommissionRowsAndSummary(
|
||||||
|
items: AdminReportRebateCommissionRow[],
|
||||||
|
total: number,
|
||||||
|
t: (key: string) => string,
|
||||||
|
pageScopedLabel: (statKey: string) => string,
|
||||||
|
): Pick<Extract<ReportResult, { key: "rebate_commission" }>, "rows" | "summary"> {
|
||||||
|
let totalRebate = 0;
|
||||||
|
let totalOrders = 0;
|
||||||
|
|
||||||
|
const rows = items.map((item) => {
|
||||||
|
totalRebate += item.total_rebate_minor;
|
||||||
|
totalOrders += item.order_count;
|
||||||
|
return {
|
||||||
|
play_code: item.play_code,
|
||||||
|
total_rebate_minor: item.total_rebate_minor,
|
||||||
|
order_count: item.order_count,
|
||||||
|
ticket_item_count: item.ticket_item_count,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
summary: [
|
||||||
|
{ label: t("preview.stats.records"), value: String(total) },
|
||||||
|
{ label: t("preview.stats.currentPage"), value: String(items.length) },
|
||||||
|
{ label: pageScopedLabel("rebate"), value: formatPlainMoney(totalRebate, "NPR") },
|
||||||
|
{ label: pageScopedLabel("orders"), value: String(totalOrders) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function metaFromList(meta: { current_page: number; per_page: number; total: number; last_page: number }): ReportMeta {
|
function metaFromList(meta: { current_page: number; per_page: number; total: number; last_page: number }): ReportMeta {
|
||||||
return {
|
return {
|
||||||
total: meta.total,
|
total: meta.total,
|
||||||
@@ -724,30 +871,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
const payload = await getAdminReportDailyProfit(
|
const payload = await getAdminReportDailyProfit(
|
||||||
reportListParams(filters, page, perPage),
|
reportListParams(filters, page, perPage),
|
||||||
);
|
);
|
||||||
const rows = payload.items.map((item) => ({
|
const next = buildDailyProfitRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel);
|
||||||
business_date: item.business_date,
|
|
||||||
total_bet_minor: item.total_bet_minor,
|
|
||||||
total_payout_minor: item.total_payout_minor,
|
|
||||||
approx_house_gross_minor: item.approx_house_gross_minor,
|
|
||||||
}));
|
|
||||||
const totalBet = payload.items.reduce((sum, item) => sum + item.total_bet_minor, 0);
|
|
||||||
const totalPayout = payload.items.reduce((sum, item) => sum + item.total_payout_minor, 0);
|
|
||||||
const totalGross = payload.items.reduce((sum, item) => sum + item.approx_house_gross_minor, 0);
|
|
||||||
setResult({
|
setResult({
|
||||||
key: "daily_profit",
|
key: "daily_profit",
|
||||||
raw: payload.items,
|
raw: payload.items,
|
||||||
rows,
|
rows: next.rows,
|
||||||
meta: metaFromList(payload.meta),
|
meta: metaFromList(payload.meta),
|
||||||
summary: [
|
summary: next.summary,
|
||||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
|
||||||
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, "NPR") },
|
|
||||||
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, "NPR") },
|
|
||||||
{
|
|
||||||
label: pageScopedLabel("houseGross"),
|
|
||||||
value: formatPlainMoney(totalGross, "NPR"),
|
|
||||||
tone: totalGross >= 0 ? "good" : "bad",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -792,32 +922,20 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
created_from: filters.dateFrom || undefined,
|
created_from: filters.dateFrom || undefined,
|
||||||
created_to: filters.dateTo || undefined,
|
created_to: filters.dateTo || undefined,
|
||||||
});
|
});
|
||||||
const rows = payload.items.map((item) => ({
|
const next = buildPlayerTransferRowsAndSummary(
|
||||||
id: item.id,
|
payload.items,
|
||||||
transfer_no: item.transfer_no,
|
payload.total,
|
||||||
player_id: item.player_id,
|
payload.page,
|
||||||
username: item.username,
|
payload.per_page,
|
||||||
nickname: item.nickname,
|
t,
|
||||||
direction: item.direction,
|
pageScopedLabel,
|
||||||
currency_code: item.currency_code,
|
);
|
||||||
amount: item.amount,
|
|
||||||
status: item.status,
|
|
||||||
external_ref_no: item.external_ref_no,
|
|
||||||
fail_reason: item.fail_reason,
|
|
||||||
created_at: formatExportInstant(item.created_at),
|
|
||||||
finished_at: formatExportInstant(item.finished_at),
|
|
||||||
}));
|
|
||||||
setResult({
|
setResult({
|
||||||
key: "player_transfer",
|
key: "player_transfer",
|
||||||
raw: payload.items,
|
raw: payload.items,
|
||||||
rows,
|
rows: next.rows,
|
||||||
meta: { total: payload.total, page: payload.page, perPage: payload.per_page, lastPage: Math.max(1, Math.ceil(payload.total / payload.per_page)) },
|
meta: next.meta,
|
||||||
summary: [
|
summary: next.summary,
|
||||||
{ label: t("preview.stats.records"), value: String(payload.total) },
|
|
||||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
|
||||||
{ label: pageScopedLabel("transferIn"), value: String(payload.items.filter((item) => item.direction === "in").length), tone: "good" },
|
|
||||||
{ label: pageScopedLabel("transferOut"), value: String(payload.items.filter((item) => item.direction === "out").length), tone: "warn" },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -913,24 +1031,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
const payload = await getAdminReportPlayDimension(
|
const payload = await getAdminReportPlayDimension(
|
||||||
reportListParams(filters, page, perPage),
|
reportListParams(filters, page, perPage),
|
||||||
);
|
);
|
||||||
const rows = payload.items.map((item) => ({
|
const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel);
|
||||||
play_code: item.play_code,
|
|
||||||
dimension: item.dimension,
|
|
||||||
total_bet_minor: item.total_bet_minor,
|
|
||||||
total_payout_minor: item.total_payout_minor,
|
|
||||||
approx_house_gross_minor: item.approx_house_gross_minor,
|
|
||||||
}));
|
|
||||||
setResult({
|
setResult({
|
||||||
key: "play_dimension",
|
key: "play_dimension",
|
||||||
raw: payload.items,
|
raw: payload.items,
|
||||||
rows,
|
rows: next.rows,
|
||||||
meta: metaFromList(payload.meta),
|
meta: metaFromList(payload.meta),
|
||||||
summary: [
|
summary: next.summary,
|
||||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
|
||||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
|
||||||
{ label: pageScopedLabel("bet"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_bet_minor, 0), "NPR") },
|
|
||||||
{ label: pageScopedLabel("payout"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_payout_minor, 0), "NPR") },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -938,23 +1045,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
const payload = await getAdminReportRebateCommission(
|
const payload = await getAdminReportRebateCommission(
|
||||||
reportListParams(filters, page, perPage),
|
reportListParams(filters, page, perPage),
|
||||||
);
|
);
|
||||||
const rows = payload.items.map((item) => ({
|
const next = buildRebateCommissionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel);
|
||||||
play_code: item.play_code,
|
|
||||||
total_rebate_minor: item.total_rebate_minor,
|
|
||||||
order_count: item.order_count,
|
|
||||||
ticket_item_count: item.ticket_item_count,
|
|
||||||
}));
|
|
||||||
setResult({
|
setResult({
|
||||||
key: "rebate_commission",
|
key: "rebate_commission",
|
||||||
raw: payload.items,
|
raw: payload.items,
|
||||||
rows,
|
rows: next.rows,
|
||||||
meta: metaFromList(payload.meta),
|
meta: metaFromList(payload.meta),
|
||||||
summary: [
|
summary: next.summary,
|
||||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
|
||||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
|
||||||
{ label: pageScopedLabel("rebate"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_rebate_minor, 0), "NPR") },
|
|
||||||
{ label: pageScopedLabel("orders"), value: String(payload.items.reduce((s, i) => s + i.order_count, 0)) },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user