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

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