diff --git a/src/components/admin/admin-table-export-button.tsx b/src/components/admin/admin-table-export-button.tsx index 881d8ee..061535d 100644 --- a/src/components/admin/admin-table-export-button.tsx +++ b/src/components/admin/admin-table-export-button.tsx @@ -3,7 +3,6 @@ import { Download } from "lucide-react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import * as XLSX from "xlsx"; import { Button } from "@/components/ui/button"; @@ -60,6 +59,17 @@ function stripOmittedColumns(table: HTMLTableElement): HTMLTableElement { return clone; } +async function exportTableToWorkbook(table: HTMLTableElement, sheetName: string, filename: string): Promise { + 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({ tableId, filename, @@ -73,7 +83,7 @@ export function AdminTableExportButton({ }) { const { t } = useTranslation("common"); - const onExport = () => { + const onExport = async () => { const table = document.getElementById(tableId); if (!(table instanceof HTMLTableElement)) { toast.error(t("toast.exportFailed")); @@ -81,13 +91,7 @@ export function AdminTableExportButton({ } try { - 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); + await exportTableToWorkbook(table, sheetName, filename); toast.success(t("toast.exportDone")); } catch { toast.error(t("toast.exportFailed")); diff --git a/src/modules/reports/reports-console.tsx b/src/modules/reports/reports-console.tsx index bb779d4..f96d45e 100644 --- a/src/modules/reports/reports-console.tsx +++ b/src/modules/reports/reports-console.tsx @@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import * as XLSX from "xlsx"; import { CalendarDays, CircleDollarSign, @@ -279,7 +278,7 @@ function toCsvValue(value: ExportCell): string { 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 { if (rows.length === 0) { throw new LotteryApiBizError("no_data", -1, null); } @@ -295,12 +294,160 @@ function exportRows(rows: ExportRow[], filename: string, sheetName: string, form return; } + const XLSX = await import("xlsx"); const worksheet = XLSX.utils.json_to_sheet(rows); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); XLSX.writeFile(workbook, `${filename}.xlsx`); } +function buildDailyProfitRowsAndSummary( + items: AdminReportDailyProfitRow[], + total: number, + t: (key: string) => string, + pageScopedLabel: (statKey: string) => string, +): Pick, "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, "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, "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, "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 { return { total: meta.total, @@ -724,30 +871,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa const payload = await getAdminReportDailyProfit( reportListParams(filters, page, perPage), ); - const rows = payload.items.map((item) => ({ - 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); + const next = buildDailyProfitRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel); setResult({ key: "daily_profit", raw: payload.items, - rows, + rows: next.rows, meta: metaFromList(payload.meta), - 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", - }, - ], + summary: next.summary, }); break; } @@ -792,32 +922,20 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa created_from: filters.dateFrom || undefined, created_to: filters.dateTo || undefined, }); - const rows = payload.items.map((item) => ({ - 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), - })); + const next = buildPlayerTransferRowsAndSummary( + payload.items, + payload.total, + payload.page, + payload.per_page, + t, + pageScopedLabel, + ); setResult({ key: "player_transfer", raw: payload.items, - rows, - meta: { total: payload.total, page: payload.page, perPage: payload.per_page, lastPage: Math.max(1, Math.ceil(payload.total / payload.per_page)) }, - 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" }, - ], + rows: next.rows, + meta: next.meta, + summary: next.summary, }); break; } @@ -913,24 +1031,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa const payload = await getAdminReportPlayDimension( reportListParams(filters, page, perPage), ); - const rows = payload.items.map((item) => ({ - 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, - })); + const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel); setResult({ key: "play_dimension", raw: payload.items, - rows, + rows: next.rows, meta: metaFromList(payload.meta), - 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") }, - ], + summary: next.summary, }); break; } @@ -938,23 +1045,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa const payload = await getAdminReportRebateCommission( reportListParams(filters, page, perPage), ); - const rows = payload.items.map((item) => ({ - play_code: item.play_code, - total_rebate_minor: item.total_rebate_minor, - order_count: item.order_count, - ticket_item_count: item.ticket_item_count, - })); + const next = buildRebateCommissionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel); setResult({ key: "rebate_commission", raw: payload.items, - rows, + rows: next.rows, meta: metaFromList(payload.meta), - 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)) }, - ], + summary: next.summary, }); break; }