refactor: 合并多语言支持的显示名称字段,优化奖池手动爆发功能的返回数据结构,增强管理端权限控制

This commit is contained in:
2026-05-25 14:31:24 +08:00
parent 7d01e5c47e
commit ddedef824e
101 changed files with 3033 additions and 641 deletions

View File

@@ -42,4 +42,17 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
settings: Settings,
};
/** 旧版 localStorage / 接口缓存中的 segment避免首屏侧栏崩溃 */
const legacyAdminNavIconBySegment: Record<string, LucideIcon> = {
config: SlidersHorizontal,
};
export function resolveAdminNavIcon(segment: string): LucideIcon {
return (
adminNavIconBySegment[segment as AdminNavItem["segment"]] ??
legacyAdminNavIconBySegment[segment] ??
LayoutDashboard
);
}
export { LogIn };

View File

@@ -32,16 +32,16 @@ export function AccountSettingsConsole() {
async function handleUpdateProfile() {
if (!nickname.trim()) {
toast.error(t("validation.required", { field: t("fields.nickname", { defaultValue: "昵称" }) }));
toast.error(t("validation.required", { field: t("fields.nickname") }));
return;
}
setLoading(true);
try {
await putAdminMe({ nickname: nickname.trim() });
toast.success(t("actions.updateSuccess", { defaultValue: "更新成功" }));
toast.success(t("actions.updateSuccess"));
void refreshAdminProfile();
} catch (err) {
toast.error(err instanceof LotteryApiBizError ? err.message : t("actions.updateFailed", { defaultValue: "更新失败" }));
toast.error(err instanceof LotteryApiBizError ? err.message : t("actions.updateFailed"));
} finally {
setLoading(false);
}
@@ -49,21 +49,21 @@ export function AccountSettingsConsole() {
async function handleUpdatePassword() {
if (!password) {
toast.error(t("validation.required", { field: t("fields.newPassword", { defaultValue: "新密码" }) }));
toast.error(t("validation.required", { field: t("fields.newPassword") }));
return;
}
if (password !== confirmPassword) {
toast.error(t("validation.passwordMismatch", { defaultValue: "两次输入的密码不一致" }));
toast.error(t("validation.passwordMismatch"));
return;
}
setLoading(true);
try {
await putAdminMe({ password });
toast.success(t("actions.updateSuccess", { defaultValue: "更新成功" }));
toast.success(t("actions.updateSuccess"));
setPassword("");
setConfirmPassword("");
} catch (err) {
toast.error(err instanceof LotteryApiBizError ? err.message : t("actions.updateFailed", { defaultValue: "更新失败" }));
toast.error(err instanceof LotteryApiBizError ? err.message : t("actions.updateFailed"));
} finally {
setLoading(false);
}
@@ -73,68 +73,68 @@ export function AccountSettingsConsole() {
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-4 md:p-6 lg:p-8">
<div className="flex flex-col gap-1">
<h1 className="text-xl font-semibold tracking-tight text-[#13315f]">
{t("accountSettings", { defaultValue: "账号设置" })}
{t("accountSettings")}
</h1>
<p className="text-sm text-muted-foreground">
{t("accountSettingsDesc", { defaultValue: "管理您的基本账号资料及安全设置。" })}
{t("accountSettingsDesc")}
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("profileSettings", { defaultValue: "基本资料" })}</CardTitle>
<CardTitle className="text-base">{t("profileSettings")}</CardTitle>
<CardDescription>
{t("profileSettingsDesc", { defaultValue: "更新您的显示名称。" })}
{t("profileSettingsDesc")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 max-w-md">
<div className="space-y-1.5">
<Label htmlFor="nickname">{t("fields.nickname", { defaultValue: "昵称" })}</Label>
<Label htmlFor="nickname">{t("fields.nickname")}</Label>
<Input
id="nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder={t("placeholders.nickname", { defaultValue: "请输入昵称" })}
placeholder={t("placeholders.nickname")}
/>
</div>
<Button onClick={handleUpdateProfile} disabled={loading}>
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
{t("actions.save", { defaultValue: "保存修改" })}
{t("actions.save")}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("securitySettings", { defaultValue: "安全设置" })}</CardTitle>
<CardTitle className="text-base">{t("securitySettings")}</CardTitle>
<CardDescription>
{t("securitySettingsDesc", { defaultValue: "修改您的登录密码。如不修改请留空。" })}
{t("securitySettingsDesc")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 max-w-md">
<div className="space-y-1.5">
<Label htmlFor="password">{t("fields.newPassword", { defaultValue: "新密码" })}</Label>
<Label htmlFor="password">{t("fields.newPassword")}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("placeholders.password", { defaultValue: "请输入新密码" })}
placeholder={t("placeholders.password")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm-password">{t("fields.confirmPassword", { defaultValue: "确认密码" })}</Label>
<Label htmlFor="confirm-password">{t("fields.confirmPassword")}</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t("placeholders.confirmPassword", { defaultValue: "请再次输入新密码" })}
placeholder={t("placeholders.confirmPassword")}
/>
</div>
<Button onClick={handleUpdatePassword} disabled={loading || !password}>
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
{t("actions.updatePassword", { defaultValue: "更新密码" })}
{t("actions.updatePassword")}
</Button>
</CardContent>
</Card>

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown } from "lucide-react";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -37,7 +38,10 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_ADMIN_ROLE_MANAGE } from "@/lib/admin-prd";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import type { AdminPermissionCatalogData, AdminRoleRow } from "@/types/api/index";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -53,6 +57,9 @@ function permissionLabel(slug: string, fallback: string, t: (key: string) => str
export function AdminRolesConsole(): React.ReactElement {
const { t } = useTranslation(["adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_ROLE_MANAGE]);
const exportLabels = useExportLabels("adminRoles");
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
@@ -130,13 +137,13 @@ export function AdminRolesConsole(): React.ReactElement {
}, [load]);
function isDirectGroupOpen(key: string): boolean {
return directMenuExpanded[key] !== false;
return directMenuExpanded[key] === true;
}
function toggleDirectGroup(key: string): void {
setDirectMenuExpanded((prev) => {
const wasOpen = prev[key] !== false;
return { ...prev, [key]: wasOpen ? false : true };
const wasOpen = prev[key] === true;
return { ...prev, [key]: !wasOpen };
});
}
@@ -307,9 +314,11 @@ export function AdminRolesConsole(): React.ReactElement {
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<CardTitle>{t("roleListTitle")}</CardTitle>
<Button type="button" size="sm" onClick={() => openCreateRole()}>
{t("createRole")}
</Button>
{canManageRoles ? (
<Button type="button" size="sm" onClick={() => openCreateRole()}>
{t("createRole")}
</Button>
) : null}
</div>
<div className="admin-list-actions">
<AdminTableExportButton
@@ -374,23 +383,27 @@ export function AdminRolesConsole(): React.ReactElement {
<TableCell className="tabular-nums">{role.user_count}</TableCell>
<TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
<Button type="button" size="sm" variant="outline" onClick={() => openRolePermissionEditor(role)}>
{t("roleActions.permissions")}
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => openEditRole(role)}>
{t("actions.edit")}
</Button>
<Button
type="button"
size="sm"
variant="destructive"
disabled={role.is_system || role.user_count > 0}
onClick={() => setRoleDeleteTarget(role)}
>
{t("actions.delete")}
</Button>
</div>
{canManageRoles ? (
<div className="flex flex-wrap gap-1">
<Button type="button" size="sm" variant="outline" onClick={() => openRolePermissionEditor(role)}>
{t("roleActions.permissions")}
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => openEditRole(role)}>
{t("actions.edit")}
</Button>
<Button
type="button"
size="sm"
variant="destructive"
disabled={role.is_system || role.user_count > 0}
onClick={() => setRoleDeleteTarget(role)}
>
{t("actions.delete")}
</Button>
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))
@@ -500,7 +513,20 @@ export function AdminRolesConsole(): React.ReactElement {
<Button type="button" variant="outline" onClick={() => handleRolePermissionDialogOpenChange(false)}>
{t("actions.cancel")}
</Button>
<Button type="button" disabled={!selectedRole || roleSaving} onClick={() => void saveRolePermissions()}>
<Button
type="button"
disabled={!selectedRole || roleSaving}
onClick={() =>
selectedRole &&
requestConfirm({
title: t("confirmSaveRolePermissionsTitle"),
description: t("confirmSaveRolePermissionsDescription", { name: selectedRole.name }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
confirmVariant: "destructive",
onConfirm: () => saveRolePermissions(),
})
}
>
{roleSaving ? t("saving") : t("actions.save")}
</Button>
</div>
@@ -540,7 +566,21 @@ export function AdminRolesConsole(): React.ReactElement {
<Button type="button" variant="outline" onClick={() => handleRoleDialogOpenChange(false)}>
{t("actions.cancel")}
</Button>
<Button type="button" disabled={roleFormSaving} onClick={() => void submitRole()}>
<Button
type="button"
disabled={roleFormSaving}
onClick={() =>
requestConfirm({
title: t("confirmSaveRoleTitle"),
description:
roleMode === "create"
? t("confirmSaveRoleCreateDescription", { name: roleName || roleSlug || "—" })
: t("confirmSaveRoleEditDescription", { name: roleName || "—" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => submitRole(),
})
}
>
{roleFormSaving ? t("saving") : t("actions.save")}
</Button>
</div>
@@ -565,6 +605,7 @@ export function AdminRolesConsole(): React.ReactElement {
</div>
</DialogContent>
</Dialog>
<ConfirmDialog />
</div>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -38,6 +39,8 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_ADMIN_USER_MANAGE } from "@/lib/admin-prd";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import type { AdminPermissionCatalogData, AdminUserPermissionRow } from "@/types/api/index";
@@ -45,8 +48,10 @@ import { LotteryApiBizError } from "@/types/api/errors";
export function AdminUsersConsole(): React.ReactElement {
const { t } = useTranslation(["adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const exportLabels = useExportLabels("adminUsers");
const profile = useAdminProfile();
const canManageUsers = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_USER_MANAGE]);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [keyword, setKeyword] = useState("");
@@ -310,9 +315,11 @@ export function AdminUsersConsole(): React.ReactElement {
<CardHeader className="admin-list-header flex flex-col gap-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<CardTitle className="admin-list-title">{t("listTitle")}</CardTitle>
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
{t("createAdmin")}
</Button>
{canManageUsers ? (
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
{t("createAdmin")}
</Button>
) : null}
</div>
<div className="admin-list-toolbar">
<div className="admin-list-field xl:min-w-0">
@@ -411,6 +418,7 @@ export function AdminUsersConsole(): React.ReactElement {
<TableCell className="tabular-nums">{row.effective_permissions.length}</TableCell>
<TableCell className="text-center">
<div className="flex w-full flex-nowrap justify-center gap-1 whitespace-nowrap">
{canManageUsers ? (
<Button
type="button"
size="sm"
@@ -419,6 +427,8 @@ export function AdminUsersConsole(): React.ReactElement {
>
{t("actions.permissions")}
</Button>
) : null}
{canManageUsers ? (
<Button
type="button"
size="sm"
@@ -427,6 +437,8 @@ export function AdminUsersConsole(): React.ReactElement {
>
{t("actions.edit")}
</Button>
) : null}
{canManageUsers ? (
<Button
type="button"
size="sm"
@@ -441,6 +453,7 @@ export function AdminUsersConsole(): React.ReactElement {
>
{t("actions.delete")}
</Button>
) : null}
</div>
</TableCell>
</TableRow>
@@ -518,7 +531,15 @@ export function AdminUsersConsole(): React.ReactElement {
type="button"
className="w-full shrink-0 sm:w-auto"
disabled={!selectedUser || savingRoles}
onClick={() => void saveRoles()}
onClick={() =>
selectedUser &&
requestConfirm({
title: t("confirmSaveRolesTitle"),
description: t("confirmSaveRolesDescription", { name: selectedUser.username }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => saveRoles(),
})
}
>
{savingRoles ? t("saving") : t("permissionDialog.saveRoles")}
</Button>
@@ -633,7 +654,23 @@ export function AdminUsersConsole(): React.ReactElement {
>
{t("actions.cancel")}
</Button>
<Button type="button" disabled={accountSaving} onClick={() => void submitAccount()}>
<Button
type="button"
disabled={accountSaving}
onClick={() =>
requestConfirm({
title: t("confirmSaveAccountTitle"),
description:
accountMode === "create"
? t("confirmSaveAccountCreateDescription")
: t("confirmSaveAccountEditDescription", {
name: formUsername || "—",
}),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => submitAccount(),
})
}
>
{accountSaving ? t("saving") : t("actions.save")}
</Button>
</div>
@@ -668,6 +705,7 @@ export function AdminUsersConsole(): React.ReactElement {
</div>
</DialogContent>
</Dialog>
<ConfirmDialog />
</div>
);
}

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { AdminSectionHeader } from "@/components/admin/admin-section-header";
import { cn } from "@/lib/utils";
type ConfigSectionProps = {
@@ -22,15 +23,7 @@ export function ConfigSection({
}: ConfigSectionProps) {
return (
<section id={id} className={cn("scroll-mt-24 space-y-4", className)}>
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border/60 pb-3">
<div className="min-w-0 space-y-1">
<h3 className="text-base font-semibold text-foreground">{title}</h3>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
</div>
<AdminSectionHeader title={title} description={description} actions={actions} />
{children}
</section>
);

View File

@@ -8,6 +8,8 @@ import { cn } from "@/lib/utils";
type ConfigVersionActionsProps = {
isDraft: boolean;
/** 为 false 时仅保留刷新,隐藏新建/保存/发布(只读权限) */
canManage?: boolean;
loadingList?: boolean;
loadingDetail?: boolean;
saving?: boolean;
@@ -21,6 +23,7 @@ type ConfigVersionActionsProps = {
export function ConfigVersionActions({
isDraft,
canManage = true,
loadingList = false,
loadingDetail = false,
saving = false,
@@ -41,11 +44,13 @@ export function ConfigVersionActions({
<RefreshCw className={loadingList ? "size-4 animate-spin" : "size-4"} aria-hidden />
{loadingList ? t("versionActions.refreshing") : t("versionActions.refresh")}
</Button>
<Button type="button" disabled={saving} onClick={onNewDraft}>
<Plus className="size-4" aria-hidden />
{t("versionActions.newDraft")}
</Button>
{isDraft ? (
{canManage ? (
<Button type="button" disabled={saving} onClick={onNewDraft}>
<Plus className="size-4" aria-hidden />
{t("versionActions.newDraft")}
</Button>
) : null}
{canManage && isDraft ? (
<>
<Button
type="button"

View File

@@ -32,6 +32,10 @@ import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminPlayTypeRow,
@@ -41,9 +45,9 @@ import type {
} from "@/types/api/admin-config";
import {
PRIZE_SCOPE_LABELS,
PRIZE_SCOPE_MULTIPLIER_HINT,
PRIZE_SCOPE_ORDER,
prizeScopeLabel,
type PrizeScopeCode,
} from "@/modules/config/doc/prize-scopes";
@@ -67,7 +71,9 @@ type OddsConfigDocScreenProps = {
};
export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenProps) {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const { t, i18n } = useTranslation(["config", "adminUsers", "common"]);
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [list, setList] = useState<ConfigVersionSummary[]>([]);
@@ -190,6 +196,7 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
const isDraft = selectedStatus === "draft";
const canEditDraft = isDraft && canManage;
const scopeRows = useMemo(() => {
const rows: Partial<Record<PrizeScopeCode, OddsItemRow>> = {};
@@ -243,7 +250,7 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
}
async function handleSave() {
if (!detail || !isDraft) {
if (!detail || !canEditDraft) {
return;
}
setSaving(true);
@@ -270,7 +277,7 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
}
async function handlePublish() {
if (!detail || !isDraft) {
if (!detail || !canEditDraft) {
return;
}
setSaving(true);
@@ -289,7 +296,7 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
}
async function requestPublishConfirm() {
if (!detail || !isDraft) {
if (!detail || !canEditDraft) {
return;
}
const active = list.find((x) => x.status === "active");
@@ -386,12 +393,12 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
const old = activeCompareRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
return {
scope,
label: PRIZE_SCOPE_LABELS[scope],
label: prizeScopeLabel(scope, t),
oldValue: old?.odds_value ?? null,
newValue: next?.odds_value ?? null,
};
});
}, [activeCompareRows, detail, draftRows, resolvedPlayCode]);
}, [activeCompareRows, detail, draftRows, resolvedPlayCode, t, i18n.language]);
const catTabs: { id: CatTab; label: string }[] = [
{ id: "all", label: t("odds.tabs.all", { ns: "config" }) },
@@ -423,7 +430,7 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
active={resolvedPlayCode === type.play_code}
onClick={() => setPlayCode(type.play_code)}
>
{type.display_name_zh ?? type.play_code}
{resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
</ConfigChip>
))
)}
@@ -449,6 +456,7 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
actions={
<ConfigVersionActions
isDraft={isDraft}
canManage={canManage}
loadingList={loadingList}
loadingDetail={loadingDetail}
saving={saving}
@@ -499,12 +507,12 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
return (
<div key={scope} className="grid gap-1">
<Label className="flex items-baseline gap-2">
{PRIZE_SCOPE_LABELS[scope]}
{prizeScopeLabel(scope, t)}
{hint ? <span className="text-sm text-muted-foreground font-normal">{hint}</span> : null}
</Label>
{row && idx >= 0 ? (
<div className="flex flex-wrap items-center gap-2">
{isDraft ? (
{canEditDraft ? (
<Input
type="text"
inputMode="numeric"
@@ -540,7 +548,7 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
})}
<div className="grid gap-1 pt-2 border-t">
<Label>{t("odds.rebateRate", { ns: "config" })}</Label>
{isDraft ? (
{canEditDraft ? (
<Input
type="text"
inputMode="decimal"

View File

@@ -10,6 +10,7 @@ import {
getPlayConfigVersion,
getPlayConfigVersions,
postPlayConfigVersion,
patchAdminPlayType,
publishPlayConfigVersion,
putPlayConfigItems,
} from "@/api/admin-config";
@@ -43,6 +44,10 @@ import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_PLAY_SWITCH_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
ConfigVersionSummary,
@@ -55,9 +60,7 @@ type PlayConfigSaveItemPayload = {
category: string;
dimension: number | null;
bet_mode: string | null;
display_name_zh: string;
display_name_en: string | null;
display_name_ne: string | null;
display_name: string;
is_enabled: boolean;
min_bet_amount: number;
max_bet_amount: number;
@@ -117,9 +120,7 @@ function buildPlayConfigSavePayload(
category: row.category ?? "",
dimension: row.dimension,
bet_mode: row.bet_mode,
display_name_zh: row.display_name_zh ?? row.play_code,
display_name_en: row.display_name_en ?? null,
display_name_ne: row.display_name_ne ?? null,
display_name: row.display_name ?? row.play_code,
is_enabled: row.is_enabled,
min_bet_amount: row.min_bet_amount,
max_bet_amount: row.max_bet_amount,
@@ -135,6 +136,9 @@ function buildPlayConfigSavePayload(
export function PlayConfigDocScreen() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState("");
@@ -149,9 +153,7 @@ export function PlayConfigDocScreen() {
const [nameDialogOpen, setNameDialogOpen] = useState(false);
const [namePlayCode, setNamePlayCode] = useState<string | null>(null);
const [nameDraftZh, setNameDraftZh] = useState("");
const [nameDraftEn, setNameDraftEn] = useState("");
const [nameDraftNe, setNameDraftNe] = useState("");
const [nameDraft, setNameDraft] = useState("");
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [rulePlayCode, setRulePlayCode] = useState<string | null>(null);
const [ruleDraftZh, setRuleDraftZh] = useState("");
@@ -269,10 +271,25 @@ export function PlayConfigDocScreen() {
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
}
function applyBatchSwitch(group: PlayBatchSwitchGroup, enabled: boolean) {
async function applyPlayToggleInstant(playCode: string, enabled: boolean) {
try {
await patchAdminPlayType(playCode, { is_enabled: enabled });
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : t("play.toggleInstantFailed", { ns: "config" }),
);
throw e;
}
}
async function applyBatchSwitch(group: PlayBatchSwitchGroup, enabled: boolean) {
const targets = draftRows.filter(group.match);
setDraftRows((prev) =>
prev.map((row) => (group.match(row) ? { ...row, is_enabled: enabled } : row)),
);
for (const row of targets) {
await applyPlayToggleInstant(row.play_code, enabled);
}
}
const batchSwitchStates = useMemo(
@@ -359,9 +376,7 @@ export function PlayConfigDocScreen() {
function openNameEditor(play_code: string) {
const item = draftRows.find((row) => row.play_code === play_code);
setNamePlayCode(play_code);
setNameDraftZh(item?.display_name_zh ?? item?.play_code ?? "");
setNameDraftEn(item?.display_name_en ?? "");
setNameDraftNe(item?.display_name_ne ?? "");
setNameDraft(item?.display_name ?? item?.play_code ?? "");
setNameDialogOpen(true);
}
@@ -369,15 +384,13 @@ export function PlayConfigDocScreen() {
if (!namePlayCode) {
return;
}
const zh = nameDraftZh.trim();
if (!zh) {
toast.error(t("play.validation.nameZhRequired", { ns: "config" }));
const name = nameDraft.trim();
if (!name) {
toast.error(t("play.validation.displayNameRequired", { ns: "config" }));
return;
}
updateConfigRow(namePlayCode, {
display_name_zh: zh,
display_name_en: nameDraftEn.trim() || null,
display_name_ne: nameDraftNe.trim() || null,
display_name: name,
});
setNameDialogOpen(false);
setNamePlayCode(null);
@@ -408,26 +421,8 @@ export function PlayConfigDocScreen() {
}
function renderDisplayNameReadonly(row: PlayConfigItemRow) {
const lines = [
{ label: t("play.locales.zh", { ns: "config" }), value: row.display_name_zh },
{ label: t("play.locales.en", { ns: "config" }), value: row.display_name_en },
{ label: t("play.locales.ne", { ns: "config" }), value: row.display_name_ne },
].filter((line) => line.value?.trim());
if (lines.length === 0) {
return <span></span>;
}
return (
<div className="space-y-0.5 text-center text-sm">
{lines.map((line) => (
<p key={line.label}>
<span className="text-muted-foreground text-xs">{line.label}: </span>
{line.value}
</p>
))}
</div>
);
const name = row.display_name?.trim();
return <span>{name || row.play_code}</span>;
}
const activeHead = list.find((x) => x.status === "active");
@@ -461,13 +456,22 @@ export function PlayConfigDocScreen() {
actions={
<ConfigVersionActions
isDraft={isDraft}
canManage={canManage}
loadingList={loadingList}
loadingDetail={loadingDetail}
saving={saving}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSaveDraft()}
onPublish={() => void handlePublish()}
onPublish={() =>
requestConfirm({
title: t("play.publishDialog.title", { ns: "config" }),
description: t("play.publishDialog.description", { ns: "config" }),
confirmLabel: t("play.publishDialog.confirm", { ns: "config" }),
confirmVariant: "destructive",
onConfirm: () => handlePublish(),
})
}
/>
}
/>
@@ -519,7 +523,23 @@ export function PlayConfigDocScreen() {
size="sm"
variant={group.allEnabled ? "secondary" : "outline"}
disabled={!isDraft || saving || group.total === 0}
onClick={() => applyBatchSwitch(group, !group.allEnabled)}
onClick={() => {
const enable = !group.allEnabled;
const action = enable
? t("play.batchSwitchEnable", { ns: "config" })
: t("play.batchSwitchDisable", { ns: "config" });
requestConfirm({
title: t("play.batchSwitchConfirmTitle", { ns: "config", action }),
description: t("play.batchSwitchConfirmDescription", {
ns: "config",
action,
group: group.label,
count: group.total,
}),
confirmVariant: enable ? "default" : "destructive",
onConfirm: () => applyBatchSwitch(group, enable),
});
}}
>
{group.allEnabled
? t("play.actions.disable", { ns: "config" })
@@ -560,7 +580,23 @@ export function PlayConfigDocScreen() {
checked={row.is_enabled}
disabled={saving}
onCheckedChange={(v) => {
updateConfigRow(row.play_code, { is_enabled: v === true });
const enabled = v === true;
const action = enabled
? t("play.toggleEnable", { ns: "config" })
: t("play.toggleDisable", { ns: "config" });
requestConfirm({
title: t("play.toggleConfirmTitle", {
ns: "config",
action,
playCode: row.play_code,
}),
description: t("play.toggleConfirmDescription", { ns: "config" }),
confirmVariant: enabled ? "default" : "destructive",
onConfirm: () => {
updateConfigRow(row.play_code, { is_enabled: enabled });
void applyPlayToggleInstant(row.play_code, enabled);
},
});
}}
aria-label={t("play.aria.enablePlay", { ns: "config", playCode: row.play_code })}
/>
@@ -578,7 +614,7 @@ export function PlayConfigDocScreen() {
{isDraft ? (
<div className="flex flex-col items-center gap-1.5">
<p className="max-w-[10rem] truncate text-sm font-medium">
{row.display_name_zh ?? row.play_code}
{row.display_name ?? row.play_code}
</p>
<Button
type="button"
@@ -588,7 +624,7 @@ export function PlayConfigDocScreen() {
disabled={saving}
onClick={() => openNameEditor(row.play_code)}
>
{t("play.actions.displayNames", { ns: "config" })}
{t("play.actions.editDisplayName", { ns: "config" })}
</Button>
</div>
) : (
@@ -688,31 +724,13 @@ export function PlayConfigDocScreen() {
{t("play.nameDialog.description", { ns: "config", playCode: namePlayCode ?? "—" })}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div className="grid gap-1.5">
<Label htmlFor="name-zh">{t("play.locales.zh", { ns: "config" })}</Label>
<Input
id="name-zh"
value={nameDraftZh}
onChange={(e) => setNameDraftZh(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="name-en">{t("play.locales.en", { ns: "config" })}</Label>
<Input
id="name-en"
value={nameDraftEn}
onChange={(e) => setNameDraftEn(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="name-ne">{t("play.locales.ne", { ns: "config" })}</Label>
<Input
id="name-ne"
value={nameDraftNe}
onChange={(e) => setNameDraftNe(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="play-display-name">{t("play.table.displayName", { ns: "config" })}</Label>
<Input
id="play-display-name"
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setNameDialogOpen(false)}>
@@ -774,6 +792,7 @@ export function PlayConfigDocScreen() {
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog />
</ConfigDocPage>
);
}

View File

@@ -1,5 +1,7 @@
/** Prize scope order, including starter and consolation. */
import type { TFunction } from "i18next";
export const PRIZE_SCOPE_ORDER = [
"first",
"second",
@@ -10,16 +12,13 @@ export const PRIZE_SCOPE_ORDER = [
export type PrizeScopeCode = (typeof PRIZE_SCOPE_ORDER)[number];
export const PRIZE_SCOPE_LABELS: Record<PrizeScopeCode, string> = {
first: "First Prize Odds",
second: "Second Prize Odds",
third: "Third Prize Odds",
starter: "Starter Prize Odds",
consolation: "Consolation Prize Odds",
};
/** Display-only multiplier hints for starter and consolation grouped prizes. */
export const PRIZE_SCOPE_MULTIPLIER_HINT: Partial<Record<PrizeScopeCode, string>> = {
starter: "× 10",
consolation: "× 10",
};
/** Localized prize-scope label for odds / rebate config screens. */
export function prizeScopeLabel(scope: PrizeScopeCode, t: TFunction): string {
return t(`prizeScopes.${scope}`, { ns: "config" });
}

View File

@@ -23,6 +23,10 @@ import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_REBATE_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminPlayTypeRow,
@@ -54,6 +58,9 @@ type RebateConfigDocScreenProps = {
export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScreenProps) {
const { t } = useTranslation(["config", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_REBATE_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [listRows, setListRows] = useState<ConfigVersionSummary[]>([]);
@@ -162,6 +169,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
const isDraft = selectedStatus === "draft";
const canEditDraft = isDraft && canManage;
function applyDimensionPercentsToRows(rows: OddsItemRow[]): OddsItemRow[] {
const r2 = Number.parseFloat(p2);
@@ -179,7 +187,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
}
async function handleSave() {
if (!detail || !isDraft) {
if (!detail || !canEditDraft) {
return;
}
setSaving(true);
@@ -211,7 +219,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
}
async function handlePublish() {
if (!detail || !isDraft) {
if (!detail || !canEditDraft) {
return;
}
setSaving(true);
@@ -286,6 +294,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
actions={
<ConfigVersionActions
isDraft={isDraft}
canManage={canManage}
loadingList={loading}
loadingDetail={loadingDetail}
saving={saving}
@@ -293,7 +302,15 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
onPublish={() => void handlePublish()}
onPublish={() =>
requestConfirm({
title: t("rebate.publishDialog.title", { ns: "config" }),
description: t("rebate.publishDialog.description", { ns: "config" }),
confirmLabel: t("rebate.publishDialog.confirm", { ns: "config" }),
confirmVariant: "destructive",
onConfirm: () => handlePublish(),
})
}
/>
}
/>
@@ -326,7 +343,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
<div className="grid gap-5 sm:grid-cols-3">
<div className="grid gap-2">
<Label>{t("rebate.fields.d2", { ns: "config" })}</Label>
{isDraft ? (
{canEditDraft ? (
<Input
type="number"
step="0.01"
@@ -342,7 +359,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
</div>
<div className="grid gap-2">
<Label>{t("rebate.fields.d3", { ns: "config" })}</Label>
{isDraft ? (
{canEditDraft ? (
<Input
type="number"
step="0.01"
@@ -358,7 +375,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
</div>
<div className="grid gap-2">
<Label>{t("rebate.fields.d4", { ns: "config" })}</Label>
{isDraft ? (
{canEditDraft ? (
<Input
type="number"
step="0.01"
@@ -409,6 +426,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
<div className="space-y-6">
{contextBlock}
{fieldsBlock}
<ConfirmDialog />
</div>
);
}
@@ -420,6 +438,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
context={contextBlock}
>
{fieldsBlock}
<ConfirmDialog />
</ConfigDocPage>
);
}

View File

@@ -39,6 +39,10 @@ import {
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_RISK_CAP_MANAGE, PRD_RISK_CAP_VIEW } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
ConfigVersionSummary,
@@ -74,6 +78,9 @@ function defaultRiskRowFromAmount(amount: number): DraftRiskRow {
export function RiskCapDocScreen() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_RISK_CAP_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState("");
@@ -177,6 +184,7 @@ export function RiskCapDocScreen() {
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
const isDraft = selectedStatus === "draft";
const canEditDraft = isDraft && canManage;
const updateRow = (idx: number, patch: Partial<DraftRiskRow>) => {
setDraftRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
@@ -187,7 +195,7 @@ export function RiskCapDocScreen() {
}
async function handleSave() {
if (!detail || !isDraft) {
if (!detail || !canEditDraft) {
return;
}
if (draftRows.length === 0) {
@@ -236,7 +244,7 @@ export function RiskCapDocScreen() {
}
async function handlePublish() {
if (!detail || !isDraft) {
if (!detail || !canEditDraft) {
return;
}
setSaving(true);
@@ -347,13 +355,22 @@ export function RiskCapDocScreen() {
actions={
<ConfigVersionActions
isDraft={isDraft}
canManage={canManage}
loadingList={loadingList}
loadingDetail={loadingDetail}
saving={saving}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
onPublish={() => void handlePublish()}
onPublish={() =>
requestConfirm({
title: t("riskCap.publishDialog.title", { ns: "config" }),
description: t("riskCap.publishDialog.description", { ns: "config" }),
confirmLabel: t("riskCap.publishDialog.confirm", { ns: "config" }),
confirmVariant: "destructive",
onConfirm: () => handlePublish(),
})
}
/>
}
/>
@@ -379,7 +396,7 @@ export function RiskCapDocScreen() {
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1">
<Label htmlFor="default-cap">{t("riskCap.defaultCap.fieldLabel", { ns: "config" })}</Label>
{isDraft ? (
{canEditDraft ? (
<Input
id="default-cap"
type="number"
@@ -395,7 +412,7 @@ export function RiskCapDocScreen() {
</ConfigReadonlyValue>
)}
</div>
{isDraft ? (
{canEditDraft ? (
<Button type="button" variant="secondary" disabled={saving} onClick={() => setSyncOpen(true)}>
{t("riskCap.actions.update", { ns: "config" })}
</Button>
@@ -406,7 +423,7 @@ export function RiskCapDocScreen() {
<ConfigSection
title={t("riskCap.specialCaps.title", { ns: "config" })}
actions={
isDraft ? (
canEditDraft ? (
<Button
type="button"
variant="outline"
@@ -438,7 +455,7 @@ export function RiskCapDocScreen() {
{specialRows.map(({ row: r, index: idx }) => (
<TableRow key={r.clientKey}>
<TableCell>
{isDraft ? (
{canEditDraft ? (
<Input
className="h-8 font-mono tabular-nums"
maxLength={4}
@@ -455,7 +472,7 @@ export function RiskCapDocScreen() {
)}
</TableCell>
<TableCell>
{isDraft ? (
{canEditDraft ? (
<Input
type="number"
min={0}
@@ -476,7 +493,7 @@ export function RiskCapDocScreen() {
<TableCell className="text-right text-muted-foreground tabular-nums text-sm"></TableCell>
<TableCell className="text-center text-muted-foreground text-sm"></TableCell>
<TableCell>
{isDraft ? (
{canEditDraft ? (
<Button
type="button"
variant="ghost"
@@ -568,6 +585,7 @@ export function RiskCapDocScreen() {
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog />
</ConfigDocPage>
);
}

View File

@@ -8,6 +8,7 @@ import {
getAdminSettings,
updateAdminSetting,
} from "@/api/admin-settings";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { Button } from "@/components/ui/button";
import { ConfigDocPage } from "@/modules/config/config-doc-page";
import { Input } from "@/components/ui/input";
@@ -47,7 +48,8 @@ type WalletConfigDocScreenProps = {
};
export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScreenProps) {
const { t } = useTranslation(["config", "adminUsers"]);
const { t } = useTranslation(["config", "adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [draft, setDraft] = useState<Draft>({
inMin: "",
inMax: "",
@@ -170,7 +172,17 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
</div>
</div>
<div className="flex items-center gap-4 pt-2">
<Button onClick={() => void handleSave()} disabled={!dirty || loading || saving}>
<Button
onClick={() =>
requestConfirm({
title: t("wallet.confirmSaveTitle", { ns: "config" }),
description: t("wallet.confirmSaveDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => handleSave(),
})
}
disabled={!dirty || loading || saving}
>
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
</Button>
{dirty && (
@@ -185,6 +197,7 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree
</Button>
)}
</div>
<ConfirmDialog />
</>
);

View File

@@ -0,0 +1,386 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { format, subDays } from "date-fns";
import { useTranslation } from "react-i18next";
import { BarChart3, Gift, TrendingUp, Wallet } from "lucide-react";
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
import { cn } from "@/lib/utils";
import { StatCard } from "@/modules/dashboard/dashboard-visuals";
import {
DailyTrendChart,
PeriodCompareStrip,
PlayBreakdownChart,
} from "@/modules/dashboard/dashboard-trend-charts";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminDashboardAnalyticsData,
DashboardAnalyticsMetric,
DashboardAnalyticsPeriod,
} from "@/types/api/admin-dashboard-analytics";
const PERIOD_OPTIONS: DashboardAnalyticsPeriod[] = [
"today",
"last_7_days",
"last_30_days",
"this_month",
"lifetime",
"custom",
];
const METRIC_OPTIONS: DashboardAnalyticsMetric[] = ["overview", "bet", "payout", "profit"];
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
const code = (currencyCode ?? "NPR").toUpperCase();
const decimals = getAdminCurrencyDecimalPlaces(code);
const major = minor / 10 ** decimals;
try {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: code,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(major);
} catch {
return formatAdminMinorUnits(minor, code, decimals);
}
}
function formatSignedMoneyMinor(minor: number, currencyCode: string | null): string {
if (minor === 0) {
return formatMoneyMinor(0, currencyCode);
}
const s = minor > 0 ? "+" : "";
return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`;
}
export function DashboardAnalyticsPanel({
enabled,
playOptions,
}: {
enabled: boolean;
playOptions: { code: string; label: string }[];
}): ReactElement {
const { t } = useTranslation(["dashboard", "common"]);
const playLabel = useAdminPlayCodeLabel();
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
const [metric, setMetric] = useState<DashboardAnalyticsMetric>("overview");
const [playCode, setPlayCode] = useState<string>("");
const [customFrom, setCustomFrom] = useState(() => format(subDays(new Date(), 6), "yyyy-MM-dd"));
const [customTo, setCustomTo] = useState(() => format(new Date(), "yyyy-MM-dd"));
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<AdminDashboardAnalyticsData | null>(null);
const load = useCallback(async () => {
if (!enabled) {
setLoading(false);
setData(null);
return;
}
setLoading(true);
setError(null);
try {
const payload = await getAdminDashboardAnalytics({
period,
metric,
play_code: playCode !== "" ? playCode : undefined,
...(period === "custom"
? { date_from: customFrom, date_to: customTo }
: {}),
});
setData(payload);
} catch (e) {
setData(null);
const raw = e instanceof LotteryApiBizError ? e.message : "";
const needsAuthSync =
raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
setError(
needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
);
} finally {
setLoading(false);
}
}, [enabled, period, metric, playCode, customFrom, customTo, t]);
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
const currency = data?.currency_code ?? null;
const summary = data?.summary;
const periodRangeLabel = useMemo(() => {
if (!data) {
return null;
}
return data.date_from === data.date_to
? data.date_from
: `${data.date_from}${data.date_to}`;
}, [data]);
const metricLabel = useMemo(
() => t(`analytics.metrics.${metric}`),
[metric, t],
);
const playFilterLabel = useMemo(() => {
if (playCode === "") {
return t("analytics.allPlays");
}
return playOptions.find((p) => p.code === playCode)?.label ?? playCode;
}, [playCode, playOptions, t]);
const resolvePlayLabel = useCallback(
(code: string, dimension: number) => {
const base = playLabel(code);
return dimension > 0 ? `${base} · ${dimension}D` : base;
},
[playLabel],
);
if (!enabled) {
return null;
}
return (
<section className="space-y-4">
<Card className="border-border/80 shadow-sm">
<CardHeader className="space-y-4 pb-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="text-base">{t("analytics.title")}</CardTitle>
<Link
href="/admin/reports"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 gap-1.5 text-xs")}
>
<BarChart3 className="size-3.5" aria-hidden />
{t("viewReports")}
</Link>
</div>
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("analytics.periodLabel")}>
{PERIOD_OPTIONS.map((p) => (
<button
key={p}
type="button"
className={cn(
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
period === p
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:bg-muted",
)}
onClick={() => setPeriod(p)}
>
{t(`analytics.periods.${p}`)}
</button>
))}
</div>
<div className="grid gap-4 lg:grid-cols-[1fr_auto_auto] lg:items-end">
{period === "custom" ? (
<AdminDateRangeField
id="dashboard-analytics-range"
label={t("analytics.customRange")}
from={customFrom}
to={customTo}
onRangeChange={({ from, to }) => {
setCustomFrom(from);
setCustomTo(to);
}}
/>
) : (
<p className="text-sm text-muted-foreground lg:col-span-1">
{periodRangeLabel
? t("analytics.rangeHint", { range: periodRangeLabel })
: t("analytics.selectPeriod")}
</p>
)}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">{t("analytics.metricLabel")}</Label>
<Select value={metric} onValueChange={(v) => setMetric(v as DashboardAnalyticsMetric)}>
<SelectTrigger className="w-full min-w-[140px]">
<SelectValue>{metricLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
{METRIC_OPTIONS.map((m) => (
<SelectItem key={m} value={m}>
{t(`analytics.metrics.${m}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">{t("analytics.playLabel")}</Label>
<Select
value={playCode === "" ? "__all__" : playCode}
onValueChange={(v) => setPlayCode(v === "__all__" ? "" : v)}
>
<SelectTrigger className="w-full min-w-[160px]">
<SelectValue>{playFilterLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("analytics.allPlays")}</SelectItem>
{playOptions.map((p) => (
<SelectItem key={p.code} value={p.code}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{error ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{data?.chart_meta.truncated ? (
<p className="text-xs text-amber-700 dark:text-amber-400">
{t("analytics.chartTruncated", {
from: data.chart_meta.chart_date_from,
to: data.chart_meta.chart_date_to,
days: data.chart_meta.span_days,
})}
</p>
) : null}
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full rounded-xl" />
))}
</div>
) : summary ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label={t("analytics.summaryBet")}
value={formatMoneyMinor(summary.total_bet_minor, currency)}
hint={t("lifetimeActivityHint", {
draws: summary.draw_count.toLocaleString("zh-CN"),
days: summary.business_day_count.toLocaleString("zh-CN"),
})}
icon={<Wallet className="size-5" aria-hidden />}
/>
<StatCard
label={t("analytics.summaryPayout")}
value={formatMoneyMinor(summary.total_payout_minor, currency)}
hint={
summary.total_bet_minor > 0
? t("payoutRateOfBet", {
rate: ((summary.total_payout_minor / summary.total_bet_minor) * 100).toFixed(1),
})
: undefined
}
icon={<Gift className="size-5" aria-hidden />}
accent="destructive"
/>
<StatCard
label={t("analytics.summaryProfit")}
value={formatSignedMoneyMinor(summary.approx_house_gross_minor, currency)}
hint={
summary.total_bet_minor > 0
? t("marginRate", {
rate: ((summary.approx_house_gross_minor / summary.total_bet_minor) * 100).toFixed(1),
})
: undefined
}
icon={<TrendingUp className="size-5" aria-hidden />}
/>
</div>
) : null}
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
<Card className="flex h-full flex-col border-border/80 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("analytics.dailyTrend")}</CardTitle>
</CardHeader>
<CardContent className="pb-4">
{loading ? (
<Skeleton className="h-[220px] w-full" />
) : data ? (
<DailyTrendChart
series={data.daily_series}
metric={metric}
formatMoney={formatMoneyMinor}
currency={currency}
/>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
)}
</CardContent>
</Card>
<Card className="flex h-full flex-col border-border/80 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("analytics.playBreakdown")}</CardTitle>
</CardHeader>
<CardContent className="pb-4">
{loading ? (
<Skeleton className="h-[220px] w-full" />
) : data ? (
<div className="max-h-[280px] overflow-y-auto pr-1">
<PlayBreakdownChart
rows={data.play_breakdown}
metric={metric}
formatMoney={formatMoneyMinor}
currency={currency}
playLabel={resolvePlayLabel}
/>
</div>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
)}
</CardContent>
</Card>
</div>
{data && !loading ? (
<Card className="border-border/80 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("analytics.periodDistribution")}</CardTitle>
</CardHeader>
<CardContent>
<PeriodCompareStrip
series={data.daily_series}
formatMoney={formatMoneyMinor}
currency={currency}
/>
</CardContent>
</Card>
) : null}
</section>
);
}

View File

@@ -2,24 +2,28 @@
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react";
import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { useTranslation } from "react-i18next";
import {
AlertTriangle,
ClipboardList,
Diamond,
FileSearch,
Gift,
RefreshCw,
ScrollText,
Shield,
Ticket,
TrendingUp,
Wallet,
} from "lucide-react";
import { getAdminDashboard } from "@/api/admin-dashboard";
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
import { getAdminPlayTypes } from "@/api/admin-config";
import {
getAdminPlayTypesLoadPromise,
getCachedAdminPlayTypes,
resolveAdminPlayTypeDisplayName,
} from "@/lib/admin-play-types";
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -32,9 +36,10 @@ import {
ResultBatchProgress,
SettlementStatusChart,
SoldOutRing,
StatCard,
} from "@/modules/dashboard/dashboard-visuals";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { normalizeAdminLanguage } from "@/i18n";
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -69,14 +74,6 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
}
}
function formatSignedMoneyMinor(minor: number, currencyCode: string | null): string {
if (minor === 0) {
return formatMoneyMinor(0, currencyCode);
}
const s = minor > 0 ? "+" : "";
return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`;
}
function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
const raw = normalizedNumber.trim();
const digits = raw.replace(/\D/g, "");
@@ -109,18 +106,24 @@ function topPoolsForTab(pools: AdminRiskPoolRow[], tab: HotPlayTab): AdminRiskPo
}
export function DashboardConsole(): ReactElement {
const { t } = useTranslation(["dashboard", "common"]);
const { t, i18n } = useTranslation(["dashboard", "common"]);
useAdminCurrencyCatalog();
const [todayLabel] = useState(() => format(new Date(), "yyyy-MM-dd EEEE", { locale: zhCN }));
useAdminPlayTypeCatalog();
const todayLabel = useMemo(() => {
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" });
return formatAdminCalendarToday(locale, weekday);
}, [i18n.language, i18n.resolvedLanguage, t]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null);
const [drawPanel, setDrawPanel] = useState<AdminDashboardDrawPanel | null>(null);
const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null);
const [capabilities, setCapabilities] = useState<{ draw_finance_risk: boolean; wallet_transfer_view: boolean } | null>(null);
const [pendingReview, setPendingReview] = useState<number | null>(null);
const [riskLocked, setRiskLocked] = useState(0);
const [riskCap, setRiskCap] = useState(0);
@@ -128,6 +131,26 @@ export function DashboardConsole(): ReactElement {
const [soldOutBuckets, setSoldOutBuckets] = useState<SoldOutBuckets | null>(null);
const [abnormalTransferTotal, setAbnormalTransferTotal] = useState<number | null>(null);
const [hotTab, setHotTab] = useState<HotPlayTab>("4D");
const [playOptions, setPlayOptions] = useState<{ code: string; label: string }[]>([]);
const loadPlayOptions = useCallback(async () => {
try {
await getAdminPlayTypesLoadPromise(getAdminPlayTypes);
setPlayOptions(
getCachedAdminPlayTypes().map((item) => ({
code: item.play_code,
label:
resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item) || item.play_code,
})),
);
} catch {
setPlayOptions([]);
}
}, [i18n.language]);
useEffect(() => {
void loadPlayOptions();
}, [loadPlayOptions]);
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
@@ -136,8 +159,8 @@ export function DashboardConsole(): ReactElement {
setLoading(true);
}
setError(null);
setNotice(null);
setFinance(null);
setCapabilities(null);
setDrawPanel(null);
setPendingReview(null);
setDrawId(null);
@@ -155,6 +178,7 @@ export function DashboardConsole(): ReactElement {
setDrawId(d.resolved_draw.id);
}
setCapabilities(d.capabilities);
if (d.finance != null) {
setFinance(d.finance);
}
@@ -169,15 +193,6 @@ export function DashboardConsole(): ReactElement {
setSoldOutBuckets(d.risk.sold_out_buckets);
}
setAbnormalTransferTotal(d.abnormal_transfer_total);
const noticeParts: string[] = d.warnings.map((w) => w.message);
if (d.resolved_draw != null && !d.capabilities.draw_finance_risk) {
noticeParts.push(t("warnings.drawPermission"));
}
if (d.hall != null && !d.capabilities.wallet_transfer_view) {
noticeParts.push(t("warnings.walletPermission"));
}
setNotice(noticeParts.length > 0 ? noticeParts.join(" ") : null);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : t("warnings.loadFailed");
@@ -196,6 +211,7 @@ export function DashboardConsole(): ReactElement {
}, [load]);
const currency = finance?.currency_code ?? null;
const canFinance = capabilities?.draw_finance_risk ?? false;
const usagePct = riskCap > 0 ? (riskLocked / riskCap) * 100 : 0;
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
@@ -218,16 +234,6 @@ export function DashboardConsole(): ReactElement {
{ href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: <ScrollText className="size-5" /> },
];
const kpiSkeleton = (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border/80 bg-card p-5 shadow-sm">
<Skeleton className="h-20 w-full" />
</div>
))}
</div>
);
return (
<div className="space-y-6 pb-10">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -242,7 +248,7 @@ export function DashboardConsole(): ReactElement {
onClick={() => void load(true)}
>
<RefreshCw className={refreshing ? "size-4 animate-spin" : "size-4"} />
{t("refresh")}
{t("actions.refresh", { ns: "common" })}
</Button>
</div>
</div>
@@ -254,69 +260,48 @@ export function DashboardConsole(): ReactElement {
</Alert>
) : null}
{notice && !error ? (
<Alert className="border-sky-200 bg-sky-50 dark:border-sky-900/50 dark:bg-sky-950/30">
{!loading && capabilities && !capabilities.draw_finance_risk ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{notice}</AlertDescription>
<AlertDescription>{t("warnings.drawPermission")}</AlertDescription>
</Alert>
) : null}
{loading ? (
kpiSkeleton
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label={t("todayBetTotal")}
value={finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"}
hint={hall?.draw_no ? t("drawNoHint", { drawNo: hall.draw_no }) : undefined}
icon={<Wallet className="size-5" aria-hidden />}
/>
<StatCard
label={t("currentPayout")}
value={finance ? formatMoneyMinor(finance.total_payout_minor, currency) : "—"}
hint={
finance
? t("orderAndTicket", {
orders: finance.order_count.toLocaleString("zh-CN"),
tickets: finance.ticket_item_count.toLocaleString("zh-CN"),
})
: undefined
}
icon={<Gift className="size-5" aria-hidden />}
accent="destructive"
/>
<StatCard
label={t("currentProfit")}
value={finance ? formatSignedMoneyMinor(finance.approx_house_gross_minor, currency) : "—"}
hint={finance && finance.total_bet_minor > 0
? t("marginRate", {
rate: ((finance.approx_house_gross_minor / finance.total_bet_minor) * 100).toFixed(1),
})
: undefined}
icon={<TrendingUp className="size-5" aria-hidden />}
/>
<StatCard
label={t("currentDraw")}
value={<span className="font-mono text-primary">{hall?.draw_no ?? "—"}</span>}
hint={
<span className="inline-flex flex-wrap items-center gap-2">
<span>{t("drawSequence", { sequence: hall?.sequence_no ?? "—" })}</span>
<span className="inline-flex items-center gap-1.5">
<span
className={cn(
"size-1.5 rounded-full",
isOpenLike ? "bg-emerald-500" : "bg-muted-foreground",
)}
/>
{hallStatusLabel}
</span>
</span>
}
icon={<Ticket className="size-5" aria-hidden />}
accent="muted"
/>
{!loading && hall ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/80 bg-card px-4 py-3 shadow-sm">
<div className="flex flex-wrap items-center gap-3">
<Ticket className="size-5 text-primary" aria-hidden />
<div>
<p className="text-xs text-muted-foreground">{t("sections.currentDraw")}</p>
<p className="font-mono text-lg font-semibold text-foreground">{hall.draw_no}</p>
</div>
<span className="text-sm text-muted-foreground">
{t("drawSequence", { sequence: hall.sequence_no ?? "—" })}
</span>
<span className="inline-flex items-center gap-1.5 text-sm">
<span
className={cn(
"size-1.5 rounded-full",
isOpenLike ? "bg-emerald-500" : "bg-muted-foreground",
)}
/>
{hallStatusLabel}
</span>
</div>
{drawId != null ? (
<Link
href={`/admin/draws/${drawId}/finance`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "text-xs")}
>
{t("drawFinanceDetails")}
</Link>
) : null}
</div>
)}
) : null}
<DashboardAnalyticsPanel enabled={canFinance} playOptions={playOptions} />
<h2 className="text-sm font-semibold tracking-wide text-muted-foreground">{t("sections.operations")}</h2>
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
<Card className="border-border/80 shadow-sm xl:col-span-1">

View File

@@ -0,0 +1,260 @@
"use client";
import type { ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import type { AdminDashboardAnalyticsPlayRow } from "@/types/api/admin-dashboard-analytics";
import type { AdminReportDailyProfitRow } from "@/types/api/admin-reports";
import type { DashboardAnalyticsMetric } from "@/types/api/admin-dashboard-analytics";
type MoneyFormatter = (minor: number, currency: string | null) => string;
function metricValue(row: AdminReportDailyProfitRow, metric: DashboardAnalyticsMetric): number {
switch (metric) {
case "bet":
return row.total_bet_minor;
case "payout":
return row.total_payout_minor;
case "profit":
return row.approx_house_gross_minor;
default:
return row.total_bet_minor;
}
}
function playMetricValue(row: AdminDashboardAnalyticsPlayRow, metric: DashboardAnalyticsMetric): number {
switch (metric) {
case "bet":
return row.total_bet_minor;
case "payout":
return row.total_payout_minor;
case "profit":
return row.approx_house_gross_minor;
default:
return row.total_bet_minor;
}
}
export function DailyTrendChart({
series,
metric,
formatMoney,
currency,
}: {
series: AdminReportDailyProfitRow[];
metric: DashboardAnalyticsMetric;
formatMoney: MoneyFormatter;
currency: string | null;
}): ReactElement {
const { t } = useTranslation("dashboard");
if (series.length === 0) {
return <p className="py-10 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>;
}
const maxBet = Math.max(...series.map((d) => d.total_bet_minor), 1);
const maxPayout = Math.max(...series.map((d) => d.total_payout_minor), 1);
const maxProfit = Math.max(...series.map((d) => Math.abs(d.approx_house_gross_minor)), 1);
const labelEvery = series.length > 14 ? Math.ceil(series.length / 7) : 1;
const plotHeight = series.length <= 7 ? 200 : series.length <= 14 ? 220 : 240;
return (
<div className="flex flex-col gap-3">
{metric === "overview" ? (
<div className="flex shrink-0 flex-wrap gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span className="size-2.5 rounded-sm bg-primary" />
{t("chartLegend.bet")}
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2.5 rounded-sm bg-rose-500" />
{t("chartLegend.payout")}
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2.5 rounded-sm bg-emerald-500" />
{t("chartLegend.profit")}
</span>
</div>
) : null}
<div
className="flex items-end gap-1 overflow-x-auto rounded-md border border-border/60 bg-muted/20 px-2 pb-2 pt-3 sm:gap-1.5"
style={{ height: plotHeight }}
>
{series.map((day, idx) => {
const betH = (day.total_bet_minor / maxBet) * 100;
const payoutH = (day.total_payout_minor / maxPayout) * 100;
const profitRaw = day.approx_house_gross_minor;
const profitH = (Math.abs(profitRaw) / maxProfit) * 100;
const showLabel = idx % labelEvery === 0 || idx === series.length - 1;
const shortDate = day.business_date.slice(5);
return (
<div
key={day.business_date}
className="flex min-w-[28px] flex-1 flex-col items-stretch justify-end gap-1 self-stretch"
title={`${day.business_date}\n${t("todayBetTotal")}: ${formatMoney(day.total_bet_minor, currency)}\n${t("todayPayout")}: ${formatMoney(day.total_payout_minor, currency)}\n${t("todayProfit")}: ${formatMoney(day.approx_house_gross_minor, currency)}`}
>
<div className="flex w-full flex-1 items-end justify-center gap-0.5">
{metric === "overview" ? (
<>
<div
className="w-[30%] min-w-[4px] rounded-t-sm bg-primary/90 transition-all"
style={{ height: `${Math.max(betH, day.total_bet_minor > 0 ? 4 : 0)}%` }}
/>
<div
className="w-[30%] min-w-[4px] rounded-t-sm bg-rose-500/90 transition-all"
style={{ height: `${Math.max(payoutH, day.total_payout_minor > 0 ? 4 : 0)}%` }}
/>
<div
className={cn(
"w-[30%] min-w-[4px] rounded-t-sm transition-all",
profitRaw >= 0 ? "bg-emerald-500/90" : "bg-amber-500/90",
)}
style={{ height: `${Math.max(profitH, profitRaw !== 0 ? 4 : 0)}%` }}
/>
</>
) : (
<div
className={cn(
"w-[70%] min-w-[6px] max-w-[20px] rounded-t-md transition-all",
metric === "payout" && "bg-rose-500/90",
metric === "profit" && (profitRaw >= 0 ? "bg-emerald-500/90" : "bg-amber-500/90"),
metric === "bet" && "bg-primary/90",
)}
style={{
height: `${Math.max(
(metricValue(day, metric) / (metric === "bet" ? maxBet : metric === "payout" ? maxPayout : maxProfit)) * 100,
metricValue(day, metric) !== 0 ? 6 : 0,
)}%`,
}}
/>
)}
</div>
<span
className={cn(
"shrink-0 text-center text-[10px] tabular-nums text-muted-foreground",
!showLabel && "invisible",
)}
>
{shortDate}
</span>
</div>
);
})}
</div>
</div>
);
}
export function PlayBreakdownChart({
rows,
metric,
formatMoney,
currency,
playLabel,
}: {
rows: AdminDashboardAnalyticsPlayRow[];
metric: DashboardAnalyticsMetric;
formatMoney: MoneyFormatter;
currency: string | null;
playLabel: (code: string, dimension: number) => string;
}): ReactElement {
const { t } = useTranslation("dashboard");
if (rows.length === 0) {
return <p className="py-10 text-center text-sm text-muted-foreground">{t("analytics.noPlayData")}</p>;
}
const max = Math.max(...rows.map((r) => Math.abs(playMetricValue(r, metric === "overview" ? "bet" : metric))), 1);
const activeMetric = metric === "overview" ? "bet" : metric;
return (
<ul className="space-y-2.5">
{rows.map((row) => {
const value = playMetricValue(row, activeMetric);
const pct = (Math.abs(value) / max) * 100;
const label = playLabel(row.play_code, row.dimension);
return (
<li key={`${row.play_code}-${row.dimension}`}>
<div className="mb-1 flex items-center justify-between gap-2 text-sm">
<span className="truncate font-medium text-foreground">{label}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">{formatMoney(value, currency)}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn(
"h-full rounded-full transition-all",
activeMetric === "payout" && "bg-rose-500",
activeMetric === "profit" && (value >= 0 ? "bg-emerald-500" : "bg-amber-500"),
activeMetric === "bet" && "bg-primary",
)}
style={{ width: `${pct}%` }}
/>
</div>
{metric === "overview" ? (
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">
{t("playBreakdownHint", {
payout: formatMoney(row.total_payout_minor, currency),
profit: formatMoney(row.approx_house_gross_minor, currency),
})}
</p>
) : null}
</li>
);
})}
</ul>
);
}
export function PeriodCompareStrip({
series,
formatMoney,
currency,
}: {
series: AdminReportDailyProfitRow[];
formatMoney: MoneyFormatter;
currency: string | null;
}): ReactElement {
const { t } = useTranslation("dashboard");
const totalBet = series.reduce((s, d) => s + d.total_bet_minor, 0);
const totalPayout = series.reduce((s, d) => s + d.total_payout_minor, 0);
const totalProfit = series.reduce((s, d) => s + d.approx_house_gross_minor, 0);
const max = Math.max(totalBet, totalPayout, Math.abs(totalProfit), 1);
const items = [
{ key: "bet", label: t("chartLegend.bet"), value: totalBet, className: "bg-primary" },
{ key: "payout", label: t("chartLegend.payout"), value: totalPayout, className: "bg-rose-500" },
{
key: "profit",
label: t("chartLegend.profit"),
value: totalProfit,
className: totalProfit >= 0 ? "bg-emerald-500" : "bg-amber-500",
},
];
return (
<div className="grid gap-3 sm:grid-cols-3">
{items.map((item) => (
<div key={item.key} className="rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<span className={cn("size-2.5 rounded-sm", item.className)} />
{item.label}
</span>
<span className="text-sm font-semibold tabular-nums">{formatMoney(item.value, currency)}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", item.className)}
style={{ width: `${(Math.abs(item.value) / max) * 100}%` }}
/>
</div>
</div>
))}
</div>
);
}

View File

@@ -123,8 +123,8 @@ export function FinanceStructureChart({
const payoutRate = ((payout / bet) * 100).toFixed(1);
const segments = [
{ key: "win", width: winW, className: "bg-chart-2", label: t("winPayout"), value: win },
{ key: "jackpot", width: jpW, className: "bg-chart-4", label: t("jackpotPayout"), value: jackpot },
{ key: "win", width: winW, className: "bg-emerald-500", label: t("winPayout"), value: win },
{ key: "jackpot", width: jpW, className: "bg-violet-500", label: t("jackpotPayout"), value: jackpot },
{ key: "gross", width: grossW, className: "bg-primary", label: t("houseGross"), value: gross },
].filter((s) => s.width > 0.05);
@@ -176,9 +176,17 @@ export function PayoutCompositionChart({
}
const winPct = (win / total) * 100;
const winColor = "oklch(0.62 0.17 162)";
const jackpotColor = "oklch(0.56 0.22 303)";
const items = [
{ label: t("winPayout"), value: win, pct: winPct, className: "bg-chart-2" },
{ label: t("jackpotPayout"), value: jackpot, pct: 100 - winPct, className: "bg-chart-4" },
{ label: t("winPayout"), value: win, pct: winPct, className: "bg-emerald-500", color: winColor },
{
label: t("jackpotPayout"),
value: jackpot,
pct: 100 - winPct,
className: "bg-violet-500",
color: jackpotColor,
},
];
return (
@@ -186,7 +194,7 @@ export function PayoutCompositionChart({
<div
className="relative mx-auto size-36 shrink-0 rounded-full"
style={{
background: `conic-gradient(from -90deg, var(--chart-2) 0deg ${winPct * 3.6}deg, var(--chart-4) ${winPct * 3.6}deg 360deg)`,
background: `conic-gradient(from -90deg, ${winColor} 0deg ${winPct * 3.6}deg, ${jackpotColor} ${winPct * 3.6}deg 360deg)`,
mask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
WebkitMask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
}}
@@ -203,7 +211,10 @@ export function PayoutCompositionChart({
</div>
<p className="text-sm font-semibold tabular-nums">{formatMoney(item.value, currency)}</p>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full", item.className)} style={{ width: `${item.pct}%` }} />
<div
className="h-full rounded-full"
style={{ width: `${item.pct}%`, background: item.color }}
/>
</div>
</li>
))}
@@ -249,12 +260,12 @@ export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactEleme
export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
const { t } = useTranslation("dashboard");
const entries: { key: keyof SoldOutBuckets; label: string; color: string }[] = [
{ key: "d4", label: t("soldOutBuckets.d4"), color: "var(--chart-1)" },
{ key: "d3", label: t("soldOutBuckets.d3"), color: "var(--chart-2)" },
{ key: "d2", label: t("soldOutBuckets.d2"), color: "var(--chart-3)" },
{ key: "special", label: t("soldOutBuckets.special"), color: "var(--chart-4)" },
{ key: "other", label: t("soldOutBuckets.other"), color: "var(--chart-5)" },
const entries: { key: keyof SoldOutBuckets; label: string; color: string; swatch: string }[] = [
{ key: "d4", label: t("soldOutBuckets.d4"), color: "oklch(0.52 0.19 264)", swatch: "bg-blue-600" },
{ key: "d3", label: t("soldOutBuckets.d3"), color: "oklch(0.62 0.17 162)", swatch: "bg-emerald-500" },
{ key: "d2", label: t("soldOutBuckets.d2"), color: "oklch(0.72 0.16 75)", swatch: "bg-amber-500" },
{ key: "special", label: t("soldOutBuckets.special"), color: "oklch(0.56 0.22 303)", swatch: "bg-violet-500" },
{ key: "other", label: t("soldOutBuckets.other"), color: "oklch(0.58 0.2 25)", swatch: "bg-rose-500" },
];
const total = entries.reduce((s, e) => s + buckets[e.key], 0);
@@ -307,7 +318,7 @@ export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElem
<li key={e.key}>
<div className="mb-1 flex justify-between text-sm">
<span className="flex items-center gap-2 text-muted-foreground">
<span className="size-2.5 rounded-sm" style={{ background: e.color }} />
<span className={cn("size-2.5 rounded-sm", e.swatch)} />
{e.label}
</span>
<span className="font-medium tabular-nums">
@@ -381,6 +392,25 @@ export function SettlementStatusChart({
const entries = [...counts.entries()].sort((a, b) => b[1] - a[1]);
const max = Math.max(...entries.map((e) => e[1]));
const barTone = (status: string): string => {
switch (status) {
case "pending_review":
return "bg-amber-500";
case "approved":
return "bg-sky-500";
case "paid":
case "completed":
return "bg-emerald-600";
case "running":
return "bg-blue-500";
case "rejected":
case "failed":
return "bg-rose-500";
default:
return "bg-violet-500";
}
};
return (
<ul className="space-y-3">
{entries.map(([status, count]) => (
@@ -391,7 +421,7 @@ export function SettlementStatusChart({
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary/80"
className={cn("h-full rounded-full transition-all", barTone(status))}
style={{ width: `${max > 0 ? (count / max) * 100 : 0}%` }}
/>
</div>

View File

@@ -18,6 +18,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawShowData } from "@/types/api/admin-draws";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
@@ -25,7 +26,11 @@ import { useAdminProfile } from "@/stores/admin-session";
import { cn } from "@/lib/utils";
import { drawResultSourceLabel, drawStatusLabel } from "./draw-display";
import {
drawResultSourceLabel,
drawStatusLabel,
hallPreviewDiffersFromDbStatus,
} from "./draw-display";
import { DrawStatusBadge } from "./draw-status-badge";
import {
PRD_DRAW_REOPEN_MANAGE,
@@ -58,6 +63,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [acting, setActing] = useState<string | null>(null);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
@@ -120,13 +126,15 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
status={data.status}
label={drawStatusLabel(data.status, t)}
/>
<p className="flex flex-wrap items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("hallPreviewStatusLabel")}</span>
<DrawStatusBadge
status={data.hall_preview_status}
label={drawStatusLabel(data.hall_preview_status, t)}
/>
</p>
{hallPreviewDiffersFromDbStatus(data.status, data.hall_preview_status) ? (
<p className="flex flex-wrap items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("hallPreviewStatusLabel")}</span>
<DrawStatusBadge
status={data.hall_preview_status}
label={drawStatusLabel(data.hall_preview_status, t)}
/>
</p>
) : null}
</div>
</div>
</CardHeader>
@@ -186,7 +194,13 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
variant="outline"
size="sm"
disabled={!canManageDraw || acting !== null || !["pending", "open"].includes(data.status)}
onClick={() => void runAction(t("manualClose"), () => postAdminManualCloseDraw(idNum))}
onClick={() =>
requestConfirm({
title: t("confirm.manualCloseTitle"),
description: t("confirm.manualCloseDescription"),
onConfirm: () => runAction(t("manualClose"), () => postAdminManualCloseDraw(idNum)),
})
}
>
{acting === t("manualClose") ? t("processing") : t("manualClose")}
</Button>
@@ -195,7 +209,13 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
variant="outline"
size="sm"
disabled={!canManageDraw || acting !== null || !["pending", "open", "closing", "closed"].includes(data.status)}
onClick={() => void runAction(t("cancelDraw"), () => postAdminCancelDraw(idNum))}
onClick={() =>
requestConfirm({
title: t("confirm.cancelDrawTitle"),
description: t("confirm.cancelDrawDescription"),
onConfirm: () => runAction(t("cancelDraw"), () => postAdminCancelDraw(idNum)),
})
}
>
{acting === t("cancelDraw") ? t("processing") : t("cancelBeforeDraw")}
</Button>
@@ -204,7 +224,13 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
variant="outline"
size="sm"
disabled={!canManageDraw || acting !== null || data.status !== "closed"}
onClick={() => void runAction(t("rngDraw"), () => postAdminRunDrawRng(idNum))}
onClick={() =>
requestConfirm({
title: t("confirm.rngDrawTitle"),
description: t("confirm.rngDrawDescription"),
onConfirm: () => runAction(t("rngDraw"), () => postAdminRunDrawRng(idNum)),
})
}
>
{acting === t("rngDraw") ? t("generating") : t("rngAutoGenerate")}
</Button>
@@ -214,7 +240,14 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
variant="destructive"
size="sm"
disabled={acting !== null || data.status !== "cooldown"}
onClick={() => void runAction(t("reopen"), () => postAdminReopenDraw(idNum))}
onClick={() =>
requestConfirm({
title: t("confirm.reopenTitle"),
description: t("confirm.reopenDescription"),
confirmVariant: "destructive",
onConfirm: () => runAction(t("reopen"), () => postAdminReopenDraw(idNum)),
})
}
>
{acting === t("reopen") ? t("processing") : t("cooldownReopen")}
</Button>
@@ -224,13 +257,20 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
variant="outline"
size="sm"
disabled={!canRunSettlement || acting !== null || data.status !== "settling"}
onClick={() => void runAction(t("runSettlement"), () => postAdminRunDrawSettlement(idNum))}
onClick={() =>
requestConfirm({
title: t("confirm.runSettlementTitle"),
description: t("confirm.runSettlementDescription"),
onConfirm: () => runAction(t("runSettlement"), () => postAdminRunDrawSettlement(idNum)),
})
}
>
{acting === t("runSettlement") ? t("processing") : t("runSettlement")}
</Button>
</CardContent>
</Card>
) : null}
<ConfirmDialog />
</div>
);
}

View File

@@ -3,6 +3,14 @@ type DrawTranslate = (
options?: { ns?: string; index?: number },
) => string;
/** 大厅展示态是否与库内期号状态不同(仅 open 等 tick 修正时可能不同) */
export function hallPreviewDiffersFromDbStatus(
dbStatus: string,
hallPreviewStatus: string,
): boolean {
return dbStatus !== hallPreviewStatus;
}
/** 期号状态文案draws.statusOptions */
export function drawStatusLabel(status: string, t: DrawTranslate): string {
const key = `statusOptions.${status}`;

View File

@@ -27,6 +27,7 @@ import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance
import { toast } from "sonner";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { formatAdminMinorUnits } from "@/lib/money";
@@ -47,6 +48,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [settling, setSettling] = useState(false);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const load = useCallback(async () => {
if (!Number.isFinite(idNum) || idNum < 1) {
@@ -150,7 +152,13 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
type="button"
size="sm"
disabled={!canRunSettlement || settling || data.draw_status !== "settling"}
onClick={() => void runSettlement()}
onClick={() =>
requestConfirm({
title: t("confirm.runSettlementTitle"),
description: t("confirm.runSettlementDescription"),
onConfirm: () => runSettlement(),
})
}
>
{settling ? t("processing") : t("runSettlement")}
</Button>
@@ -222,6 +230,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
)}
</CardContent>
</Card>
<ConfirmDialog />
</div>
);
}

View File

@@ -1,5 +1,6 @@
/** 开奖结果发布权限 slug */
export const PRD_DRAW_RESULT_MANAGE = "prd.draw_result.manage" as const;
export const PRD_DRAW_REOPEN_MANAGE = "prd.draw_reopen.manage" as const;
export const PRD_PAYOUT_MANAGE = "prd.payout.manage" as const;
export const PRD_PAYOUT_REVIEW = "prd.payout.review" as const;
export {
PRD_DRAW_RESULT_MANAGE,
PRD_DRAW_REOPEN_MANAGE,
PRD_PAYOUT_MANAGE,
PRD_PAYOUT_REVIEW,
} from "@/lib/admin-prd";

View File

@@ -17,6 +17,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { cn } from "@/lib/utils";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
@@ -38,6 +39,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [publishing, setPublishing] = useState(false);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
@@ -184,12 +186,20 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
<Button
type="button"
disabled={!canPublish || publishing}
onClick={() => void publish()}
onClick={() =>
requestConfirm({
title: t("confirm.publishTitle"),
description: t("confirm.publishDescription"),
confirmVariant: "destructive",
onConfirm: () => publish(),
})
}
>
{publishing ? t("submitting") : t("confirmPublish")}
</Button>
</CardFooter>
</Card>
<ConfirmDialog />
</div>
);
}

View File

@@ -17,6 +17,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { cn } from "@/lib/utils";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
@@ -56,6 +57,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [savingManual, setSavingManual] = useState(false);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [manualNumbers, setManualNumbers] = useState<string[]>(
() => RESULT_SLOTS.map(() => ""),
);
@@ -172,7 +174,13 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
<Button
type="button"
disabled={!canManageDraw || savingManual || !["closed", "review"].includes(data.draw_status)}
onClick={() => void saveManualDraft()}
onClick={() =>
requestConfirm({
title: t("confirm.saveManualDraftTitle"),
description: t("confirm.saveManualDraftDescription"),
onConfirm: () => saveManualDraft(),
})
}
>
{savingManual ? t("saving") : t("saveDraft")}
</Button>
@@ -224,6 +232,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
)}
</CardContent>
</Card>
<ConfirmDialog />
</div>
);
}

View File

@@ -30,6 +30,7 @@ import {
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { formatAdminMinorUnits } from "@/lib/money";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { cn } from "@/lib/utils";
@@ -75,6 +76,7 @@ export function DrawsIndexConsole() {
const defaultCurrency = "NPR";
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [data, setData] = useState<AdminDrawListData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -148,11 +150,22 @@ export function DrawsIndexConsole() {
}, [load]);
return (
<>
<Card className="admin-list-card">
<CardHeader className="admin-list-header flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="admin-list-title">{t("statusListTitle")}</CardTitle>
{canManageDraw ? (
<Button type="button" onClick={() => void generatePlan()} disabled={generating}>
<Button
type="button"
onClick={() =>
requestConfirm({
title: t("confirm.generatePlanTitle"),
description: t("confirm.generatePlanDescription"),
onConfirm: () => generatePlan(),
})
}
disabled={generating}
>
{generating ? t("generating") : t("generatePlan")}
</Button>
) : null}
@@ -331,5 +344,7 @@ export function DrawsIndexConsole() {
) : null}
</CardContent>
</Card>
<ConfirmDialog />
</>
);
}

View File

@@ -3,10 +3,11 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
/** 奖池单页:池参数 + 流水记录,避免 ConfigDocPage / 内层 Card 重复套娃。 */
/** 奖池单页:池参数 + 流水记录,与列表/设置页共用 admin-list-card 布局。 */
export function JackpotConfigScreen() {
const { t } = useTranslation("jackpot");
@@ -23,20 +24,14 @@ export function JackpotConfigScreen() {
}, []);
return (
<div className="flex flex-col gap-10">
<section className="space-y-4">
<h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
{t("poolsSectionTitle")}
</h2>
<div className="flex w-full max-w-none flex-col gap-6">
<AdminPageCard title={t("poolsSectionTitle")}>
<JackpotPoolsConsole embedded />
</section>
</AdminPageCard>
<section id="jackpot-records" className="scroll-mt-24 space-y-4">
<h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
{t("recordsSectionTitle")}
</h2>
<AdminPageCard id="jackpot-records" title={t("recordsSectionTitle")}>
<JackpotRecordsConsole embedded />
</section>
</AdminPageCard>
</div>
);
}

View File

@@ -8,6 +8,9 @@ import {
postAdminJackpotManualBurst,
putAdminJackpotPool,
} from "@/api/admin-jackpot";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_JACKPOT_MANAGE, PRD_JACKPOT_MANUAL_BURST } from "@/lib/admin-prd";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -20,7 +23,16 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminJackpotPoolRow } from "@/types/api/admin-jackpot";
@@ -34,7 +46,6 @@ type Draft = {
combo_trigger_play_codes: string;
status: string;
manual_burst_draw_id: string;
manual_burst_amount: string;
};
function toDraft(p: AdminJackpotPoolRow): Draft {
@@ -48,7 +59,6 @@ function toDraft(p: AdminJackpotPoolRow): Draft {
combo_trigger_play_codes: p.combo_trigger_play_codes.join(","),
status: String(p.status),
manual_burst_draw_id: "",
manual_burst_amount: "",
};
}
@@ -59,11 +69,16 @@ type JackpotPoolsConsoleProps = {
export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsoleProps) {
const { t } = useTranslation(["jackpot", "common"]);
const profile = useAdminProfile();
const canManageJackpot = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANAGE]);
const canManualBurst = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANUAL_BURST]);
const { request: requestConfirm, ConfirmDialog: ConfirmActionDialog } = useConfirmAction();
const [items, setItems] = useState<AdminJackpotPoolRow[]>([]);
const [drafts, setDrafts] = useState<Record<number, Draft>>({});
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<number | null>(null);
const [burstingId, setBurstingId] = useState<number | null>(null);
const [confirmBurstPoolId, setConfirmBurstPoolId] = useState<number | null>(null);
const load = useCallback(async () => {
setLoading(true);
@@ -131,22 +146,18 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
return;
}
const amount = d.manual_burst_amount.trim()
? Number.parseInt(d.manual_burst_amount, 10)
: undefined;
setBurstingId(p.id);
try {
await postAdminJackpotManualBurst(p.id, {
draw_id: drawId,
amount: amount !== undefined && Number.isFinite(amount) ? amount : undefined,
});
toast.success(t("manualBurstSuccess"));
const res = await postAdminJackpotManualBurst(p.id, { draw_id: drawId });
toast.success(
`${t("manualBurstSuccess")} · ${res.draw_no} · ${res.winner_count} ${t("winnerCount")}`,
);
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("manualBurstFailed"));
} finally {
setBurstingId(null);
setConfirmBurstPoolId(null);
}
};
@@ -164,7 +175,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
className="space-y-4 rounded-xl border border-border/60 bg-muted/10 p-4"
>
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<fieldset disabled={!canManageJackpot} className="grid gap-4 border-0 p-0 sm:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1.5">
<Label htmlFor={`amt-${p.id}`}>{t("currentAmount")}</Label>
<Input
@@ -244,16 +255,31 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end border-t border-border/60 pt-3">
<Button type="button" disabled={savingId === p.id} onClick={() => void save(p)}>
{savingId === p.id ? t("saving") : t("save")}
</Button>
</div>
</fieldset>
{canManageJackpot ? (
<div className="flex justify-end border-t border-border/60 pt-3">
<Button
type="button"
disabled={savingId === p.id}
onClick={() =>
requestConfirm({
title: t("confirmSavePoolTitle"),
description: t("confirmSavePoolDescription"),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => save(p),
})
}
>
{savingId === p.id ? t("saving") : t("save")}
</Button>
</div>
) : null}
{canManualBurst ? (
<div className="rounded-lg border border-amber-200/80 bg-amber-50/80 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
<p className="mb-3 text-xs font-medium text-amber-900 dark:text-amber-200">
<p className="mb-1 text-xs font-medium text-amber-900 dark:text-amber-200">
{t("manualBurst")}
</p>
<p className="mb-3 text-xs text-amber-800/90 dark:text-amber-300/90">{t("manualBurstHint")}</p>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
@@ -264,34 +290,63 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
/>
</div>
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
<Label htmlFor={`burst-amount-${p.id}`}>{t("manualBurstAmount")}</Label>
<Input
id={`burst-amount-${p.id}`}
className="font-mono"
value={d.manual_burst_amount}
onChange={(e) => updateDraft(p.id, { manual_burst_amount: e.target.value })}
/>
</div>
<Button
type="button"
variant="destructive"
className="shrink-0 sm:ml-auto"
disabled={burstingId === p.id}
onClick={() => void manualBurst(p)}
onClick={() => setConfirmBurstPoolId(p.id)}
>
{burstingId === p.id ? t("processing") : t("manualBurst")}
</Button>
</div>
</div>
) : null}
</div>
);
})}
</div>
);
const confirmPool = confirmBurstPoolId !== null ? items.find((p) => p.id === confirmBurstPoolId) : null;
const confirmDraft = confirmPool ? drafts[confirmPool.id] : null;
const confirmDialog = (
<Dialog open={confirmBurstPoolId !== null} onOpenChange={(open) => !open && setConfirmBurstPoolId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("manualBurstConfirmTitle")}</DialogTitle>
<DialogDescription>
{t("manualBurstConfirmDescription", {
drawId: confirmDraft?.manual_burst_draw_id ?? "—",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setConfirmBurstPoolId(null)}>
{t("cancel")}
</Button>
<Button
type="button"
variant="destructive"
disabled={confirmPool === undefined || burstingId !== null}
onClick={() => confirmPool && void manualBurst(confirmPool)}
>
{t("manualBurstConfirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
if (embedded) {
return poolList;
return (
<>
{poolList}
{confirmDialog}
<ConfirmActionDialog />
</>
);
}
return (
@@ -302,6 +357,8 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
</CardHeader>
<CardContent>{poolList}</CardContent>
</Card>
{confirmDialog}
<ConfirmActionDialog />
</ModuleScaffold>
);
}

View File

@@ -155,8 +155,8 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
};
const filterBlock = embedded ? (
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex max-w-xs flex-1 flex-col gap-1.5">
<div className="admin-list-toolbar mb-0 border-t-0 pt-0">
<div className="admin-list-field max-w-xs flex-1">
<Label htmlFor="jk-draw">{t("drawNo")}</Label>
<Input
id="jk-draw"
@@ -166,9 +166,11 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
placeholder={t("optional")}
/>
</div>
<Button type="button" onClick={applyDraw}>
{t("apply")}
</Button>
<div className="admin-list-actions">
<Button type="button" onClick={applyDraw}>
{t("apply")}
</Button>
</div>
</div>
) : (
<Card className="mb-6">

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -9,6 +10,8 @@ import {
deleteAdminPlayer,
getAdminPlayers,
postAdminPlayer,
postAdminPlayerFreeze,
postAdminPlayerUnfreeze,
putAdminPlayer,
} from "@/api/admin-player";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
@@ -27,6 +30,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_PLAYER_FREEZE_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import {
Select,
@@ -63,10 +67,12 @@ const PLAYER_STATUS_OPTIONS = [
export function PlayersConsole(): React.ReactElement {
const { t } = useTranslation(["players", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const exportLabels = useExportLabels("players");
const profile = useAdminProfile();
useAdminCurrencyCatalog();
const canManagePlayers = adminHasAnyPermission(profile?.permissions, ["prd.users.manage"]);
const canManagePlayers = adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
const canFreezePlayers = adminHasAnyPermission(profile?.permissions, [PRD_PLAYER_FREEZE_MANAGE]);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [keyword, setKeyword] = useState("");
@@ -91,6 +97,7 @@ export function PlayersConsole(): React.ReactElement {
const [deleteTarget, setDeleteTarget] = useState<AdminPlayerRow | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [freezeBusyId, setFreezeBusyId] = useState<number | null>(null);
const editingPlayer = useMemo(
() => items.find((p) => p.id === editingAccountId) ?? null,
@@ -226,6 +233,28 @@ export function PlayersConsole(): React.ReactElement {
}
}
async function toggleFreeze(row: AdminPlayerRow, freeze: boolean): Promise<void> {
setFreezeBusyId(row.id);
try {
const updated = freeze
? await postAdminPlayerFreeze(row.id)
: await postAdminPlayerUnfreeze(row.id);
setItems((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
const name = updated.username ?? updated.site_player_id;
toast.success(freeze ? t("freezeSuccess", { name }) : t("unfreezeSuccess", { name }));
} catch (e) {
const msg =
e instanceof LotteryApiBizError
? e.message
: freeze
? t("freezeFailed")
: t("unfreezeFailed");
toast.error(msg);
} finally {
setFreezeBusyId(null);
}
}
async function confirmDelete(): Promise<void> {
if (!deleteTarget) return;
setDeleteBusy(true);
@@ -364,26 +393,66 @@ export function PlayersConsole(): React.ReactElement {
: "—"}
</TableCell>
<TableCell>
{canManagePlayers ? (
{canManagePlayers || canFreezePlayers ? (
<div className="flex flex-wrap gap-1">
<Button
type="button"
size="sm"
variant={
accountOpen && editingAccountId === row.id ? "secondary" : "outline"
}
onClick={() => openEditAccount(row)}
>
{t("edit")}
</Button>
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => setDeleteTarget(row)}
>
{t("delete")}
</Button>
{canFreezePlayers && row.status === 0 ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={freezeBusyId === row.id}
onClick={() => {
const name = row.username ?? row.site_player_id;
requestConfirm({
title: t("confirmFreezeTitle"),
description: t("confirmFreezeDescription", { name }),
onConfirm: () => toggleFreeze(row, true),
});
}}
>
{freezeBusyId === row.id ? t("saving") : t("freeze")}
</Button>
) : null}
{canFreezePlayers && row.status === 1 ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={freezeBusyId === row.id}
onClick={() => {
const name = row.username ?? row.site_player_id;
requestConfirm({
title: t("confirmUnfreezeTitle"),
description: t("confirmUnfreezeDescription", { name }),
onConfirm: () => toggleFreeze(row, false),
});
}}
>
{freezeBusyId === row.id ? t("saving") : t("unfreeze")}
</Button>
) : null}
{canManagePlayers ? (
<>
<Button
type="button"
size="sm"
variant={
accountOpen && editingAccountId === row.id ? "secondary" : "outline"
}
onClick={() => openEditAccount(row)}
>
{t("edit")}
</Button>
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => setDeleteTarget(row)}
>
{t("delete")}
</Button>
</>
) : null}
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
@@ -554,6 +623,7 @@ export function PlayersConsole(): React.ReactElement {
</div>
</DialogContent>
</Dialog>
<ConfirmDialog />
</div>
);
}

View File

@@ -26,6 +26,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { cn } from "@/lib/utils";
@@ -79,6 +80,7 @@ function reconcileTypeLabel(type: string, t: (key: string) => string): string {
export function ReconcileConsole(): React.ReactElement {
const { t } = useTranslation(["reconcile", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]);
const formatTs = useAdminDateTimeFormatter();
@@ -240,7 +242,22 @@ export function ReconcileConsole(): React.ReactElement {
}}
/>
</div>
<Button type="button" className="w-full lg:w-auto" onClick={() => void onCreate()} disabled={submitting}>
<Button
type="button"
className="w-full lg:w-auto"
disabled={submitting}
onClick={() =>
requestConfirm({
title: t("confirmCreateTitle"),
description: t("confirmCreateDescription", {
playerHint: selectedPlayer
? t("confirmCreatePlayer")
: t("confirmCreateAllPlayers"),
}),
onConfirm: () => onCreate(),
})
}
>
{submitting ? t("submitting") : t("createTask")}
</Button>
</div>
@@ -532,6 +549,7 @@ export function ReconcileConsole(): React.ReactElement {
</div>
</DialogContent>
</Dialog>
<ConfirmDialog />
</div>
);
}

View File

@@ -38,9 +38,11 @@ import {
import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk";
import { getAdminUsers } from "@/api/admin-users";
import { getAdminTransferOrders } from "@/api/admin-wallet";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -365,6 +367,9 @@ function resultRowCount(result: ReportResult | null): number {
export function ReportsConsole() {
const { t, i18n } = useTranslation(["reports", "common"]);
const profile = useAdminProfile();
const canViewReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_VIEW]);
const canExportReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_EXPORT]);
useAdminCurrencyCatalog();
useAdminPlayTypeCatalog();
const playCodeLabel = useAdminPlayCodeLabel();
@@ -446,6 +451,9 @@ export function ReportsConsole() {
}, [search.open, search.query, loadSearchOptions]);
const queryReport = useCallback(async () => {
if (!canViewReports) {
return;
}
setLoading(true);
setError(null);
try {
@@ -739,7 +747,7 @@ export function ReportsConsole() {
} finally {
setLoading(false);
}
}, [filters, page, perPage, selectedReport, t]);
}, [canViewReports, filters, page, perPage, selectedReport, t]);
useEffect(() => {
setResult(null);
@@ -766,6 +774,9 @@ export function ReportsConsole() {
}
function exportReport(format: ExportFormat): void {
if (!canExportReports) {
return;
}
if (!result || result.rows.length === 0) {
toast.info(t("empty"));
return;
@@ -1173,15 +1184,6 @@ export function ReportsConsole() {
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h1 className="text-lg font-semibold tracking-tight text-[#13315f]">{t("title")}</h1>
</div>
<Badge variant="outline" className="h-7 px-3">
{resultRowCount(result)} {t("preview.exportableRows")}
</Badge>
</div>
<div className="grid gap-5 lg:grid-cols-[18rem_minmax(0,1fr)]">
<Card className="admin-list-card self-start">
<CardHeader className="admin-list-header pb-4">
@@ -1233,7 +1235,7 @@ export function ReportsConsole() {
</Button>
<Button
type="button"
disabled={!selectedReport.connected || loading}
disabled={!canViewReports || !selectedReport.connected || loading}
onClick={() => {
setPage(1);
void queryReport();
@@ -1267,11 +1269,20 @@ export function ReportsConsole() {
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
</div>
<div className="flex shrink-0 gap-2">
<Button type="button" variant="outline" disabled={!result || exporting !== null} onClick={() => exportReport("csv")}>
<Button
type="button"
variant="outline"
disabled={!canExportReports || !result || exporting !== null}
onClick={() => exportReport("csv")}
>
<FileDown data-icon="inline-start" />
{t("formats.csv")}
</Button>
<Button type="button" disabled={!result || exporting !== null} onClick={() => exportReport("excel")}>
<Button
type="button"
disabled={!canExportReports || !result || exporting !== null}
onClick={() => exportReport("excel")}
>
<FileSpreadsheet data-icon="inline-start" />
{t("formats.excel")}
</Button>

View File

@@ -4,12 +4,13 @@ import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAdminDraw } from "@/api/admin-draws";
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "@/modules/draws/draw-display";
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawShowData } from "@/types/api/admin-draws";
export function RiskDrawHeader({ drawId }: { drawId: number }) {
const { t } = useTranslation("risk");
const { t } = useTranslation(["risk", "draws"]);
const [draw, setDraw] = useState<AdminDrawShowData | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -47,10 +48,19 @@ export function RiskDrawHeader({ drawId }: { drawId: number }) {
</h1>
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{t("databaseStatus")}</span>
<DrawStatusBadge status={draw.status} />
<span className="text-xs opacity-80">
{t("hallPreviewStatus", { status: draw.hall_preview_status })}
</span>
<DrawStatusBadge
status={draw.status}
label={drawStatusLabel(draw.status, t)}
/>
{hallPreviewDiffersFromDbStatus(draw.status, draw.hall_preview_status) ? (
<>
<span>{t("hallPreviewStatusLabel", { ns: "draws" })}</span>
<DrawStatusBadge
status={draw.hall_preview_status}
label={drawStatusLabel(draw.hall_preview_status, t)}
/>
</>
) : null}
</p>
</div>
);

View File

@@ -32,7 +32,11 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_DRAW_RESULT_MANAGE, PRD_RISK_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { formatAdminMinorUnits } from "@/lib/money";
import { cn } from "@/lib/utils";
@@ -77,6 +81,12 @@ export function RiskPoolsConsole({
allowSortChange = false,
}: RiskPoolsConsoleProps) {
const { t } = useTranslation(["risk", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canManageRiskPools = adminHasAnyPermission(profile?.permissions, [
PRD_RISK_MANAGE,
PRD_DRAW_RESULT_MANAGE,
]);
const pageTitle = titleKey ? t(titleKey) : (title ?? t("poolsTitle"));
const exportLabels = useExportLabels("riskPools");
useAdminCurrencyCatalog();
@@ -148,6 +158,7 @@ export function RiskPoolsConsole({
);
return (
<>
<Card className="admin-list-card">
<CardHeader className="admin-list-header space-y-3">
<CardTitle className="admin-list-title">{pageTitle}</CardTitle>
@@ -292,15 +303,28 @@ export function RiskPoolsConsole({
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
type="button"
size="sm"
variant={row.is_sold_out ? "outline" : "destructive"}
disabled={acting}
onClick={() => void handleManualStatus(row)}
>
{row.is_sold_out ? t("recover") : t("close")}
</Button>
{canManageRiskPools ? (
<Button
type="button"
size="sm"
variant={row.is_sold_out ? "outline" : "destructive"}
disabled={acting}
onClick={() =>
requestConfirm({
title: row.is_sold_out
? t("confirm.recoverTitle")
: t("confirm.closeTitle"),
description: row.is_sold_out
? t("confirm.recoverDescription", { number: row.normalized_number })
: t("confirm.closeDescription", { number: row.normalized_number }),
confirmVariant: row.is_sold_out ? "default" : "destructive",
onConfirm: () => handleManualStatus(row),
})
}
>
{row.is_sold_out ? t("recover") : t("close")}
</Button>
) : null}
<Link
href={`/admin/draws/${drawId}/risk/pools/${row.normalized_number}`}
className={cn(
@@ -338,5 +362,7 @@ export function RiskPoolsConsole({
)}
</CardContent>
</Card>
<ConfirmDialog />
</>
);
}

View File

@@ -3,6 +3,8 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { PRD_RULES_ODDS_ACCESS_ANY } from "@/lib/admin-prd";
import { ConfigDocPage } from "@/modules/config/config-doc-page";
import { ConfigSection } from "@/modules/config/config-section";
import { OddsConfigDocScreen } from "@/modules/config/doc/odds-config-doc-screen";
@@ -27,14 +29,20 @@ export function RulesOddsConfigScreen() {
return (
<RulesPageShell>
<ConfigDocPage title={t("nav.rulesOddsTitle")} contentClassName="space-y-10">
<ConfigSection title={t("nav.items.odds")}>
<OddsConfigDocScreen embedded />
</ConfigSection>
<ConfigSection id="rebate" title={t("nav.items.rebate")}>
<RebateConfigDocScreen embedded />
</ConfigSection>
</ConfigDocPage>
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
<ConfigDocPage
title={t("nav.rulesOddsTitle")}
description={t("nav.rulesOddsDescription")}
contentClassName="space-y-10"
>
<ConfigSection title={t("nav.items.odds")} description={t("odds.sectionHint")}>
<OddsConfigDocScreen embedded />
</ConfigSection>
<ConfigSection id="rebate" title={t("nav.items.rebate")} description={t("rebate.sectionHint")}>
<RebateConfigDocScreen embedded />
</ConfigSection>
</ConfigDocPage>
</AdminPermissionGate>
</RulesPageShell>
);
}

View File

@@ -11,10 +11,10 @@ import {
postAdminCurrency,
putAdminCurrency,
} from "@/api/admin-currencies";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
@@ -204,16 +204,21 @@ export function CurrencySettingsPanel() {
}
return (
<Card className="admin-list-card">
<CardHeader className="admin-list-header flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="admin-list-title">{t("currencies.title", { ns: "config" })}</CardTitle>
<div className="flex items-center gap-2">
<AdminTableExportButton tableId="admin-currencies-table" filename={exportLabels.filename}
sheetName={exportLabels.sheetName} />
<Button onClick={openCreate}>{t("currencies.actions.create", { ns: "config" })}</Button>
</div>
</CardHeader>
<CardContent className="admin-list-content">
<>
<AdminPageCard
title={t("currencies.title", { ns: "config" })}
description={t("currencies.description", { ns: "config" })}
actions={
<>
<AdminTableExportButton
tableId="admin-currencies-table"
filename={exportLabels.filename}
sheetName={exportLabels.sheetName}
/>
<Button onClick={openCreate}>{t("currencies.actions.create", { ns: "config" })}</Button>
</>
}
>
<div className="admin-table-shell">
<Table id="admin-currencies-table">
<TableHeader>
@@ -277,7 +282,7 @@ export function CurrencySettingsPanel() {
</TableBody>
</Table>
</div>
</CardContent>
</AdminPageCard>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
@@ -385,6 +390,6 @@ export function CurrencySettingsPanel() {
</div>
</DialogContent>
</Dialog>
</Card>
</>
);
}

View File

@@ -8,8 +8,11 @@ import {
getAdminSettings,
updateAdminSetting,
} from "@/api/admin-settings";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@@ -23,6 +26,8 @@ const DRAW_KEYS = {
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
COOLDOWN_MINUTES: "draw.cooldown_minutes",
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
AUTO_APPROVE: "settlement.auto_approve_on_tick",
AUTO_PAYOUT: "settlement.auto_payout_on_tick",
} as const;
const FRONTEND_GROUP = "frontend";
@@ -37,6 +42,8 @@ interface RuntimeDraft {
requireManualReview: boolean;
cooldownMinutes: string;
autoSettlement: boolean;
autoApprove: boolean;
autoPayout: boolean;
playRulesHtmlZh: string;
playRulesHtmlEn: string;
playRulesHtmlNe: string;
@@ -116,10 +123,13 @@ function SaveActions({
export function SystemSettingsScreen() {
const { t } = useTranslation(["common", "config", "adminUsers"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [draft, setDraft] = useState<RuntimeDraft>({
requireManualReview: false,
cooldownMinutes: "15",
autoSettlement: true,
autoApprove: true,
autoPayout: true,
playRulesHtmlZh: "",
playRulesHtmlEn: "",
playRulesHtmlNe: "",
@@ -128,6 +138,8 @@ export function SystemSettingsScreen() {
requireManualReview: false,
cooldownMinutes: "15",
autoSettlement: true,
autoApprove: true,
autoPayout: true,
playRulesHtmlZh: "",
playRulesHtmlEn: "",
playRulesHtmlNe: "",
@@ -155,6 +167,8 @@ export function SystemSettingsScreen() {
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true),
autoApprove: Boolean(kv[DRAW_KEYS.AUTO_APPROVE] ?? true),
autoPayout: Boolean(kv[DRAW_KEYS.AUTO_PAYOUT] ?? true),
playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
@@ -189,6 +203,8 @@ export function SystemSettingsScreen() {
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
);
await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement);
await updateAdminSetting(DRAW_KEYS.AUTO_APPROVE, draft.autoApprove);
await updateAdminSetting(DRAW_KEYS.AUTO_PAYOUT, draft.autoPayout);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe);
@@ -210,12 +226,11 @@ export function SystemSettingsScreen() {
const discardLabel = t("system.discard", { ns: "config" });
return (
<div className="flex flex-col gap-10">
<section className="space-y-4">
<h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
{t("system.title", { ns: "config" })}
</h2>
<div className="flex w-full max-w-none flex-col gap-6">
<AdminPageCard
title={t("system.title", { ns: "config" })}
description={t("system.description", { ns: "config" })}
>
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
@@ -243,6 +258,32 @@ export function SystemSettingsScreen() {
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoApprove", { ns: "config" })}</Label>
<BinaryChoice
active={draft.autoApprove}
disabled={loading || saving}
onChange={(value) => updateDraft("autoApprove", value)}
leftLabel={t("system.states.disabled", { ns: "config" })}
rightLabel={t("system.states.enabled", { ns: "config" })}
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoPayout", { ns: "config" })}</Label>
<BinaryChoice
active={draft.autoPayout}
disabled={loading || saving}
onChange={(value) => updateDraft("autoPayout", value)}
leftLabel={t("system.states.disabled", { ns: "config" })}
rightLabel={t("system.states.enabled", { ns: "config" })}
/>
</div>
<div className="h-px bg-border/60" />
<div className="grid max-w-xs gap-2">
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
{t("system.fields.cooldownMinutes", { ns: "config" })}
@@ -258,21 +299,16 @@ export function SystemSettingsScreen() {
/>
</div>
</div>
</AdminPageCard>
</section>
<section className="space-y-4">
<h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
{t("wallet.title", { ns: "config" })}
</h2>
<AdminPageCard
title={t("wallet.title", { ns: "config" })}
description={t("wallet.description", { ns: "config" })}
>
<WalletConfigDocScreen embedded />
</section>
<section className="space-y-4">
<h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
{t("system.frontendConfig", { ns: "config" })}
</h2>
</AdminPageCard>
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
<div className="grid gap-2">
<Label className="text-sm font-medium">
{t("system.fields.playRulesHtml", { ns: "config" })}
@@ -318,22 +354,33 @@ export function SystemSettingsScreen() {
</TabsContent>
</Tabs>
</div>
</AdminPageCard>
</section>
<SaveActions
dirty={dirty}
loading={loading}
saving={saving}
onSave={() => void handleSave()}
onDiscard={() => {
setDraft(saved);
setDirty(false);
}}
saveLabel={saveLabel}
savingLabel={savingLabel}
discardLabel={discardLabel}
/>
<Card className="admin-list-card">
<CardContent className="admin-list-content">
<SaveActions
dirty={dirty}
loading={loading}
saving={saving}
onSave={() =>
requestConfirm({
title: t("system.confirmSaveTitle", { ns: "config" }),
description: t("system.confirmSaveDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => handleSave(),
})
}
onDiscard={() => {
setDraft(saved);
setDirty(false);
}}
saveLabel={saveLabel}
savingLabel={savingLabel}
discardLabel={discardLabel}
/>
</CardContent>
</Card>
<ConfirmDialog />
</div>
);
}

View File

@@ -42,7 +42,7 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorUnits } from "@/lib/money";
import { cn } from "@/lib/utils";
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/modules/draws/draw-prd";
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
@@ -86,6 +86,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
const [acting, setActing] = useState<string | null>(null);
const [pendingAction, setPendingAction] = useState<SettlementAction | null>(null);
const [reviewRemark, setReviewRemark] = useState("");
const batchCurrency = summary?.currency_code ?? "NPR";
const load = useCallback(async () => {
setLoading(true);
@@ -277,32 +278,38 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
<span className="text-muted-foreground">{t("endedAt")}</span> {formatDt(summary.finished_at)}
</p>
<div className="flex flex-wrap gap-2 sm:col-span-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={!canReviewSettlement || acting !== null || summary.status !== "pending_review"}
onClick={() => openActionDialog("approve")}
>
{t("approve")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={!canReviewSettlement || acting !== null || summary.status !== "pending_review"}
onClick={() => openActionDialog("reject")}
>
{t("reject")}
</Button>
<Button
type="button"
size="sm"
disabled={!canManagePayout || acting !== null || summary.status !== "approved"}
onClick={() => openActionDialog("payout")}
>
{t("runPayout")}
</Button>
{canReviewSettlement ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={acting !== null || summary.status !== "pending_review"}
onClick={() => openActionDialog("approve")}
>
{t("approve")}
</Button>
) : null}
{canReviewSettlement ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={acting !== null || summary.status !== "pending_review"}
onClick={() => openActionDialog("reject")}
>
{t("reject")}
</Button>
) : null}
{canManagePayout ? (
<Button
type="button"
size="sm"
disabled={acting !== null || summary.status !== "approved"}
onClick={() => openActionDialog("payout")}
>
{t("runPayout")}
</Button>
) : null}
<Button type="button" size="sm" variant="secondary" disabled={acting !== null} onClick={() => void exportCsv()}>
{t("exportSettlementReport")}
</Button>
@@ -341,12 +348,12 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
</TableCell>
<TableCell className="text-xs">{r.matched_prize_tier ?? "—"}</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
{formatAdminMinorUnits(r.win_amount, r.currency_code ?? summary.currency_code ?? "NPR")}
{formatAdminMinorUnits(r.win_amount, r.currency_code ?? batchCurrency)}
</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
{formatAdminMinorUnits(
r.jackpot_allocation_amount,
r.currency_code ?? summary.currency_code ?? "NPR",
r.currency_code ?? batchCurrency,
)}
</TableCell>
</TableRow>

View File

@@ -48,7 +48,7 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorUnits } from "@/lib/money";
import { cn } from "@/lib/utils";
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/modules/draws/draw-prd";
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminSettlementBatchListData, AdminSettlementBatchRow } from "@/types/api/admin-settlement";
@@ -295,32 +295,38 @@ export function SettlementBatchesConsole() {
>
{t("details")}
</Link>
<Button
type="button"
size="sm"
variant="outline"
disabled={!canReviewSettlement || actingId !== null || row.status !== "pending_review"}
onClick={() => openActionDialog(row, "approve")}
>
{t("pass")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={!canReviewSettlement || actingId !== null || row.status !== "pending_review"}
onClick={() => openActionDialog(row, "reject")}
>
{t("reject")}
</Button>
<Button
type="button"
size="sm"
disabled={!canManagePayout || actingId !== null || row.status !== "approved"}
onClick={() => openActionDialog(row, "payout")}
>
{t("payout")}
</Button>
{canReviewSettlement ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={actingId !== null || row.status !== "pending_review"}
onClick={() => openActionDialog(row, "approve")}
>
{t("pass")}
</Button>
) : null}
{canReviewSettlement ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={actingId !== null || row.status !== "pending_review"}
onClick={() => openActionDialog(row, "reject")}
>
{t("reject")}
</Button>
) : null}
{canManagePayout ? (
<Button
type="button"
size="sm"
disabled={actingId !== null || row.status !== "approved"}
onClick={() => openActionDialog(row, "payout")}
>
{t("payout")}
</Button>
) : null}
</div>
</TableCell>
</TableRow>

View File

@@ -36,6 +36,7 @@ import { ChevronDown } from "lucide-react";
const TICKET_STATUS_OPTIONS = [
"pending_confirm",
"partial_pending_confirm",
"pending_draw",
"success",
"failed",
"pending_payout",

View File

@@ -36,8 +36,12 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_WALLET_WRITE_ANY } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { formatAdminMinorUnits } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -202,19 +206,31 @@ function walletAdminSelectDisplayedLabel(
return key ? (t ? t(key) : key) : v;
}
function canReverseTransferOrder(row: { status: string; can_reverse?: boolean }): boolean {
return row.can_reverse ?? row.status === "pending_reconcile";
function canReverseTransferOrder(
row: { status: string; can_reverse?: boolean },
canWriteWallet: boolean,
): boolean {
return canWriteWallet && (row.can_reverse ?? row.status === "pending_reconcile");
}
function canManuallyProcessTransferOrder(row: {
status: string;
can_manually_process?: boolean;
}): boolean {
return row.can_manually_process ?? ["processing", "failed", "pending_reconcile"].includes(row.status);
function canManuallyProcessTransferOrder(
row: {
status: string;
can_manually_process?: boolean;
},
canWriteWallet: boolean,
): boolean {
return (
canWriteWallet &&
(row.can_manually_process ?? ["processing", "failed", "pending_reconcile"].includes(row.status))
);
}
export function TransferOrdersPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canWriteWallet = adminHasAnyPermission(profile?.permissions, [...PRD_WALLET_WRITE_ANY]);
const exportLabels = useExportLabels("walletTransferOrders");
useAdminCurrencyCatalog();
const formatTs = useAdminDateTimeFormatter();
@@ -249,10 +265,20 @@ export function TransferOrdersPanel(): React.ReactElement {
};
const handleReverse = (transferNo: string) =>
doAction(transferNo, () => reverseTransferOrder(transferNo), t("reverseSuccess"));
requestConfirm({
title: t("confirm.reverseTitle"),
description: t("confirm.reverseDescription", { transferNo }),
confirmVariant: "destructive",
onConfirm: () => doAction(transferNo, () => reverseTransferOrder(transferNo), t("reverseSuccess")),
});
const handleManuallyProcess = (transferNo: string) =>
doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), t("manualProcessSuccess"));
requestConfirm({
title: t("confirm.manualProcessTitle"),
description: t("confirm.manualProcessDescription", { transferNo }),
onConfirm: () =>
doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), t("manualProcessSuccess")),
});
const load = useCallback(async () => {
setLoading(true);
@@ -302,6 +328,7 @@ export function TransferOrdersPanel(): React.ReactElement {
};
return (
<>
<Card>
<CardHeader>
<CardTitle>{t("transferOrders")}</CardTitle>
@@ -480,9 +507,10 @@ export function TransferOrdersPanel(): React.ReactElement {
{formatTs(row.finished_at)}
</TableCell>
<TableCell>
{canReverseTransferOrder(row) || canManuallyProcessTransferOrder(row) ? (
{canReverseTransferOrder(row, canWriteWallet) ||
canManuallyProcessTransferOrder(row, canWriteWallet) ? (
<div className="flex flex-col gap-1">
{canReverseTransferOrder(row) ? (
{canReverseTransferOrder(row, canWriteWallet) ? (
<Button
size="sm"
variant="destructive"
@@ -493,7 +521,7 @@ export function TransferOrdersPanel(): React.ReactElement {
{actionLoading.has(row.transfer_no) ? t("processing") : t("reverse")}
</Button>
) : null}
{canManuallyProcessTransferOrder(row) ? (
{canManuallyProcessTransferOrder(row, canWriteWallet) ? (
<Button
size="sm"
variant="outline"
@@ -532,6 +560,8 @@ export function TransferOrdersPanel(): React.ReactElement {
) : null}
</CardContent>
</Card>
<ConfirmDialog />
</>
);
}