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");
/** 改成服务器上的绝对路径,例如 /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 = {
apps: [
{
name: "lotteryadmin",
cwd: APP_CWD,
script: ".next/standalone/server.js",
script: "server.js",
interpreter: "node",
exec_mode: "fork",
instances: 1,
@@ -31,8 +34,8 @@ module.exports = {
max_memory_restart: "1G",
time: true,
merge_logs: true,
out_file: path.join(APP_CWD, "logs/pm2-out.log"),
error_file: path.join(APP_CWD, "logs/pm2-error.log"),
out_file: path.join(DEPLOY_ROOT, "logs/pm2-out.log"),
error_file: path.join(DEPLOY_ROOT, "logs/pm2-error.log"),
env: {
NODE_ENV: "production",
@@ -41,7 +44,7 @@ module.exports = {
},
// 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;
status?: string;
agent_node_id?: number;
has_player_bets?: boolean;
};
export async function getAdminDraws(q: AdminDrawListQuery = {}): Promise<AdminDrawListData> {

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -41,6 +41,7 @@ import type { AdminBetProviderRow } from "@/types/api/admin-bet-provider";
type ProviderFormState = {
code: string;
name: string;
short_code: string;
is_enabled: boolean;
sort_order: string;
};
@@ -48,6 +49,7 @@ type ProviderFormState = {
const EMPTY_FORM: ProviderFormState = {
code: "",
name: "",
short_code: "",
is_enabled: true,
sort_order: "0",
};
@@ -56,6 +58,7 @@ function toFormState(row: AdminBetProviderRow): ProviderFormState {
return {
code: row.code,
name: row.name,
short_code: row.short_code,
is_enabled: row.is_enabled,
sort_order: String(row.sort_order),
};
@@ -121,8 +124,9 @@ export function BetProviderSettingsPanel() {
async function handleSubmit(): Promise<void> {
const name = form.name.trim();
const sortOrder = parseSortOrder(form.sort_order);
if (name === "" || (mode === "create" && form.code.trim() === "")) {
toast.error(t("betProviders.form.required", { ns: "config", defaultValue: "请填写代码和名称" }));
const shortCode = form.short_code.trim().toUpperCase();
if (name === "" || shortCode.length !== 1 || !/^[A-Z]$/.test(shortCode) || (mode === "create" && form.code.trim() === "")) {
toast.error(t("betProviders.form.required", { ns: "config", defaultValue: "请填写代码、名称和一个字母缩写" }));
return;
}
@@ -132,6 +136,7 @@ export function BetProviderSettingsPanel() {
await postAdminBetProvider({
code: form.code.trim().toUpperCase(),
name,
short_code: shortCode,
is_enabled: form.is_enabled,
sort_order: sortOrder,
});
@@ -139,6 +144,7 @@ export function BetProviderSettingsPanel() {
} else if (editingId !== null) {
await putAdminBetProvider(editingId, {
name,
short_code: shortCode,
is_enabled: form.is_enabled,
sort_order: sortOrder,
});
@@ -173,6 +179,7 @@ export function BetProviderSettingsPanel() {
<TableRow>
<TableHead className="whitespace-nowrap">{t("betProviders.table.code", { 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="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)]">
@@ -183,12 +190,12 @@ export function BetProviderSettingsPanel() {
<TableBody>
{loading ? (
<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: "加载中..." })}
</TableCell>
</TableRow>
) : items.length === 0 ? (
<AdminTableNoResourceRow colSpan={5} cellClassName="text-center" />
<AdminTableNoResourceRow colSpan={6} cellClassName="text-center" />
) : (
items.map((row) => (
<TableRow key={row.id}>
@@ -201,6 +208,7 @@ export function BetProviderSettingsPanel() {
</span>
) : null}
</TableCell>
<TableCell className="text-center font-mono font-bold">{row.short_code}</TableCell>
<TableCell>
<AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
{row.is_enabled
@@ -267,6 +275,17 @@ export function BetProviderSettingsPanel() {
disabled={saving}
/>
</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">
<Label htmlFor="provider-sort">{t("betProviders.form.sort", { ns: "config", defaultValue: "排序" })}</Label>
<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 { OddsConfigPlayNav } from "@/modules/config/doc/odds-config-play-nav";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
buildOddsPlayFilterGroups,
filterOddsPlayTypesByCategory,
@@ -746,85 +738,10 @@ export function OddsConfigDocScreen({
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 = (
<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 }) => (
<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">
<Label className="text-xs font-medium text-muted-foreground">
{prizeScopeLabel(scope, t)}
@@ -861,7 +778,7 @@ export function OddsConfigDocScreen({
)}
</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">
{t("odds.rebateRate", { ns: "config" })}
</Label>
@@ -896,7 +813,7 @@ export function OddsConfigDocScreen({
/>
) : resolvedPlayCode ? (
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
{mergedLayout ? mergedOddsTable : classicOddsGrid}
{classicOddsGrid}
</div>
) : null}
</>
@@ -976,8 +893,8 @@ export function OddsConfigDocScreen({
return (
<div className="space-y-4">
{toolbarBlock}
<div className="grid gap-0 rounded-lg border border-border/60 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
<aside className="space-y-4 border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
<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 bg-muted/20 px-4 py-4 lg:border-r lg:border-b-0">
<ConfigChipGroup label={t("betProviders.title", { ns: "config", defaultValue: "开注商" })}>
<ConfigChip
active={providerCode === GLOBAL_PROVIDER_CODE}
@@ -1004,9 +921,12 @@ export function OddsConfigDocScreen({
/>
</aside>
<div className="min-w-0">
<div className="border-b border-border/50 px-4 py-2.5 sm:px-5">
<h3 className="text-base font-semibold">{activePlayLabel}</h3>
<p className="text-xs text-muted-foreground">{activeProviderLabel}</p>
<div className="flex items-center justify-between border-b border-border/50 px-4 py-3 sm:px-5">
<div>
<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 className="px-4 py-4 sm:px-5">{mainBlock}</div>
{isDraft && canManage ? (

View File

@@ -72,8 +72,7 @@ export function OddsConfigPlayNav({
))}
</ConfigChipGroup>
{/* 小屏:下拉快速切换玩法 */}
<div className="space-y-1.5 lg:hidden">
<div className="space-y-1.5">
<p className="text-sm font-medium">{t("odds.playType")}</p>
<Select
modal={false}
@@ -114,45 +113,6 @@ export function OddsConfigPlayNav({
</Select>
</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>
);
}

View File

@@ -4,6 +4,12 @@ export type OddsCategoryTab = "all" | "d4" | "d3" | "d2";
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 = {
key: OddsPlayFilterGroupKey;
match: (row: AdminPlayTypeRow) => boolean;
@@ -39,11 +45,10 @@ export function filterOddsPlayTypesByCategory(
tab: OddsCategoryTab,
types: AdminPlayTypeRow[],
): AdminPlayTypeRow[] {
if (tab === "all") {
return types;
}
const traditional = types.filter((type) => TRADITIONAL_CODES.has(type.play_code));
if (tab === "all") return traditional;
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(

View File

@@ -86,6 +86,12 @@ type PlayBatchSwitchGroup = {
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[] = [
{
key: "d2",
@@ -267,7 +273,7 @@ export function PlayConfigDocScreen() {
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),
),
[draftRows],

View File

@@ -67,6 +67,11 @@ import {
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
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(
dim: 2 | 3 | 4,
@@ -195,9 +200,10 @@ export function RebateConfigDocScreen({
if (!workspace) {
return;
}
setP2(inferRebatePercentFromDimension(2, workspace.draftRows, workspace.types));
setP3(inferRebatePercentFromDimension(3, workspace.draftRows, workspace.types));
setP4(inferRebatePercentFromDimension(4, workspace.draftRows, workspace.types));
const traditional = workspace.types.filter((type) => TRADITIONAL_PLAY_CODES.has(type.play_code));
setP2(inferRebatePercentFromDimension(2, workspace.draftRows, traditional));
setP3(inferRebatePercentFromDimension(3, workspace.draftRows, traditional));
setP4(inferRebatePercentFromDimension(4, workspace.draftRows, traditional));
}, [workspace?.draftRows, workspace?.types, workspace]);
async function handleWinEnjoyChange(checked: boolean): Promise<void> {
@@ -294,7 +300,7 @@ export function RebateConfigDocScreen({
return false;
}
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;
}
}
@@ -318,6 +324,9 @@ export function RebateConfigDocScreen({
const rate3 = Number.isFinite(r3) ? r3 / 100 : 0;
const rate4 = Number.isFinite(r4) ? r4 / 100 : 0;
return rows.map((row) => {
if (!TRADITIONAL_PLAY_CODES.has(row.play_code)) {
return row;
}
const t = typesByCode.get(row.play_code);
const dim = (t?.dimension ?? 2) as 2 | 3 | 4;
const rate = dim === 4 ? rate4 : dim === 3 ? rate3 : rate2;

View File

@@ -265,7 +265,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactEl
) : null}
{canManageDraw && pendingReview > 0 ? (
<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"
>
{t("batchSummaryPending", { count: pendingReview })}
@@ -290,7 +290,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }): React.ReactEl
</div>
) : canManageDraw ? (
<Link
href={`/admin/draws/${drawId}/review`}
href={`/admin/draws/${drawId}/results`}
className="text-sm font-medium text-primary hover:underline"
>
{t("goToReviewTab")}

View File

@@ -70,7 +70,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
open
onOpenChange={(open) => {
if (!open) {
router.replace(`/admin/draws/${drawId}/review`);
router.replace(`/admin/draws/${drawId}/results`);
}
}}
drawId={idNum}
@@ -81,7 +81,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
}}
onDiscarded={() => {
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 { getAdminDrawResultBatches } from "@/api/admin-draws";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-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 type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
@@ -71,9 +61,11 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
{published.length === 0 ? (
<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} />
))
))}
</div>
)}
</div>
);
@@ -81,51 +73,85 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
function BatchTable({ batch }: { batch: AdminDrawBatchRow }) {
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 (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0 pb-2">
<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>
</CardHeader>
<CardContent className="overflow-x-auto pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("prize")}</TableHead>
<TableHead>#</TableHead>
<TableHead className="font-mono">4D</TableHead>
<TableHead className="hidden sm:table-cell">{t("tail3")}</TableHead>
<TableHead className="hidden sm:table-cell">{t("tail2")}</TableHead>
<TableHead className="hidden md:table-cell">{t("headTail")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{batch.items.map((it) => (
<TableRow key={`${it.prize_type}-${it.prize_index}`}>
<TableCell className="text-xs">
{drawPrizeTypeLabel(it.prize_type, it.prize_index, t)}
</TableCell>
<TableCell className="font-mono text-xs">{it.prize_index}</TableCell>
<TableCell className="font-mono text-sm font-semibold">{it.number_4d}</TableCell>
<TableCell className="hidden font-mono text-xs sm:table-cell">
{it.suffix_3d ?? "—"}
</TableCell>
<TableCell className="hidden font-mono text-xs sm:table-cell">
{it.suffix_2d ?? "—"}
</TableCell>
<TableCell className="hidden text-xs md:table-cell">
{it.head_digit ?? "—"} / {it.tail_digit ?? "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<section className="overflow-hidden rounded-lg border border-border bg-card">
<header className="flex items-center justify-between border-b border-border bg-muted/40 px-3 py-2">
<h2 className="text-sm font-bold">{batch.provider_name || batch.provider_code || t("provider")}</h2>
<span className="font-mono text-[11px] text-muted-foreground">v{batch.result_version}</span>
</header>
<div className="space-y-3 p-3">
<div className="grid grid-cols-3 gap-2">
{primaryPrizes.map((prizeType) => {
const item = itemByPrize.get(`${prizeType}:0`);
return (
<div key={prizeType} className="border border-border/70 bg-background px-2 py-1.5 text-center">
<p className="text-[10px] text-muted-foreground">{drawPrizeTypeLabel(prizeType, 0, t)}</p>
<ResultNumberDetail item={item} prominent />
</div>
);
})}
</div>
<ResultNumberGroup title={t("resultGroups.starter")} items={groupNumbers("starter")} />
<ResultNumberGroup title={t("resultGroups.consolation")} items={groupNumbers("consolation")} />
</div>
</section>
);
}
function ResultNumberGroup({ title, items }: { title: string; items: AdminDrawBatchRow["items"] }) {
return (
<div>
<p className="mb-1 text-[10px] font-semibold text-muted-foreground">{title}</p>
<div className="grid grid-cols-5 gap-1">
{items.map((item) => (
<span key={`${item.prize_type}-${item.prize_index}`} className="bg-muted px-1 py-1 text-center tabular-nums">
<ResultNumberDetail item={item} />
</span>
))}
</div>
</div>
);
}
function ResultNumberDetail({
item,
prominent = false,
}: {
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";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-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 { adminHasAnyPermission } from "@/lib/admin-permissions";
@@ -192,65 +184,63 @@ export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactEl
return (
<div className="space-y-4">
<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>
<span className="font-mono text-xs text-muted-foreground">{pending.length}</span>
</div>
<div className="p-3">
{pending.length === 0 ? (
<AdminNoResourceState className="py-6" message={t("noPendingBatches")} />
) : (
<Table>
<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) => (
<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 ? (
<AdminRowActionsMenu
busy={discardingBatchId === b.id}
actions={[
{
key: "publish",
label: t("reviewAndPublishAction"),
icon: Rocket,
onClick: () => setPublishBatch(b),
},
{
key: "discard",
label: t("discardPendingBatch"),
icon: Trash2,
destructive: true,
disabled: discardingBatchId !== null,
onClick: () =>
requestConfirm({
title: t("confirm.discardPendingBatchTitle"),
description: t("confirm.discardPendingBatchDescription"),
confirmVariant: "destructive",
onConfirm: () => discardPendingBatch(b.id),
}),
},
]}
/>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{pending.map((b) => (
<section key={b.id} className="border border-border/70 bg-muted/20">
<div className="flex items-start justify-between gap-3 border-b border-border/60 px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-sm font-bold">{b.provider_name || b.provider_code || "—"}</p>
<p className="mt-0.5 font-mono text-[11px] text-muted-foreground">{b.provider_code || "GLOBAL"}</p>
</div>
{canManageDraw ? (
<AdminRowActionsMenu
busy={discardingBatchId === b.id}
actions={[
{
key: "publish",
label: t("reviewAndPublishAction"),
icon: Rocket,
onClick: () => setPublishBatch(b),
},
{
key: "discard",
label: t("discardPendingBatch"),
icon: Trash2,
destructive: true,
disabled: discardingBatchId !== null,
onClick: () =>
requestConfirm({
title: t("confirm.discardPendingBatchTitle"),
description: t("confirm.discardPendingBatchDescription"),
confirmVariant: "destructive",
onConfirm: () => discardPendingBatch(b.id),
}),
},
]}
/>
) : null}
</div>
<div className="grid grid-cols-2 divide-x divide-border/60">
<div className="px-3 py-2">
<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>
))}
</div>
)}
</div>
</div>

View File

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

View File

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

View File

@@ -88,7 +88,11 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
const { t } = useTranslation(["jackpot", "common"]);
const tRef = useTranslationRef(["jackpot", "common"]);
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 canManageJackpot = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANAGE]);
const canManualBurst = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANUAL_BURST]);

View File

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