feat(settlement): 支持结算时区与已关账漏账流水显示
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

- 為結算期間相關接口與前端組件新增 settlement_timezone 字段
- 在账期范围格式化函数中支持传入时区参数显示当地日期
- 在开账面板和账期视图中增加漏账流水(orphanActivity)高亮显示
- 统一调整账期视图默认子页面为“账单处理”而非“待处理”
- 在开奖管理中添加开奖提供商(provider)信息展示和选择功能
- 结算中心增加账单状态筛选项支持待确认、待收付、已结清、调账/冲正
- 移除部分账期页状态筛选标签,改用文本方式显示待确认与待收付数量
- 集成站点管理中新增结算时区字段,支持编辑与保存
- 多语言新增与更新与结算时区和漏账流水相关的文案提示及翻译
This commit is contained in:
2026-07-09 15:48:03 +08:00
parent 330063abf6
commit 1164d9d728
33 changed files with 359 additions and 111 deletions

View File

@@ -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[];
};

View File

@@ -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-['']",
}}

View File

@@ -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.",

View File

@@ -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",

View File

@@ -20,6 +20,7 @@
"failed": "Create failed"
},
"drawNo": "Draw no.",
"provider": "Provider",
"status": "Status",
"startTime": "Start time",
"closeTime": "Close time",

View File

@@ -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."

View File

@@ -19,6 +19,8 @@
"player": "Player",
"orderNo": "Order no.",
"drawNo": "Draw no.",
"provider": "Provider",
"allProviders": "All providers",
"playCode": "Play",
"number": "Number",
"betAmount": "Bet amount",

View File

@@ -311,11 +311,13 @@
"closed": "अवधि बन्द भयो, बिल सिर्जना भयो",
"closeFailed": "अवधि बन्द गर्न असफल",
"hintsFailed": "खोल्ने सुझाव लोड गर्न असफल",
"openDesc": "सुरु र अन्त्य मिति चयन गर्नुहोस्; खैरो स्ट्राइकथ्रु भएका मितिहरू अधिकृत अवधि हुन्, एम्बर बिन्दुहरू पेन्डिङ कारोबार हुन्, माथि-दायाँ रातो बिन्दुहरू नबुझिएको अवधि हुन्।",
"openDesc": "सुरु र अन्त्य मिति चयन गर्नुहोस्; खैरो स्ट्राइकथ्रु भएका मितिहरू अधिकृत अवधि हुन्, एम्बर बिन्दुहरू पेन्डिङ कारोबार हुन्, बैजनी बिन्दुहरू बन्द अवधिमा छुटेका कारोबार हुन्, माथि-दायाँ रातो बिन्दुहरू नबुझिएको अवधि हुन्।",
"timezoneHint": "अवधि मिति साइट समयक्षेत्र {{tz}} अनुसार गणना हुन्छ।",
"rangeLabel": "अवधि दायरा",
"rangeHint": "पहिले सुरु मिति चयन गर्नुहोस्, त्यसपछि अन्त्य मिति; एकल-दिन अवधिको लागि एउटै मिति दुई पटक क्लिक गर्नुहोस्।",
"markerOccupied": "अधिकृत अवधि",
"markerPending": "पेन्डिङ कारोबार",
"markerOrphan": "बन्द अवधिमा छुटेको कारोबार",
"markerUnpaid": "नबुझिएको अवधि",
"overlapsOccupied": "चयन गरिएको दायरा अवस्थित अवधिसँग ओभरल्याप हुन्छ। कृपया खैरो स्ट्राइकथ्रु भएका मितिहरू नछान्नुहोस्।"
},

View File

@@ -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": "पुष्टि",

View File

@@ -100,6 +100,7 @@
"drawDetail": "ड्रअ विवरण",
"drawNo": "ड्रअ नं.",
"drawTime": "ड्रअ समय",
"provider": "Provider",
"editDraw": {
"action": "सम्पादन",
"description": "ड्रअ {{drawNo}} · स्थानीय समयक्षेत्र {{tz}} मा सम्पादन",

View File

@@ -14,7 +14,7 @@
"closeDialogTitle": "अवधि बन्द गर्नुहोस्",
"closeDialogDesc": "{{range}} भित्रको लेजर संक्षेप गरी बिल सिर्जना गर्नुहोस्।",
"closeDialogShare": "{{count}} लेजर प्रविष्टिहरू",
"closeDialogUnsettled": "{{count}} टिकटहरू अझै नसेटल",
"closeDialogUnsettled": "{{count}} टिकटहरू अझै नसेटल छन्। बन्द गर्नु अघि ड्र सेटलमेन्ट पूरा गर्नुहोस्।",
"closeDialogIrreversible": "पूर्ववत गर्न सकिँदैन। त्रुटि सच्याउन समायोजन वा रिभर्सल प्रयोग गर्नुहोस्।",
"closeDialogConfirm": "अवधि बन्द गर्नुहोस्",
"closeDialogEmpty": "यस अवधिमा शेयर लेजर छैन; बन्द गर्दा बिल सिर्जना हुँदैन।"

View File

@@ -13,6 +13,8 @@
"detailLoadFailed": "टिकट विवरण लोड असफल",
"detailTitle": "टिकट विवरण",
"drawNo": "ड्रअ नं.",
"provider": "Provider",
"allProviders": "सबै provider",
"drawNoOptional": "ड्रअ नम्बर (वैकल्पिक)",
"drawNoPlaceholder": "जस्तै 20260520-001",
"failReason": "असफल कारण",

View File

@@ -326,11 +326,13 @@
"closeFailed": "关账失败",
"datesRequired": "请填写账期起止日期",
"invalidRange": "结束日期不能早于开始日期",
"openDesc": "选择账期起止日期;灰色删除线为已有账期不可再开,琥珀点为待入账,右上角红点为未结清。",
"openDesc": "选择账期起止日期;灰色删除线为已有账期不可再开,琥珀点为待入账,紫点为已关账漏账,右上角红点为未结清。",
"timezoneHint": "账期日期按站点时区 {{tz}} 计算。",
"rangeLabel": "账期范围",
"rangeHint": "先选开始日期,再选结束日期;单日账期可点同一天两次。",
"markerOccupied": "已有账期",
"markerPending": "待入账流水",
"markerOrphan": "已关账漏账",
"markerUnpaid": "未结清账期",
"overlapsOccupied": "所选范围与已有账期重叠,请避开灰色删除线的日期。",
"hintsFailed": "无法加载开账建议"

View File

@@ -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",

View File

@@ -20,6 +20,7 @@
"failed": "创建失败"
},
"drawNo": "期号",
"provider": "开注商",
"status": "状态",
"startTime": "开始时间",
"closeTime": "封盘时间",

View File

@@ -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": "账期视图",

View File

@@ -19,6 +19,8 @@
"player": "玩家",
"orderNo": "订单号",
"drawNo": "期号",
"provider": "开注商",
"allProviders": "全部开注商",
"playCode": "玩法",
"number": "号码",
"betAmount": "下注",

View File

@@ -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}`;
}

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}
}

View File

@@ -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>

View File

@@ -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>
);
}
}

View File

@@ -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

View File

@@ -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 &&

View File

@@ -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}

View File

@@ -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}

View File

@@ -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: "搜索" })}

View File

@@ -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>
</>
);
}
}

View File

@@ -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>

View File

@@ -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}>

View File

@@ -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

View File

@@ -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;
}>;
};

View File

@@ -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;