From 1164d9d728bcdcf6fa4514d1141d09055328d0fb Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 9 Jul 2026 15:48:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(settlement):=20=E6=94=AF=E6=8C=81=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E6=97=B6=E5=8C=BA=E4=B8=8E=E5=B7=B2=E5=85=B3=E8=B4=A6?= =?UTF-8?q?=E6=BC=8F=E8=B4=A6=E6=B5=81=E6=B0=B4=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 為結算期間相關接口與前端組件新增 settlement_timezone 字段 - 在账期范围格式化函数中支持传入时区参数显示当地日期 - 在开账面板和账期视图中增加漏账流水(orphanActivity)高亮显示 - 统一调整账期视图默认子页面为“账单处理”而非“待处理” - 在开奖管理中添加开奖提供商(provider)信息展示和选择功能 - 结算中心增加账单状态筛选项支持待确认、待收付、已结清、调账/冲正 - 移除部分账期页状态筛选标签,改用文本方式显示待确认与待收付数量 - 集成站点管理中新增结算时区字段,支持编辑与保存 - 多语言新增与更新与结算时区和漏账流水相关的文案提示及翻译 --- src/api/admin-agent-settlement.ts | 2 + .../admin/admin-date-range-field.tsx | 12 ++++ src/i18n/locales/en/agents.json | 4 +- src/i18n/locales/en/config.json | 2 + src/i18n/locales/en/draws.json | 1 + src/i18n/locales/en/settlementCenter.json | 2 +- src/i18n/locales/en/tickets.json | 2 + src/i18n/locales/ne/agents.json | 4 +- src/i18n/locales/ne/config.json | 2 + src/i18n/locales/ne/draws.json | 1 + src/i18n/locales/ne/settlementCenter.json | 2 +- src/i18n/locales/ne/tickets.json | 2 + src/i18n/locales/zh/agents.json | 4 +- src/i18n/locales/zh/config.json | 2 + src/i18n/locales/zh/draws.json | 1 + src/i18n/locales/zh/settlementCenter.json | 3 +- src/i18n/locales/zh/tickets.json | 2 + src/lib/agent-settlement-period-range.ts | 28 +++++++- src/modules/draws/draw-publish-dialog.tsx | 3 +- src/modules/draws/draw-publish-panel.tsx | 8 ++- src/modules/draws/draw-results-console.tsx | 6 +- src/modules/draws/draw-review-console.tsx | 56 ++++++++++++++- .../integration/integration-sites-console.tsx | 21 +++++- .../settlement/settlement-center-nav.ts | 6 +- .../settlement-center-period-detail.tsx | 8 +-- .../settlement/settlement-center-shell.tsx | 14 +++- .../settlement/settlement-main-panel.tsx | 69 +++++++++++++++++-- .../settlement/settlement-period-header.tsx | 68 +++++------------- .../settlement-period-workbench.tsx | 44 ++++++++---- .../settlement/settlement-periods-table.tsx | 4 +- .../tickets/player-tickets-console.tsx | 69 +++++++++++++++---- src/types/api/admin-draws.ts | 16 +++++ src/types/api/admin-integration-site.ts | 2 + 33 files changed, 359 insertions(+), 111 deletions(-) diff --git a/src/api/admin-agent-settlement.ts b/src/api/admin-agent-settlement.ts index 601333e..64b00ac 100644 --- a/src/api/admin-agent-settlement.ts +++ b/src/api/admin-agent-settlement.ts @@ -102,8 +102,10 @@ export async function postSettlementPeriod(body: { export type SettlementPeriodOpenHints = { suggested_start: string; suggested_end: string; + settlement_timezone: string; occupied_period_dates: string[]; pending_activity_dates: string[]; + orphan_activity_dates?: string[]; unpaid_bill_dates: string[]; }; diff --git a/src/components/admin/admin-date-range-field.tsx b/src/components/admin/admin-date-range-field.tsx index 7c8a939..2bcbce3 100644 --- a/src/components/admin/admin-date-range-field.tsx +++ b/src/components/admin/admin-date-range-field.tsx @@ -19,6 +19,8 @@ export type AdminDateRangeCalendarMarkers = { occupiedPeriod?: string[]; /** 本地日历 `yyyy-MM-dd` */ pendingActivity?: string[]; + /** 本地日历 `yyyy-MM-dd` — 已关账账期内仍有未归属流水 */ + orphanActivity?: string[]; /** 本地日历 `yyyy-MM-dd` — 已有账期内的未结清日 */ unpaidBill?: string[]; }; @@ -113,6 +115,13 @@ export function AdminDateRangeField({ .filter((d): d is Date => d instanceof Date), [calendarMarkers?.unpaidBill], ); + const orphanActivityDates = React.useMemo( + () => + (calendarMarkers?.orphanActivity ?? []) + .map((ymd) => parseYmd(ymd)) + .filter((d): d is Date => d instanceof Date), + [calendarMarkers?.orphanActivity], + ); return (
@@ -166,6 +175,7 @@ export function AdminDateRangeField({ modifiers={{ occupiedPeriod: occupiedPeriodDates, pendingActivity: pendingActivityDates, + orphanActivity: orphanActivityDates, unpaidBill: unpaidBillDates, }} modifiersClassNames={{ @@ -173,6 +183,8 @@ export function AdminDateRangeField({ "[&>button]:cursor-not-allowed [&>button]:bg-muted/80 [&>button]:text-muted-foreground [&>button]:line-through [&>button]:opacity-70", pendingActivity: "[&:not([data-disabled=true])>button]:relative [&:not([data-disabled=true])>button]:after:absolute [&:not([data-disabled=true])>button]:after:bottom-0.5 [&:not([data-disabled=true])>button]:after:left-1/2 [&:not([data-disabled=true])>button]:after:size-1 [&:not([data-disabled=true])>button]:after:-translate-x-1/2 [&:not([data-disabled=true])>button]:after:rounded-full [&:not([data-disabled=true])>button]:after:bg-amber-500 [&:not([data-disabled=true])>button]:after:content-['']", + orphanActivity: + "[&>button]:relative [&>button]:after:absolute [&>button]:after:bottom-0.5 [&>button]:after:left-1/2 [&>button]:after:size-1.5 [&>button]:after:-translate-x-1/2 [&>button]:after:rounded-full [&>button]:after:bg-violet-600 [&>button]:after:content-['']", unpaidBill: "[&>button]:relative [&>button]:before:absolute [&>button]:before:top-0.5 [&>button]:before:right-0.5 [&>button]:before:size-1.5 [&>button]:before:rounded-full [&>button]:before:bg-rose-500 [&>button]:before:content-['']", }} diff --git a/src/i18n/locales/en/agents.json b/src/i18n/locales/en/agents.json index 116cda1..16fb33c 100644 --- a/src/i18n/locales/en/agents.json +++ b/src/i18n/locales/en/agents.json @@ -295,11 +295,13 @@ "manageTitle": "Period management", "markerOccupied": "Occupied period", "markerPending": "Pending turnover", + "markerOrphan": "Closed-period orphan turnover", "markerUnpaid": "Unsettled period", "open": "Open period", - "openDesc": "Select the start and end dates; gray strikethrough dates are occupied, amber dots indicate pending turnover, red dots at top-right indicate unsettled periods.", + "openDesc": "Select the start and end dates; gray strikethrough dates are occupied, amber dots indicate pending turnover, violet dots indicate closed-period orphan turnover, red dots at top-right indicate unsettled periods.", "openFailed": "Failed to open period", "openHint": "Local dates ({{tz}})", + "timezoneHint": "Period dates are calculated in site timezone {{tz}}.", "openWithPreset": "Apply quick preset", "opened": "Period opened", "overlapsOccupied": "The selected range overlaps with existing periods. Please avoid dates with gray strikethrough lines.", diff --git a/src/i18n/locales/en/config.json b/src/i18n/locales/en/config.json index 111096c..70c76dc 100644 --- a/src/i18n/locales/en/config.json +++ b/src/i18n/locales/en/config.json @@ -131,6 +131,7 @@ "lotteryH5BaseUrl": "Lottery H5 base URL (optional)", "name": "Site name", "notes": "Notes", + "settlementTimezone": "Settlement timezone", "ssoSecret": "SSO secret", "status": "Status", "walletApiKey": "Wallet API key", @@ -161,6 +162,7 @@ "lotteryH5BaseUrl": "Enter H5 URL", "name": "Enter site name", "notes": "Enter notes", + "settlementTimezone": "Enter IANA timezone, for example Asia/Kathmandu", "walletApiUrl": "Enter wallet API URL" }, "rotateConfirm": "Rotate", diff --git a/src/i18n/locales/en/draws.json b/src/i18n/locales/en/draws.json index c2855e6..9e3cfca 100644 --- a/src/i18n/locales/en/draws.json +++ b/src/i18n/locales/en/draws.json @@ -20,6 +20,7 @@ "failed": "Create failed" }, "drawNo": "Draw no.", + "provider": "Provider", "status": "Status", "startTime": "Start time", "closeTime": "Close time", diff --git a/src/i18n/locales/en/settlementCenter.json b/src/i18n/locales/en/settlementCenter.json index 5fb0a9e..bf87aff 100644 --- a/src/i18n/locales/en/settlementCenter.json +++ b/src/i18n/locales/en/settlementCenter.json @@ -14,7 +14,7 @@ "closeDialogTitle": "Close period", "closeDialogDesc": "Summarize {{range}} and generate bills.", "closeDialogShare": "{{count}} ledger entries", - "closeDialogUnsettled": "{{count}} tickets still unsettled", + "closeDialogUnsettled": "{{count}} ticket(s) still unsettled. Finish draw settlement before closing.", "closeDialogIrreversible": "Cannot undo. Use adjustments or reversals to fix errors.", "closeDialogConfirm": "Close period", "closeDialogEmpty": "No share ledger this period; closing will not generate bills." diff --git a/src/i18n/locales/en/tickets.json b/src/i18n/locales/en/tickets.json index 92bb4ec..4600dd6 100644 --- a/src/i18n/locales/en/tickets.json +++ b/src/i18n/locales/en/tickets.json @@ -19,6 +19,8 @@ "player": "Player", "orderNo": "Order no.", "drawNo": "Draw no.", + "provider": "Provider", + "allProviders": "All providers", "playCode": "Play", "number": "Number", "betAmount": "Bet amount", diff --git a/src/i18n/locales/ne/agents.json b/src/i18n/locales/ne/agents.json index 65018ec..d69d4a9 100644 --- a/src/i18n/locales/ne/agents.json +++ b/src/i18n/locales/ne/agents.json @@ -311,11 +311,13 @@ "closed": "अवधि बन्द भयो, बिल सिर्जना भयो", "closeFailed": "अवधि बन्द गर्न असफल", "hintsFailed": "खोल्ने सुझाव लोड गर्न असफल", - "openDesc": "सुरु र अन्त्य मिति चयन गर्नुहोस्; खैरो स्ट्राइकथ्रु भएका मितिहरू अधिकृत अवधि हुन्, एम्बर बिन्दुहरू पेन्डिङ कारोबार हुन्, माथि-दायाँ रातो बिन्दुहरू नबुझिएको अवधि हुन्।", + "openDesc": "सुरु र अन्त्य मिति चयन गर्नुहोस्; खैरो स्ट्राइकथ्रु भएका मितिहरू अधिकृत अवधि हुन्, एम्बर बिन्दुहरू पेन्डिङ कारोबार हुन्, बैजनी बिन्दुहरू बन्द अवधिमा छुटेका कारोबार हुन्, माथि-दायाँ रातो बिन्दुहरू नबुझिएको अवधि हुन्।", + "timezoneHint": "अवधि मिति साइट समयक्षेत्र {{tz}} अनुसार गणना हुन्छ।", "rangeLabel": "अवधि दायरा", "rangeHint": "पहिले सुरु मिति चयन गर्नुहोस्, त्यसपछि अन्त्य मिति; एकल-दिन अवधिको लागि एउटै मिति दुई पटक क्लिक गर्नुहोस्।", "markerOccupied": "अधिकृत अवधि", "markerPending": "पेन्डिङ कारोबार", + "markerOrphan": "बन्द अवधिमा छुटेको कारोबार", "markerUnpaid": "नबुझिएको अवधि", "overlapsOccupied": "चयन गरिएको दायरा अवस्थित अवधिसँग ओभरल्याप हुन्छ। कृपया खैरो स्ट्राइकथ्रु भएका मितिहरू नछान्नुहोस्।" }, diff --git a/src/i18n/locales/ne/config.json b/src/i18n/locales/ne/config.json index fabb0ba..a50769d 100644 --- a/src/i18n/locales/ne/config.json +++ b/src/i18n/locales/ne/config.json @@ -131,6 +131,7 @@ "lotteryH5BaseUrl": "लटरी H5 आधार URL (वैकल्पिक)", "name": "साइट नाम", "notes": "टिप्पणी", + "settlementTimezone": "सेटलमेन्ट समयक्षेत्र", "ssoSecret": "SSO गोप्य", "status": "स्थिति", "walletApiKey": "वालेट API कुञ्जी", @@ -159,6 +160,7 @@ "lotteryH5BaseUrl": "H5 ठेगाना प्रविष्ट गर्नुहोस्", "name": "साइट नाम प्रविष्ट गर्नुहोस्", "notes": "टिप्पणी प्रविष्ट गर्नुहोस्", + "settlementTimezone": "IANA समयक्षेत्र प्रविष्ट गर्नुहोस्, जस्तै Asia/Kathmandu", "walletApiUrl": "वालेट API ठेगाना प्रविष्ट गर्नुहोस्" }, "rotateConfirm": "पुष्टि", diff --git a/src/i18n/locales/ne/draws.json b/src/i18n/locales/ne/draws.json index 4704bf8..787da23 100644 --- a/src/i18n/locales/ne/draws.json +++ b/src/i18n/locales/ne/draws.json @@ -100,6 +100,7 @@ "drawDetail": "ड्रअ विवरण", "drawNo": "ड्रअ नं.", "drawTime": "ड्रअ समय", + "provider": "Provider", "editDraw": { "action": "सम्पादन", "description": "ड्रअ {{drawNo}} · स्थानीय समयक्षेत्र {{tz}} मा सम्पादन", diff --git a/src/i18n/locales/ne/settlementCenter.json b/src/i18n/locales/ne/settlementCenter.json index be24b81..0eeba69 100644 --- a/src/i18n/locales/ne/settlementCenter.json +++ b/src/i18n/locales/ne/settlementCenter.json @@ -14,7 +14,7 @@ "closeDialogTitle": "अवधि बन्द गर्नुहोस्", "closeDialogDesc": "{{range}} भित्रको लेजर संक्षेप गरी बिल सिर्जना गर्नुहोस्।", "closeDialogShare": "{{count}} लेजर प्रविष्टिहरू", - "closeDialogUnsettled": "{{count}} टिकटहरू अझै नसेटल", + "closeDialogUnsettled": "{{count}} टिकटहरू अझै नसेटल छन्। बन्द गर्नु अघि ड्र सेटलमेन्ट पूरा गर्नुहोस्।", "closeDialogIrreversible": "पूर्ववत गर्न सकिँदैन। त्रुटि सच्याउन समायोजन वा रिभर्सल प्रयोग गर्नुहोस्।", "closeDialogConfirm": "अवधि बन्द गर्नुहोस्", "closeDialogEmpty": "यस अवधिमा शेयर लेजर छैन; बन्द गर्दा बिल सिर्जना हुँदैन।" diff --git a/src/i18n/locales/ne/tickets.json b/src/i18n/locales/ne/tickets.json index 194f0a9..6a48269 100644 --- a/src/i18n/locales/ne/tickets.json +++ b/src/i18n/locales/ne/tickets.json @@ -13,6 +13,8 @@ "detailLoadFailed": "टिकट विवरण लोड असफल", "detailTitle": "टिकट विवरण", "drawNo": "ड्रअ नं.", + "provider": "Provider", + "allProviders": "सबै provider", "drawNoOptional": "ड्रअ नम्बर (वैकल्पिक)", "drawNoPlaceholder": "जस्तै 20260520-001", "failReason": "असफल कारण", diff --git a/src/i18n/locales/zh/agents.json b/src/i18n/locales/zh/agents.json index d8fff97..e69e305 100644 --- a/src/i18n/locales/zh/agents.json +++ b/src/i18n/locales/zh/agents.json @@ -326,11 +326,13 @@ "closeFailed": "关账失败", "datesRequired": "请填写账期起止日期", "invalidRange": "结束日期不能早于开始日期", - "openDesc": "选择账期起止日期;灰色删除线为已有账期不可再开,琥珀点为待入账,右上角红点为未结清。", + "openDesc": "选择账期起止日期;灰色删除线为已有账期不可再开,琥珀点为待入账,紫点为已关账漏账,右上角红点为未结清。", + "timezoneHint": "账期日期按站点时区 {{tz}} 计算。", "rangeLabel": "账期范围", "rangeHint": "先选开始日期,再选结束日期;单日账期可点同一天两次。", "markerOccupied": "已有账期", "markerPending": "待入账流水", + "markerOrphan": "已关账漏账", "markerUnpaid": "未结清账期", "overlapsOccupied": "所选范围与已有账期重叠,请避开灰色删除线的日期。", "hintsFailed": "无法加载开账建议" diff --git a/src/i18n/locales/zh/config.json b/src/i18n/locales/zh/config.json index bc9b559..1ec901b 100644 --- a/src/i18n/locales/zh/config.json +++ b/src/i18n/locales/zh/config.json @@ -127,6 +127,7 @@ "adminPassword": "初始密码", "adminEmail": "邮箱(可选)", "currency": "默认币种", + "settlementTimezone": "结算时区", "status": "状态", "walletApiUrl": "主站钱包根 URL", "lotteryH5BaseUrl": "彩票 H5 基址(可选)", @@ -143,6 +144,7 @@ "adminPassword": "至少 6 位", "adminEmail": "请输入邮箱", "currency": "请输入币种代码,如 NPR", + "settlementTimezone": "例如 Asia/Kathmandu", "walletApiUrl": "请输入钱包接口地址", "lotteryH5BaseUrl": "请输入 H5 地址", "iframeOrigins": "请输入允许的来源地址,如 https://www.example.com", diff --git a/src/i18n/locales/zh/draws.json b/src/i18n/locales/zh/draws.json index 8054cec..47c5ceb 100644 --- a/src/i18n/locales/zh/draws.json +++ b/src/i18n/locales/zh/draws.json @@ -20,6 +20,7 @@ "failed": "创建失败" }, "drawNo": "期号", + "provider": "开注商", "status": "状态", "startTime": "开始时间", "closeTime": "封盘时间", diff --git a/src/i18n/locales/zh/settlementCenter.json b/src/i18n/locales/zh/settlementCenter.json index 0ceab9c..89aef6c 100644 --- a/src/i18n/locales/zh/settlementCenter.json +++ b/src/i18n/locales/zh/settlementCenter.json @@ -15,7 +15,7 @@ "closeDialogDesc": "将汇总 {{range}} 内的流水并生成账单。", "closeDialogShare": "流水 {{count}} 笔", "closeDialogEmpty": "本期暂无占成流水,关账后不会生成账单。", - "closeDialogUnsettled": "仍有 {{count}} 笔注单未结算", + "closeDialogUnsettled": "仍有 {{count}} 笔注单未结算,需先完成开奖结算后才能关账", "closeDialogIrreversible": "关账后不可撤销,差错请通过调账或冲正处理。", "closeDialogConfirm": "确认关账" }, @@ -52,6 +52,7 @@ "periods": "账期", "pendingWorkbench": "待处理", "allBills": "全部账单", + "billWorkbench": "账单处理", "bills": "账单", "operations": "收付与调账", "aria": "账期视图", diff --git a/src/i18n/locales/zh/tickets.json b/src/i18n/locales/zh/tickets.json index 2588531..2b223fa 100644 --- a/src/i18n/locales/zh/tickets.json +++ b/src/i18n/locales/zh/tickets.json @@ -19,6 +19,8 @@ "player": "玩家", "orderNo": "订单号", "drawNo": "期号", + "provider": "开注商", + "allProviders": "全部开注商", "playCode": "玩法", "number": "号码", "betAmount": "下注", diff --git a/src/lib/agent-settlement-period-range.ts b/src/lib/agent-settlement-period-range.ts index c483f9a..7c38792 100644 --- a/src/lib/agent-settlement-period-range.ts +++ b/src/lib/agent-settlement-period-range.ts @@ -73,7 +73,10 @@ export function localDateRangeToUtcPeriodBounds( } /** UTC 账期时刻 → 本地日历 `YYYY-MM-DD`(列表展示) */ -export function utcPeriodInstantToLocalYmd(iso: string | null | undefined): string { +export function utcPeriodInstantToLocalYmd( + iso: string | null | undefined, + timeZone?: string | null, +): string { if (iso == null || iso === "") { return "—"; } @@ -81,6 +84,24 @@ export function utcPeriodInstantToLocalYmd(iso: string | null | undefined): stri if (Number.isNaN(ms)) { return iso.slice(0, 10); } + if (timeZone && typeof Intl !== "undefined") { + try { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date(ms)); + const y = parts.find((part) => part.type === "year")?.value; + const m = parts.find((part) => part.type === "month")?.value; + const d = parts.find((part) => part.type === "day")?.value; + if (y && m && d) { + return `${y}-${m}-${d}`; + } + } catch { + // Fall back to browser-local formatting below. + } + } return toDateYmdValue(new Date(ms)); } @@ -107,9 +128,10 @@ export function utcStorageDateToLocalFormYmd(utcYmd: string): string { export function formatSettlementPeriodSpan( periodStart: string | undefined, periodEnd: string | undefined, + timeZone?: string | null, ): string { - const start = utcPeriodInstantToLocalYmd(periodStart); - const end = utcPeriodInstantToLocalYmd(periodEnd); + const start = utcPeriodInstantToLocalYmd(periodStart, timeZone); + const end = utcPeriodInstantToLocalYmd(periodEnd, timeZone); return `${start} ~ ${end}`; } diff --git a/src/modules/draws/draw-publish-dialog.tsx b/src/modules/draws/draw-publish-dialog.tsx index 13bd630..786f3e0 100644 --- a/src/modules/draws/draw-publish-dialog.tsx +++ b/src/modules/draws/draw-publish-dialog.tsx @@ -37,6 +37,7 @@ export function DrawPublishDialog({ {t("publishTitle")} {batch ? ` · v${batch.result_version}` : ""} + {batch?.provider_name || batch?.provider_code ? ` · ${batch.provider_name || batch.provider_code}` : ""}
@@ -58,4 +59,4 @@ export function DrawPublishDialog({ ); -} \ No newline at end of file +} diff --git a/src/modules/draws/draw-publish-panel.tsx b/src/modules/draws/draw-publish-panel.tsx index cd00b6b..4a79c25 100644 --- a/src/modules/draws/draw-publish-panel.tsx +++ b/src/modules/draws/draw-publish-panel.tsx @@ -82,6 +82,12 @@ export function DrawPublishPanel({ return (
+
+ {t("provider")}: + + {batch.provider_name || batch.provider_code || "—"} + +
@@ -144,4 +150,4 @@ export function DrawPublishPanel({ ); -} \ No newline at end of file +} diff --git a/src/modules/draws/draw-results-console.tsx b/src/modules/draws/draw-results-console.tsx index af3dcd3..68b1522 100644 --- a/src/modules/draws/draw-results-console.tsx +++ b/src/modules/draws/draw-results-console.tsx @@ -72,7 +72,7 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) { ) : ( published.map((batch) => ( - + )) )} @@ -85,7 +85,9 @@ function BatchTable({ batch }: { batch: AdminDrawBatchRow }) { return ( - {t("version", { version: batch.result_version })} + + {batch.provider_name || batch.provider_code || t("provider")} · {t("version", { version: batch.result_version })} +

{formatDt(batch.confirmed_at)}

diff --git a/src/modules/draws/draw-review-console.tsx b/src/modules/draws/draw-review-console.tsx index e364f99..f3eef6e 100644 --- a/src/modules/draws/draw-review-console.tsx +++ b/src/modules/draws/draw-review-console.tsx @@ -12,9 +12,17 @@ import { getAdminDrawResultBatches, postAdminCreateManualResultBatch, } from "@/api/admin-draws"; +import { getAdminBetProviders } from "@/api/admin-bet-providers"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { @@ -31,6 +39,7 @@ import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws"; +import type { AdminBetProviderRow } from "@/types/api/admin-bet-provider"; import { DrawPublishDialog } from "@/modules/draws/draw-publish-dialog"; import { useDrawDetail } from "@/modules/draws/draw-detail-context"; @@ -69,6 +78,9 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [savingManual, setSavingManual] = useState(false); + const [providersLoading, setProvidersLoading] = useState(false); + const [betProviders, setBetProviders] = useState([]); + const [manualProviderCode, setManualProviderCode] = useState(""); const [discardingBatchId, setDiscardingBatchId] = useState(null); const [manualOpen, setManualOpen] = useState(false); const [publishBatch, setPublishBatch] = useState(null); @@ -97,6 +109,19 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl void load(); }, [load]); + useAsyncEffect(async () => { + setProvidersLoading(true); + try { + const res = await getAdminBetProviders(); + setBetProviders(res.items); + setManualProviderCode((current) => current || res.items.find((item) => item.is_default)?.code || res.items[0]?.code || ""); + } catch { + setBetProviders([]); + } finally { + setProvidersLoading(false); + } + }, []); + const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [data]); function fillRandomManualNumbers(): void { @@ -129,6 +154,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl setSavingManual(true); try { const res = await postAdminCreateManualResultBatch(idNum, { + provider_code: manualProviderCode || undefined, items: RESULT_SLOTS.map((slot, i) => ({ prize_type: slot.prize_type, prize_index: slot.prize_index, @@ -177,6 +203,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl {t("version", { version: "" }).replace(" v", "").trim()} + {t("provider")} {t("numberCount")} {t("table.actions", { ns: "common" })} @@ -185,6 +212,9 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl {pending.map((b) => ( v{b.result_version} + + {b.provider_name || b.provider_code || "—"} + {b.items.length} {canManageDraw ? ( @@ -241,6 +271,28 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl {manualOpen ? (
+
+ {t("provider")} + +
{RESULT_SLOTS.map((slot, i) => (
); -} \ No newline at end of file +} diff --git a/src/modules/integration/integration-sites-console.tsx b/src/modules/integration/integration-sites-console.tsx index 55e1954..e6228be 100644 --- a/src/modules/integration/integration-sites-console.tsx +++ b/src/modules/integration/integration-sites-console.tsx @@ -120,6 +120,7 @@ type FormState = { admin_password: string; admin_email: string; currency_code: string; + settlement_timezone: string; status: number; wallet_api_url: string; wallet_debit_path: string; @@ -139,6 +140,7 @@ const EMPTY_FORM: FormState = { admin_password: "", admin_email: "", currency_code: "NPR", + settlement_timezone: "UTC", status: 1, wallet_api_url: "", wallet_debit_path: "/wallet/debit-for-lottery", @@ -170,6 +172,7 @@ function rowToForm(row: AdminIntegrationSiteDetail): FormState { admin_password: "", admin_email: "", currency_code: row.currency_code, + settlement_timezone: row.settlement_timezone || "UTC", status: row.status, wallet_api_url: row.wallet_api_url ?? "", wallet_debit_path: row.wallet_debit_path ?? "/wallet/debit-for-lottery", @@ -191,6 +194,7 @@ function formToPayload( const base = { name: form.name.trim(), currency_code: form.currency_code.trim() || "NPR", + settlement_timezone: form.settlement_timezone.trim() || "UTC", status: form.status, wallet_api_url: form.wallet_api_url.trim() || null, wallet_debit_path: form.wallet_debit_path.trim(), @@ -795,7 +799,7 @@ export function IntegrationSitesConsole({
) : null} -
+
updateForm("currency_code", e.target.value)} />
+
+ + updateForm("settlement_timezone", e.target.value)} + /> +
+ onValueChange={(v) => { + const next = (v ?? "all") as BillTypeFilter; setDraft((d) => ({ ...d, - billType: (v ?? "all") as BillTypeFilter, - })) - } + billType: next, + })); + setApplied((d) => ({ + ...d, + billType: next, + })); + setPage(1); + }} > {() => billTypeLabel(draft.billType)} @@ -378,6 +407,38 @@ export function SettlementMainPanel({
) : null} +
+ + +
- @@ -220,4 +186,4 @@ export function SettlementPeriodHeader({ ); -} \ No newline at end of file +} diff --git a/src/modules/settlement/settlement-period-workbench.tsx b/src/modules/settlement/settlement-period-workbench.tsx index 716c07f..c180b7f 100644 --- a/src/modules/settlement/settlement-period-workbench.tsx +++ b/src/modules/settlement/settlement-period-workbench.tsx @@ -37,10 +37,7 @@ import { import { formatSettlementPeriodSpan, isSettlementLocalDateRangeValid, - localDateRangeToUtcPeriodBounds, settlementRangeOverlapsOccupiedDates, - utcStorageDateToLocalFormYmd, - utcStorageDatesToLocalMarks, } from "@/lib/agent-settlement-period-range"; import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -59,6 +56,7 @@ type SettlementPeriodWorkbenchProps = { onReloadPeriods: () => Promise; onPeriodOpened?: (periodId: number) => void; onPeriodClosed?: (result: SettlementPeriodCloseResult) => void; + settlementTimezone?: string; }; export function SettlementPeriodWorkbench({ @@ -71,6 +69,7 @@ export function SettlementPeriodWorkbench({ onReloadPeriods, onPeriodOpened, onPeriodClosed, + settlementTimezone = "UTC", }: SettlementPeriodWorkbenchProps): React.ReactElement { const { t } = useTranslation(["settlementCenter", "agents", "common"]); const [draftStatus, setDraftStatus] = useState("all"); @@ -122,14 +121,15 @@ export function SettlementPeriodWorkbench({ return undefined; } return { - occupiedPeriod: utcStorageDatesToLocalMarks(openHints.occupied_period_dates), - pendingActivity: utcStorageDatesToLocalMarks(openHints.pending_activity_dates), - unpaidBill: utcStorageDatesToLocalMarks(openHints.unpaid_bill_dates), + occupiedPeriod: openHints.occupied_period_dates, + pendingActivity: openHints.pending_activity_dates, + orphanActivity: openHints.orphan_activity_dates ?? [], + unpaidBill: openHints.unpaid_bill_dates, }; }, [openHints]); const occupiedLocalDates = useMemo( - () => utcStorageDatesToLocalMarks(openHints?.occupied_period_dates ?? []), + () => openHints?.occupied_period_dates ?? [], [openHints], ); @@ -171,13 +171,12 @@ export function SettlementPeriodWorkbench({ ); return; } - const bounds = localDateRangeToUtcPeriodBounds(startYmd, endYmd); setBusy(true); try { const row = await postSettlementPeriod({ admin_site_id: adminSiteId, - period_start: bounds.period_start, - period_end: bounds.period_end, + period_start: startYmd, + period_end: endYmd, }); await onReloadPeriods(); onPeriodOpened?.(row.id); @@ -213,9 +212,9 @@ export function SettlementPeriodWorkbench({ setCustomStart(""); setCustomEnd(""); if (hints.suggested_start && hints.suggested_end) { - const from = utcStorageDateToLocalFormYmd(hints.suggested_start); - const to = utcStorageDateToLocalFormYmd(hints.suggested_end); - const occupied = utcStorageDatesToLocalMarks(hints.occupied_period_dates); + const from = hints.suggested_start; + const to = hints.suggested_end; + const occupied = hints.occupied_period_dates; if (!settlementRangeOverlapsOccupiedDates(from, to, occupied)) { setCustomStart(from); setCustomEnd(to); @@ -423,6 +422,7 @@ export function SettlementPeriodWorkbench({ canManage={canManage} busy={busy} currencyCode={currencyCode} + settlementTimezone={settlementTimezone} emptyMessage={tableEmptyMessage} onViewDetail={onViewDetail} onRequestClose={requestClose} @@ -450,11 +450,17 @@ export function SettlementPeriodWorkbench({ {t("agents:settlementPeriods.openDesc", { defaultValue: - "选择账期起止日期;灰色删除线为已有账期不可再开,琥珀点为待入账,右上角红点为未结清。", + "选择账期起止日期;灰色删除线为已有账期不可再开,琥珀点为待入账,紫点为已关账漏账,右上角红点为未结清。", })}
+

+ {t("agents:settlementPeriods.timezoneHint", { + defaultValue: "账期日期按站点时区 {{tz}} 计算。", + tz: openHints?.settlement_timezone ?? settlementTimezone, + })} +

{t("agents:settlementPeriods.markerPending", { defaultValue: "待入账流水" })} + + + {t("agents:settlementPeriods.markerOrphan", { defaultValue: "已关账漏账" })} + setCloseDialogOpen(false)}> {t("common:cancel", { defaultValue: "取消" })} - diff --git a/src/modules/settlement/settlement-periods-table.tsx b/src/modules/settlement/settlement-periods-table.tsx index 6997211..aed1b3e 100644 --- a/src/modules/settlement/settlement-periods-table.tsx +++ b/src/modules/settlement/settlement-periods-table.tsx @@ -30,6 +30,7 @@ type SettlementPeriodsTableProps = { canManage: boolean; busy?: boolean; currencyCode?: string; + settlementTimezone?: string; emptyMessage?: string; onViewDetail: (periodId: number) => void; onRequestClose: (row: SettlementPeriodRow) => void; @@ -41,6 +42,7 @@ export function SettlementPeriodsTable({ canManage, busy = false, currencyCode = "NPR", + settlementTimezone = "UTC", emptyMessage, onViewDetail, onRequestClose, @@ -105,7 +107,7 @@ export function SettlementPeriodsTable({ return ( - {formatSettlementPeriodSpan(row.period_start, row.period_end)} + {formatSettlementPeriodSpan(row.period_start, row.period_end, settlementTimezone)} diff --git a/src/modules/tickets/player-tickets-console.tsx b/src/modules/tickets/player-tickets-console.tsx index a7e3aa1..5c5d12d 100644 --- a/src/modules/tickets/player-tickets-console.tsx +++ b/src/modules/tickets/player-tickets-console.tsx @@ -8,6 +8,7 @@ import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useTranslationRef } from "@/hooks/use-translation-ref"; import { getAdminTicketItems } from "@/api/admin-tickets"; +import { getAdminBetProviders } from "@/api/admin-bet-providers"; import { AdminDateRangeField } from "@/components/admin/admin-date-range-field"; import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; @@ -26,6 +27,13 @@ import { } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { Table, @@ -41,6 +49,7 @@ import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges import { adminPlayerDetailPath } from "@/lib/admin-player-paths"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminTicketItemsData } from "@/types/api/admin-tickets"; +import type { AdminBetProviderRow } from "@/types/api/admin-bet-provider"; import { ChevronDown, Eye } from "lucide-react"; import Link from "next/link"; @@ -78,6 +87,8 @@ const emptyTicketFilters: TicketFilters = { statuses: [], }; +const ALL_PROVIDERS_VALUE = "__all__"; + type TicketTranslateFn = ( key: string, options?: { count?: number }, @@ -138,16 +149,20 @@ export function PlayerTicketsConsole(): React.ReactElement { const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim(); const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim(); const numberKeywordFromUrl = (searchParams.get("number") ?? "").trim(); + const providerCodeFromUrl = (searchParams.get("provider_code") ?? "").trim().toUpperCase(); const initialFilters: TicketFilters = { ...emptyTicketFilters, playerQuery: playerIdFromUrl, drawNo: drawNoFromUrl, + providerCode: providerCodeFromUrl, numberKeyword: numberKeywordFromUrl, }; const [draft, setDraft] = useState(initialFilters); const [applied, setApplied] = useState(initialFilters); const [data, setData] = useState(null); + const [betProviders, setBetProviders] = useState([]); const [loading, setLoading] = useState(false); + const [providersLoading, setProvidersLoading] = useState(false); const [err, setErr] = useState(null); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); @@ -189,6 +204,18 @@ export function PlayerTicketsConsole(): React.ReactElement { void load(); }, [applied, page, perPage]); + useAsyncEffect(async () => { + setProvidersLoading(true); + try { + const d = await getAdminBetProviders(); + setBetProviders(d.items); + } catch { + setBetProviders([]); + } finally { + setProvidersLoading(false); + } + }, []); + const runSearch = () => { setErr(null); setApplied({ @@ -271,21 +298,35 @@ export function PlayerTicketsConsole(): React.ReactElement { }} /> - - - setDraft((current) => ({ ...current, providerCode: e.target.value.toUpperCase() })) - } - onKeyDown={(e) => { - if (e.key === "Enter") { - runSearch(); - } + + ; }; diff --git a/src/types/api/admin-integration-site.ts b/src/types/api/admin-integration-site.ts index 6c9bee4..73649f8 100644 --- a/src/types/api/admin-integration-site.ts +++ b/src/types/api/admin-integration-site.ts @@ -4,6 +4,7 @@ export type AdminIntegrationSiteRow = { name: string; has_line_root: boolean; currency_code: string; + settlement_timezone: string; status: number; wallet_api_url: string | null; lottery_h5_base_url: string | null; @@ -48,6 +49,7 @@ export type AdminIntegrationSiteCreatePayload = { name: string; admin_account: AdminIntegrationSiteAdminAccountPayload; currency_code?: string; + settlement_timezone?: string; status?: number; wallet_api_url?: string | null; wallet_debit_path?: string;