feat(settlement): 支持结算时区与已关账漏账流水显示
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
- 為結算期間相關接口與前端組件新增 settlement_timezone 字段 - 在账期范围格式化函数中支持传入时区参数显示当地日期 - 在开账面板和账期视图中增加漏账流水(orphanActivity)高亮显示 - 统一调整账期视图默认子页面为“账单处理”而非“待处理” - 在开奖管理中添加开奖提供商(provider)信息展示和选择功能 - 结算中心增加账单状态筛选项支持待确认、待收付、已结清、调账/冲正 - 移除部分账期页状态筛选标签,改用文本方式显示待确认与待收付数量 - 集成站点管理中新增结算时区字段,支持编辑与保存 - 多语言新增与更新与结算时区和漏账流水相关的文案提示及翻译
This commit is contained in:
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid gap-1.5">
|
||||
@@ -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-['']",
|
||||
}}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"failed": "Create failed"
|
||||
},
|
||||
"drawNo": "Draw no.",
|
||||
"provider": "Provider",
|
||||
"status": "Status",
|
||||
"startTime": "Start time",
|
||||
"closeTime": "Close time",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"player": "Player",
|
||||
"orderNo": "Order no.",
|
||||
"drawNo": "Draw no.",
|
||||
"provider": "Provider",
|
||||
"allProviders": "All providers",
|
||||
"playCode": "Play",
|
||||
"number": "Number",
|
||||
"betAmount": "Bet amount",
|
||||
|
||||
@@ -311,11 +311,13 @@
|
||||
"closed": "अवधि बन्द भयो, बिल सिर्जना भयो",
|
||||
"closeFailed": "अवधि बन्द गर्न असफल",
|
||||
"hintsFailed": "खोल्ने सुझाव लोड गर्न असफल",
|
||||
"openDesc": "सुरु र अन्त्य मिति चयन गर्नुहोस्; खैरो स्ट्राइकथ्रु भएका मितिहरू अधिकृत अवधि हुन्, एम्बर बिन्दुहरू पेन्डिङ कारोबार हुन्, माथि-दायाँ रातो बिन्दुहरू नबुझिएको अवधि हुन्।",
|
||||
"openDesc": "सुरु र अन्त्य मिति चयन गर्नुहोस्; खैरो स्ट्राइकथ्रु भएका मितिहरू अधिकृत अवधि हुन्, एम्बर बिन्दुहरू पेन्डिङ कारोबार हुन्, बैजनी बिन्दुहरू बन्द अवधिमा छुटेका कारोबार हुन्, माथि-दायाँ रातो बिन्दुहरू नबुझिएको अवधि हुन्।",
|
||||
"timezoneHint": "अवधि मिति साइट समयक्षेत्र {{tz}} अनुसार गणना हुन्छ।",
|
||||
"rangeLabel": "अवधि दायरा",
|
||||
"rangeHint": "पहिले सुरु मिति चयन गर्नुहोस्, त्यसपछि अन्त्य मिति; एकल-दिन अवधिको लागि एउटै मिति दुई पटक क्लिक गर्नुहोस्।",
|
||||
"markerOccupied": "अधिकृत अवधि",
|
||||
"markerPending": "पेन्डिङ कारोबार",
|
||||
"markerOrphan": "बन्द अवधिमा छुटेको कारोबार",
|
||||
"markerUnpaid": "नबुझिएको अवधि",
|
||||
"overlapsOccupied": "चयन गरिएको दायरा अवस्थित अवधिसँग ओभरल्याप हुन्छ। कृपया खैरो स्ट्राइकथ्रु भएका मितिहरू नछान्नुहोस्।"
|
||||
},
|
||||
|
||||
@@ -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": "पुष्टि",
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"drawDetail": "ड्रअ विवरण",
|
||||
"drawNo": "ड्रअ नं.",
|
||||
"drawTime": "ड्रअ समय",
|
||||
"provider": "Provider",
|
||||
"editDraw": {
|
||||
"action": "सम्पादन",
|
||||
"description": "ड्रअ {{drawNo}} · स्थानीय समयक्षेत्र {{tz}} मा सम्पादन",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"closeDialogTitle": "अवधि बन्द गर्नुहोस्",
|
||||
"closeDialogDesc": "{{range}} भित्रको लेजर संक्षेप गरी बिल सिर्जना गर्नुहोस्।",
|
||||
"closeDialogShare": "{{count}} लेजर प्रविष्टिहरू",
|
||||
"closeDialogUnsettled": "{{count}} टिकटहरू अझै नसेटल",
|
||||
"closeDialogUnsettled": "{{count}} टिकटहरू अझै नसेटल छन्। बन्द गर्नु अघि ड्र सेटलमेन्ट पूरा गर्नुहोस्।",
|
||||
"closeDialogIrreversible": "पूर्ववत गर्न सकिँदैन। त्रुटि सच्याउन समायोजन वा रिभर्सल प्रयोग गर्नुहोस्।",
|
||||
"closeDialogConfirm": "अवधि बन्द गर्नुहोस्",
|
||||
"closeDialogEmpty": "यस अवधिमा शेयर लेजर छैन; बन्द गर्दा बिल सिर्जना हुँदैन।"
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"detailLoadFailed": "टिकट विवरण लोड असफल",
|
||||
"detailTitle": "टिकट विवरण",
|
||||
"drawNo": "ड्रअ नं.",
|
||||
"provider": "Provider",
|
||||
"allProviders": "सबै provider",
|
||||
"drawNoOptional": "ड्रअ नम्बर (वैकल्पिक)",
|
||||
"drawNoPlaceholder": "जस्तै 20260520-001",
|
||||
"failReason": "असफल कारण",
|
||||
|
||||
@@ -326,11 +326,13 @@
|
||||
"closeFailed": "关账失败",
|
||||
"datesRequired": "请填写账期起止日期",
|
||||
"invalidRange": "结束日期不能早于开始日期",
|
||||
"openDesc": "选择账期起止日期;灰色删除线为已有账期不可再开,琥珀点为待入账,右上角红点为未结清。",
|
||||
"openDesc": "选择账期起止日期;灰色删除线为已有账期不可再开,琥珀点为待入账,紫点为已关账漏账,右上角红点为未结清。",
|
||||
"timezoneHint": "账期日期按站点时区 {{tz}} 计算。",
|
||||
"rangeLabel": "账期范围",
|
||||
"rangeHint": "先选开始日期,再选结束日期;单日账期可点同一天两次。",
|
||||
"markerOccupied": "已有账期",
|
||||
"markerPending": "待入账流水",
|
||||
"markerOrphan": "已关账漏账",
|
||||
"markerUnpaid": "未结清账期",
|
||||
"overlapsOccupied": "所选范围与已有账期重叠,请避开灰色删除线的日期。",
|
||||
"hintsFailed": "无法加载开账建议"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"failed": "创建失败"
|
||||
},
|
||||
"drawNo": "期号",
|
||||
"provider": "开注商",
|
||||
"status": "状态",
|
||||
"startTime": "开始时间",
|
||||
"closeTime": "封盘时间",
|
||||
|
||||
@@ -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": "账期视图",
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"player": "玩家",
|
||||
"orderNo": "订单号",
|
||||
"drawNo": "期号",
|
||||
"provider": "开注商",
|
||||
"allProviders": "全部开注商",
|
||||
"playCode": "玩法",
|
||||
"number": "号码",
|
||||
"betAmount": "下注",
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export function DrawPublishDialog({
|
||||
<DialogTitle className="text-base">
|
||||
{t("publishTitle")}
|
||||
{batch ? ` · v${batch.result_version}` : ""}
|
||||
{batch?.provider_name || batch?.provider_code ? ` · ${batch.provider_name || batch.provider_code}` : ""}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
@@ -58,4 +59,4 @@ export function DrawPublishDialog({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,12 @@ export function DrawPublishPanel({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="admin-table-shell overflow-x-auto rounded-md border">
|
||||
<div className="border-b px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("provider")}:
|
||||
<span className="font-mono text-foreground">
|
||||
{batch.provider_name || batch.provider_code || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -144,4 +150,4 @@ export function DrawPublishPanel({
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
|
||||
<AdminNoResourceState message={t("noPublishedBatch")} />
|
||||
) : (
|
||||
published.map((batch) => (
|
||||
<BatchTable key={batch.id} batch={batch} />
|
||||
<BatchTable key={`${batch.provider_code ?? "GLOBAL"}-${batch.id}`} batch={batch} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -85,7 +85,9 @@ function BatchTable({ batch }: { batch: AdminDrawBatchRow }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm">{t("version", { version: batch.result_version })}</CardTitle>
|
||||
<CardTitle className="text-sm">
|
||||
{batch.provider_name || batch.provider_code || t("provider")} · {t("version", { version: batch.result_version })}
|
||||
</CardTitle>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{formatDt(batch.confirmed_at)}
|
||||
</p>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingManual, setSavingManual] = useState(false);
|
||||
const [providersLoading, setProvidersLoading] = useState(false);
|
||||
const [betProviders, setBetProviders] = useState<AdminBetProviderRow[]>([]);
|
||||
const [manualProviderCode, setManualProviderCode] = useState("");
|
||||
const [discardingBatchId, setDiscardingBatchId] = useState<number | null>(null);
|
||||
const [manualOpen, setManualOpen] = useState(false);
|
||||
const [publishBatch, setPublishBatch] = useState<AdminDrawBatchRow | null>(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
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
|
||||
<TableHead>{t("provider")}</TableHead>
|
||||
<TableHead>{t("numberCount")}</TableHead>
|
||||
<TableHead className="w-14 text-center">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
@@ -185,6 +212,9 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl
|
||||
{pending.map((b) => (
|
||||
<TableRow key={b.id}>
|
||||
<TableCell>v{b.result_version}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{b.provider_name || b.provider_code || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{b.items.length}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{canManageDraw ? (
|
||||
@@ -241,6 +271,28 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl
|
||||
</button>
|
||||
{manualOpen ? (
|
||||
<div className="space-y-3 border-t border-border/60 px-3 py-3">
|
||||
<div className="max-w-xs space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">{t("provider")}</span>
|
||||
<Select
|
||||
modal={false}
|
||||
value={manualProviderCode}
|
||||
disabled={savingManual || providersLoading || betProviders.length === 0}
|
||||
onValueChange={(value) => setManualProviderCode(String(value ?? ""))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full">
|
||||
<SelectValue>
|
||||
{betProviders.find((item) => item.code === manualProviderCode)?.name ?? manualProviderCode}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
{betProviders.map((provider) => (
|
||||
<SelectItem key={provider.code} value={provider.code}>
|
||||
{provider.name} ({provider.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{RESULT_SLOTS.map((slot, i) => (
|
||||
<label key={`${slot.prize_type}-${slot.prize_index}`} className="space-y-1">
|
||||
@@ -288,7 +340,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={savingManual || !["closed", "review"].includes(data.draw_status)}
|
||||
disabled={savingManual || !manualProviderCode || !["closed", "review"].includes(data.draw_status)}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.saveManualDraftTitle"),
|
||||
@@ -320,4 +372,4 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-currency">{t("integrationSites.fields.currency")}</Label>
|
||||
<Input
|
||||
@@ -805,6 +809,21 @@ export function IntegrationSitesConsole({
|
||||
onChange={(e) => updateForm("currency_code", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-settlement-timezone">
|
||||
{t("integrationSites.fields.settlementTimezone", {
|
||||
defaultValue: "结算时区",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="is-settlement-timezone"
|
||||
value={form.settlement_timezone}
|
||||
placeholder={t("integrationSites.placeholders.settlementTimezone", {
|
||||
defaultValue: "例如 Asia/Kathmandu",
|
||||
})}
|
||||
onChange={(e) => updateForm("settlement_timezone", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-status">{t("integrationSites.fields.status")}</Label>
|
||||
<select
|
||||
|
||||
@@ -44,7 +44,7 @@ export function settlementCenterScopeHref(
|
||||
|
||||
export function settlementPeriodViewHref(
|
||||
periodId: number,
|
||||
view: SettlementPeriodView = "pending",
|
||||
view: SettlementPeriodView = "bills",
|
||||
adminSiteId?: number | null,
|
||||
billScope?: SettlementBillScopeFilter | null,
|
||||
): string {
|
||||
@@ -88,11 +88,11 @@ export function parseSettlementCenterView(
|
||||
billScope: SettlementBillScopeFilter;
|
||||
} {
|
||||
const normalizedView =
|
||||
viewRaw === "reports" || viewRaw === "operations" ? "bills" : viewRaw;
|
||||
viewRaw === "reports" || viewRaw === "operations" || viewRaw === "pending" ? "bills" : viewRaw;
|
||||
const view =
|
||||
normalizedView !== null && VALID_VIEWS.includes(normalizedView as SettlementPeriodView)
|
||||
? (normalizedView as SettlementPeriodView)
|
||||
: "pending";
|
||||
: "bills";
|
||||
|
||||
const billScope =
|
||||
scopeRaw !== null &&
|
||||
|
||||
@@ -19,6 +19,7 @@ type SettlementCenterPeriodDetailProps = {
|
||||
billScope: SettlementBillScopeFilter;
|
||||
adminSiteId: number;
|
||||
currencyCode: string;
|
||||
settlementTimezone: string;
|
||||
canOperateBills: boolean;
|
||||
canManagePeriods: boolean;
|
||||
boundAgentId?: number | null;
|
||||
@@ -34,6 +35,7 @@ export function SettlementCenterPeriodDetail({
|
||||
billScope,
|
||||
adminSiteId,
|
||||
currencyCode,
|
||||
settlementTimezone,
|
||||
canOperateBills,
|
||||
canManagePeriods,
|
||||
boundAgentId = null,
|
||||
@@ -45,8 +47,7 @@ export function SettlementCenterPeriodDetail({
|
||||
const { t } = useTranslation("settlementCenter");
|
||||
|
||||
const subViews: { key: SettlementPeriodView; label: string }[] = [
|
||||
{ key: "pending", label: t("nav.pendingWorkbench", { defaultValue: "待处理" }) },
|
||||
{ key: "bills", label: t("nav.allBills", { defaultValue: "全部账单" }) },
|
||||
{ key: "bills", label: t("nav.billWorkbench", { defaultValue: "账单处理" }) },
|
||||
{ key: "ledger", label: t("nav.ledgerAudit", { defaultValue: "流水审计" }) },
|
||||
];
|
||||
|
||||
@@ -56,7 +57,7 @@ export function SettlementCenterPeriodDetail({
|
||||
period={period}
|
||||
adminSiteId={adminSiteId}
|
||||
currencyCode={currencyCode}
|
||||
billScope={billScope}
|
||||
settlementTimezone={settlementTimezone}
|
||||
canManage={canManagePeriods}
|
||||
onPeriodClosed={onPeriodClosed}
|
||||
onReloadPeriods={onReloadPeriods}
|
||||
@@ -80,7 +81,6 @@ export function SettlementCenterPeriodDetail({
|
||||
adminSiteId={adminSiteId}
|
||||
currencyCode={currencyCode}
|
||||
periodFilter={period.id}
|
||||
mode="workbench"
|
||||
onOpenBillDetail={onOpenBillDetail}
|
||||
refreshKey={refreshKey}
|
||||
canOperateBills={canOperateBills}
|
||||
|
||||
@@ -41,7 +41,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
type SiteOption = { id: number; label: string; code: string; currency_code: string };
|
||||
type SiteOption = { id: number; label: string; code: string; currency_code: string; settlement_timezone: string };
|
||||
|
||||
function siteOptionsFromProfile(profile: ReturnType<typeof useAdminProfile>): SiteOption[] {
|
||||
const byId = new Map<number, SiteOption>();
|
||||
@@ -52,6 +52,7 @@ function siteOptionsFromProfile(profile: ReturnType<typeof useAdminProfile>): Si
|
||||
label: formatAdminSiteLabel(profile.site.name, profile.site.code),
|
||||
code: profile.site.code,
|
||||
currency_code: "NPR",
|
||||
settlement_timezone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,6 +62,7 @@ function siteOptionsFromProfile(profile: ReturnType<typeof useAdminProfile>): Si
|
||||
label: formatAdminSiteLabel(site.name, site.code),
|
||||
code: site.code,
|
||||
currency_code: "NPR",
|
||||
settlement_timezone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,6 +137,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
label,
|
||||
code: siteCode,
|
||||
currency_code: match?.currency_code ?? "NPR",
|
||||
settlement_timezone: match?.settlement_timezone ?? "UTC",
|
||||
}]);
|
||||
setAdminSiteId(siteId);
|
||||
} catch {
|
||||
@@ -143,6 +146,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
label,
|
||||
code: siteCode,
|
||||
currency_code: "NPR",
|
||||
settlement_timezone: "UTC",
|
||||
}]);
|
||||
setAdminSiteId(siteId);
|
||||
} finally {
|
||||
@@ -177,6 +181,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
label: formatAdminSiteLabel(site.name, site.code),
|
||||
code: site.code,
|
||||
currency_code: site.currency_code ?? "NPR",
|
||||
settlement_timezone: site.settlement_timezone ?? "UTC",
|
||||
}));
|
||||
setSiteOptions(options);
|
||||
setAdminSiteId((current) => {
|
||||
@@ -206,6 +211,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
const selectedSite = siteOptions.find((s) => s.id === siteId) ?? null;
|
||||
const siteLabel = selectedSite?.label ?? null;
|
||||
const currency = siteOptions.find((s) => s.id === siteId)?.currency_code ?? "NPR";
|
||||
const settlementTimezone = siteOptions.find((s) => s.id === siteId)?.settlement_timezone ?? "UTC";
|
||||
const filteredSites = siteKeyword.trim().toLowerCase()
|
||||
? siteOptions.filter((site) => site.label.toLowerCase().includes(siteKeyword.trim().toLowerCase()))
|
||||
: siteOptions;
|
||||
@@ -325,7 +331,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
const activePeriod =
|
||||
activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null;
|
||||
|
||||
const openPeriodView = (periodId: number, view: SettlementPeriodView = "pending"): void => {
|
||||
const openPeriodView = (periodId: number, view: SettlementPeriodView = "bills"): void => {
|
||||
const period = periods.find((row) => row.id === periodId);
|
||||
const scope = preferredBillScopeForPeriod(period?.summary);
|
||||
router.push(settlementPeriodViewHref(periodId, view, siteId, scope));
|
||||
@@ -432,8 +438,9 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
canManage={canManagePeriods}
|
||||
periods={periods}
|
||||
headerActions={headerActions}
|
||||
onViewDetail={(id) => openPeriodView(id, "pending")}
|
||||
onViewDetail={(id) => openPeriodView(id, "bills")}
|
||||
onReloadPeriods={loadPeriods}
|
||||
settlementTimezone={settlementTimezone}
|
||||
onPeriodOpened={() => {
|
||||
setRefreshKey((n) => n + 1);
|
||||
}}
|
||||
@@ -473,6 +480,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
billScope={activeBillScope}
|
||||
adminSiteId={siteId}
|
||||
currencyCode={currency}
|
||||
settlementTimezone={settlementTimezone}
|
||||
canOperateBills={canOperateBills}
|
||||
canManagePeriods={canManagePeriods}
|
||||
boundAgentId={boundAgent?.id ?? null}
|
||||
|
||||
@@ -198,6 +198,29 @@ export function SettlementMainPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const statusScopeOptions: SettlementBillScopeFilter[] = [
|
||||
"all",
|
||||
"pending_confirm",
|
||||
"awaiting_payment",
|
||||
"settled",
|
||||
"adjustment",
|
||||
];
|
||||
|
||||
const statusScopeLabel = (value: SettlementBillScopeFilter): string => {
|
||||
switch (value) {
|
||||
case "pending_confirm":
|
||||
return t("billsPanel.category.pendingConfirm", { defaultValue: "待确认" });
|
||||
case "awaiting_payment":
|
||||
return t("billsPanel.category.awaitingPayment", { defaultValue: "待收付" });
|
||||
case "settled":
|
||||
return t("billStatus.settled", { defaultValue: "已结清" });
|
||||
case "adjustment":
|
||||
return t("billsPanel.filterAdjustment", { defaultValue: "调账 / 冲正" });
|
||||
default:
|
||||
return t("billsPanel.filterAll", { defaultValue: "全部状态" });
|
||||
}
|
||||
};
|
||||
|
||||
const requestConfirmBill = (row: SettlementBillRow): void => {
|
||||
requestConfirm({
|
||||
title: t("billsPanel.confirmOneTitle", { defaultValue: "确认账单 #{{id}}?", id: row.id }),
|
||||
@@ -358,12 +381,18 @@ export function SettlementMainPanel({
|
||||
<Select
|
||||
modal={false}
|
||||
value={draft.billType}
|
||||
onValueChange={(v) =>
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="sb-type" className="h-9 w-full sm:w-36">
|
||||
<SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue>
|
||||
@@ -378,6 +407,38 @@ export function SettlementMainPanel({
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="sb-status" className="sm:shrink-0">
|
||||
{t("billsPanel.status", { defaultValue: "账单状态" })}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draft.statusScope}
|
||||
onValueChange={(v) => {
|
||||
const next = (v ?? "all") as SettlementBillScopeFilter;
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
statusScope: next,
|
||||
}));
|
||||
setApplied((d) => ({
|
||||
...d,
|
||||
statusScope: next,
|
||||
}));
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="sb-status" className="h-9 w-full sm:w-36">
|
||||
<SelectValue>{() => statusScopeLabel(draft.statusScope)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusScopeOptions.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{statusScopeLabel(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("billsPanel.searchBtn", { defaultValue: "搜索" })}
|
||||
|
||||
@@ -22,39 +22,25 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import {
|
||||
settlementCenterListHref,
|
||||
settlementPeriodViewHref,
|
||||
type SettlementBillScopeFilter,
|
||||
} from "@/modules/settlement/settlement-center-nav";
|
||||
import { settlementCenterListHref } from "@/modules/settlement/settlement-center-nav";
|
||||
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SettlementPeriodHeaderProps = {
|
||||
period: SettlementPeriodRow;
|
||||
adminSiteId: number;
|
||||
currencyCode: string;
|
||||
billScope: SettlementBillScopeFilter;
|
||||
settlementTimezone?: string;
|
||||
canManage: boolean;
|
||||
onPeriodClosed?: (result: SettlementPeriodCloseResult) => void;
|
||||
onReloadPeriods?: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function scopeChipClass(active: boolean): string {
|
||||
return cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border/70 bg-muted/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground",
|
||||
);
|
||||
}
|
||||
|
||||
export function SettlementPeriodHeader({
|
||||
period,
|
||||
adminSiteId,
|
||||
currencyCode,
|
||||
billScope,
|
||||
settlementTimezone = "UTC",
|
||||
canManage,
|
||||
onPeriodClosed,
|
||||
onReloadPeriods,
|
||||
@@ -70,23 +56,6 @@ export function SettlementPeriodHeader({
|
||||
const unsettledCount = period.pipeline?.unsettled_ticket_count ?? 0;
|
||||
const isOpen = period.status === "open";
|
||||
|
||||
const scopeChips: { key: SettlementBillScopeFilter; label: string; count?: number }[] = [
|
||||
{
|
||||
key: "all",
|
||||
label: t("billsPanel.filterAll", { defaultValue: "全部" }),
|
||||
},
|
||||
{
|
||||
key: "pending_confirm",
|
||||
label: t("billsPanel.category.pendingConfirm", { defaultValue: "待确认" }),
|
||||
count: pendingConfirm,
|
||||
},
|
||||
{
|
||||
key: "awaiting_payment",
|
||||
label: t("billsPanel.category.awaitingPayment", { defaultValue: "待收付" }),
|
||||
count: awaitingPayment,
|
||||
},
|
||||
];
|
||||
|
||||
async function confirmClose(): Promise<void> {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -129,7 +98,7 @@ export function SettlementPeriodHeader({
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-semibold tracking-tight">
|
||||
{formatSettlementPeriodSpan(period.period_start, period.period_end)}
|
||||
{formatSettlementPeriodSpan(period.period_start, period.period_end, settlementTimezone)}
|
||||
</h2>
|
||||
<AdminStatusBadge status={period.status}>
|
||||
{settlementPeriodStatusLabel(period.status, t)}
|
||||
@@ -144,20 +113,17 @@ export function SettlementPeriodHeader({
|
||||
</div>
|
||||
|
||||
{!isOpen ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{scopeChips.map((chip) => {
|
||||
const active = billScope === chip.key;
|
||||
const href = settlementPeriodViewHref(period.id, "bills", adminSiteId, chip.key);
|
||||
|
||||
return (
|
||||
<Link key={chip.key} href={href} className={scopeChipClass(active)}>
|
||||
{chip.label}
|
||||
{chip.count != null && chip.count > 0 ? ` ${chip.count}` : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("overview.pendingConfirm", { defaultValue: "待确认" })}{" "}
|
||||
<span className="font-medium tabular-nums text-foreground">{pendingConfirm}</span>
|
||||
</span>
|
||||
<span>
|
||||
{t("overview.awaitingPayment", { defaultValue: "待收付" })}{" "}
|
||||
<span className="font-medium tabular-nums text-foreground">{awaitingPayment}</span>
|
||||
</span>
|
||||
{totalUnpaid > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("overview.totalUnpaid", { defaultValue: "未结合计" })}{" "}
|
||||
<span className="font-medium tabular-nums text-foreground">
|
||||
{formatDashboardMoneyMinor(totalUnpaid, currencyCode)}
|
||||
@@ -184,7 +150,7 @@ export function SettlementPeriodHeader({
|
||||
<DialogDescription>
|
||||
{t("period.closeDialogDesc", {
|
||||
defaultValue: "将汇总 {{range}} 内的流水并生成账单。",
|
||||
range: formatSettlementPeriodSpan(period.period_start, period.period_end),
|
||||
range: formatSettlementPeriodSpan(period.period_start, period.period_end, settlementTimezone),
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -212,7 +178,7 @@ export function SettlementPeriodHeader({
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => setCloseDialogOpen(false)}>
|
||||
{t("common:cancel", { defaultValue: "取消" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={busy} onClick={() => void confirmClose()}>
|
||||
<Button type="button" disabled={busy || unsettledCount > 0} onClick={() => void confirmClose()}>
|
||||
{t("period.closeDialogConfirm", { defaultValue: "确认关账" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -220,4 +186,4 @@ export function SettlementPeriodHeader({
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SettlementPeriodRow[]>;
|
||||
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<PeriodStatusFilter>("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({
|
||||
<DialogDescription>
|
||||
{t("agents:settlementPeriods.openDesc", {
|
||||
defaultValue:
|
||||
"选择账期起止日期;灰色删除线为已有账期不可再开,琥珀点为待入账,右上角红点为未结清。",
|
||||
"选择账期起止日期;灰色删除线为已有账期不可再开,琥珀点为待入账,紫点为已关账漏账,右上角红点为未结清。",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("agents:settlementPeriods.timezoneHint", {
|
||||
defaultValue: "账期日期按站点时区 {{tz}} 计算。",
|
||||
tz: openHints?.settlement_timezone ?? settlementTimezone,
|
||||
})}
|
||||
</p>
|
||||
<AdminDateRangeField
|
||||
id="sp-dialog-range"
|
||||
label={t("agents:settlementPeriods.rangeLabel", { defaultValue: "账期范围" })}
|
||||
@@ -485,6 +491,13 @@ export function SettlementPeriodWorkbench({
|
||||
/>
|
||||
{t("agents:settlementPeriods.markerPending", { defaultValue: "待入账流水" })}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block size-1.5 rounded-full bg-violet-600"
|
||||
aria-hidden
|
||||
/>
|
||||
{t("agents:settlementPeriods.markerOrphan", { defaultValue: "已关账漏账" })}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="relative inline-block size-3 rounded ring-1 ring-border"
|
||||
@@ -542,6 +555,7 @@ export function SettlementPeriodWorkbench({
|
||||
range: formatSettlementPeriodSpan(
|
||||
closeTarget.period_start,
|
||||
closeTarget.period_end,
|
||||
settlementTimezone,
|
||||
),
|
||||
})
|
||||
: null}
|
||||
@@ -578,7 +592,7 @@ export function SettlementPeriodWorkbench({
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => setCloseDialogOpen(false)}>
|
||||
{t("common:cancel", { defaultValue: "取消" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={busy} onClick={() => void confirmClose()}>
|
||||
<Button type="button" disabled={busy || unsettledCount > 0} onClick={() => void confirmClose()}>
|
||||
{t("period.closeDialogConfirm", { defaultValue: "确认关账" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -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 (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-medium whitespace-nowrap">
|
||||
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
|
||||
{formatSettlementPeriodSpan(row.period_start, row.period_end, settlementTimezone)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge status={row.status}>
|
||||
|
||||
@@ -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<TicketFilters>(initialFilters);
|
||||
const [applied, setApplied] = useState<TicketFilters>(initialFilters);
|
||||
const [data, setData] = useState<AdminTicketItemsData | null>(null);
|
||||
const [betProviders, setBetProviders] = useState<AdminBetProviderRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [providersLoading, setProvidersLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(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 {
|
||||
}}
|
||||
/>
|
||||
</TicketFilterField>
|
||||
<TicketFilterField id="pt-provider" label={t("provider", { defaultValue: "开注商" })}>
|
||||
<Input
|
||||
id="pt-provider"
|
||||
className="h-8 w-full font-mono uppercase"
|
||||
placeholder="SG / MY / TH"
|
||||
value={draft.providerCode}
|
||||
onChange={(e) =>
|
||||
setDraft((current) => ({ ...current, providerCode: e.target.value.toUpperCase() }))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
}
|
||||
<TicketFilterField id="pt-provider" label={t("provider")}>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draft.providerCode || ALL_PROVIDERS_VALUE}
|
||||
disabled={providersLoading}
|
||||
onValueChange={(value) => {
|
||||
const nextProviderCode = String(value ?? "");
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
providerCode: nextProviderCode === ALL_PROVIDERS_VALUE ? "" : nextProviderCode,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger id="pt-provider" className="h-8 w-full">
|
||||
<SelectValue>
|
||||
{draft.providerCode
|
||||
? (betProviders.find((item) => item.code === draft.providerCode)?.name ?? draft.providerCode)
|
||||
: t("allProviders")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
<SelectItem value={ALL_PROVIDERS_VALUE}>{t("allProviders")}</SelectItem>
|
||||
{betProviders.map((provider) => (
|
||||
<SelectItem key={provider.code} value={provider.code}>
|
||||
{provider.name} ({provider.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TicketFilterField>
|
||||
<TicketFilterField id="pt-date-range" label={t("placedDateRange")}>
|
||||
<AdminDateRangeField
|
||||
|
||||
@@ -101,6 +101,8 @@ export type AdminDrawBatchItemRow = {
|
||||
|
||||
export type AdminDrawBatchRow = {
|
||||
id: number;
|
||||
provider_code?: string | null;
|
||||
provider_name?: string | null;
|
||||
result_version: number;
|
||||
source_type?: string;
|
||||
rng_seed_hash?: string | null;
|
||||
@@ -123,6 +125,8 @@ export type AdminDrawBatchesData = {
|
||||
export type AdminDrawPublishResponse = {
|
||||
draw_no: string;
|
||||
status: string;
|
||||
provider_code?: string | null;
|
||||
provider_name?: string | null;
|
||||
result_version: number;
|
||||
};
|
||||
|
||||
@@ -142,6 +146,7 @@ export type AdminDrawPlanGenerateResponse = {
|
||||
};
|
||||
|
||||
export type AdminDrawManualBatchPayload = {
|
||||
provider_code?: string;
|
||||
items: Array<{
|
||||
prize_type: string;
|
||||
prize_index: number;
|
||||
@@ -154,9 +159,20 @@ export type AdminDrawBatchCreateResponse = {
|
||||
status: string;
|
||||
batch: {
|
||||
id: number;
|
||||
provider_code?: string | null;
|
||||
provider_name?: string | null;
|
||||
result_version: number;
|
||||
source_type: string;
|
||||
status: string;
|
||||
items_count: number;
|
||||
};
|
||||
batches?: Array<{
|
||||
id: number;
|
||||
provider_code?: string | null;
|
||||
provider_name?: string | null;
|
||||
result_version: number;
|
||||
source_type: string;
|
||||
status: string;
|
||||
items_count: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user