diff --git a/src/api/admin-report-jobs.ts b/src/api/admin-report-jobs.ts new file mode 100644 index 0000000..f5eec2c --- /dev/null +++ b/src/api/admin-report-jobs.ts @@ -0,0 +1,65 @@ +import { adminHttp, adminRequest } from "@/lib/admin-http"; +import { withAdminAuthHeader } from "@/lib/admin-auth"; +import { withAdminLocaleHeaders } from "@/lib/admin-locale"; + +import { API_V1_PREFIX } from "./paths"; + +import type { + AdminReportJobCreatePayload, + AdminReportJobCreateResult, + AdminReportJobListData, + AdminReportJobRow, +} from "@/types/api/admin-report-jobs"; + +const A = `${API_V1_PREFIX}/admin/report-jobs`; + +export async function getAdminReportJobs(params?: { + page?: number; + per_page?: number; +}): Promise { + return adminRequest.get(A, { params }); +} + +export async function postAdminReportJob( + payload: AdminReportJobCreatePayload, +): Promise { + return adminRequest.post(A, payload); +} + +export async function getAdminReportJob(id: number): Promise { + return adminRequest.get(`${A}/${id}`); +} + +function filenameFromContentDisposition(header: string | undefined): string | null { + if (!header) { + return null; + } + const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(header); + if (utf8?.[1]) { + try { + return decodeURIComponent(utf8[1].trim()); + } catch { + return utf8[1].trim(); + } + } + const plain = /filename="?([^";]+)"?/i.exec(header); + return plain?.[1]?.trim() ?? null; +} + +export async function downloadAdminReportJob( + id: number, +): Promise<{ blob: Blob; filename: string | null }> { + const res = await adminHttp.request( + withAdminAuthHeader( + withAdminLocaleHeaders({ + url: `${A}/${id}/download`, + method: "GET", + responseType: "blob", + }), + ), + ); + const filename = filenameFromContentDisposition( + typeof res.headers["content-disposition"] === "string" ? res.headers["content-disposition"] : undefined, + ); + return { blob: res.data, filename }; +} diff --git a/src/api/index.ts b/src/api/index.ts index 096e01c..b1ddd6a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -20,6 +20,12 @@ export { getAdminReportPlayerWinLoss, getAdminReportRebateCommission, } from "@/api/admin-reports"; +export { + downloadAdminReportJob, + getAdminReportJob, + getAdminReportJobs, + postAdminReportJob, +} from "@/api/admin-report-jobs"; export { getAdminDraw, getAdminDrawFinanceSummary, diff --git a/src/i18n/locales/en/config.json b/src/i18n/locales/en/config.json index ee6dcdd..a79eff8 100644 --- a/src/i18n/locales/en/config.json +++ b/src/i18n/locales/en/config.json @@ -64,7 +64,14 @@ "refresh": "Refresh versions", "newDraft": "New draft", "saveDraft": "Save draft", - "saveFailed": "Failed to save configuration" + "saveFailed": "Failed to save configuration", + "rollbackSuccess": "Cloned v{{fromVersion}} into new draft v{{version}}", + "rollbackFailed": "Rollback failed", + "rollbackDialog": { + "title": "Confirm rollback", + "description": "A new draft will be cloned from version v{{version}}. The active version will not be overwritten directly.", + "confirm": "Confirm rollback" + } }, "wallet": { "title": "Wallet transfer limit settings", @@ -327,7 +334,8 @@ }, "winEnjoy": { "label": "Apply rebate on winning tickets", - "description": "Placeholder field. It can later be aligned with risk and settlement rules and persisted." + "description": "Placeholder field. It can later be aligned with risk and settlement rules and persisted.", + "pendingNote": "Required by the product spec, but the API has no field yet. This row is informational only—you cannot change it here." }, "effectiveTime": "Effective time (current active odds version)" }, @@ -372,12 +380,21 @@ "actions": "Actions" }, "occupancy": { - "title": "All number occupancy", - "description": "Placeholder view: filters and exports still need ticket-summary integration. Data below still comes from the current draft list.", "searchLabel": "Search number", - "searchPlaceholder": "e.g. 8888", - "filterPending": "Sold-out / high-risk preset filter is pending integration", - "exportPending": "CSV export is pending integration" + "searchPlaceholder": "e.g. 8888" + }, + "runtime": { + "title": "Per-draw occupancy (live)", + "description": "Loaded from the draw risk-pool API, not from the version draft above. Select a draw to see used, remaining, and sold-out state.", + "drawLabel": "Draw", + "drawPlaceholder": "Select draw", + "filterAll": "All", + "filterSoldOut": "Sold out only", + "filterHighRisk": "High usage", + "manageHint": "Use the links above for full risk operations on this draw.", + "noDraws": "No draws available; cannot load occupancy.", + "soldYes": "Yes", + "soldNo": "No" }, "actions": { "update": "Update", diff --git a/src/i18n/locales/en/draws.json b/src/i18n/locales/en/draws.json index ae4cdeb..aa346d1 100644 --- a/src/i18n/locales/en/draws.json +++ b/src/i18n/locales/en/draws.json @@ -155,6 +155,7 @@ "finance": "Draw finance", "review": "Review & publish", "riskOccupancy": "Risk occupancy", + "riskLockLogs": "Lock logs", "riskHot": "Hot numbers", "riskSoldOut": "Sold-out numbers", "riskPools": "Risk pools" diff --git a/src/i18n/locales/en/reports.json b/src/i18n/locales/en/reports.json index 60b078e..8aedae0 100644 --- a/src/i18n/locales/en/reports.json +++ b/src/i18n/locales/en/reports.json @@ -10,8 +10,11 @@ "dimension": "Dimension", "exportPending": "{{report}} {{format}} export API is not connected yet", "exportSuccess": "Exported {{report}} ({{format}})", + "exportServerSuccess": "Job {{jobNo}} created and downloaded {{report}} ({{format}})", "exportFailed": "Export failed", - "exportHint": "Once export APIs are connected, the current filters will generate the selected file format.", + "exportHint": "Server exports use the current filters to build a full file. Completed jobs can be downloaded again below.", + "exportServerHint": "Full export via server job (preview query not required)", + "exportClientHint": "Export current preview page (run query first)", "validation": { "drawNoRequired": "Please enter a draw number", "drawNoNotFound": "Draw number «{{drawNo}}» was not found", @@ -19,7 +22,43 @@ }, "formats": { "csv": "CSV", - "excel": "Excel" + "excel": "Excel", + "csvServer": "Export CSV (full)", + "excelServer": "Export Excel (full)" + }, + "tasks": { + "refresh": "Refresh", + "download": "Download", + "loadFailed": "Failed to load export jobs", + "downloadSuccess": "Downloaded {{jobNo}}", + "downloadFailed": "Download failed", + "columns": { + "jobNo": "Job no.", + "report": "Report", + "format": "Format", + "status": "Status", + "createdAt": "Created", + "actions": "Actions" + }, + "status": { + "pending": "Pending", + "processing": "Processing", + "completed": "Completed", + "failed": "Failed" + } + }, + "jobTypes": { + "draw_profit_summary": "Draw P&L", + "daily_profit_summary": "Daily P&L", + "player_win_loss": "Player win/loss", + "wallet_transfer_report": "Wallet transfers", + "wallet_txns_daily": "Wallet txns (daily)", + "transfer_orders_daily": "Transfer orders (daily)", + "hot_number_risk_report": "Hot number risk", + "play_dimension_report": "Play dimension", + "sold_out_number_report": "Sold-out numbers", + "rebate_commission_report": "Rebate / commission", + "audit_operation_report": "Admin audit" }, "empty": "No matching reports", "backendPending": "This report is temporarily unavailable", diff --git a/src/i18n/locales/ne/config.json b/src/i18n/locales/ne/config.json index c64cb88..320e51a 100644 --- a/src/i18n/locales/ne/config.json +++ b/src/i18n/locales/ne/config.json @@ -64,7 +64,14 @@ "refresh": "संस्करण रिफ्रेस", "newDraft": "नयाँ ड्राफ्ट", "saveDraft": "ड्राफ्ट सेभ गर्नुहोस्", - "saveFailed": "कन्फिगरेसन सुरक्षित गर्न असफल" + "saveFailed": "कन्फिगरेसन सुरक्षित गर्न असफल", + "rollbackSuccess": "v{{fromVersion}} बाट नयाँ ड्राफ्ट v{{version}} क्लोन गरियो", + "rollbackFailed": "रोलब्याक असफल भयो", + "rollbackDialog": { + "title": "रोलब्याक पुष्टि गर्ने?", + "description": "संस्करण v{{version}} बाट नयाँ ड्राफ्ट क्लोन हुनेछ। सक्रिय संस्करण सिधै अधिलेखन हुँदैन।", + "confirm": "रोलब्याक पुष्टि" + } }, "wallet": { "title": "वालेट ट्रान्सफर सीमा सेटिङ", @@ -327,7 +334,8 @@ }, "winEnjoy": { "label": "जितेका टिकटहरूमा पनि रिबेट लागू गर्ने", - "description": "यो placeholder field हो। पछि risk र settlement नियमसँग मिलाएर स्थायी रूपमा राख्न सकिन्छ।" + "description": "यो placeholder field हो। पछि risk र settlement नियमसँग मिलाएर स्थायी रूपमा राख्न सकिन्छ।", + "pendingNote": "उत्पादन विनिर्देशनले यो switch चाहिन्छ, तर API मा field छैन। यहाँ केवल जानकारी देखाइन्छ—यहाँबाट बदल्न मिल्दैन।" }, "effectiveTime": "लागू समय (हाल सक्रिय अड्स संस्करण)" }, @@ -372,12 +380,21 @@ "actions": "कार्य" }, "occupancy": { - "title": "सबै नम्बर occupancy", - "description": "यो placeholder दृश्य हो। filter र export ले ticket-summary एकीकरण अझै चाहिन्छ। तलको data अहिले पनि हालको ड्राफ्ट सूचीबाट आउँछ।", "searchLabel": "नम्बर खोज्नुहोस्", - "searchPlaceholder": "जस्तै 8888", - "filterPending": "Sold-out / high-risk preset filter अझै एकीकृत भएको छैन", - "exportPending": "CSV export अझै एकीकृत भएको छैन" + "searchPlaceholder": "जस्तै 8888" + }, + "runtime": { + "title": "ड्रअनुसार occupancy (लाइभ)", + "description": "माथिको संस्करण ड्राफ्ट होइन—चयन गरिएको ड्रअको risk-pool API बाट लोड हुन्छ। प्रयोग, बाँकी र sold-out हेर्नुहोस्।", + "drawLabel": "ड्र", + "drawPlaceholder": "ड्र छान्नुहोस्", + "filterAll": "सबै", + "filterSoldOut": "मात्र sold-out", + "filterHighRisk": "उच्च प्रयोग", + "manageHint": "पूर्ण risk सञ्चालनका लागि माथिको लिङ्कबाट ड्र subpage खोल्नुहोस्।", + "noDraws": "कुनै ड्र छैन; occupancy लोड गर्न सकिँदैन।", + "soldYes": "हो", + "soldNo": "होइन" }, "actions": { "update": "अपडेट", diff --git a/src/i18n/locales/ne/draws.json b/src/i18n/locales/ne/draws.json index 086fd6a..c9f0f60 100644 --- a/src/i18n/locales/ne/draws.json +++ b/src/i18n/locales/ne/draws.json @@ -155,6 +155,7 @@ "finance": "ड्रअ वित्त", "review": "समीक्षा र प्रकाशन", "riskOccupancy": "जोखिम अकुपेन्सी", + "riskLockLogs": "लक लग", "riskHot": "हट नम्बर", "riskSoldOut": "बिक्री समाप्त नम्बर", "riskPools": "जोखिम पूल" diff --git a/src/i18n/locales/ne/reports.json b/src/i18n/locales/ne/reports.json index bf66701..710188a 100644 --- a/src/i18n/locales/ne/reports.json +++ b/src/i18n/locales/ne/reports.json @@ -10,8 +10,11 @@ "dimension": "आयाम", "exportPending": "{{report}} {{format}} निर्यात API अझै जोडिएको छैन", "exportSuccess": "{{report}} ({{format}}) निर्यात भयो", + "exportServerSuccess": "कार्य {{jobNo}} सिर्जना भई {{report}} ({{format}}) डाउनलोड भयो", "exportFailed": "निर्यात असफल भयो", - "exportHint": "निर्यात API जोडिएपछि हालका फिल्टरअनुसार छानिएको फाइल ढाँचा बनाइनेछ।", + "exportHint": "सर्भर निर्यातले हालका फिल्टरअनुसार पूर्ण फाइल बनाउँछ। सम्पन्न कार्य तलबाट पुन: डाउनलोड गर्न सकिन्छ।", + "exportServerHint": "पूर्ण निर्यात सर्भर कार्यबाट (पूर्वावलोकन क्वेरी अनिवार्य छैन)", + "exportClientHint": "हालको पूर्वावलोकन पृष्ठ निर्यात (पहिले क्वेरी चलाउनुहोस्)", "validation": { "drawNoRequired": "कृपया ड्र नं. प्रविष्ट गर्नुहोस्", "drawNoNotFound": "ड्र नं. «{{drawNo}}» फेला परेन", @@ -19,7 +22,43 @@ }, "formats": { "csv": "CSV", - "excel": "Excel" + "excel": "Excel", + "csvServer": "CSV निर्यात (पूर्ण)", + "excelServer": "Excel निर्यात (पूर्ण)" + }, + "tasks": { + "refresh": "रिफ्रेस", + "download": "डाउनलोड", + "loadFailed": "कार्य सूची लोड असफल", + "downloadSuccess": "{{jobNo}} डाउनलोड भयो", + "downloadFailed": "डाउनलोड असफल", + "columns": { + "jobNo": "कार्य नं.", + "report": "रिपोर्ट", + "format": "ढाँचा", + "status": "स्थिति", + "createdAt": "सिर्जना", + "actions": "कार्य" + }, + "status": { + "pending": "पर्खाइ", + "processing": "प्रक्रियामा", + "completed": "सम्पन्न", + "failed": "असफल" + } + }, + "jobTypes": { + "draw_profit_summary": "ड्र P&L", + "daily_profit_summary": "दैनिक P&L", + "player_win_loss": "खेलाडी जित/हार", + "wallet_transfer_report": "वालेट ट्रान्सफर", + "wallet_txns_daily": "वालेट लेनदेन (दैनिक)", + "transfer_orders_daily": "ट्रान्सफर अर्डर (दैनिक)", + "hot_number_risk_report": "लोकप्रिय नम्बर जोखिम", + "play_dimension_report": "प्ले आयाम", + "sold_out_number_report": "बिक्री समाप्त नम्बर", + "rebate_commission_report": "रिबेट / कमिसन", + "audit_operation_report": "प्रशासक अडिट" }, "empty": "मिल्ने रिपोर्ट छैन", "backendPending": "यो रिपोर्ट अस्थायी रूपमा उपलब्ध छैन", diff --git a/src/i18n/locales/zh/config.json b/src/i18n/locales/zh/config.json index 5424e27..ea3d9bf 100644 --- a/src/i18n/locales/zh/config.json +++ b/src/i18n/locales/zh/config.json @@ -64,7 +64,14 @@ "refresh": "刷新版本", "newDraft": "新建草稿", "saveDraft": "保存草稿", - "saveFailed": "配置保存失败" + "saveFailed": "配置保存失败", + "rollbackSuccess": "已从 v{{fromVersion}} 克隆出新草稿 v{{version}}", + "rollbackFailed": "回滚失败", + "rollbackDialog": { + "title": "确认回滚", + "description": "系统会基于版本 v{{version}} 克隆出新的草稿,不会直接覆盖当前生效版本。", + "confirm": "确认回滚" + } }, "wallet": { "title": "钱包转账限额配置", @@ -327,7 +334,8 @@ }, "winEnjoy": { "label": "中奖注单也应用回水", - "description": "这是预留字段,后续可和风控、结算规则对齐后再真正落库存储。" + "description": "这是预留字段,后续可和风控、结算规则对齐后再真正落库存储。", + "pendingNote": "产品要求支持该开关,但后端尚未提供配置字段;当前仅展示说明,无法在此修改。" }, "effectiveTime": "生效时间(当前赔率生效版本)" }, @@ -372,12 +380,21 @@ "actions": "操作" }, "occupancy": { - "title": "全号码占用视图", - "description": "这里还是占位视图,筛选和导出后续还需要接入真实注单汇总;下方数据目前仍来自当前草稿列表。", "searchLabel": "搜索号码", - "searchPlaceholder": "例如 8888", - "filterPending": "售罄 / 高风险预设筛选尚未接入", - "exportPending": "CSV 导出尚未接入" + "searchPlaceholder": "例如 8888" + }, + "runtime": { + "title": "按期号查看占用(实时)", + "description": "数据来自该期号风险池 API,与上方版本草稿无关。请选择期号后查看已占用、剩余额度与售罄状态。", + "drawLabel": "期号", + "drawPlaceholder": "选择期号", + "filterAll": "全部", + "filterSoldOut": "仅售罄", + "filterHighRisk": "高占用", + "manageHint": "完整风控操作请使用上方链接进入期号风控子页。", + "noDraws": "暂无可用期号,无法加载占用数据。", + "soldYes": "是", + "soldNo": "否" }, "actions": { "update": "更新", diff --git a/src/i18n/locales/zh/draws.json b/src/i18n/locales/zh/draws.json index 25509cb..819df24 100644 --- a/src/i18n/locales/zh/draws.json +++ b/src/i18n/locales/zh/draws.json @@ -155,6 +155,7 @@ "finance": "期号收支", "review": "审核与发布", "riskOccupancy": "风控占用", + "riskLockLogs": "占用流水", "riskHot": "热门号码", "riskSoldOut": "售罄号码", "riskPools": "风险池" diff --git a/src/i18n/locales/zh/reports.json b/src/i18n/locales/zh/reports.json index 5d4fe69..a9eac55 100644 --- a/src/i18n/locales/zh/reports.json +++ b/src/i18n/locales/zh/reports.json @@ -10,8 +10,11 @@ "dimension": "维度", "exportPending": "{{report}} 的 {{format}} 导出接口待接入", "exportSuccess": "已导出 {{report}}({{format}})", + "exportServerSuccess": "已生成任务 {{jobNo}} 并下载 {{report}}({{format}})", "exportFailed": "导出失败", - "exportHint": "接入导出接口后,会按当前条件生成对应格式的文件。", + "exportHint": "服务端导出会按当前筛选条件生成全量文件;任务完成后可在下方列表再次下载。", + "exportServerHint": "全量导出走服务端任务(无需先查询预览)", + "exportClientHint": "导出当前预览页数据(需先查询)", "validation": { "drawNoRequired": "请输入期号", "drawNoNotFound": "未找到期号「{{drawNo}}」", @@ -19,7 +22,43 @@ }, "formats": { "csv": "CSV", - "excel": "Excel" + "excel": "Excel", + "csvServer": "导出 CSV(全量)", + "excelServer": "导出 Excel(全量)" + }, + "tasks": { + "refresh": "刷新", + "download": "下载", + "loadFailed": "任务列表加载失败", + "downloadSuccess": "已下载 {{jobNo}}", + "downloadFailed": "下载失败", + "columns": { + "jobNo": "任务编号", + "report": "报表", + "format": "格式", + "status": "状态", + "createdAt": "创建时间", + "actions": "操作" + }, + "status": { + "pending": "排队中", + "processing": "处理中", + "completed": "已完成", + "failed": "失败" + } + }, + "jobTypes": { + "draw_profit_summary": "期号盈亏", + "daily_profit_summary": "每日盈亏汇总", + "player_win_loss": "玩家输赢", + "wallet_transfer_report": "玩家转入转出", + "wallet_txns_daily": "钱包流水(日)", + "transfer_orders_daily": "转账订单(日)", + "hot_number_risk_report": "热门号码风险", + "play_dimension_report": "玩法维度", + "sold_out_number_report": "售罄号码", + "rebate_commission_report": "佣金/回水", + "audit_operation_report": "后台操作审计" }, "empty": "没有匹配的报表", "backendPending": "该报表暂不可用", diff --git a/src/lib/admin-page-title.ts b/src/lib/admin-page-title.ts index fdd69ed..a81a1e2 100644 --- a/src/lib/admin-page-title.ts +++ b/src/lib/admin-page-title.ts @@ -56,7 +56,7 @@ const ROUTE_PATTERNS: RoutePattern[] = [ }, { test: (p) => /^\/admin\/draws\/\d+\/risk\/occupancy$/.test(p) || /^\/admin\/risk\/draws\/\d+\/occupancy$/.test(p), - resolve: () => ({ ns: "draws", key: "subnav.riskOccupancy" }), + resolve: () => ({ ns: "draws", key: "subnav.riskLockLogs" }), }, { test: (p) => /^\/admin\/draws\/\d+\/risk\/hot$/.test(p) || /^\/admin\/risk\/draws\/\d+\/hot$/.test(p), diff --git a/src/lib/report-export-map.ts b/src/lib/report-export-map.ts new file mode 100644 index 0000000..503c031 --- /dev/null +++ b/src/lib/report-export-map.ts @@ -0,0 +1,89 @@ +export type ReportFilterSnapshot = { + dateFrom: string; + dateTo: string; + playerId: number | null; + play: string; + operatorId: number | null; + drawId: number | null; + drawNo: string; + number: string; +}; + +/** UI report keys used in reports-console */ +export type ReportUiKey = + | "draw_profit" + | "daily_profit" + | "player_win_loss" + | "player_transfer" + | "hot_number_risk" + | "play_dimension" + | "sold_out_number" + | "rebate_commission" + | "admin_audit"; + +/** Maps UI keys to POST /admin/report-jobs `report_type` */ +export const REPORT_UI_TO_JOB_TYPE: Record = { + draw_profit: "draw_profit_summary", + daily_profit: "daily_profit_summary", + player_win_loss: "player_win_loss", + player_transfer: "wallet_transfer_report", + hot_number_risk: "hot_number_risk_report", + play_dimension: "play_dimension_report", + sold_out_number: "sold_out_number_report", + rebate_commission: "rebate_commission_report", + admin_audit: "audit_operation_report", +}; + +/** Report types with full server-side export (POST /admin/report-jobs). */ +export const REPORT_UI_SERVER_FULL_EXPORT = new Set([ + "draw_profit", + "daily_profit", + "player_win_loss", + "player_transfer", + "hot_number_risk", + "play_dimension", + "sold_out_number", + "rebate_commission", + "admin_audit", +]); + +export function buildReportJobParameters( + key: ReportUiKey, + filters: ReportFilterSnapshot, +): Record { + const params: Record = {}; + + if (filters.dateFrom) { + params.date_from = filters.dateFrom; + } + if (filters.dateTo) { + params.date_to = filters.dateTo; + } + if (filters.playerId != null && filters.playerId > 0) { + params.player_id = filters.playerId; + } + if (filters.operatorId != null && filters.operatorId > 0) { + params.operator_id = filters.operatorId; + } + if (filters.play.trim()) { + params.play_code = filters.play.trim(); + } + + if (key === "draw_profit" || key === "hot_number_risk" || key === "sold_out_number") { + if (filters.drawId != null && filters.drawId > 0) { + params.draw_id = filters.drawId; + } + if (filters.drawNo.trim()) { + params.draw_no = filters.drawNo.trim(); + } + } + + if (key === "hot_number_risk" && filters.number.trim()) { + const digits = filters.number.trim(); + if (/^\d{4}$/.test(digits)) { + params.normalized_number = digits; + } + } + + return params; +} diff --git a/src/modules/config/doc/play-config-doc-screen.tsx b/src/modules/config/doc/play-config-doc-screen.tsx index f3d06bc..f88a33d 100644 --- a/src/modules/config/doc/play-config-doc-screen.tsx +++ b/src/modules/config/doc/play-config-doc-screen.tsx @@ -151,6 +151,8 @@ export function PlayConfigDocScreen() { const [loadingDetail, setLoadingDetail] = useState(false); const [saving, setSaving] = useState(false); const [creatingDraftId, setCreatingDraftId] = useState(null); + const [rollbackOpen, setRollbackOpen] = useState(false); + const [rollbackTarget, setRollbackTarget] = useState(null); const [error, setError] = useState(null); const detailRequestSeq = useRef(0); @@ -400,6 +402,41 @@ export function PlayConfigDocScreen() { } } + function requestRollback(row: ConfigVersionSummary) { + setRollbackTarget(row); + setRollbackOpen(true); + } + + async function handleRollback() { + if (!rollbackTarget) { + return; + } + setSaving(true); + try { + const d = await postPlayConfigVersion({ + reason: `rollback from v${rollbackTarget.version_no}`, + clone_from_version_id: rollbackTarget.id, + }); + toast.success( + t("versionActions.rollbackSuccess", { + ns: "config", + fromVersion: rollbackTarget.version_no, + version: d.version_no, + }), + ); + await refreshList(); + setSelectedId(String(d.id)); + setDetail(d); + setDraftRows(d.items.map((it) => ({ ...it }))); + setRollbackOpen(false); + setRollbackTarget(null); + } catch (e) { + toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.rollbackFailed", { ns: "config" })); + } finally { + setSaving(false); + } + } + return ( } actions={ @@ -744,6 +783,29 @@ export function PlayConfigDocScreen() { + + + + + {t("versionActions.rollbackDialog.title", { ns: "config" })} + + {t("versionActions.rollbackDialog.description", { + ns: "config", + version: rollbackTarget?.version_no ?? "—", + })} + + + + + + + + + ); diff --git a/src/modules/config/doc/rebate-config-doc-screen.tsx b/src/modules/config/doc/rebate-config-doc-screen.tsx index b61244c..a5ad980 100644 --- a/src/modules/config/doc/rebate-config-doc-screen.tsx +++ b/src/modules/config/doc/rebate-config-doc-screen.tsx @@ -19,8 +19,16 @@ import { ConfigVersionToolbarMeta, ConfigVersionToolbarMetaEmphasis, } from "@/modules/config/config-version-toolbar-meta"; -import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value"; @@ -116,6 +124,8 @@ export function RebateConfigDocScreen({ const [p2, setP2] = useState("0"); const [p3, setP3] = useState("0"); const [p4, setP4] = useState("0"); + const [rollbackOpen, setRollbackOpen] = useState(false); + const [rollbackTarget, setRollbackTarget] = useState(null); const refreshTypes = useCallback(async () => { try { @@ -328,6 +338,45 @@ export function RebateConfigDocScreen({ const activeHead = listRows.find((x) => x.status === "active"); + function requestRollback(row: ConfigVersionSummary) { + setRollbackTarget(row); + setRollbackOpen(true); + } + + async function handleRollback() { + if (!rollbackTarget) { + return; + } + setSaving(true); + try { + const d = await postOddsVersion({ + reason: `rollback from v${rollbackTarget.version_no}`, + clone_from_version_id: rollbackTarget.id, + }); + toast.success( + t("versionActions.rollbackSuccess", { + ns: "config", + fromVersion: rollbackTarget.version_no, + version: d.version_no, + }), + ); + await refreshList(); + setSelectedId(String(d.id)); + const rows = d.items.map((it) => ({ ...it })); + setDetail(d); + setDraftRows(rows); + setP2(inferPercentFrom(2, rows, types)); + setP3(inferPercentFrom(3, rows, types)); + setP4(inferPercentFrom(4, rows, types)); + setRollbackOpen(false); + setRollbackTarget(null); + } catch (e) { + toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.rollbackFailed", { ns: "config" })); + } finally { + setSaving(false); + } + } + async function handleDeleteVersion(row: ConfigVersionSummary) { try { await deleteOddsVersion(row.id); @@ -350,6 +399,8 @@ export function RebateConfigDocScreen({ sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`} sheetDescription={t("rebate.sheetDescription", { ns: "config" })} onDeleteVersion={handleDeleteVersion} + onRollbackVersion={requestRollback} + rollbackBusy={saving} /> } actions={ @@ -460,12 +511,13 @@ export function RebateConfigDocScreen({ -
-

{t("rebate.winEnjoy.label", { ns: "config" })}

- - {t("system.states.enabled", { ns: "config" })} - -
+ + + {t("rebate.winEnjoy.label", { ns: "config" })} + {" — "} + {t("rebate.winEnjoy.pendingNote", { ns: "config" })} + + {!embedded ? (
@@ -482,10 +534,35 @@ export function RebateConfigDocScreen({ ); + const rollbackDialog = ( + + + + {t("versionActions.rollbackDialog.title", { ns: "config" })} + + {t("versionActions.rollbackDialog.description", { + ns: "config", + version: rollbackTarget?.version_no ?? "—", + })} + + + + + + + + + ); + if (embedded) { return (
{fieldsBlock} + {rollbackDialog}
); @@ -497,6 +574,7 @@ export function RebateConfigDocScreen({ toolbar={toolbarBlock} > {fieldsBlock} + {rollbackDialog} ); diff --git a/src/modules/config/doc/risk-cap-doc-screen.tsx b/src/modules/config/doc/risk-cap-doc-screen.tsx index fbd8ddf..2ed009a 100644 --- a/src/modules/config/doc/risk-cap-doc-screen.tsx +++ b/src/modules/config/doc/risk-cap-doc-screen.tsx @@ -31,6 +31,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher"; +import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel"; import { Table, TableBody, @@ -98,8 +99,9 @@ export function RiskCapDocScreen() { const [defaultCapStr, setDefaultCapStr] = useState(""); const [syncOpen, setSyncOpen] = useState(false); + const [rollbackOpen, setRollbackOpen] = useState(false); + const [rollbackTarget, setRollbackTarget] = useState(null); - const [occSearch, setOccSearch] = useState(""); const amountCurrencyCode = "NPR"; const refreshList = useCallback(async () => { @@ -315,7 +317,7 @@ export function RiskCapDocScreen() { function applyDefaultCap() { const n = parseAdminMajorToMinor(defaultCapStr, amountCurrencyCode); - if (!Number.isFinite(n) || n <= 0) { + if (n == null || !Number.isFinite(n) || n <= 0) { toast.error(t("riskCap.validation.enterValidCapAmount", { ns: "config" })); return; } @@ -327,14 +329,6 @@ export function RiskCapDocScreen() { toast.message(t("riskCap.savedLocalDraft", { ns: "config" })); } - const occFiltered = useMemo(() => { - const q = occSearch.trim(); - if (!q) { - return draftRows.filter((row) => !isDefaultRiskRow(row)); - } - return draftRows.filter((r) => !isDefaultRiskRow(r) && r.normalized_number.includes(q)); - }, [draftRows, occSearch]); - const specialRows = useMemo( () => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)), [draftRows], @@ -351,6 +345,49 @@ export function RiskCapDocScreen() { } } + function requestRollback(row: ConfigVersionSummary) { + setRollbackTarget(row); + setRollbackOpen(true); + } + + async function handleRollback() { + if (!rollbackTarget) { + return; + } + setSaving(true); + try { + const d = await postRiskCapVersion({ + reason: `rollback from v${rollbackTarget.version_no}`, + clone_from_version_id: rollbackTarget.id, + }); + toast.success( + t("versionActions.rollbackSuccess", { + ns: "config", + fromVersion: rollbackTarget.version_no, + version: d.version_no, + }), + ); + await refreshList(); + setSelectedId(String(d.id)); + setDetail(d); + const mapped = d.items.map((it) => ({ + clientKey: `srv-${it.id}`, + draw_id: it.draw_id, + normalized_number: it.normalized_number, + cap_amount: it.cap_amount, + cap_type: it.cap_type, + })); + setDraftRows(mapped); + syncDefaultCapFromRows(mapped); + setRollbackOpen(false); + setRollbackTarget(null); + } catch (e) { + toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.rollbackFailed", { ns: "config" })); + } finally { + setSaving(false); + } + } + return ( } actions={ @@ -466,9 +505,6 @@ export function RiskCapDocScreen() { {t("riskCap.table.number", { ns: "config" })} {t("riskCap.table.capAmount", { ns: "config" })} - {t("riskCap.table.used", { ns: "config" })} - {t("riskCap.table.remaining", { ns: "config" })} - {t("riskCap.table.soldOut", { ns: "config" })} {t("riskCap.table.actions", { ns: "config" })} @@ -513,9 +549,6 @@ export function RiskCapDocScreen() { )} - - - {canEditDraft ? ( - -
- - - - {t("riskCap.table.number", { ns: "config" })} - {t("riskCap.table.used", { ns: "config" })} - {t("riskCap.table.remaining", { ns: "config" })} - {t("riskCap.table.ratio", { ns: "config" })} - {t("riskCap.table.soldOut", { ns: "config" })} - {t("riskCap.table.actions", { ns: "config" })} - - - - {occFiltered.map((r) => ( - - {r.normalized_number} - - - - - - - ))} - -
- + @@ -605,6 +591,29 @@ export function RiskCapDocScreen() { + + + + + {t("versionActions.rollbackDialog.title", { ns: "config" })} + + {t("versionActions.rollbackDialog.description", { + ns: "config", + version: rollbackTarget?.version_no ?? "—", + })} + + + + + + + + + ); diff --git a/src/modules/config/risk-cap-runtime-panel.tsx b/src/modules/config/risk-cap-runtime-panel.tsx new file mode 100644 index 0000000..7a643d3 --- /dev/null +++ b/src/modules/config/risk-cap-runtime-panel.tsx @@ -0,0 +1,275 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; + +import { getAdminDraws } from "@/api/admin-draws"; +import { getAdminRiskPools } from "@/api/admin-risk"; +import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Input } from "@/components/ui/input"; +import { ConfigSection } from "@/modules/config/config-section"; +import { formatAdminMinorUnits } from "@/lib/money"; +import { cn } from "@/lib/utils"; +import { LotteryApiBizError } from "@/types/api/errors"; +import type { AdminDrawListItem } from "@/types/api/admin-draws"; +import type { AdminRiskPoolRow } from "@/types/api/admin-risk"; + +type PoolFilter = "all" | "sold_out" | "high_risk"; + +export function RiskCapRuntimePanel() { + const { t } = useTranslation(["config", "risk", "draws", "common"]); + const [draws, setDraws] = useState([]); + const [drawsLoading, setDrawsLoading] = useState(true); + const [drawId, setDrawId] = useState(""); + + const [numberQ, setNumberQ] = useState(""); + const [appliedNumber, setAppliedNumber] = useState(""); + const [poolFilter, setPoolFilter] = useState("all"); + + const [pools, setPools] = useState([]); + const [currencyCode, setCurrencyCode] = useState(null); + const [poolsLoading, setPoolsLoading] = useState(false); + const [poolsError, setPoolsError] = useState(null); + + const selectedDraw = useMemo( + () => draws.find((d) => String(d.id) === drawId) ?? null, + [draws, drawId], + ); + + const loadDraws = useCallback(async () => { + setDrawsLoading(true); + try { + const data = await getAdminDraws({ page: 1, per_page: 50 }); + setDraws(data.items); + if (data.items.length > 0) { + setDrawId((prev) => (prev === "" ? String(data.items[0].id) : prev)); + } + } catch (e) { + toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" })); + setDraws([]); + } finally { + setDrawsLoading(false); + } + }, [t]); + + const loadPools = useCallback(async () => { + if (!drawId) { + setPools([]); + return; + } + const id = Number(drawId); + if (!Number.isFinite(id)) { + return; + } + setPoolsLoading(true); + setPoolsError(null); + try { + const data = await getAdminRiskPools(id, { + page: 1, + per_page: 200, + normalized_number: appliedNumber.trim() || undefined, + sold_out_only: poolFilter === "sold_out", + high_risk_only: poolFilter === "high_risk", + sort: poolFilter === "high_risk" ? "usage_desc" : "number_asc", + }); + setPools(data.items); + setCurrencyCode(data.currency_code); + } catch (e) { + setPoolsError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" })); + setPools([]); + } finally { + setPoolsLoading(false); + } + }, [appliedNumber, drawId, poolFilter, t]); + + useEffect(() => { + void loadDraws(); + }, [loadDraws]); + + useEffect(() => { + void loadPools(); + }, [loadPools]); + + const riskBase = drawId ? `/admin/draws/${drawId}/risk` : null; + + return ( + +
+
+ + +
+ {riskBase ? ( +
+ + {t("subnav.riskPools", { ns: "draws" })} + + + {t("subnav.riskHot", { ns: "draws" })} + + + {t("subnav.riskSoldOut", { ns: "draws" })} + + + {t("subnav.riskLockLogs", { ns: "draws" })} + +
+ ) : null} +
+ + {drawId ? ( + <> +
+
+ + setNumberQ(e.target.value)} + /> +
+
+ {( + [ + { id: "all", label: t("riskCap.runtime.filterAll", { ns: "config" }) }, + { id: "sold_out", label: t("riskCap.runtime.filterSoldOut", { ns: "config" }) }, + { id: "high_risk", label: t("riskCap.runtime.filterHighRisk", { ns: "config" }) }, + ] as const + ).map((f) => ( + + ))} +
+ + + {pools.length > 0 ? ( + + ) : null} +
+ + {poolsError ?

{poolsError}

: null} + +
+ + + + {t("riskCap.table.number", { ns: "config" })} + {t("riskCap.table.used", { ns: "config" })} + {t("riskCap.table.remaining", { ns: "config" })} + {t("riskCap.table.ratio", { ns: "config" })} + {t("riskCap.table.soldOut", { ns: "config" })} + + + + {poolsLoading ? ( + + + {t("states.loading", { ns: "common" })} + + + ) : pools.length === 0 ? ( + + + {t("states.noData", { ns: "common" })} + + + ) : ( + pools.map((row) => ( + = 0.8 && "bg-amber-500/10", + )} + > + {row.normalized_number} + + {formatAdminMinorUnits(row.locked_amount, currencyCode ?? undefined)} + + + {formatAdminMinorUnits(row.remaining_amount, currencyCode ?? undefined)} + + + {row.usage_ratio != null ? `${Math.round(row.usage_ratio * 100)}%` : "—"} + + + {row.is_sold_out + ? t("riskCap.runtime.soldYes", { ns: "config" }) + : t("riskCap.runtime.soldNo", { ns: "config" })} + + + )) + )} + +
+
+

+ {t("riskCap.runtime.manageHint", { ns: "config" })} +

+ + ) : ( +

{t("riskCap.runtime.noDraws", { ns: "config" })}

+ )} +
+ ); +} diff --git a/src/modules/draws/draw-subnav.tsx b/src/modules/draws/draw-subnav.tsx index c167031..45f1989 100644 --- a/src/modules/draws/draw-subnav.tsx +++ b/src/modules/draws/draw-subnav.tsx @@ -12,7 +12,7 @@ const segments = [ { suffix: "/results", key: "results", label: "subnav.results" }, { suffix: "/finance", key: "finance", label: "subnav.finance" }, { suffix: "/review", key: "review", label: "subnav.review" }, - { suffix: "/risk/occupancy", key: "riskOccupancy", label: "subnav.riskOccupancy" }, + { suffix: "/risk/occupancy", key: "riskLockLogs", label: "subnav.riskLockLogs" }, { suffix: "/risk/hot", key: "riskHot", label: "subnav.riskHot" }, { suffix: "/risk/sold-out", key: "riskSoldOut", label: "subnav.riskSoldOut" }, { suffix: "/risk/pools", key: "riskPools", label: "subnav.riskPools" }, diff --git a/src/modules/reports/report-jobs-panel.tsx b/src/modules/reports/report-jobs-panel.tsx new file mode 100644 index 0000000..02884c7 --- /dev/null +++ b/src/modules/reports/report-jobs-panel.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Download, RefreshCw } from "lucide-react"; + +import { downloadAdminReportJob, getAdminReportJobs } from "@/api/admin-report-jobs"; +import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; +import { LotteryApiBizError } from "@/types/api/errors"; +import type { AdminReportJobRow } from "@/types/api/admin-report-jobs"; + +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +type ReportJobsPanelProps = { + canExport: boolean; + refreshToken?: number; +}; + +export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanelProps) { + const { t } = useTranslation(["reports", "common"]); + const formatTs = useAdminDateTimeFormatter(); + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + const [downloadingId, setDownloadingId] = useState(null); + + const loadJobs = useCallback(async () => { + setLoading(true); + try { + const data = await getAdminReportJobs({ page: 1, per_page: 10 }); + setJobs(data.items); + } catch (e) { + toast.error(e instanceof LotteryApiBizError ? e.message : t("tasks.loadFailed")); + setJobs([]); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void loadJobs(); + }, [loadJobs, refreshToken]); + + async function handleDownload(job: AdminReportJobRow): Promise { + if (!canExport || job.status !== "completed") { + return; + } + setDownloadingId(job.id); + try { + const { blob, filename } = await downloadAdminReportJob(job.id); + const fallback = `${job.job_no}.${job.export_format}`; + downloadBlob(blob, filename ?? fallback); + toast.success(t("tasks.downloadSuccess", { jobNo: job.job_no })); + } catch (e) { + toast.error(e instanceof LotteryApiBizError ? e.message : t("tasks.downloadFailed")); + } finally { + setDownloadingId(null); + } + } + + function reportTypeLabel(reportType: string): string { + const key = `jobTypes.${reportType}`; + const label = t(key); + return label === key ? reportType : label; + } + + return ( + + + {t("recentTasks")} + + + +

{t("exportHint")}

+ + + + {t("tasks.columns.jobNo")} + {t("tasks.columns.report")} + {t("tasks.columns.format")} + {t("tasks.columns.status")} + {t("tasks.columns.createdAt")} + {t("tasks.columns.actions")} + + + + {loading ? ( + + + {t("states.loading", { ns: "common" })} + + + ) : jobs.length === 0 ? ( + + + {t("taskEmpty")} + + + ) : ( + jobs.map((job) => ( + + {job.job_no} + {reportTypeLabel(job.report_type)} + {job.export_format} + + + {t(`tasks.status.${job.status}`, { defaultValue: job.status })} + + + + {formatTs(job.created_at ?? job.finished_at)} + + + + + + )) + )} + +
+
+
+ ); +} diff --git a/src/modules/reports/reports-console.tsx b/src/modules/reports/reports-console.tsx index 2b05bb4..9b4d315 100644 --- a/src/modules/reports/reports-console.tsx +++ b/src/modules/reports/reports-console.tsx @@ -29,12 +29,20 @@ import { } from "@/lib/admin-play-types"; import { getAdminDraws, getAdminDrawFinanceSummary } from "@/api/admin-draws"; import { getAdminPlayers } from "@/api/admin-player"; +import { downloadAdminReportJob, postAdminReportJob } from "@/api/admin-report-jobs"; import { getAdminReportDailyProfit, getAdminReportPlayDimension, getAdminReportPlayerWinLoss, getAdminReportRebateCommission, } from "@/api/admin-reports"; +import { + buildReportJobParameters, + REPORT_UI_SERVER_FULL_EXPORT, + REPORT_UI_TO_JOB_TYPE, + type ReportUiKey, +} from "@/lib/report-export-map"; +import { ReportJobsPanel } from "@/modules/reports/report-jobs-panel"; import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk"; import { getAdminUsers } from "@/api/admin-users"; import { getAdminTransferOrders } from "@/api/admin-wallet"; @@ -387,6 +395,7 @@ export function ReportsConsole() { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(20); const [exporting, setExporting] = useState(null); + const [jobRefreshToken, setJobRefreshToken] = useState(0); const [search, setSearch] = useState(emptySearch); const [playOptions, setPlayOptions] = useState([]); @@ -778,10 +787,55 @@ export function ReportsConsole() { setPage(1); } + const usesServerExport = REPORT_UI_SERVER_FULL_EXPORT.has(selectedReport.key as ReportUiKey); + + async function exportViaServer(format: ExportFormat): Promise { + if (!canExportReports) { + return; + } + setExporting(format); + try { + const parameters = buildReportJobParameters(selectedReport.key as ReportUiKey, { + dateFrom: filters.dateFrom, + dateTo: filters.dateTo, + playerId: filters.playerId, + play: filters.play, + operatorId: filters.operatorId, + drawId: filters.drawId, + drawNo: filters.drawNo, + number: filters.number, + }); + const job = await postAdminReportJob({ + report_type: REPORT_UI_TO_JOB_TYPE[selectedReport.key as ReportUiKey], + export_format: format === "excel" ? "xlsx" : "csv", + parameters, + }); + setJobRefreshToken((n) => n + 1); + const { blob, filename } = await downloadAdminReportJob(job.id); + const ext = job.export_format === "xlsx" ? "xlsx" : "csv"; + downloadBlob(blob, filename ?? `${exportFileBase}.${ext}`); + toast.success( + t("exportServerSuccess", { + report: t(`items.${selectedReport.key}.title`), + format: t(`formats.${format}`), + jobNo: job.job_no, + }), + ); + } catch (err) { + toast.error(err instanceof LotteryApiBizError ? err.message : t("exportFailed")); + } finally { + setExporting(null); + } + } + function exportReport(format: ExportFormat): void { if (!canExportReports) { return; } + if (usesServerExport) { + void exportViaServer(format); + return; + } if (!result || result.rows.length === 0) { toast.info(t("empty")); return; @@ -1273,24 +1327,39 @@ export function ReportsConsole() {
{t("preview.title")}
-
- - +
+ {usesServerExport ? ( +

{t("exportServerHint")}

+ ) : ( +

{t("exportClientHint")}

+ )} +
+ + +
@@ -1329,6 +1398,8 @@ export function ReportsConsole() {
+ + ); } diff --git a/src/types/api/admin-report-jobs.ts b/src/types/api/admin-report-jobs.ts new file mode 100644 index 0000000..536d422 --- /dev/null +++ b/src/types/api/admin-report-jobs.ts @@ -0,0 +1,39 @@ +export type AdminReportJobExportFormat = "csv" | "xlsx"; + +export type AdminReportJobStatus = "pending" | "processing" | "completed" | "failed" | string; + +export type AdminReportJobRow = { + id: number; + job_no: string; + admin_user_id: number | null; + report_type: string; + export_format: AdminReportJobExportFormat; + status: AdminReportJobStatus; + finished_at: string | null; + created_at: string | null; +}; + +export type AdminReportJobListData = { + items: AdminReportJobRow[]; + meta: { + current_page: number; + per_page: number; + total: number; + last_page: number; + }; +}; + +export type AdminReportJobCreatePayload = { + report_type: string; + export_format?: AdminReportJobExportFormat; + parameters?: Record; + filter_json?: Record; +}; + +export type AdminReportJobCreateResult = { + id: number; + job_no: string; + report_type: string; + export_format: AdminReportJobExportFormat; + status: AdminReportJobStatus; +};