feat(draws, config, deployment): 合并开奖结果/审核页,新增下注筛选并完善开注商配置
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

This commit is contained in:
2026-07-10 17:04:42 +08:00
parent d6826d5a1c
commit 8232ac337d
24 changed files with 272 additions and 325 deletions

View File

@@ -15,14 +15,17 @@
const path = require("path"); const path = require("path");
/** 改成服务器上的绝对路径,例如 /www/wwwroot/lottery-admin.cjdhr.top */ /** 改成服务器上的绝对路径,例如 /www/wwwroot/lottery-admin.cjdhr.top */
const APP_CWD = path.resolve(__dirname); const DEPLOY_ROOT = path.resolve(__dirname);
// deploy.sh uploads the standalone bundle to app/. Keep runtime and static assets
// on the same build so Next.js chunk references cannot become mismatched.
const APP_CWD = path.join(DEPLOY_ROOT, "app");
module.exports = { module.exports = {
apps: [ apps: [
{ {
name: "lotteryadmin", name: "lotteryadmin",
cwd: APP_CWD, cwd: APP_CWD,
script: ".next/standalone/server.js", script: "server.js",
interpreter: "node", interpreter: "node",
exec_mode: "fork", exec_mode: "fork",
instances: 1, instances: 1,
@@ -31,8 +34,8 @@ module.exports = {
max_memory_restart: "1G", max_memory_restart: "1G",
time: true, time: true,
merge_logs: true, merge_logs: true,
out_file: path.join(APP_CWD, "logs/pm2-out.log"), out_file: path.join(DEPLOY_ROOT, "logs/pm2-out.log"),
error_file: path.join(APP_CWD, "logs/pm2-error.log"), error_file: path.join(DEPLOY_ROOT, "logs/pm2-error.log"),
env: { env: {
NODE_ENV: "production", NODE_ENV: "production",
@@ -41,7 +44,7 @@ module.exports = {
}, },
// LOTTERY_API_UPSTREAM 只写在 .env勿在此硬编码会覆盖 .env 导致 API 502 // LOTTERY_API_UPSTREAM 只写在 .env勿在此硬编码会覆盖 .env 导致 API 502
env_file: path.join(APP_CWD, ".env"), env_file: path.join(DEPLOY_ROOT, ".env"),
}, },
], ],
}; };

BIN
public/logo_副本.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

View File

@@ -23,6 +23,7 @@ export type AdminDrawListQuery = {
draw_no?: string; draw_no?: string;
status?: string; status?: string;
agent_node_id?: number; agent_node_id?: number;
has_player_bets?: boolean;
}; };
export async function getAdminDraws(q: AdminDrawListQuery = {}): Promise<AdminDrawListData> { export async function getAdminDraws(q: AdminDrawListQuery = {}): Promise<AdminDrawListData> {

View File

@@ -1,6 +1,4 @@
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; import { redirect } from "next/navigation";
import { DrawPublishConsole } from "@/modules/draws/draw-publish-console";
import { PRD_DRAW_RESULT_MANAGE } from "@/lib/admin-prd";
import { buildPageMetadata } from "@/lib/page-metadata"; import { buildPageMetadata } from "@/lib/page-metadata";
import type { Metadata } from "next"; import type { Metadata } from "next";
@@ -9,10 +7,6 @@ export const metadata: Metadata = buildPageMetadata("draws", "publishTitle");
export default async function AdminDrawPublishBatchPage(props: { export default async function AdminDrawPublishBatchPage(props: {
params: Promise<{ drawId: string; batchId: string }>; params: Promise<{ drawId: string; batchId: string }>;
}) { }) {
const { drawId, batchId } = await props.params; const { drawId } = await props.params;
return ( redirect(`/admin/draws/${drawId}/results`);
<AdminPermissionGate requiredAny={[PRD_DRAW_RESULT_MANAGE]}>
<DrawPublishConsole drawId={drawId} batchId={batchId} />
</AdminPermissionGate>
);
} }

View File

@@ -1,6 +1,7 @@
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { DrawResultsConsole } from "@/modules/draws/draw-results-console"; import { DrawResultsConsole } from "@/modules/draws/draw-results-console";
import { PRD_DRAW_ACCESS_ANY } from "@/lib/admin-prd"; import { DrawReviewConsole } from "@/modules/draws/draw-review-console";
import { PRD_DRAW_ACCESS_ANY, PRD_DRAW_RESULT_MANAGE } from "@/lib/admin-prd";
export default async function AdminDrawResultsPage(props: { export default async function AdminDrawResultsPage(props: {
params: Promise<{ drawId: string }>; params: Promise<{ drawId: string }>;
@@ -8,7 +9,12 @@ export default async function AdminDrawResultsPage(props: {
const { drawId } = await props.params; const { drawId } = await props.params;
return ( return (
<AdminPermissionGate requiredAny={PRD_DRAW_ACCESS_ANY}> <AdminPermissionGate requiredAny={PRD_DRAW_ACCESS_ANY}>
<div className="space-y-6">
<DrawResultsConsole drawId={drawId} /> <DrawResultsConsole drawId={drawId} />
<AdminPermissionGate requiredAny={[PRD_DRAW_RESULT_MANAGE]}>
<DrawReviewConsole drawId={drawId} />
</AdminPermissionGate>
</div>
</AdminPermissionGate> </AdminPermissionGate>
); );
} }

View File

@@ -1,14 +1,8 @@
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; import { redirect } from "next/navigation";
import { DrawReviewConsole } from "@/modules/draws/draw-review-console";
import { PRD_DRAW_RESULT_MANAGE } from "@/lib/admin-prd";
export default async function AdminDrawReviewPage(props: { export default async function AdminDrawReviewPage(props: {
params: Promise<{ drawId: string }>; params: Promise<{ drawId: string }>;
}) { }) {
const { drawId } = await props.params; const { drawId } = await props.params;
return ( redirect(`/admin/draws/${drawId}/results`);
<AdminPermissionGate requiredAny={[PRD_DRAW_RESULT_MANAGE]}>
<DrawReviewConsole drawId={drawId} />
</AdminPermissionGate>
);
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -32,6 +32,7 @@
"queryDraw": "Search draw", "queryDraw": "Search draw",
"reset": "Reset", "reset": "Reset",
"fuzzyDrawNo": "Fuzzy draw no.", "fuzzyDrawNo": "Fuzzy draw no.",
"hasPlayerBets": "Has player bets",
"viewDetails": "View draw details", "viewDetails": "View draw details",
"editDraw": { "editDraw": {
"action": "Edit draw", "action": "Edit draw",
@@ -82,6 +83,10 @@
"rng": "RNG auto-generated", "rng": "RNG auto-generated",
"manual": "Manual entry" "manual": "Manual entry"
}, },
"resultGroups": {
"starter": "Starter",
"consolation": "Consolation"
},
"batchStatusOptions": { "batchStatusOptions": {
"pending_review": "Pending review", "pending_review": "Pending review",
"published": "Published" "published": "Published"

View File

@@ -116,6 +116,7 @@
"financeOverview": "ड्रअ वित्तीय सारांश", "financeOverview": "ड्रअ वित्तीय सारांश",
"finishedAt": "समाप्त समय", "finishedAt": "समाप्त समय",
"fuzzyDrawNo": "फजी ड्रअ नं.", "fuzzyDrawNo": "फजी ड्रअ नं.",
"hasPlayerBets": "खेलाडीको दांव भएको",
"generateFailed": "सिर्जना असफल भयो", "generateFailed": "सिर्जना असफल भयो",
"generatePlan": "ड्रअ योजना सिर्जना", "generatePlan": "ड्रअ योजना सिर्जना",
"generateSuccess": "{{created}} ड्रअ सिर्जना भयो, बफर {{upcoming}}/{{target}}", "generateSuccess": "{{created}} ड्रअ सिर्जना भयो, बफर {{upcoming}}/{{target}}",
@@ -176,6 +177,10 @@
"manual": "म्यानुअल प्रविष्टि", "manual": "म्यानुअल प्रविष्टि",
"rng": "RNG स्वचालित" "rng": "RNG स्वचालित"
}, },
"resultGroups": {
"starter": "विशेष",
"consolation": "सान्त्वना"
},
"resultsTitle": "परिणाम", "resultsTitle": "परिणाम",
"reviewAndPublish": "समीक्षा / प्रकाशित", "reviewAndPublish": "समीक्षा / प्रकाशित",
"reviewAndPublishAction": "जाँचेर प्रकाशित गर्नुहोस्", "reviewAndPublishAction": "जाँचेर प्रकाशित गर्नुहोस्",

View File

@@ -32,6 +32,7 @@
"queryDraw": "查询期号", "queryDraw": "查询期号",
"reset": "重置", "reset": "重置",
"fuzzyDrawNo": "模糊匹配期号", "fuzzyDrawNo": "模糊匹配期号",
"hasPlayerBets": "有玩家下注",
"viewDetails": "查看期号详情", "viewDetails": "查看期号详情",
"editDraw": { "editDraw": {
"action": "编辑期号", "action": "编辑期号",
@@ -82,6 +83,10 @@
"rng": "RNG 自动生成", "rng": "RNG 自动生成",
"manual": "人工录入" "manual": "人工录入"
}, },
"resultGroups": {
"starter": "特别奖",
"consolation": "安慰奖"
},
"batchStatusOptions": { "batchStatusOptions": {
"pending_review": "待审核", "pending_review": "待审核",
"published": "已发布" "published": "已发布"

View File

@@ -41,6 +41,7 @@ import type { AdminBetProviderRow } from "@/types/api/admin-bet-provider";
type ProviderFormState = { type ProviderFormState = {
code: string; code: string;
name: string; name: string;
short_code: string;
is_enabled: boolean; is_enabled: boolean;
sort_order: string; sort_order: string;
}; };
@@ -48,6 +49,7 @@ type ProviderFormState = {
const EMPTY_FORM: ProviderFormState = { const EMPTY_FORM: ProviderFormState = {
code: "", code: "",
name: "", name: "",
short_code: "",
is_enabled: true, is_enabled: true,
sort_order: "0", sort_order: "0",
}; };
@@ -56,6 +58,7 @@ function toFormState(row: AdminBetProviderRow): ProviderFormState {
return { return {
code: row.code, code: row.code,
name: row.name, name: row.name,
short_code: row.short_code,
is_enabled: row.is_enabled, is_enabled: row.is_enabled,
sort_order: String(row.sort_order), sort_order: String(row.sort_order),
}; };
@@ -121,8 +124,9 @@ export function BetProviderSettingsPanel() {
async function handleSubmit(): Promise<void> { async function handleSubmit(): Promise<void> {
const name = form.name.trim(); const name = form.name.trim();
const sortOrder = parseSortOrder(form.sort_order); const sortOrder = parseSortOrder(form.sort_order);
if (name === "" || (mode === "create" && form.code.trim() === "")) { const shortCode = form.short_code.trim().toUpperCase();
toast.error(t("betProviders.form.required", { ns: "config", defaultValue: "请填写代码和名称" })); if (name === "" || shortCode.length !== 1 || !/^[A-Z]$/.test(shortCode) || (mode === "create" && form.code.trim() === "")) {
toast.error(t("betProviders.form.required", { ns: "config", defaultValue: "请填写代码、名称和一个字母缩写" }));
return; return;
} }
@@ -132,6 +136,7 @@ export function BetProviderSettingsPanel() {
await postAdminBetProvider({ await postAdminBetProvider({
code: form.code.trim().toUpperCase(), code: form.code.trim().toUpperCase(),
name, name,
short_code: shortCode,
is_enabled: form.is_enabled, is_enabled: form.is_enabled,
sort_order: sortOrder, sort_order: sortOrder,
}); });
@@ -139,6 +144,7 @@ export function BetProviderSettingsPanel() {
} else if (editingId !== null) { } else if (editingId !== null) {
await putAdminBetProvider(editingId, { await putAdminBetProvider(editingId, {
name, name,
short_code: shortCode,
is_enabled: form.is_enabled, is_enabled: form.is_enabled,
sort_order: sortOrder, sort_order: sortOrder,
}); });
@@ -173,6 +179,7 @@ export function BetProviderSettingsPanel() {
<TableRow> <TableRow>
<TableHead className="whitespace-nowrap">{t("betProviders.table.code", { ns: "config", defaultValue: "代码" })}</TableHead> <TableHead className="whitespace-nowrap">{t("betProviders.table.code", { ns: "config", defaultValue: "代码" })}</TableHead>
<TableHead>{t("betProviders.table.name", { ns: "config", defaultValue: "名称" })}</TableHead> <TableHead>{t("betProviders.table.name", { ns: "config", defaultValue: "名称" })}</TableHead>
<TableHead className="whitespace-nowrap text-center">{t("betProviders.table.shortCode", { ns: "config", defaultValue: "缩写" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("betProviders.table.enabled", { ns: "config", defaultValue: "状态" })}</TableHead> <TableHead className="whitespace-nowrap">{t("betProviders.table.enabled", { ns: "config", defaultValue: "状态" })}</TableHead>
<TableHead className="text-center">{t("betProviders.table.sort", { ns: "config", defaultValue: "排序" })}</TableHead> <TableHead className="text-center">{t("betProviders.table.sort", { ns: "config", defaultValue: "排序" })}</TableHead>
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"> <TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
@@ -183,12 +190,12 @@ export function BetProviderSettingsPanel() {
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground"> <TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
{t("betProviders.loading", { ns: "config", defaultValue: "加载中..." })} {t("betProviders.loading", { ns: "config", defaultValue: "加载中..." })}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<AdminTableNoResourceRow colSpan={5} cellClassName="text-center" /> <AdminTableNoResourceRow colSpan={6} cellClassName="text-center" />
) : ( ) : (
items.map((row) => ( items.map((row) => (
<TableRow key={row.id}> <TableRow key={row.id}>
@@ -201,6 +208,7 @@ export function BetProviderSettingsPanel() {
</span> </span>
) : null} ) : null}
</TableCell> </TableCell>
<TableCell className="text-center font-mono font-bold">{row.short_code}</TableCell>
<TableCell> <TableCell>
<AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}> <AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
{row.is_enabled {row.is_enabled
@@ -267,6 +275,17 @@ export function BetProviderSettingsPanel() {
disabled={saving} disabled={saving}
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="provider-short-code">{t("betProviders.form.shortCode", { ns: "config", defaultValue: "缩写1 个英文字母)" })}</Label>
<Input
id="provider-short-code"
value={form.short_code}
maxLength={1}
placeholder="S"
onChange={(e) => updateForm("short_code", e.target.value.toUpperCase().replace(/[^A-Z]/g, ""))}
disabled={saving}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="provider-sort">{t("betProviders.form.sort", { ns: "config", defaultValue: "排序" })}</Label> <Label htmlFor="provider-sort">{t("betProviders.form.sort", { ns: "config", defaultValue: "排序" })}</Label>
<Input <Input

View File

@@ -55,14 +55,6 @@ import type { AdminBetProviderRow } from "@/types/api/admin-bet-provider";
import { OddsConfigDraftBar } from "@/modules/config/doc/odds-config-draft-bar"; import { OddsConfigDraftBar } from "@/modules/config/doc/odds-config-draft-bar";
import { OddsConfigPlayNav } from "@/modules/config/doc/odds-config-play-nav"; import { OddsConfigPlayNav } from "@/modules/config/doc/odds-config-play-nav";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { import {
buildOddsPlayFilterGroups, buildOddsPlayFilterGroups,
filterOddsPlayTypesByCategory, filterOddsPlayTypesByCategory,
@@ -746,85 +738,10 @@ export function OddsConfigDocScreen({
return { scope, row, hint, sourceLabel, sourceClassName }; return { scope, row, hint, sourceLabel, sourceClassName };
}); });
const mergedOddsTable = (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("odds.table.prizeScope", { ns: "config" })}</TableHead>
<TableHead className="w-[10rem] text-right">{t("odds.table.multiplier", { ns: "config" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{scopeEditorRows.map(({ scope, row, hint, sourceLabel, sourceClassName }) => (
<TableRow key={scope}>
<TableCell className="font-medium">
<div className="flex flex-wrap items-center gap-1.5">
<span>{prizeScopeLabel(scope, t)}</span>
{hint ? <span className="text-xs font-normal text-muted-foreground">{hint}</span> : null}
{row ? (
<span className={cn("rounded-full border px-1.5 py-0.5 text-[11px] font-semibold", sourceClassName)}>
{sourceLabel}
</span>
) : null}
</div>
</TableCell>
<TableCell className="text-right">
{row ? (
canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="ml-auto h-9 w-full max-w-[9rem] text-base font-semibold"
disabled={saving}
value={oddsMultiplierLabel(row.odds_value)}
placeholder={t("odds.placeholders.multiplier", { ns: "config" })}
onChange={(e) =>
updateOddsForScope(scope, {
odds_value: parseOddsMultiplierInput(e.target.value),
})
}
/>
) : (
<ConfigReadonlyValue className="ml-auto h-9 w-full max-w-[9rem] justify-center text-base font-semibold">
{oddsMultiplierLabel(row.odds_value)}
</ConfigReadonlyValue>
)
) : (
<span className="text-xs text-destructive">
{t("odds.missingScopeRow", { ns: "config", scope })}
</span>
)}
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell className="font-medium">{t("odds.rebateRate", { ns: "config" })}</TableCell>
<TableCell className="text-right">
{canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="ml-auto h-9 w-full max-w-[9rem] text-base font-semibold"
disabled={saving}
value={rebatePercentUi}
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
onChange={(e) => setRebateForPlayPercent(e.target.value)}
/>
) : (
<ConfigReadonlyValue className="ml-auto h-9 w-full max-w-[9rem] justify-center text-base font-semibold">
{rebatePercentUi}
</ConfigReadonlyValue>
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
);
const classicOddsGrid = ( const classicOddsGrid = (
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
{scopeEditorRows.map(({ scope, row, hint, sourceLabel, sourceClassName }) => ( {scopeEditorRows.map(({ scope, row, hint, sourceLabel, sourceClassName }) => (
<div key={scope} className="grid min-w-0 gap-1.5"> <div key={scope} className="grid min-w-0 gap-2 border border-border/60 bg-muted/20 p-3">
<div className="flex min-w-0 flex-wrap items-center gap-1.5"> <div className="flex min-w-0 flex-wrap items-center gap-1.5">
<Label className="text-xs font-medium text-muted-foreground"> <Label className="text-xs font-medium text-muted-foreground">
{prizeScopeLabel(scope, t)} {prizeScopeLabel(scope, t)}
@@ -861,7 +778,7 @@ export function OddsConfigDocScreen({
)} )}
</div> </div>
))} ))}
<div className="grid min-w-0 gap-1.5"> <div className="grid min-w-0 gap-2 border border-border/60 bg-muted/20 p-3">
<Label className="truncate text-xs font-medium text-muted-foreground"> <Label className="truncate text-xs font-medium text-muted-foreground">
{t("odds.rebateRate", { ns: "config" })} {t("odds.rebateRate", { ns: "config" })}
</Label> </Label>
@@ -896,7 +813,7 @@ export function OddsConfigDocScreen({
/> />
) : resolvedPlayCode ? ( ) : resolvedPlayCode ? (
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}> <div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
{mergedLayout ? mergedOddsTable : classicOddsGrid} {classicOddsGrid}
</div> </div>
) : null} ) : null}
</> </>
@@ -976,8 +893,8 @@ export function OddsConfigDocScreen({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{toolbarBlock} {toolbarBlock}
<div className="grid gap-0 rounded-lg border border-border/60 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]"> <div className="grid gap-0 overflow-hidden rounded-lg border border-border/60 lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
<aside className="space-y-4 border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0"> <aside className="space-y-4 border-b border-border/50 bg-muted/20 px-4 py-4 lg:border-r lg:border-b-0">
<ConfigChipGroup label={t("betProviders.title", { ns: "config", defaultValue: "开注商" })}> <ConfigChipGroup label={t("betProviders.title", { ns: "config", defaultValue: "开注商" })}>
<ConfigChip <ConfigChip
active={providerCode === GLOBAL_PROVIDER_CODE} active={providerCode === GLOBAL_PROVIDER_CODE}
@@ -1004,9 +921,12 @@ export function OddsConfigDocScreen({
/> />
</aside> </aside>
<div className="min-w-0"> <div className="min-w-0">
<div className="border-b border-border/50 px-4 py-2.5 sm:px-5"> <div className="flex items-center justify-between border-b border-border/50 px-4 py-3 sm:px-5">
<h3 className="text-base font-semibold">{activePlayLabel}</h3> <div>
<p className="text-xs text-muted-foreground">{activeProviderLabel}</p> <p className="text-xs font-medium text-muted-foreground">{activeProviderLabel}</p>
<h3 className="mt-0.5 text-lg font-bold">{activePlayLabel}</h3>
</div>
<span className="rounded-md bg-muted px-2 py-1 font-mono text-xs text-muted-foreground">{resolvedPlayCode}</span>
</div> </div>
<div className="px-4 py-4 sm:px-5">{mainBlock}</div> <div className="px-4 py-4 sm:px-5">{mainBlock}</div>
{isDraft && canManage ? ( {isDraft && canManage ? (

View File

@@ -72,8 +72,7 @@ export function OddsConfigPlayNav({
))} ))}
</ConfigChipGroup> </ConfigChipGroup>
{/* 小屏:下拉快速切换玩法 */} <div className="space-y-1.5">
<div className="space-y-1.5 lg:hidden">
<p className="text-sm font-medium">{t("odds.playType")}</p> <p className="text-sm font-medium">{t("odds.playType")}</p>
<Select <Select
modal={false} modal={false}
@@ -114,45 +113,6 @@ export function OddsConfigPlayNav({
</Select> </Select>
</div> </div>
{/* 大屏:侧栏玩法列表,点选即切换 */}
<nav className="hidden lg:block" aria-label={t("odds.playType")}>
<p className="mb-2 text-sm font-medium">{t("odds.playType")}</p>
{filteredTypes.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("odds.noPlayTypes")}</p>
) : (
<div className="max-h-[min(28rem,calc(100vh-16rem))] space-y-4 overflow-y-auto pr-1">
{playGroups.map((group) => (
<div key={group.key} className="space-y-1">
<p className="px-2 text-xs font-medium text-muted-foreground">
{t(`odds.playGroups.${group.key}`)}
</p>
<ul className="space-y-0.5">
{group.types.map((type) => {
const active = resolvedPlayCode === type.play_code;
return (
<li key={type.play_code}>
<button
type="button"
className={cn(
"w-full rounded-md px-2.5 py-2 text-left text-sm transition-colors",
active
? "bg-muted font-medium text-foreground"
: "text-foreground hover:bg-muted/60",
)}
onClick={() => onPlayCodeChange(type.play_code)}
aria-current={active ? "true" : undefined}
>
{playLabel(type.play_code)}
</button>
</li>
);
})}
</ul>
</div>
))}
</div>
)}
</nav>
</div> </div>
); );
} }

View File

@@ -4,6 +4,12 @@ export type OddsCategoryTab = "all" | "d4" | "d3" | "d2";
export type OddsPlayFilterGroupKey = "bigSmall" | "combo4" | "number3" | "number2" | "other"; export type OddsPlayFilterGroupKey = "bigSmall" | "combo4" | "number3" | "number2" | "other";
const TRADITIONAL_CODES = new Set([
"big", "small", "pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e", "four_any", "four_top", "four_lower",
"pos_3a", "pos_3lower", "pos_3b", "pos_3c", "pos_3d", "pos_3e",
"pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any",
]);
type GroupDef = { type GroupDef = {
key: OddsPlayFilterGroupKey; key: OddsPlayFilterGroupKey;
match: (row: AdminPlayTypeRow) => boolean; match: (row: AdminPlayTypeRow) => boolean;
@@ -39,11 +45,10 @@ export function filterOddsPlayTypesByCategory(
tab: OddsCategoryTab, tab: OddsCategoryTab,
types: AdminPlayTypeRow[], types: AdminPlayTypeRow[],
): AdminPlayTypeRow[] { ): AdminPlayTypeRow[] {
if (tab === "all") { const traditional = types.filter((type) => TRADITIONAL_CODES.has(type.play_code));
return types; if (tab === "all") return traditional;
}
const dim = tab === "d4" ? 4 : tab === "d3" ? 3 : 2; const dim = tab === "d4" ? 4 : tab === "d3" ? 3 : 2;
return types.filter((t) => t.dimension === dim); return traditional.filter((t) => t.dimension === dim);
} }
export function buildOddsPlayFilterGroups( export function buildOddsPlayFilterGroups(

View File

@@ -86,6 +86,12 @@ type PlayBatchSwitchGroup = {
match: (row: PlayConfigItemRow) => boolean; match: (row: PlayConfigItemRow) => boolean;
}; };
const TRADITIONAL_PLAY_CODES = new Set([
"big", "small", "pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e", "four_any", "four_top", "four_lower",
"pos_3a", "pos_3lower", "pos_3b", "pos_3c", "pos_3d", "pos_3e",
"pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any",
]);
const PLAY_BATCH_SWITCH_GROUPS: PlayBatchSwitchGroup[] = [ const PLAY_BATCH_SWITCH_GROUPS: PlayBatchSwitchGroup[] = [
{ {
key: "d2", key: "d2",
@@ -267,7 +273,7 @@ export function PlayConfigDocScreen() {
const orderedRows = useMemo( const orderedRows = useMemo(
() => () =>
[...draftRows].sort( draftRows.filter((row) => TRADITIONAL_PLAY_CODES.has(row.play_code)).sort(
(a, b) => a.display_order - b.display_order || a.play_code.localeCompare(b.play_code), (a, b) => a.display_order - b.display_order || a.play_code.localeCompare(b.play_code),
), ),
[draftRows], [draftRows],

View File

@@ -67,6 +67,11 @@ import {
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes"; import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
const APPLY_REBATE_TO_PAYOUT_KEY = "settlement.apply_rebate_to_payout"; const APPLY_REBATE_TO_PAYOUT_KEY = "settlement.apply_rebate_to_payout";
const TRADITIONAL_PLAY_CODES = new Set([
"big", "small", "pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e", "four_any", "four_top", "four_lower",
"pos_3a", "pos_3lower", "pos_3b", "pos_3c", "pos_3d", "pos_3e",
"pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any",
]);
function dimensionDistinctPrimaryScopePercents( function dimensionDistinctPrimaryScopePercents(
dim: 2 | 3 | 4, dim: 2 | 3 | 4,
@@ -195,9 +200,10 @@ export function RebateConfigDocScreen({
if (!workspace) { if (!workspace) {
return; return;
} }
setP2(inferRebatePercentFromDimension(2, workspace.draftRows, workspace.types)); const traditional = workspace.types.filter((type) => TRADITIONAL_PLAY_CODES.has(type.play_code));
setP3(inferRebatePercentFromDimension(3, workspace.draftRows, workspace.types)); setP2(inferRebatePercentFromDimension(2, workspace.draftRows, traditional));
setP4(inferRebatePercentFromDimension(4, workspace.draftRows, workspace.types)); setP3(inferRebatePercentFromDimension(3, workspace.draftRows, traditional));
setP4(inferRebatePercentFromDimension(4, workspace.draftRows, traditional));
}, [workspace?.draftRows, workspace?.types, workspace]); }, [workspace?.draftRows, workspace?.types, workspace]);
async function handleWinEnjoyChange(checked: boolean): Promise<void> { async function handleWinEnjoyChange(checked: boolean): Promise<void> {
@@ -294,7 +300,7 @@ export function RebateConfigDocScreen({
return false; return false;
} }
for (const dim of [2, 3, 4] as const) { for (const dim of [2, 3, 4] as const) {
if (dimensionDistinctPrimaryScopePercents(dim, resolvedDraftRows, resolvedTypes).size > 1) { if (dimensionDistinctPrimaryScopePercents(dim, resolvedDraftRows, resolvedTypes.filter((type) => TRADITIONAL_PLAY_CODES.has(type.play_code))).size > 1) {
return true; return true;
} }
} }
@@ -318,6 +324,9 @@ export function RebateConfigDocScreen({
const rate3 = Number.isFinite(r3) ? r3 / 100 : 0; const rate3 = Number.isFinite(r3) ? r3 / 100 : 0;
const rate4 = Number.isFinite(r4) ? r4 / 100 : 0; const rate4 = Number.isFinite(r4) ? r4 / 100 : 0;
return rows.map((row) => { return rows.map((row) => {
if (!TRADITIONAL_PLAY_CODES.has(row.play_code)) {
return row;
}
const t = typesByCode.get(row.play_code); const t = typesByCode.get(row.play_code);
const dim = (t?.dimension ?? 2) as 2 | 3 | 4; const dim = (t?.dimension ?? 2) as 2 | 3 | 4;
const rate = dim === 4 ? rate4 : dim === 3 ? rate3 : rate2; const rate = dim === 4 ? rate4 : dim === 3 ? rate3 : rate2;

View File

@@ -265,7 +265,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactEl
) : null} ) : null}
{canManageDraw && pendingReview > 0 ? ( {canManageDraw && pendingReview > 0 ? (
<Link <Link
href={`/admin/draws/${drawId}/review`} href={`/admin/draws/${drawId}/results`}
className="rounded-md bg-amber-500/15 px-2 py-1 font-medium text-amber-800 dark:text-amber-200" className="rounded-md bg-amber-500/15 px-2 py-1 font-medium text-amber-800 dark:text-amber-200"
> >
{t("batchSummaryPending", { count: pendingReview })} {t("batchSummaryPending", { count: pendingReview })}
@@ -290,7 +290,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactEl
</div> </div>
) : canManageDraw ? ( ) : canManageDraw ? (
<Link <Link
href={`/admin/draws/${drawId}/review`} href={`/admin/draws/${drawId}/results`}
className="text-sm font-medium text-primary hover:underline" className="text-sm font-medium text-primary hover:underline"
> >
{t("goToReviewTab")} {t("goToReviewTab")}

View File

@@ -70,7 +70,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
open open
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
router.replace(`/admin/draws/${drawId}/review`); router.replace(`/admin/draws/${drawId}/results`);
} }
}} }}
drawId={idNum} drawId={idNum}
@@ -81,7 +81,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
}} }}
onDiscarded={() => { onDiscarded={() => {
void refreshDraw(); void refreshDraw();
router.replace(`/admin/draws/${drawId}/review`); router.replace(`/admin/draws/${drawId}/results`);
}} }}
/> />
); );

View File

@@ -6,18 +6,8 @@ import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref"; import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminDrawResultBatches } from "@/api/admin-draws"; import { getAdminDrawResultBatches } from "@/api/admin-draws";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws"; import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
@@ -71,9 +61,11 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
{published.length === 0 ? ( {published.length === 0 ? (
<AdminNoResourceState message={t("noPublishedBatch")} /> <AdminNoResourceState message={t("noPublishedBatch")} />
) : ( ) : (
published.map((batch) => ( <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{published.map((batch) => (
<BatchTable key={`${batch.provider_code ?? "GLOBAL"}-${batch.id}`} batch={batch} /> <BatchTable key={`${batch.provider_code ?? "GLOBAL"}-${batch.id}`} batch={batch} />
)) ))}
</div>
)} )}
</div> </div>
); );
@@ -81,51 +73,85 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
function BatchTable({ batch }: { batch: AdminDrawBatchRow }) { function BatchTable({ batch }: { batch: AdminDrawBatchRow }) {
const { t } = useTranslation("draws"); const { t } = useTranslation("draws");
const formatDt = useAdminDateTimeFormatter(); const primaryPrizes = ["first", "second", "third"] as const;
const itemByPrize = new Map(batch.items.map((item) => [`${item.prize_type}:${item.prize_index}`, item]));
const groupNumbers = (prizeType: "starter" | "consolation") =>
batch.items
.filter((item) => item.prize_type === prizeType)
.sort((a, b) => a.prize_index - b.prize_index);
return ( return (
<Card> <section className="overflow-hidden rounded-lg border border-border bg-card">
<CardHeader className="flex-row items-center justify-between space-y-0 pb-2"> <header className="flex items-center justify-between border-b border-border bg-muted/40 px-3 py-2">
<CardTitle className="text-sm"> <h2 className="text-sm font-bold">{batch.provider_name || batch.provider_code || t("provider")}</h2>
{batch.provider_name || batch.provider_code || t("provider")} · {t("version", { version: batch.result_version })} <span className="font-mono text-[11px] text-muted-foreground">v{batch.result_version}</span>
</CardTitle> </header>
<p className="font-mono text-xs text-muted-foreground"> <div className="space-y-3 p-3">
{formatDt(batch.confirmed_at)} <div className="grid grid-cols-3 gap-2">
</p> {primaryPrizes.map((prizeType) => {
</CardHeader> const item = itemByPrize.get(`${prizeType}:0`);
<CardContent className="overflow-x-auto pt-0"> return (
<Table> <div key={prizeType} className="border border-border/70 bg-background px-2 py-1.5 text-center">
<TableHeader> <p className="text-[10px] text-muted-foreground">{drawPrizeTypeLabel(prizeType, 0, t)}</p>
<TableRow> <ResultNumberDetail item={item} prominent />
<TableHead>{t("prize")}</TableHead> </div>
<TableHead>#</TableHead> );
<TableHead className="font-mono">4D</TableHead> })}
<TableHead className="hidden sm:table-cell">{t("tail3")}</TableHead> </div>
<TableHead className="hidden sm:table-cell">{t("tail2")}</TableHead> <ResultNumberGroup title={t("resultGroups.starter")} items={groupNumbers("starter")} />
<TableHead className="hidden md:table-cell">{t("headTail")}</TableHead> <ResultNumberGroup title={t("resultGroups.consolation")} items={groupNumbers("consolation")} />
</TableRow> </div>
</TableHeader> </section>
<TableBody> );
{batch.items.map((it) => ( }
<TableRow key={`${it.prize_type}-${it.prize_index}`}>
<TableCell className="text-xs"> function ResultNumberGroup({ title, items }: { title: string; items: AdminDrawBatchRow["items"] }) {
{drawPrizeTypeLabel(it.prize_type, it.prize_index, t)} return (
</TableCell> <div>
<TableCell className="font-mono text-xs">{it.prize_index}</TableCell> <p className="mb-1 text-[10px] font-semibold text-muted-foreground">{title}</p>
<TableCell className="font-mono text-sm font-semibold">{it.number_4d}</TableCell> <div className="grid grid-cols-5 gap-1">
<TableCell className="hidden font-mono text-xs sm:table-cell"> {items.map((item) => (
{it.suffix_3d ?? "—"} <span key={`${item.prize_type}-${item.prize_index}`} className="bg-muted px-1 py-1 text-center tabular-nums">
</TableCell> <ResultNumberDetail item={item} />
<TableCell className="hidden font-mono text-xs sm:table-cell"> </span>
{it.suffix_2d ?? "—"} ))}
</TableCell> </div>
<TableCell className="hidden text-xs md:table-cell"> </div>
{it.head_digit ?? "—"} / {it.tail_digit ?? "—"} );
</TableCell> }
</TableRow>
))} function ResultNumberDetail({
</TableBody> item,
</Table> prominent = false,
</CardContent> }: {
</Card> item: AdminDrawBatchRow["items"][number] | undefined;
prominent?: boolean;
}) {
if (!item) {
return <span className="mt-0.5 block font-mono text-sm font-black"></span>;
}
return (
<>
<span className={prominent ? "mt-0.5 block font-mono text-sm font-black tracking-wide" : "block font-mono text-[11px] font-black"}>
{item.number_4d}
</span>
<span className="mt-1 flex items-center justify-center gap-1 font-mono text-[9px] leading-tight tabular-nums">
<span className="bg-blue-50 px-1 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300">
3D {item.suffix_3d ?? "—"}
</span>
<span className="bg-amber-50 px-1 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300">
2D {item.suffix_2d ?? "—"}
</span>
</span>
<span className="mt-0.5 flex items-center justify-center gap-1 font-mono text-[9px] leading-tight tabular-nums">
<span className="bg-emerald-50 px-1 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300">
H {item.head_digit ?? "—"}
</span>
<span className="bg-rose-50 px-1 text-rose-800 dark:bg-rose-950/40 dark:text-rose-300">
T {item.tail_digit ?? "—"}
</span>
</span>
</>
); );
} }

View File

@@ -25,14 +25,6 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
@@ -192,31 +184,22 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg border border-border/60"> <div className="rounded-lg border border-border/60">
<div className="border-b border-border/60 px-3 py-2.5"> <div className="flex items-center justify-between border-b border-border/60 px-3 py-2.5">
<h2 className="text-sm font-semibold">{t("pendingBatches")}</h2> <h2 className="text-sm font-semibold">{t("pendingBatches")}</h2>
<span className="font-mono text-xs text-muted-foreground">{pending.length}</span>
</div> </div>
<div className="p-3"> <div className="p-3">
{pending.length === 0 ? ( {pending.length === 0 ? (
<AdminNoResourceState className="py-6" message={t("noPendingBatches")} /> <AdminNoResourceState className="py-6" message={t("noPendingBatches")} />
) : ( ) : (
<Table> <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
<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>
</TableHeader>
<TableBody>
{pending.map((b) => ( {pending.map((b) => (
<TableRow key={b.id}> <section key={b.id} className="border border-border/70 bg-muted/20">
<TableCell>v{b.result_version}</TableCell> <div className="flex items-start justify-between gap-3 border-b border-border/60 px-3 py-2.5">
<TableCell className="font-mono text-xs"> <div className="min-w-0">
{b.provider_name || b.provider_code || "—"} <p className="truncate text-sm font-bold">{b.provider_name || b.provider_code || "—"}</p>
</TableCell> <p className="mt-0.5 font-mono text-[11px] text-muted-foreground">{b.provider_code || "GLOBAL"}</p>
<TableCell className="tabular-nums">{b.items.length}</TableCell> </div>
<TableCell className="text-center">
{canManageDraw ? ( {canManageDraw ? (
<AdminRowActionsMenu <AdminRowActionsMenu
busy={discardingBatchId === b.id} busy={discardingBatchId === b.id}
@@ -243,14 +226,21 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl
}, },
]} ]}
/> />
) : ( ) : null}
<span className="text-xs text-muted-foreground"></span> </div>
)} <div className="grid grid-cols-2 divide-x divide-border/60">
</TableCell> <div className="px-3 py-2">
</TableRow> <p className="text-[10px] text-muted-foreground">{t("version", { version: "" }).replace(" v", "").trim()}</p>
<p className="mt-0.5 font-mono text-sm font-black">v{b.result_version}</p>
</div>
<div className="px-3 py-2">
<p className="text-[10px] text-muted-foreground">{t("numberCount")}</p>
<p className="mt-0.5 font-mono text-sm font-black">{b.items.length}</p>
</div>
</div>
</section>
))} ))}
</TableBody> </div>
</Table>
)} )}
</div> </div>
</div> </div>

View File

@@ -5,18 +5,15 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav"; import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
import { Badge } from "@/components/ui/badge";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_RISK_ACCESS_ANY } from "@/lib/admin-prd"; import { PRD_RISK_ACCESS_ANY } from "@/lib/admin-prd";
import { canManageDrawResults, canViewDrawFinance, canViewDrawResults } from "@/lib/draw-access"; import { canViewDrawFinance, canViewDrawResults } from "@/lib/draw-access";
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
const segments = [ const segments = [
{ suffix: "", key: "status", label: "subnav.status", requiresManage: false }, { suffix: "", key: "status", label: "subnav.status", requiresManage: false },
{ suffix: "/results", key: "results", label: "subnav.results", requiresManage: false }, { suffix: "/results", key: "results", label: "subnav.results", requiresManage: false },
{ suffix: "/finance", key: "finance", label: "subnav.finance", requiresManage: false }, { suffix: "/finance", key: "finance", label: "subnav.finance", requiresManage: false },
{ suffix: "/review", key: "review", label: "subnav.review", requiresManage: true },
{ {
suffix: "/risk/pools", suffix: "/risk/pools",
key: "riskPools", key: "riskPools",
@@ -42,17 +39,6 @@ function isRiskPoolsTabActive(pathname: string, base: string): boolean {
); );
} }
function isReviewTabActive(pathname: string, base: string): boolean {
const reviewPrefix = `${base}/review`;
const publishPrefix = `${base}/publish`;
return (
pathname === reviewPrefix
|| pathname.startsWith(`${reviewPrefix}/`)
|| pathname.startsWith(`${publishPrefix}/`)
);
}
export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement { export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
const { t } = useTranslation("draws"); const { t } = useTranslation("draws");
const pathname = usePathname(); const pathname = usePathname();
@@ -61,11 +47,8 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
const perms = profile?.permissions ?? []; const perms = profile?.permissions ?? [];
const canViewDraw = canViewDrawResults(perms); const canViewDraw = canViewDrawResults(perms);
const canManageDraw = canManageDrawResults(perms);
const canViewFinance = canViewDrawFinance(perms); const canViewFinance = canViewDrawFinance(perms);
const canViewRisk = adminHasAnyPermission(perms, [...PRD_RISK_ACCESS_ANY]); const canViewRisk = adminHasAnyPermission(perms, [...PRD_RISK_ACCESS_ANY]);
const { draw } = useDrawDetail();
const pendingReview = draw?.result_batch_counts.pending_review ?? 0;
const visibleSegments = useMemo( const visibleSegments = useMemo(
() => () =>
@@ -85,7 +68,7 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
return true; return true;
}), }),
[canManageDraw, canViewDraw, canViewFinance, canViewRisk], [canViewDraw, canViewFinance, canViewRisk],
); );
return ( return (
@@ -95,8 +78,6 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
const active = const active =
suffix === "" suffix === ""
? pathname === base || pathname === `${base}/` ? pathname === base || pathname === `${base}/`
: suffix === "/review"
? isReviewTabActive(pathname, base)
: key === "riskPools" : key === "riskPools"
? isRiskPoolsTabActive(pathname, base) ? isRiskPoolsTabActive(pathname, base)
: pathname === href || pathname.startsWith(`${href}/`); : pathname === href || pathname.startsWith(`${href}/`);
@@ -105,11 +86,6 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
<AdminSubnavLink key={key} href={href} active={active}> <AdminSubnavLink key={key} href={href} active={active}>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
{t(label)} {t(label)}
{key === "review" && pendingReview > 0 ? (
<Badge variant="secondary" className="h-5 min-w-5 px-1 text-[11px] tabular-nums">
{pendingReview}
</Badge>
) : null}
</span> </span>
</AdminSubnavLink> </AdminSubnavLink>
); );

View File

@@ -105,8 +105,10 @@ export function DrawsIndexConsole() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [draftDrawNo, setDraftDrawNo] = useState(""); const [draftDrawNo, setDraftDrawNo] = useState("");
const [draftStatus, setDraftStatus] = useState(""); const [draftStatus, setDraftStatus] = useState("");
const [draftHasPlayerBets, setDraftHasPlayerBets] = useState(false);
const [appliedDrawNo, setAppliedDrawNo] = useState(""); const [appliedDrawNo, setAppliedDrawNo] = useState("");
const [appliedStatus, setAppliedStatus] = useState(""); const [appliedStatus, setAppliedStatus] = useState("");
const [appliedHasPlayerBets, setAppliedHasPlayerBets] = useState(false);
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined); const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState<number>(10); const [perPage, setPerPage] = useState<number>(10);
@@ -140,6 +142,7 @@ export function DrawsIndexConsole() {
? undefined ? undefined
: appliedStatus.trim(), : appliedStatus.trim(),
agent_node_id: appliedAgentNodeId, agent_node_id: appliedAgentNodeId,
has_player_bets: appliedHasPlayerBets || undefined,
}); });
setData(d); setData(d);
} catch (e) { } catch (e) {
@@ -150,7 +153,7 @@ export function DrawsIndexConsole() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId, tRef]); }, [page, perPage, appliedDrawNo, appliedStatus, appliedHasPlayerBets, appliedAgentNodeId, tRef]);
async function generatePlan(): Promise<void> { async function generatePlan(): Promise<void> {
setGenerating(true); setGenerating(true);
@@ -173,7 +176,7 @@ export function DrawsIndexConsole() {
useAsyncEffect(() => { useAsyncEffect(() => {
void load(); void load();
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]); }, [page, perPage, appliedDrawNo, appliedStatus, appliedHasPlayerBets, appliedAgentNodeId]);
const handleSelectAll = useCallback((checked: boolean) => { const handleSelectAll = useCallback((checked: boolean) => {
if (checked && data) { if (checked && data) {
@@ -336,6 +339,16 @@ export function DrawsIndexConsole() {
</Select> </Select>
</div> </div>
</div> </div>
<div className="admin-list-field">
<Checkbox
id="draw-filter-has-player-bets"
checked={draftHasPlayerBets}
onCheckedChange={(checked) => setDraftHasPlayerBets(checked === true)}
/>
<Label htmlFor="draw-filter-has-player-bets" className="cursor-pointer whitespace-nowrap">
{t("hasPlayerBets")}
</Label>
</div>
<div className="admin-list-actions"> <div className="admin-list-actions">
<AdminTableExportButton <AdminTableExportButton
tableId="draws-index-table" tableId="draws-index-table"
@@ -347,6 +360,7 @@ export function DrawsIndexConsole() {
onClick={() => { onClick={() => {
setAppliedDrawNo(draftDrawNo); setAppliedDrawNo(draftDrawNo);
setAppliedStatus(draftStatus); setAppliedStatus(draftStatus);
setAppliedHasPlayerBets(draftHasPlayerBets);
setAppliedAgentNodeId(undefined); setAppliedAgentNodeId(undefined);
setPage(1); setPage(1);
}} }}
@@ -359,8 +373,10 @@ export function DrawsIndexConsole() {
onClick={() => { onClick={() => {
setDraftDrawNo(""); setDraftDrawNo("");
setDraftStatus(""); setDraftStatus("");
setDraftHasPlayerBets(false);
setAppliedDrawNo(""); setAppliedDrawNo("");
setAppliedStatus(""); setAppliedStatus("");
setAppliedHasPlayerBets(false);
setAppliedAgentNodeId(undefined); setAppliedAgentNodeId(undefined);
setPage(1); setPage(1);
}} }}

View File

@@ -88,7 +88,11 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
const { t } = useTranslation(["jackpot", "common"]); const { t } = useTranslation(["jackpot", "common"]);
const tRef = useTranslationRef(["jackpot", "common"]); const tRef = useTranslationRef(["jackpot", "common"]);
useAdminPlayTypeCatalog(); useAdminPlayTypeCatalog();
const playOptions = useCachedPlayTypeOptions(); const playOptions = useCachedPlayTypeOptions().filter((option) => new Set([
"big", "small", "pos_4a", "pos_4b", "pos_4c", "pos_4d", "pos_4e", "four_any", "four_top", "four_lower",
"pos_3a", "pos_3lower", "pos_3b", "pos_3c", "pos_3d", "pos_3e",
"pos_2a", "pos_2b", "pos_2c", "pos_2d", "pos_2e", "pos_2any",
]).has(option.code));
const profile = useAdminProfile(); const profile = useAdminProfile();
const canManageJackpot = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANAGE]); const canManageJackpot = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANAGE]);
const canManualBurst = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANUAL_BURST]); const canManualBurst = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANUAL_BURST]);

View File

@@ -2,6 +2,7 @@ export type AdminBetProviderRow = {
id: number; id: number;
code: string; code: string;
name: string; name: string;
short_code: string;
is_enabled: boolean; is_enabled: boolean;
sort_order: number; sort_order: number;
is_default: boolean; is_default: boolean;
@@ -16,12 +17,14 @@ export type AdminBetProviderListData = {
export type AdminBetProviderCreatePayload = { export type AdminBetProviderCreatePayload = {
code: string; code: string;
name: string; name: string;
short_code: string;
is_enabled?: boolean; is_enabled?: boolean;
sort_order?: number; sort_order?: number;
}; };
export type AdminBetProviderUpdatePayload = { export type AdminBetProviderUpdatePayload = {
name?: string; name?: string;
short_code?: string;
is_enabled?: boolean; is_enabled?: boolean;
sort_order?: number; sort_order?: number;
}; };