feat: 增加角色管理与奖池配置迁移,优化管理端权限与样式

This commit is contained in:
2026-05-19 14:40:04 +08:00
parent d625c59393
commit a1fb163f1b
45 changed files with 1080 additions and 518 deletions

View File

@@ -1,7 +1,6 @@
import type { LucideIcon } from "lucide-react";
import {
CalendarClock,
CircleDollarSign,
FileSpreadsheet,
Landmark,
LayoutDashboard,
@@ -30,11 +29,11 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
wallet: Wallet,
risk: ShieldAlert,
settlement: Landmark,
jackpot: CircleDollarSign,
reports: FileSpreadsheet,
reconcile: Scale,
audit: ScrollText,
admin_users: ShieldCheck,
admin_roles: ShieldCheck,
settings: Settings,
};

View File

@@ -10,11 +10,11 @@ export type AdminNavSegment =
| "risk"
| "settings"
| "settlement"
| "jackpot"
| "reports"
| "reconcile"
| "audit"
| "admin_users";
| "admin_users"
| "admin_roles";
export type AdminNavItem = {
label: string;

View File

@@ -0,0 +1,501 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
deleteAdminRole,
getAdminRoles,
getAdminUserPermissionCatalog,
postAdminRole,
putAdminRole,
putAdminRolePermissions,
} from "@/api/admin-users";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { AdminPermissionCatalogData, AdminRoleRow } from "@/types/api/index";
import { LotteryApiBizError } from "@/types/api/errors";
export function AdminRolesConsole(): React.ReactElement {
const { t } = useTranslation(["adminUsers", "common"]);
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [directMenuExpanded, setDirectMenuExpanded] = useState<Record<string, boolean>>({});
const [rolePermissionOpen, setRolePermissionOpen] = useState(false);
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
const [draftRolePermissions, setDraftRolePermissions] = useState<string[]>([]);
const [roleSaving, setRoleSaving] = useState(false);
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
const [roleMode, setRoleMode] = useState<"create" | "edit">("create");
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
const [roleSlug, setRoleSlug] = useState("");
const [roleName, setRoleName] = useState("");
const [roleDescription, setRoleDescription] = useState("");
const [roleStatus, setRoleStatus] = useState(1);
const [roleFormSaving, setRoleFormSaving] = useState(false);
const [roleDeleteTarget, setRoleDeleteTarget] = useState<AdminRoleRow | null>(null);
const [roleDeleteBusy, setRoleDeleteBusy] = useState(false);
const selectedRole = useMemo(
() => roles.find((role) => role.id === selectedRoleId) ?? null,
[roles, selectedRoleId],
);
const selectClassName = cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base outline-none transition-colors",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm",
"dark:bg-input/30 disabled:cursor-not-allowed disabled:opacity-50",
);
const directPermissionGroups = useMemo(() => {
const groups = catalog?.permission_menu_groups;
if (groups && groups.length > 0) {
return groups;
}
const flatPermissions = catalog?.permissions ?? [];
if (flatPermissions.length > 0) {
return [{ key: "all", label: t("allPermissions"), permissions: flatPermissions }];
}
return [];
}, [catalog, t]);
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const [catalogData, roleData] = await Promise.all([
getAdminUserPermissionCatalog(),
getAdminRoles(),
]);
setCatalog(catalogData);
setRoles(roleData.items);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("roleLoadFailed");
setErr(msg);
setRoles([]);
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
function isDirectGroupOpen(key: string): boolean {
return directMenuExpanded[key] !== false;
}
function toggleDirectGroup(key: string): void {
setDirectMenuExpanded((prev) => {
const wasOpen = prev[key] !== false;
return { ...prev, [key]: wasOpen ? false : true };
});
}
function toggleRolePermission(slug: string, checked: boolean): void {
setDraftRolePermissions((prev) => {
if (checked) {
return Array.from(new Set([...prev, slug])).sort();
}
return prev.filter((value) => value !== slug);
});
}
function openCreateRole(): void {
setRoleMode("create");
setEditingRoleId(null);
setRoleSlug("");
setRoleName("");
setRoleDescription("");
setRoleStatus(1);
setRoleDialogOpen(true);
}
function openEditRole(role: AdminRoleRow): void {
setRoleMode("edit");
setEditingRoleId(role.id);
setRoleSlug(role.slug);
setRoleName(role.name);
setRoleDescription(role.description ?? "");
setRoleStatus(role.status);
setRoleDialogOpen(true);
}
function openRolePermissionEditor(role: AdminRoleRow): void {
setSelectedRoleId(role.id);
setDraftRolePermissions([...role.permission_slugs].sort());
setDirectMenuExpanded({});
setRolePermissionOpen(true);
}
function handleRoleDialogOpenChange(open: boolean): void {
setRoleDialogOpen(open);
if (!open) {
setEditingRoleId(null);
}
}
function handleRolePermissionDialogOpenChange(open: boolean): void {
setRolePermissionOpen(open);
if (!open) {
setSelectedRoleId(null);
}
}
async function saveRolePermissions(): Promise<void> {
if (!selectedRole) {
return;
}
setRoleSaving(true);
try {
const result = await putAdminRolePermissions(selectedRole.id, draftRolePermissions);
setDraftRolePermissions([...result.permission_slugs].sort());
setRoles((prev) => prev.map((role) => (role.id === result.id ? result : role)));
setCatalog((prev) =>
prev ? { ...prev, roles: prev.roles.map((role) => (role.id === result.id ? result : role)) } : prev,
);
toast.success(t("rolePermissionSaveSuccess"));
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("rolePermissionSaveFailed");
toast.error(msg);
} finally {
setRoleSaving(false);
}
}
async function submitRole(): Promise<void> {
const name = roleName.trim();
const slug = roleSlug.trim().toLowerCase();
if (name === "" || slug === "") {
toast.error(t("roleFormRequired"));
return;
}
setRoleFormSaving(true);
try {
if (roleMode === "create") {
const created = await postAdminRole({
slug,
name,
description: roleDescription.trim() === "" ? null : roleDescription.trim(),
status: roleStatus,
});
setRoles((prev) => [...prev, created]);
setCatalog((prev) => (prev ? { ...prev, roles: [...prev.roles, created] } : prev));
toast.success(t("roleCreateSuccess", { name: created.name }));
} else {
if (editingRoleId === null) {
return;
}
const updated = await putAdminRole(editingRoleId, {
slug,
name,
description: roleDescription.trim() === "" ? null : roleDescription.trim(),
status: roleStatus,
});
setRoles((prev) => prev.map((role) => (role.id === updated.id ? updated : role)));
setCatalog((prev) =>
prev ? { ...prev, roles: prev.roles.map((role) => (role.id === updated.id ? updated : role)) } : prev,
);
toast.success(t("roleUpdateSuccess", { name: updated.name }));
}
handleRoleDialogOpenChange(false);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("roleSaveFailed");
toast.error(msg);
} finally {
setRoleFormSaving(false);
}
}
async function confirmRoleDelete(): Promise<void> {
if (!roleDeleteTarget) {
return;
}
setRoleDeleteBusy(true);
try {
await deleteAdminRole(roleDeleteTarget.id);
setRoles((prev) => prev.filter((role) => role.id !== roleDeleteTarget.id));
setCatalog((prev) =>
prev ? { ...prev, roles: prev.roles.filter((role) => role.id !== roleDeleteTarget.id) } : prev,
);
toast.success(t("roleDeleteSuccess", { name: roleDeleteTarget.name }));
setRoleDeleteTarget(null);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("roleDeleteFailed");
toast.error(msg);
} finally {
setRoleDeleteBusy(false);
}
}
return (
<div className="flex w-full max-w-none flex-col gap-6">
<Card>
<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>
</div>
<Button type="button" variant="secondary" onClick={() => void load()}>
{t("actions.refresh", { ns: "common" })}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && roles.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead>{t("roleTable.name")}</TableHead>
<TableHead>{t("roleTable.slug")}</TableHead>
<TableHead>{t("roleTable.type")}</TableHead>
<TableHead>{t("roleTable.status")}</TableHead>
<TableHead>{t("roleTable.users")}</TableHead>
<TableHead>{t("roleTable.permissions")}</TableHead>
<TableHead>{t("roleTable.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
roles.map((role) => (
<TableRow key={role.id}>
<TableCell>{role.id}</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{role.name}</span>
<span className="text-xs text-muted-foreground">{role.description ?? ""}</span>
</div>
</TableCell>
<TableCell>{role.slug}</TableCell>
<TableCell>
{role.is_system ? (
<Badge variant="secondary">{t("roleType.system")}</Badge>
) : (
<Badge variant="outline">{t("roleType.custom")}</Badge>
)}
</TableCell>
<TableCell>
{role.status === 1 ? (
<Badge variant="secondary" className="font-normal">
{t("status.enabled")}
</Badge>
) : (
<Badge
variant="outline"
className="border-amber-600/50 text-amber-800 dark:text-amber-400"
>
{t("status.disabled")}
</Badge>
)}
</TableCell>
<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>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Dialog open={rolePermissionOpen} onOpenChange={handleRolePermissionDialogOpenChange}>
<DialogContent
showCloseButton
className="flex h-[min(86vh,780px)] !max-w-[min(760px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden p-0"
>
<DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12">
<DialogTitle className="text-base">{t("rolePermissionDialog.title")}</DialogTitle>
<DialogDescription>
{selectedRole ? `${selectedRole.name} · ${selectedRole.slug}` : null}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/25 px-5 py-4">
<div className="space-y-3">
{directPermissionGroups.map((group) => {
const isOpen = isDirectGroupOpen(group.key);
const selectedCount = group.permissions.filter((permission) =>
draftRolePermissions.includes(permission.slug),
).length;
return (
<section key={group.key} className="overflow-hidden rounded-lg border bg-background shadow-sm">
<button
type="button"
className="flex w-full items-center gap-3 border-b px-4 py-3 text-left text-sm hover:bg-muted/45"
onClick={() => toggleDirectGroup(group.key)}
>
<ChevronDown
aria-hidden
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
isOpen && "rotate-180",
)}
/>
<span className="min-w-0 flex-1 text-base font-semibold leading-none">{group.label}</span>
<span className="shrink-0 rounded-full bg-muted px-2.5 py-1 tabular-nums text-xs font-medium text-muted-foreground">
{selectedCount}/{group.permissions.length}
</span>
</button>
{isOpen ? (
<div className="divide-y">
{group.permissions.map((permission) => (
<label
key={permission.slug}
className={cn(
"flex cursor-pointer items-start gap-3 px-4 py-3 text-sm transition-colors hover:bg-muted/35",
draftRolePermissions.includes(permission.slug) && "bg-muted/25",
)}
>
<Checkbox
className="mt-0.5"
checked={draftRolePermissions.includes(permission.slug)}
onCheckedChange={(value) =>
toggleRolePermission(permission.slug, value === true)
}
/>
<span className="min-w-0 whitespace-normal break-words font-medium leading-6 text-foreground">
{permission.name}
</span>
</label>
))}
</div>
) : null}
</section>
);
})}
</div>
</div>
<div className="flex shrink-0 justify-end gap-2 border-t bg-background px-5 py-4">
<Button type="button" variant="outline" onClick={() => handleRolePermissionDialogOpenChange(false)}>
{t("actions.cancel")}
</Button>
<Button type="button" disabled={!selectedRole || roleSaving} onClick={() => void saveRolePermissions()}>
{roleSaving ? t("saving") : t("actions.save")}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={roleDialogOpen} onOpenChange={handleRoleDialogOpenChange}>
<DialogContent showCloseButton className="max-w-lg gap-4">
<DialogHeader>
<DialogTitle>
{roleMode === "create" ? t("roleDialog.createTitle") : t("roleDialog.editTitle")}
</DialogTitle>
<DialogDescription>{t("roleDialog.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none">{t("roleDialog.slug")}</div>
<Input value={roleSlug} onChange={(e) => setRoleSlug(e.target.value)} disabled={roleMode === "edit"} />
</div>
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none">{t("roleDialog.name")}</div>
<Input value={roleName} onChange={(e) => setRoleName(e.target.value)} />
</div>
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none">{t("roleDialog.descriptionLabel")}</div>
<Input value={roleDescription} onChange={(e) => setRoleDescription(e.target.value)} />
</div>
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none">{t("roleDialog.status")}</div>
<select className={selectClassName} value={roleStatus} onChange={(e) => setRoleStatus(Number(e.target.value))}>
<option value={1}>{t("status.enabled")}</option>
<option value={0}>{t("status.disabled")}</option>
</select>
</div>
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={() => handleRoleDialogOpenChange(false)}>
{t("actions.cancel")}
</Button>
<Button type="button" disabled={roleFormSaving} onClick={() => void submitRole()}>
{roleFormSaving ? t("saving") : t("actions.save")}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={roleDeleteTarget !== null} onOpenChange={(open) => !open && setRoleDeleteTarget(null)}>
<DialogContent showCloseButton className="max-w-md gap-4">
<DialogHeader>
<DialogTitle>{t("roleDelete.confirmTitle")}</DialogTitle>
<DialogDescription>
{roleDeleteTarget ? t("roleDelete.confirmDescription", { name: roleDeleteTarget.name }) : null}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" disabled={roleDeleteBusy} onClick={() => setRoleDeleteTarget(null)}>
{t("actions.cancel")}
</Button>
<Button type="button" variant="destructive" disabled={roleDeleteBusy} onClick={() => void confirmRoleDelete()}>
{roleDeleteBusy ? t("deleting") : t("actions.delete")}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,5 @@
export const adminRolesModuleMeta = {
segment: "admin_roles",
title: "Roles",
description: "",
} as const;

View File

@@ -1,7 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -11,7 +10,6 @@ import {
getAdminUsers,
postAdminUser,
putAdminUser,
putAdminUserPermissions,
putAdminUserRoles,
} from "@/api/admin-users";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
@@ -35,10 +33,10 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { LotteryApiBizError } from "@/types/api/errors";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import type { AdminPermissionCatalogData, AdminUserPermissionRow } from "@/types/api/index";
import { LotteryApiBizError } from "@/types/api/errors";
export function AdminUsersConsole(): React.ReactElement {
const { t } = useTranslation(["adminUsers", "common"]);
@@ -57,12 +55,8 @@ export function AdminUsersConsole(): React.ReactElement {
const [selectedId, setSelectedId] = useState<number | null>(null);
const [draftRoles, setDraftRoles] = useState<string[]>([]);
const [draftPermissions, setDraftPermissions] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [savingRoles, setSavingRoles] = useState(false);
const [permissionOpen, setPermissionOpen] = useState(false);
/** `false` = collapsed; default expanded */
const [directMenuExpanded, setDirectMenuExpanded] = useState<Record<string, boolean>>({});
const [accountOpen, setAccountOpen] = useState(false);
const [accountMode, setAccountMode] = useState<"create" | "edit">("create");
@@ -83,11 +77,66 @@ export function AdminUsersConsole(): React.ReactElement {
[items, selectedId],
);
const selectClassName = cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base outline-none transition-colors",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm",
"dark:bg-input/30 disabled:cursor-not-allowed disabled:opacity-50",
);
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const [catalogData, listData] = await Promise.all([
getAdminUserPermissionCatalog(),
getAdminUsers({
page,
per_page: perPage,
keyword: query.trim() || undefined,
}),
]);
setCatalog(catalogData);
setItems(listData.items);
setTotal(listData.meta.total);
setLastPage(Math.max(1, listData.meta.last_page));
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("loadFailed");
setErr(msg);
setItems([]);
setTotal(0);
setLastPage(1);
} finally {
setLoading(false);
}
}, [page, perPage, query, t]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
function toggleFormCreateRole(slug: string, checked: boolean): void {
setFormCreateRoles((prev) => {
if (checked) {
return Array.from(new Set([...prev, slug])).sort();
}
return prev.filter((value) => value !== slug);
});
}
function toggleRole(slug: string, checked: boolean): void {
setDraftRoles((prev) => {
if (checked) {
return Array.from(new Set([...prev, slug])).sort();
}
return prev.filter((value) => value !== slug);
});
}
function openPermissionEditor(row: AdminUserPermissionRow): void {
setSelectedId(row.id);
setDraftRoles([...row.roles].sort());
setDraftPermissions([...row.direct_permissions].sort());
setDirectMenuExpanded({});
setPermissionOpen(true);
}
@@ -129,8 +178,8 @@ export function AdminUsersConsole(): React.ReactElement {
}
async function submitAccount(): Promise<void> {
const nick = formNickname.trim();
if (nick === "") {
const nickname = formNickname.trim();
if (nickname === "") {
toast.error(t("nicknameRequired"));
return;
}
@@ -138,7 +187,6 @@ export function AdminUsersConsole(): React.ReactElement {
toast.error(t("newPasswordMin"));
return;
}
if (accountMode === "create" && formCreateRoles.length === 0) {
toast.error(t("roleRequired"));
return;
@@ -147,8 +195,8 @@ export function AdminUsersConsole(): React.ReactElement {
setAccountSaving(true);
try {
if (accountMode === "create") {
const u = formUsername.trim();
if (u === "") {
const username = formUsername.trim();
if (username === "") {
toast.error(t("usernameRequired"));
return;
}
@@ -157,15 +205,15 @@ export function AdminUsersConsole(): React.ReactElement {
return;
}
const created = await postAdminUser({
username: u.toLowerCase(),
nickname: nick,
username: username.toLowerCase(),
nickname,
email: formEmail.trim() === "" ? null : formEmail.trim(),
password: formPassword,
status: formStatus,
role_slugs: formCreateRoles,
});
setItems((prev) => [created, ...prev]);
setTotal((t) => t + 1);
setTotal((prev) => prev + 1);
toast.success(t("createSuccess", { name: created.username }));
handleAccountDialogOpenChange(false);
} else {
@@ -179,7 +227,7 @@ export function AdminUsersConsole(): React.ReactElement {
status: number;
password?: string;
} = {
nickname: nick,
nickname,
email: formEmail.trim() === "" ? null : formEmail.trim(),
status: formStatus,
};
@@ -199,114 +247,6 @@ export function AdminUsersConsole(): React.ReactElement {
}
}
async function confirmDelete(): Promise<void> {
if (!deleteTarget) {
return;
}
setDeleteBusy(true);
try {
await deleteAdminUser(deleteTarget.id);
setItems((prev) => prev.filter((r) => r.id !== deleteTarget.id));
setTotal((t) => Math.max(0, t - 1));
toast.success(t("deleteSuccess", { name: deleteTarget.username }));
setDeleteTarget(null);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("deleteFailed");
toast.error(msg);
} finally {
setDeleteBusy(false);
}
}
const selectClassName = cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base outline-none transition-colors",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm",
"dark:bg-input/30 disabled:cursor-not-allowed disabled:opacity-50",
);
const directPermissionGroups = useMemo(() => {
const g = catalog?.permission_menu_groups;
if (g && g.length > 0) {
return g;
}
const flat = catalog?.permissions ?? [];
if (flat.length > 0) {
return [{ key: "all", label: t("allPermissions"), permissions: flat }];
}
return [];
}, [catalog, t]);
function isDirectGroupOpen(key: string): boolean {
return directMenuExpanded[key] !== false;
}
function toggleDirectGroup(key: string): void {
setDirectMenuExpanded((prev) => {
const wasOpen = prev[key] !== false;
return { ...prev, [key]: wasOpen ? false : true };
});
}
function toggleFormCreateRole(slug: string, checked: boolean): void {
setFormCreateRoles((prev) => {
if (checked) {
return Array.from(new Set([...prev, slug])).sort();
}
return prev.filter((s) => s !== slug);
});
}
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const [catalogData, listData] = await Promise.all([
getAdminUserPermissionCatalog(),
getAdminUsers({
page,
per_page: perPage,
keyword: query.trim() || undefined,
}),
]);
setCatalog(catalogData);
setItems(listData.items);
setTotal(listData.meta.total);
setLastPage(Math.max(1, listData.meta.last_page));
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("loadFailed");
setErr(msg);
setItems([]);
setTotal(0);
setLastPage(1);
} finally {
setLoading(false);
}
}, [page, perPage, query, t]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
function togglePermission(slug: string, checked: boolean): void {
setDraftPermissions((prev) => {
if (checked) {
return Array.from(new Set([...prev, slug])).sort();
}
return prev.filter((s) => s !== slug);
});
}
function toggleRole(slug: string, checked: boolean): void {
setDraftRoles((prev) => {
if (checked) {
return Array.from(new Set([...prev, slug])).sort();
}
return prev.filter((s) => s !== slug);
});
}
async function saveRoles(): Promise<void> {
if (!selectedUser) {
return;
@@ -335,33 +275,22 @@ export function AdminUsersConsole(): React.ReactElement {
}
}
async function savePermissions(): Promise<void> {
if (!selectedUser) {
async function confirmDelete(): Promise<void> {
if (!deleteTarget) {
return;
}
setSaving(true);
setDeleteBusy(true);
try {
const result = await putAdminUserPermissions(selectedUser.id, draftPermissions);
setDraftPermissions([...result.direct_permissions].sort());
setDraftRoles([...result.roles].sort());
setItems((prev) =>
prev.map((row) =>
row.id === result.id
? {
...row,
direct_permissions: result.direct_permissions,
effective_permissions: result.effective_permissions,
roles: result.roles,
}
: row,
),
);
toast.success(t("savePermissionSuccess", { name: result.username }));
await deleteAdminUser(deleteTarget.id);
setItems((prev) => prev.filter((row) => row.id !== deleteTarget.id));
setTotal((prev) => Math.max(0, prev - 1));
toast.success(t("deleteSuccess", { name: deleteTarget.username }));
setDeleteTarget(null);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("savePermissionFailed");
const msg = e instanceof LotteryApiBizError ? e.message : t("deleteFailed");
toast.error(msg);
} finally {
setSaving(false);
setDeleteBusy(false);
}
}
@@ -415,7 +344,6 @@ export function AdminUsersConsole(): React.ReactElement {
<TableHead>{t("table.nickname")}</TableHead>
<TableHead className="w-20 whitespace-nowrap">{t("table.status")}</TableHead>
<TableHead>{t("table.roles")}</TableHead>
<TableHead>{t("table.direct")}</TableHead>
<TableHead>{t("table.effective")}</TableHead>
<TableHead className="min-w-[11rem]">{t("table.actions")}</TableHead>
</TableRow>
@@ -423,7 +351,7 @@ export function AdminUsersConsole(): React.ReactElement {
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
<TableCell colSpan={7} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
@@ -444,7 +372,10 @@ export function AdminUsersConsole(): React.ReactElement {
{t("status.enabled")}
</Badge>
) : (
<Badge variant="outline" className="border-amber-600/50 text-amber-800 dark:text-amber-400">
<Badge
variant="outline"
className="border-amber-600/50 text-amber-800 dark:text-amber-400"
>
{t("status.disabled")}
</Badge>
)}
@@ -462,7 +393,6 @@ export function AdminUsersConsole(): React.ReactElement {
)}
</div>
</TableCell>
<TableCell className="tabular-nums">{row.direct_permissions.length}</TableCell>
<TableCell className="tabular-nums">{row.effective_permissions.length}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
@@ -470,18 +400,14 @@ export function AdminUsersConsole(): React.ReactElement {
type="button"
size="sm"
variant={permissionOpen && selectedId === row.id ? "default" : "outline"}
onClick={() => {
openPermissionEditor(row);
}}
onClick={() => openPermissionEditor(row)}
>
{t("actions.permissions")}
</Button>
<Button
type="button"
size="sm"
variant={
accountOpen && editingAccountId === row.id ? "secondary" : "outline"
}
variant={accountOpen && editingAccountId === row.id ? "secondary" : "outline"}
onClick={() => openEditAccount(row)}
>
{t("actions.edit")}
@@ -515,8 +441,8 @@ export function AdminUsersConsole(): React.ReactElement {
lastPage={lastPage}
perPage={perPage}
loading={loading}
onPerPageChange={(n) => {
setPerPage(n);
onPerPageChange={(value) => {
setPerPage(value);
setPage(1);
}}
onPageChange={setPage}
@@ -527,7 +453,7 @@ export function AdminUsersConsole(): React.ReactElement {
<Dialog open={permissionOpen} onOpenChange={handlePermissionDialogOpenChange}>
<DialogContent
showCloseButton
className="flex h-[min(88vh,800px)] max-h-[90vh] w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:h-[min(85vh,780px)] sm:max-w-3xl"
className="flex max-h-[90vh] w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl"
>
<DialogHeader className="shrink-0 space-y-1 border-b px-4 py-3 pr-12">
<DialogTitle>{t("permissionDialog.title")}</DialogTitle>
@@ -541,143 +467,46 @@ export function AdminUsersConsole(): React.ReactElement {
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
<div className="space-y-6 pb-1">
<section className="space-y-3">
<div>
<h3 className="text-sm font-medium leading-none">{t("permissionDialog.rolesTitle")}</h3>
<p className="mt-1.5 text-xs text-muted-foreground">
{t("permissionDialog.rolesDescription")}
</p>
</div>
<div className="grid gap-3 rounded-md border p-3 sm:grid-cols-2">
{(catalog?.roles ?? []).map((r) => {
const checked = draftRoles.includes(r.slug);
return (
<label key={r.slug} className="flex items-start gap-2 text-sm">
<Checkbox
checked={checked}
onCheckedChange={(v) => toggleRole(r.slug, v === true)}
/>
<span className="space-y-0.5">
<span className="block leading-none font-medium">{r.name}</span>
<span className="text-xs text-muted-foreground">{r.slug}</span>
<span className="block text-xs text-muted-foreground/90">
{t("permissionDialog.rolePermissionCount", { count: r.permission_slugs.length })}
</span>
</span>
</label>
);
})}
</div>
</section>
<section className="space-y-3">
<div>
<h3 className="text-sm font-medium leading-none">{t("permissionDialog.directTitle")}</h3>
<p className="mt-1.5 text-xs text-muted-foreground">
{t("permissionDialog.directDescription")}
</p>
</div>
<div className="rounded-md border bg-muted/20 p-2.5 text-xs text-muted-foreground">
{t("permissionDialog.selectedRoles")}
{draftRoles.length === 0 ? (
<span className="ml-1 text-foreground/80">{t("common.none")}</span>
) : (
<span className="ml-1 inline-flex flex-wrap gap-1 align-middle">
{draftRoles.map((slug) => (
<Badge key={slug} variant="secondary" className="text-xs font-normal">
{slug}
</Badge>
))}
</span>
)}
</div>
<div className="overflow-hidden rounded-md border">
{directPermissionGroups.map((group) => {
const isOpen = isDirectGroupOpen(group.key);
const nSelected = group.permissions.filter((p) =>
draftPermissions.includes(p.slug),
).length;
return (
<div key={group.key} className="border-b last:border-b-0">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm hover:bg-muted/50"
onClick={() => toggleDirectGroup(group.key)}
>
<ChevronDown
aria-hidden
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
isOpen && "rotate-180",
)}
/>
<span className="min-w-0 flex-1 font-medium">{group.label}</span>
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
{nSelected}/{group.permissions.length}
</span>
</button>
{isOpen ? (
<div className="space-y-2 border-t bg-muted/20 px-3 py-3 sm:grid sm:grid-cols-2 sm:gap-2 sm:gap-y-3">
{group.permissions.map((p) => {
const checked = draftPermissions.includes(p.slug);
return (
<label key={p.slug} className="flex items-start gap-2 text-sm">
<Checkbox
checked={checked}
onCheckedChange={(v) => togglePermission(p.slug, v === true)}
/>
<span className="space-y-0.5">
<span className="block leading-none">{p.name}</span>
<span className="text-xs text-muted-foreground">{p.slug}</span>
</span>
</label>
);
})}
</div>
) : null}
</div>
);
})}
</div>
</section>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4">
<div className="space-y-3">
<p className="text-xs text-muted-foreground">{t("permissionDialog.rolesDescription")}</p>
<div className="grid gap-3 rounded-md border p-3 sm:grid-cols-2">
{(catalog?.roles ?? []).map((role) => {
const checked = draftRoles.includes(role.slug);
return (
<label key={role.slug} className="flex items-start gap-2 text-sm">
<Checkbox
checked={checked}
onCheckedChange={(value) => toggleRole(role.slug, value === true)}
/>
<span className="space-y-0.5">
<span className="block leading-none font-medium">{role.name}</span>
<span className="text-xs text-muted-foreground">{role.slug}</span>
</span>
</label>
);
})}
</div>
</div>
</div>
<div
className="flex shrink-0 flex-col gap-3 border-t bg-muted/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
data-slot="dialog-footer-actions"
>
<div className="flex shrink-0 flex-col gap-3 border-t bg-muted/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<Button
type="button"
variant="outline"
className="w-full shrink-0 sm:w-auto"
onClick={() => {
handlePermissionDialogOpenChange(false);
}}
onClick={() => handlePermissionDialogOpenChange(false)}
>
{t("actions.close", { ns: "common" })}
</Button>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-nowrap sm:justify-end">
<Button
type="button"
variant="secondary"
className="w-full shrink-0 sm:w-auto"
disabled={!selectedUser || savingRoles}
onClick={() => void saveRoles()}
>
{savingRoles ? t("saving") : t("permissionDialog.saveRoles")}
</Button>
<Button
type="button"
className="w-full shrink-0 sm:w-auto"
disabled={!selectedUser || saving}
onClick={() => void savePermissions()}
>
{saving ? t("saving") : t("permissionDialog.saveDirect")}
</Button>
</div>
<Button
type="button"
className="w-full shrink-0 sm:w-auto"
disabled={!selectedUser || savingRoles}
onClick={() => void saveRoles()}
>
{savingRoles ? t("saving") : t("permissionDialog.saveRoles")}
</Button>
</div>
</DialogContent>
</Dialog>
@@ -741,26 +570,26 @@ export function AdminUsersConsole(): React.ReactElement {
{accountMode === "create" ? (
<div className="space-y-2">
<div className="text-sm font-medium leading-none">{t("accountDialog.rolesRequired")}</div>
<p className="text-xs text-muted-foreground">
{t("accountDialog.rolesDescription")}
</p>
<p className="text-xs text-muted-foreground">{t("accountDialog.rolesDescription")}</p>
<div className="max-h-52 space-y-2 overflow-y-auto rounded-md border p-2.5 sm:grid sm:max-h-56 sm:grid-cols-2 sm:gap-2 sm:space-y-0">
{(catalog?.roles ?? []).length === 0 ? (
<p className="col-span-full text-xs text-muted-foreground">
{t("accountDialog.noRoles")}
</p>
) : (
(catalog?.roles ?? []).map((r) => {
const checked = formCreateRoles.includes(r.slug);
(catalog?.roles ?? []).map((role) => {
const checked = formCreateRoles.includes(role.slug);
return (
<label key={r.slug} className="flex items-start gap-2 text-sm">
<label key={role.slug} className="flex items-start gap-2 text-sm">
<Checkbox
checked={checked}
onCheckedChange={(v) => toggleFormCreateRole(r.slug, v === true)}
onCheckedChange={(value) =>
toggleFormCreateRole(role.slug, value === true)
}
/>
<span className="space-y-0.5">
<span className="block leading-none font-medium">{r.name}</span>
<span className="text-xs text-muted-foreground">{r.slug}</span>
<span className="block leading-none font-medium">{role.name}</span>
<span className="text-xs text-muted-foreground">{role.slug}</span>
</span>
</label>
);
@@ -801,9 +630,7 @@ export function AdminUsersConsole(): React.ReactElement {
<DialogHeader>
<DialogTitle>{t("delete.confirmTitle")}</DialogTitle>
<DialogDescription>
{deleteTarget ? (
<>{t("delete.confirmDescription", { name: deleteTarget.username })}</>
) : null}
{deleteTarget ? t("delete.confirmDescription", { name: deleteTarget.username }) : null}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">

View File

@@ -27,6 +27,10 @@ export const CONFIG_NAV_GROUPS: readonly ConfigNavGroup[] = [
href: "/admin/config/rebate",
key: "rebate",
},
{
href: "/admin/config/jackpot",
key: "jackpot",
},
],
},
{

View File

@@ -46,7 +46,7 @@ import {
type PrizeScopeCode,
} from "@/modules/config/doc/prize-scopes";
type CatTab = "all" | "d4" | "d3" | "d2" | "jackpot";
type CatTab = "all" | "d4" | "d3" | "d2";
function oddsMultiplierLabel(oddsValue: number): string {
return (oddsValue / 10000).toFixed(4);
@@ -56,9 +56,6 @@ function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[]
if (tab === "all") {
return types;
}
if (tab === "jackpot") {
return types.filter((t) => t.category.toLowerCase().includes("jackpot"));
}
const dim = tab === "d4" ? 4 : tab === "d3" ? 3 : 2;
return types.filter((t) => t.dimension === dim);
}
@@ -389,7 +386,6 @@ export function OddsConfigDocScreen() {
{ id: "d4", label: "4D" },
{ id: "d3", label: "3D" },
{ id: "d2", label: "2D" },
{ id: "jackpot", label: "Jackpot" },
];
return (

View File

@@ -26,7 +26,12 @@ import { useAdminProfile } from "@/stores/admin-session";
import { cn } from "@/lib/utils";
import { DrawStatusBadge } from "./draw-status-badge";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import {
PRD_DRAW_REOPEN_MANAGE,
PRD_DRAW_RESULT_MANAGE,
PRD_PAYOUT_MANAGE,
PRD_PAYOUT_REVIEW,
} from "./draw-prd";
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
@@ -42,7 +47,11 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
const idNum = Number(drawId);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
const isSuperAdmin = profile?.permissions?.includes("prd.admin_user.manage") ?? false;
const canReopenDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_REOPEN_MANAGE]);
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [
PRD_PAYOUT_MANAGE,
PRD_PAYOUT_REVIEW,
]);
const formatDt = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminDrawShowData | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -159,17 +168,16 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("drawActions")}</CardTitle>
<p className="text-sm text-muted-foreground">
{t("drawActionsDesc")}
</p>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{(canManageDraw || canReopenDraw || canRunSettlement) ? (
<Card>
<CardHeader>
<CardTitle className="text-base">{t("drawActions")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
variant="outline"
size="sm"
disabled={!canManageDraw || acting !== null || !["pending", "open"].includes(data.status)}
onClick={() => void runAction(t("manualClose"), () => postAdminManualCloseDraw(idNum))}
>
@@ -178,6 +186,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
<Button
type="button"
variant="outline"
size="sm"
disabled={!canManageDraw || acting !== null || !["pending", "open", "closing", "closed"].includes(data.status)}
onClick={() => void runAction(t("cancelDraw"), () => postAdminCancelDraw(idNum))}
>
@@ -185,15 +194,18 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={!canManageDraw || acting !== null || data.status !== "closed"}
onClick={() => void runAction(t("rngDraw"), () => postAdminRunDrawRng(idNum))}
>
{acting === t("rngDraw") ? t("generating") : t("rngAutoGenerate")}
</Button>
{isSuperAdmin ? (
{canReopenDraw ? (
<Button
type="button"
variant="destructive"
size="sm"
disabled={acting !== null || data.status !== "cooldown"}
onClick={() => void runAction(t("reopen"), () => postAdminReopenDraw(idNum))}
>
@@ -203,13 +215,15 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
<Button
type="button"
variant="outline"
disabled={acting !== null || !["settling", "cooldown"].includes(data.status)}
size="sm"
disabled={!canRunSettlement || acting !== null || data.status !== "settling"}
onClick={() => void runAction(t("runSettlement"), () => postAdminRunDrawSettlement(idNum))}
>
{acting === t("runSettlement") ? t("processing") : t("runSettlement")}
</Button>
</CardContent>
</Card>
</CardContent>
</Card>
) : null}
</div>
);
}

View File

@@ -16,14 +16,23 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import { toast } from "sonner";
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd";
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
const { t } = useTranslation(["draws", "common"]);
const idNum = Number(drawId);
const profile = useAdminProfile();
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [
PRD_PAYOUT_MANAGE,
PRD_PAYOUT_REVIEW,
]);
const [data, setData] = useState<AdminDrawFinanceSummaryData | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -122,7 +131,12 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
{t("actions.refresh", { ns: "common" })}
</Button>
<Button type="button" size="sm" disabled={settling} onClick={() => void runSettlement()}>
<Button
type="button"
size="sm"
disabled={!canRunSettlement || settling || data.draw_status !== "settling"}
onClick={() => void runSettlement()}
>
{settling ? t("processing") : t("runSettlement")}
</Button>
<Link

View File

@@ -1,2 +1,5 @@
/** 开奖结果发布权限 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;

View File

@@ -27,10 +27,13 @@ import {
TableRow,
} from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
/** 下拉「不限」;请求时不传 status */
@@ -62,6 +65,8 @@ function drawAdminStatusSelectLabel(raw: unknown, t: (key: string) => string): s
export function DrawsIndexConsole() {
const { t } = useTranslation(["draws", "common"]);
const formatDt = useAdminDateTimeFormatter();
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
const [data, setData] = useState<AdminDrawListData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -138,9 +143,11 @@ export function DrawsIndexConsole() {
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="text-lg">{t("statusListTitle")}</CardTitle>
<Button type="button" onClick={() => void generatePlan()} disabled={generating}>
{generating ? t("generating") : t("generatePlan")}
</Button>
{canManageDraw ? (
<Button type="button" onClick={() => void generatePlan()} disabled={generating}>
{generating ? t("generating") : t("generatePlan")}
</Button>
) : null}
</CardHeader>
<CardContent className="space-y-4">
{/* Grid桌面端标签一行 / 控件一行,避免 flex+items-end 与各列实际高度不一致;移动端单列自上而下 */}

View File

@@ -7,8 +7,8 @@ import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
const LINKS: { href: string; label: string }[] = [
{ href: "/admin/jackpot/pools", label: "subnavPools" },
{ href: "/admin/jackpot/records", label: "subnavRecords" },
{ href: "/admin/config/jackpot", label: "subnavPools" },
{ href: "/admin/config/jackpot/records", label: "subnavRecords" },
];
export function JackpotSubNav() {

View File

@@ -23,6 +23,8 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
import {
Select,
SelectContent,
@@ -70,6 +72,8 @@ const PLAYER_STATUS_OPTIONS = [
export function PlayersConsole(): React.ReactElement {
const { t } = useTranslation(["players", "common"]);
const profile = useAdminProfile();
const canManagePlayers = adminHasAnyPermission(profile?.permissions, ["prd.users.manage"]);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
const [keyword, setKeyword] = useState("");
@@ -249,9 +253,11 @@ export function PlayersConsole(): 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("listTitle")}</CardTitle>
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
{t("createPlayer")}
</Button>
{canManagePlayers ? (
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
{t("createPlayer")}
</Button>
) : null}
</div>
<div className="flex w-full max-w-lg gap-2">
<Input
@@ -348,26 +354,30 @@ export function PlayersConsole(): React.ReactElement {
: "—"}
</TableCell>
<TableCell>
<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>
</div>
{canManagePlayers ? (
<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>
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))

View File

@@ -213,6 +213,17 @@ 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 canManuallyProcessTransferOrder(row: {
status: string;
can_manually_process?: boolean;
}): boolean {
return row.can_manually_process ?? ["processing", "failed", "pending_reconcile"].includes(row.status);
}
export function TransferOrdersPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]);
const formatTs = useAdminDateTimeFormatter();
@@ -473,26 +484,30 @@ export function TransferOrdersPanel(): React.ReactElement {
{formatTs(row.finished_at)}
</TableCell>
<TableCell>
{row.status === "pending_reconcile" ? (
{canReverseTransferOrder(row) || canManuallyProcessTransferOrder(row) ? (
<div className="flex flex-col gap-1">
<Button
size="sm"
variant="destructive"
className="h-6 px-2 text-xs"
disabled={actionLoading.has(row.transfer_no)}
onClick={() => handleReverse(row.transfer_no)}
>
{actionLoading.has(row.transfer_no) ? t("processing") : t("reverse")}
</Button>
<Button
size="sm"
variant="outline"
className="h-6 px-2 text-xs"
disabled={actionLoading.has(row.transfer_no)}
onClick={() => handleManuallyProcess(row.transfer_no)}
>
{actionLoading.has(row.transfer_no) ? t("processing") : t("manualProcess")}
</Button>
{canReverseTransferOrder(row) ? (
<Button
size="sm"
variant="destructive"
className="h-6 px-2 text-xs"
disabled={actionLoading.has(row.transfer_no)}
onClick={() => handleReverse(row.transfer_no)}
>
{actionLoading.has(row.transfer_no) ? t("processing") : t("reverse")}
</Button>
) : null}
{canManuallyProcessTransferOrder(row) ? (
<Button
size="sm"
variant="outline"
className="h-6 px-2 text-xs"
disabled={actionLoading.has(row.transfer_no)}
onClick={() => handleManuallyProcess(row.transfer_no)}
>
{actionLoading.has(row.transfer_no) ? t("processing") : t("manualProcess")}
</Button>
) : null}
</div>
) : (
<span className="text-xs text-muted-foreground"></span>