feat(integration): 新增 site_code 支持并增强玩家与注单管理界面
在后台玩家与注单相关 API 中新增 site_code 参数,支持按站点筛选数据。 更新 PlayersConsole 与 PlayerTicketsConsole UI 组件,新增站点选择筛选功能。 增强国际化支持,在英文与中文语言包中新增站点相关文案。 优化配置中心页面,新增跳转至集成站点管理的入口,提升后台导航体验。
This commit is contained in:
@@ -41,6 +41,12 @@ const HUB_CARDS: HubCard[] = [
|
||||
descKey: "hub.riskCapDesc",
|
||||
requiredAny: ["prd.risk_cap.manage", "prd.risk_cap.view"],
|
||||
},
|
||||
{
|
||||
href: "/admin/config/integration-sites",
|
||||
titleKey: "hub.integrationTitle",
|
||||
descKey: "hub.integrationDesc",
|
||||
requiredAny: ["prd.integration.view", "prd.integration.manage"],
|
||||
},
|
||||
];
|
||||
|
||||
export function ConfigHubScreen() {
|
||||
|
||||
677
src/modules/integration/integration-sites-console.tsx
Normal file
677
src/modules/integration/integration-sites-console.tsx
Normal file
@@ -0,0 +1,677 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminIntegrationSite,
|
||||
getAdminIntegrationSiteExport,
|
||||
getAdminIntegrationSites,
|
||||
postAdminIntegrationSite,
|
||||
postAdminIntegrationSiteConnectivityTest,
|
||||
postAdminIntegrationSiteRotateSecrets,
|
||||
putAdminIntegrationSite,
|
||||
} from "@/api/admin-integration-sites";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminIntegrationSiteConnectivityResult,
|
||||
AdminIntegrationSiteRow,
|
||||
AdminIntegrationSiteSecrets,
|
||||
AdminIntegrationSiteWithSecrets,
|
||||
} from "@/types/api/admin-integration-site";
|
||||
|
||||
type FormState = {
|
||||
code: string;
|
||||
name: string;
|
||||
currency_code: string;
|
||||
status: number;
|
||||
wallet_api_url: string;
|
||||
wallet_debit_path: string;
|
||||
wallet_credit_path: string;
|
||||
wallet_balance_path: string;
|
||||
wallet_timeout_seconds: string;
|
||||
iframe_allowed_origins: string;
|
||||
lottery_h5_base_url: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
code: "",
|
||||
name: "",
|
||||
currency_code: "NPR",
|
||||
status: 1,
|
||||
wallet_api_url: "",
|
||||
wallet_debit_path: "/wallet/debit-for-lottery",
|
||||
wallet_credit_path: "/wallet/credit-from-lottery",
|
||||
wallet_balance_path: "/wallet/balance",
|
||||
wallet_timeout_seconds: "10",
|
||||
iframe_allowed_origins: "",
|
||||
lottery_h5_base_url: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
function originsToText(origins: string[] | undefined): string {
|
||||
return (origins ?? []).join("\n");
|
||||
}
|
||||
|
||||
function textToOrigins(text: string): string[] {
|
||||
return text
|
||||
.split(/[\n,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function rowToForm(row: AdminIntegrationSiteRow & Partial<FormState>): FormState {
|
||||
return {
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
currency_code: row.currency_code,
|
||||
status: row.status,
|
||||
wallet_api_url: row.wallet_api_url ?? "",
|
||||
wallet_debit_path: row.wallet_debit_path ?? "/wallet/debit-for-lottery",
|
||||
wallet_credit_path: row.wallet_credit_path ?? "/wallet/credit-from-lottery",
|
||||
wallet_balance_path: row.wallet_balance_path ?? "/wallet/balance",
|
||||
wallet_timeout_seconds: String(row.wallet_timeout_seconds ?? 10),
|
||||
iframe_allowed_origins: originsToText(row.iframe_allowed_origins as string[] | undefined),
|
||||
lottery_h5_base_url: row.lottery_h5_base_url ?? "",
|
||||
notes: row.notes ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function formToPayload(form: FormState, includeCode: boolean) {
|
||||
const base = {
|
||||
name: form.name.trim(),
|
||||
currency_code: form.currency_code.trim() || "NPR",
|
||||
status: form.status,
|
||||
wallet_api_url: form.wallet_api_url.trim() || null,
|
||||
wallet_debit_path: form.wallet_debit_path.trim(),
|
||||
wallet_credit_path: form.wallet_credit_path.trim(),
|
||||
wallet_balance_path: form.wallet_balance_path.trim(),
|
||||
wallet_timeout_seconds: Number.parseInt(form.wallet_timeout_seconds, 10) || 10,
|
||||
iframe_allowed_origins: textToOrigins(form.iframe_allowed_origins),
|
||||
lottery_h5_base_url: form.lottery_h5_base_url.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
};
|
||||
|
||||
if (includeCode) {
|
||||
return { code: form.code.trim(), ...base };
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
export function IntegrationSitesConsole() {
|
||||
const { t } = useTranslation("config");
|
||||
const profile = useAdminProfile();
|
||||
const canView = adminHasAnyPermission(profile?.permissions, [
|
||||
"prd.integration.view",
|
||||
"prd.integration.manage",
|
||||
]);
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.integration.manage"]);
|
||||
|
||||
const [items, setItems] = useState<AdminIntegrationSiteRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [mode, setMode] = useState<"create" | "edit">("create");
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [rotateTarget, setRotateTarget] = useState<AdminIntegrationSiteRow | null>(null);
|
||||
const [rotateBusy, setRotateBusy] = useState(false);
|
||||
const [secretsDialog, setSecretsDialog] = useState<{
|
||||
siteCode: string;
|
||||
secrets: AdminIntegrationSiteSecrets;
|
||||
} | null>(null);
|
||||
const [connectivityTarget, setConnectivityTarget] = useState<AdminIntegrationSiteRow | null>(
|
||||
null,
|
||||
);
|
||||
const [connectivityPlayerId, setConnectivityPlayerId] = useState("10001");
|
||||
const [connectivityCurrency, setConnectivityCurrency] = useState("NPR");
|
||||
const [connectivityBusy, setConnectivityBusy] = useState(false);
|
||||
const [connectivityResult, setConnectivityResult] =
|
||||
useState<AdminIntegrationSiteConnectivityResult | null>(null);
|
||||
const [exportBusyId, setExportBusyId] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!canView) {
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAdminIntegrationSites();
|
||||
setItems(data.items);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("integrationSites.loadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canView, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
function openCreate(): void {
|
||||
setMode("create");
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
async function openEdit(row: AdminIntegrationSiteRow): Promise<void> {
|
||||
setMode("edit");
|
||||
setEditingId(row.id);
|
||||
setDialogOpen(true);
|
||||
try {
|
||||
const detail = await getAdminIntegrationSite(row.id);
|
||||
setForm(rowToForm(detail));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("integrationSites.loadFailed"),
|
||||
);
|
||||
setDialogOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateForm<K extends keyof FormState>(key: K, value: FormState[K]): void {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function showSecretsOnce(result: AdminIntegrationSiteWithSecrets): void {
|
||||
if (result.secrets) {
|
||||
setSecretsDialog({ siteCode: result.code, secrets: result.secrets });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!canManage) return;
|
||||
|
||||
if (form.name.trim() === "") {
|
||||
toast.error(t("integrationSites.form.required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "create" && form.code.trim() === "") {
|
||||
toast.error(t("integrationSites.form.codeRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const created = await postAdminIntegrationSite(formToPayload(form, true));
|
||||
toast.success(t("integrationSites.createSuccess", { code: created.code }));
|
||||
showSecretsOnce(created);
|
||||
} else if (editingId !== null) {
|
||||
await putAdminIntegrationSite(editingId, formToPayload(form, false));
|
||||
toast.success(t("integrationSites.updateSuccess", { code: form.code }));
|
||||
}
|
||||
setDialogOpen(false);
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("integrationSites.saveFailed"),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRotate(): Promise<void> {
|
||||
if (!rotateTarget || !canManage) return;
|
||||
|
||||
setRotateBusy(true);
|
||||
try {
|
||||
const result = await postAdminIntegrationSiteRotateSecrets(rotateTarget.id);
|
||||
toast.success(t("integrationSites.rotateSuccess", { code: rotateTarget.code }));
|
||||
setRotateTarget(null);
|
||||
showSecretsOnce(result);
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("integrationSites.rotateFailed"),
|
||||
);
|
||||
} finally {
|
||||
setRotateBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openConnectivity(row: AdminIntegrationSiteRow): void {
|
||||
setConnectivityTarget(row);
|
||||
setConnectivityPlayerId("10001");
|
||||
setConnectivityCurrency(row.currency_code || "NPR");
|
||||
setConnectivityResult(null);
|
||||
}
|
||||
|
||||
async function runConnectivityTest(): Promise<void> {
|
||||
if (!connectivityTarget) return;
|
||||
|
||||
setConnectivityBusy(true);
|
||||
setConnectivityResult(null);
|
||||
try {
|
||||
const result = await postAdminIntegrationSiteConnectivityTest(connectivityTarget.id, {
|
||||
site_player_id: connectivityPlayerId.trim(),
|
||||
currency_code: connectivityCurrency.trim() || undefined,
|
||||
});
|
||||
setConnectivityResult(result);
|
||||
if (result.probe.success) {
|
||||
toast.success(t("integrationSites.connectivitySuccess"));
|
||||
} else {
|
||||
toast.error(result.probe.message ?? t("integrationSites.connectivityFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError
|
||||
? error.message
|
||||
: t("integrationSites.connectivityFailed"),
|
||||
);
|
||||
} finally {
|
||||
setConnectivityBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportParameterSheet(row: AdminIntegrationSiteRow): Promise<void> {
|
||||
setExportBusyId(row.id);
|
||||
try {
|
||||
const sheet = await getAdminIntegrationSiteExport(row.id);
|
||||
const blob = new Blob([JSON.stringify(sheet, null, 2)], {
|
||||
type: "application/json;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `integration-${row.code}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("integrationSites.exportSuccess", { code: row.code }));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("integrationSites.exportFailed"),
|
||||
);
|
||||
} finally {
|
||||
setExportBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(label: string, value: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success(t("integrationSites.copied", { field: label }));
|
||||
} catch {
|
||||
toast.error(t("integrationSites.copyFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<AdminPageCard title={t("integrationSites.title")}>
|
||||
<p className="text-sm text-muted-foreground">{t("integrationSites.noPermission")}</p>
|
||||
</AdminPageCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageCard
|
||||
title={t("integrationSites.title")}
|
||||
description={t("integrationSites.description")}
|
||||
actions={
|
||||
canManage ? (
|
||||
<Button type="button" onClick={openCreate}>
|
||||
{t("integrationSites.create")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">{t("integrationSites.loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("integrationSites.empty")}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("integrationSites.columns.code")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.name")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.status")}</TableHead>
|
||||
<TableHead>{t("integrationSites.columns.walletUrl")}</TableHead>
|
||||
<TableHead className="text-right">{t("integrationSites.columns.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.code}</TableCell>
|
||||
<TableCell>{row.name}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge
|
||||
tone={row.status === 1 ? "success" : "muted"}
|
||||
label={
|
||||
row.status === 1
|
||||
? t("integrationSites.statusEnabled")
|
||||
: t("integrationSites.statusDisabled")
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[240px] truncate text-xs text-muted-foreground">
|
||||
{row.wallet_api_url ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openConnectivity(row)}
|
||||
>
|
||||
{t("integrationSites.connectivityTest")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={exportBusyId === row.id}
|
||||
onClick={() => void exportParameterSheet(row)}
|
||||
>
|
||||
{t("integrationSites.exportParams")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void openEdit(row)}>
|
||||
{t("integrationSites.edit")}
|
||||
</Button>
|
||||
{canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setRotateTarget(row)}
|
||||
>
|
||||
{t("integrationSites.rotateSecrets")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</AdminPageCard>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "create"
|
||||
? t("integrationSites.dialogCreateTitle")
|
||||
: t("integrationSites.dialogEditTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("integrationSites.dialogDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-code">{t("integrationSites.fields.code")}</Label>
|
||||
<Input
|
||||
id="is-code"
|
||||
value={form.code}
|
||||
disabled={mode === "edit"}
|
||||
onChange={(e) => updateForm("code", e.target.value)}
|
||||
placeholder="partner-a"
|
||||
/>
|
||||
{mode === "edit" ? (
|
||||
<p className="text-xs text-muted-foreground">{t("integrationSites.codeImmutable")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-name">{t("integrationSites.fields.name")}</Label>
|
||||
<Input
|
||||
id="is-name"
|
||||
value={form.name}
|
||||
onChange={(e) => updateForm("name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-currency">{t("integrationSites.fields.currency")}</Label>
|
||||
<Input
|
||||
id="is-currency"
|
||||
value={form.currency_code}
|
||||
onChange={(e) => updateForm("currency_code", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-status">{t("integrationSites.fields.status")}</Label>
|
||||
<select
|
||||
id="is-status"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
|
||||
value={form.status}
|
||||
onChange={(e) => updateForm("status", Number(e.target.value))}
|
||||
>
|
||||
<option value={1}>{t("integrationSites.statusEnabled")}</option>
|
||||
<option value={0}>{t("integrationSites.statusDisabled")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-wallet-url">{t("integrationSites.fields.walletApiUrl")}</Label>
|
||||
<Input
|
||||
id="is-wallet-url"
|
||||
value={form.wallet_api_url}
|
||||
onChange={(e) => updateForm("wallet_api_url", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-h5">{t("integrationSites.fields.lotteryH5BaseUrl")}</Label>
|
||||
<Input
|
||||
id="is-h5"
|
||||
value={form.lottery_h5_base_url}
|
||||
onChange={(e) => updateForm("lottery_h5_base_url", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-origins">{t("integrationSites.fields.iframeOrigins")}</Label>
|
||||
<Textarea
|
||||
id="is-origins"
|
||||
rows={3}
|
||||
value={form.iframe_allowed_origins}
|
||||
onChange={(e) => updateForm("iframe_allowed_origins", e.target.value)}
|
||||
placeholder="https://www.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="is-notes">{t("integrationSites.fields.notes")}</Label>
|
||||
<Textarea
|
||||
id="is-notes"
|
||||
rows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => updateForm("notes", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
{t("integrationSites.cancel")}
|
||||
</Button>
|
||||
{canManage ? (
|
||||
<Button type="button" disabled={saving} onClick={() => void handleSubmit()}>
|
||||
{saving ? t("integrationSites.saving") : t("integrationSites.save")}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={rotateTarget !== null} onOpenChange={(open) => !open && setRotateTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("integrationSites.rotateConfirmTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("integrationSites.rotateConfirmDescription", {
|
||||
code: rotateTarget?.code ?? "",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRotateTarget(null)}>
|
||||
{t("integrationSites.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={rotateBusy}
|
||||
onClick={() => void confirmRotate()}
|
||||
>
|
||||
{rotateBusy ? t("integrationSites.saving") : t("integrationSites.rotateConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={connectivityTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConnectivityTarget(null);
|
||||
setConnectivityResult(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("integrationSites.connectivityTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("integrationSites.connectivityDescription", {
|
||||
code: connectivityTarget?.code ?? "",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ct-player">{t("integrationSites.connectivityPlayerId")}</Label>
|
||||
<Input
|
||||
id="ct-player"
|
||||
value={connectivityPlayerId}
|
||||
onChange={(e) => setConnectivityPlayerId(e.target.value)}
|
||||
placeholder="10001"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ct-currency">{t("integrationSites.fields.currency")}</Label>
|
||||
<Input
|
||||
id="ct-currency"
|
||||
value={connectivityCurrency}
|
||||
onChange={(e) => setConnectivityCurrency(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{connectivityResult ? (
|
||||
<div className="rounded-md border bg-muted/40 p-3 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(connectivityResult.probe, null, 2)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setConnectivityTarget(null)}>
|
||||
{t("integrationSites.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={connectivityBusy || connectivityPlayerId.trim() === ""}
|
||||
onClick={() => void runConnectivityTest()}
|
||||
>
|
||||
{connectivityBusy
|
||||
? t("integrationSites.connectivityRunning")
|
||||
: t("integrationSites.connectivityRun")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={secretsDialog !== null} onOpenChange={(open) => !open && setSecretsDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("integrationSites.secretsTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("integrationSites.secretsDescription", { code: secretsDialog?.siteCode ?? "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{secretsDialog ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("integrationSites.fields.ssoSecret")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={secretsDialog.secrets.sso_jwt_secret} className="font-mono text-xs" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void copyText(
|
||||
t("integrationSites.fields.ssoSecret"),
|
||||
secretsDialog.secrets.sso_jwt_secret,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("integrationSites.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("integrationSites.fields.walletApiKey")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={secretsDialog.secrets.wallet_api_key} className="font-mono text-xs" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void copyText(
|
||||
t("integrationSites.fields.walletApiKey"),
|
||||
secretsDialog.secrets.wallet_api_key,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("integrationSites.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={() => setSecretsDialog(null)}>
|
||||
{t("integrationSites.secretsDismiss")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
||||
@@ -87,8 +88,11 @@ export function PlayersConsole(): React.ReactElement {
|
||||
const canFreezePlayers = adminHasAnyPermission(profile?.permissions, [PRD_PLAYER_FREEZE_MANAGE]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [siteCode, setSiteCode] = useState("");
|
||||
const [appliedSiteCode, setAppliedSiteCode] = useState("");
|
||||
|
||||
const [items, setItems] = useState<AdminPlayerRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -124,6 +128,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
page,
|
||||
per_page: perPage,
|
||||
keyword: query.trim() || undefined,
|
||||
site_code: appliedSiteCode.trim() || undefined,
|
||||
});
|
||||
setItems(data.items);
|
||||
setTotal(data.meta.total);
|
||||
@@ -137,7 +142,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, query, t]);
|
||||
}, [page, perPage, query, appliedSiteCode, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
@@ -297,6 +302,24 @@ export function PlayersConsole(): React.ReactElement {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="admin-list-toolbar">
|
||||
{canChooseSite ? (
|
||||
<div className="admin-list-field">
|
||||
<Label className="sm:w-20 sm:shrink-0">{t("filterSite")}</Label>
|
||||
<Select value={siteCode || "__all__"} onValueChange={(v) => setSiteCode(v === "__all__" ? "" : v)}>
|
||||
<SelectTrigger className="w-full sm:w-[12rem]">
|
||||
<SelectValue placeholder={t("filterAllSites")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.code} value={site.code}>
|
||||
{site.code} — {site.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
|
||||
{t("search")}
|
||||
@@ -311,6 +334,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
if (e.key === "Enter") {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
setAppliedSiteCode(siteCode.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -326,6 +350,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
setAppliedSiteCode(siteCode.trim());
|
||||
}}
|
||||
>
|
||||
{t("search")}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminTicketItems } from "@/api/admin-tickets";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
@@ -19,6 +20,13 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -48,6 +56,7 @@ const TICKET_STATUS_OPTIONS = [
|
||||
] as const;
|
||||
|
||||
type TicketFilters = {
|
||||
siteCode: string;
|
||||
playerQuery: string;
|
||||
drawNo: string;
|
||||
numberKeyword: string;
|
||||
@@ -57,6 +66,7 @@ type TicketFilters = {
|
||||
};
|
||||
|
||||
const emptyTicketFilters: TicketFilters = {
|
||||
siteCode: "",
|
||||
playerQuery: "",
|
||||
drawNo: "",
|
||||
numberKeyword: "",
|
||||
@@ -90,6 +100,7 @@ function ticketStatusSummary(statuses: string[], t: TicketTranslateFn): string {
|
||||
|
||||
export function PlayerTicketsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["tickets", "common"]);
|
||||
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
const exportLabels = useExportLabels("tickets");
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
@@ -118,6 +129,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
page,
|
||||
per_page: perPage,
|
||||
...query,
|
||||
site_code: applied.siteCode.trim() || undefined,
|
||||
draw_no: applied.drawNo.trim() || undefined,
|
||||
status: applied.statuses.length > 0 ? applied.statuses : undefined,
|
||||
number: applied.numberKeyword.trim() || undefined,
|
||||
@@ -143,6 +155,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
setErr(null);
|
||||
setApplied({
|
||||
...draft,
|
||||
siteCode: draft.siteCode.trim(),
|
||||
playerQuery: draft.playerQuery.trim(),
|
||||
drawNo: draft.drawNo.trim(),
|
||||
numberKeyword: draft.numberKeyword.trim(),
|
||||
@@ -173,6 +186,32 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
{canChooseSite ? (
|
||||
<div className="admin-list-field">
|
||||
<Label className="sm:shrink-0">{t("filterSite")}</Label>
|
||||
<Select
|
||||
value={draft.siteCode || "__all__"}
|
||||
onValueChange={(v) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
siteCode: v === "__all__" ? "" : v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[12rem]">
|
||||
<SelectValue placeholder={t("filterAllSites")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.code} value={site.code}>
|
||||
{site.code} — {site.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
|
||||
<Label htmlFor="pt-player" className="sm:shrink-0">
|
||||
{t("playerId")}
|
||||
|
||||
Reference in New Issue
Block a user