feat(integration): 新增 site_code 支持并增强玩家与注单管理界面
在后台玩家与注单相关 API 中新增 site_code 参数,支持按站点筛选数据。 更新 PlayersConsole 与 PlayerTicketsConsole UI 组件,新增站点选择筛选功能。 增强国际化支持,在英文与中文语言包中新增站点相关文案。 优化配置中心页面,新增跳转至集成站点管理的入口,提升后台导航体验。
This commit is contained in:
66
src/api/admin-integration-sites.ts
Normal file
66
src/api/admin-integration-sites.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { adminRequest } from "@/lib/admin-http";
|
||||||
|
|
||||||
|
import { API_V1_PREFIX } from "./paths";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AdminIntegrationSiteConnectivityResult,
|
||||||
|
AdminIntegrationSiteCreatePayload,
|
||||||
|
AdminIntegrationSiteDetail,
|
||||||
|
AdminIntegrationSiteListData,
|
||||||
|
AdminIntegrationSiteParameterSheet,
|
||||||
|
AdminIntegrationSiteUpdatePayload,
|
||||||
|
AdminIntegrationSiteWithSecrets,
|
||||||
|
} from "@/types/api/admin-integration-site";
|
||||||
|
|
||||||
|
const A = `${API_V1_PREFIX}/admin`;
|
||||||
|
|
||||||
|
export async function getAdminIntegrationSites(): Promise<AdminIntegrationSiteListData> {
|
||||||
|
return adminRequest.get<AdminIntegrationSiteListData>(`${A}/integration-sites`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminIntegrationSite(id: number): Promise<AdminIntegrationSiteDetail> {
|
||||||
|
return adminRequest.get<AdminIntegrationSiteDetail>(`${A}/integration-sites/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postAdminIntegrationSite(
|
||||||
|
body: AdminIntegrationSiteCreatePayload,
|
||||||
|
): Promise<AdminIntegrationSiteWithSecrets> {
|
||||||
|
return adminRequest.post<AdminIntegrationSiteWithSecrets>(`${A}/integration-sites`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putAdminIntegrationSite(
|
||||||
|
id: number,
|
||||||
|
body: AdminIntegrationSiteUpdatePayload,
|
||||||
|
): Promise<AdminIntegrationSiteDetail> {
|
||||||
|
return adminRequest.put<AdminIntegrationSiteDetail>(`${A}/integration-sites/${id}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postAdminIntegrationSiteRotateSecrets(
|
||||||
|
id: number,
|
||||||
|
): Promise<AdminIntegrationSiteWithSecrets> {
|
||||||
|
return adminRequest.post<AdminIntegrationSiteWithSecrets>(
|
||||||
|
`${A}/integration-sites/${id}/rotate-secrets`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postAdminIntegrationSiteConnectivityTest(
|
||||||
|
id: number,
|
||||||
|
body: { site_player_id: string; currency_code?: string },
|
||||||
|
): Promise<AdminIntegrationSiteConnectivityResult> {
|
||||||
|
return adminRequest.post<AdminIntegrationSiteConnectivityResult>(
|
||||||
|
`${A}/integration-sites/${id}/connectivity-test`,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminIntegrationSiteExport(
|
||||||
|
id: number,
|
||||||
|
format: "json" | "csv" = "json",
|
||||||
|
): Promise<AdminIntegrationSiteParameterSheet> {
|
||||||
|
return adminRequest.get<AdminIntegrationSiteParameterSheet>(
|
||||||
|
`${A}/integration-sites/${id}/export`,
|
||||||
|
{ params: { format } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export async function getAdminPlayers(params?: {
|
|||||||
per_page?: number;
|
per_page?: number;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
|
site_code?: string;
|
||||||
}): Promise<AdminPlayerListData> {
|
}): Promise<AdminPlayerListData> {
|
||||||
return adminRequest.get<AdminPlayerListData>(`${A}/players`, { params });
|
return adminRequest.get<AdminPlayerListData>(`${A}/players`, { params });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type TicketItemsListQuery = {
|
|||||||
per_page?: number;
|
per_page?: number;
|
||||||
player_id?: number;
|
player_id?: number;
|
||||||
player_account?: string;
|
player_account?: string;
|
||||||
|
site_code?: string;
|
||||||
draw_no?: string;
|
draw_no?: string;
|
||||||
status?: string[];
|
status?: string[];
|
||||||
number?: string;
|
number?: string;
|
||||||
|
|||||||
14
src/app/admin/(shell)/config/integration-sites/page.tsx
Normal file
14
src/app/admin/(shell)/config/integration-sites/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
|
import { IntegrationSitesConsole } from "@/modules/integration/integration-sites-console";
|
||||||
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = buildPageMetadata("config", "integrationSites.title");
|
||||||
|
|
||||||
|
export default function AdminIntegrationSitesPage() {
|
||||||
|
return (
|
||||||
|
<ModuleScaffold>
|
||||||
|
<IntegrationSitesConsole />
|
||||||
|
</ModuleScaffold>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/hooks/use-admin-site-code-options.ts
Normal file
66
src/hooks/use-admin-site-code-options.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||||
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
|
|
||||||
|
export type AdminSiteCodeOption = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接入站点下拉(已按当前管理员站点权限过滤)。
|
||||||
|
*/
|
||||||
|
export function useAdminSiteCodeOptions(): {
|
||||||
|
sites: AdminSiteCodeOption[];
|
||||||
|
loading: boolean;
|
||||||
|
canChooseSite: boolean;
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
} {
|
||||||
|
const profile = useAdminProfile();
|
||||||
|
const canLoad = adminHasAnyPermission(profile?.permissions, [
|
||||||
|
"prd.integration.view",
|
||||||
|
"prd.integration.manage",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [sites, setSites] = useState<AdminSiteCodeOption[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
if (!canLoad) {
|
||||||
|
setSites([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getAdminIntegrationSites();
|
||||||
|
setSites(
|
||||||
|
data.items.map((row) => ({
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setSites([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [canLoad]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void reload();
|
||||||
|
});
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sites,
|
||||||
|
loading,
|
||||||
|
canChooseSite: canLoad && sites.length > 0,
|
||||||
|
reload,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,7 +29,77 @@
|
|||||||
"jackpotTitle": "Jackpot",
|
"jackpotTitle": "Jackpot",
|
||||||
"jackpotDesc": "Pool parameters and ledger records",
|
"jackpotDesc": "Pool parameters and ledger records",
|
||||||
"riskCapTitle": "Risk cap rules",
|
"riskCapTitle": "Risk cap rules",
|
||||||
"riskCapDesc": "Per-number payout caps and occupancy"
|
"riskCapDesc": "Per-number payout caps and occupancy",
|
||||||
|
"integrationTitle": "Integration sites",
|
||||||
|
"integrationDesc": "site_code, JWT secrets, partner wallet URL, iframe allowlist"
|
||||||
|
},
|
||||||
|
"integrationSites": {
|
||||||
|
"title": "Integration sites",
|
||||||
|
"description": "Maintain partner integration settings in admin. site_code cannot be changed after creation.",
|
||||||
|
"create": "New site",
|
||||||
|
"edit": "Edit",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"copy": "Copy",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"empty": "No integration sites",
|
||||||
|
"loadFailed": "Failed to load integration sites",
|
||||||
|
"saveFailed": "Save failed",
|
||||||
|
"createSuccess": "Created site {{code}}",
|
||||||
|
"updateSuccess": "Updated site {{code}}",
|
||||||
|
"connectivityTest": "Test connectivity",
|
||||||
|
"connectivityTitle": "Partner wallet connectivity",
|
||||||
|
"connectivityDescription": "Call the balance API for site {{code}} using a test player.",
|
||||||
|
"connectivityPlayerId": "Test site_player_id",
|
||||||
|
"connectivityRun": "Run test",
|
||||||
|
"connectivityRunning": "Testing…",
|
||||||
|
"connectivitySuccess": "Connectivity OK",
|
||||||
|
"connectivityFailed": "Connectivity failed",
|
||||||
|
"exportParams": "Export params",
|
||||||
|
"exportSuccess": "Exported parameter sheet for {{code}}",
|
||||||
|
"exportFailed": "Export failed",
|
||||||
|
"rotateSecrets": "Rotate secrets",
|
||||||
|
"rotateSuccess": "Rotated secrets for {{code}}",
|
||||||
|
"rotateFailed": "Failed to rotate secrets",
|
||||||
|
"rotateConfirmTitle": "Rotate secrets?",
|
||||||
|
"rotateConfirmDescription": "New SSO and wallet keys will be generated for {{code}}. Old keys stop working immediately.",
|
||||||
|
"rotateConfirm": "Rotate",
|
||||||
|
"secretsTitle": "Save these secrets now",
|
||||||
|
"secretsDescription": "Secrets for {{code}} are shown only once.",
|
||||||
|
"secretsDismiss": "I have saved them",
|
||||||
|
"copied": "Copied {{field}}",
|
||||||
|
"copyFailed": "Copy failed",
|
||||||
|
"noPermission": "No permission to view integration sites",
|
||||||
|
"codeImmutable": "site_code cannot be changed after creation",
|
||||||
|
"statusEnabled": "Enabled",
|
||||||
|
"statusDisabled": "Disabled",
|
||||||
|
"dialogCreateTitle": "New integration site",
|
||||||
|
"dialogEditTitle": "Edit integration site",
|
||||||
|
"dialogDescription": "Default wallet paths are fine unless the partner uses custom URLs.",
|
||||||
|
"form": {
|
||||||
|
"required": "Site name is required",
|
||||||
|
"codeRequired": "site_code is required"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"code": "site_code",
|
||||||
|
"name": "Name",
|
||||||
|
"status": "Status",
|
||||||
|
"walletUrl": "Wallet API",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"code": "site_code",
|
||||||
|
"name": "Site name",
|
||||||
|
"currency": "Default currency",
|
||||||
|
"status": "Status",
|
||||||
|
"walletApiUrl": "Partner wallet base URL",
|
||||||
|
"lotteryH5BaseUrl": "Lottery H5 base URL (optional)",
|
||||||
|
"iframeOrigins": "iframe allowlist (one origin per line)",
|
||||||
|
"notes": "Notes",
|
||||||
|
"ssoSecret": "SSO secret",
|
||||||
|
"walletApiKey": "Wallet API key"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"versionStatus": {
|
"versionStatus": {
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
"listTitle": "Player list",
|
"listTitle": "Player list",
|
||||||
"createPlayer": "Create player",
|
"createPlayer": "Create player",
|
||||||
"searchPlaceholder": "Search by player ID / username / nickname",
|
"searchPlaceholder": "Search by player ID / username / nickname",
|
||||||
|
"filterSite": "Site",
|
||||||
|
"filterAllSites": "All sites",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"loadFailed": "Failed to load player list",
|
"loadFailed": "Failed to load player list",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"title": "Ticket list",
|
"title": "Ticket list",
|
||||||
"playerTicketQuery": "Ticket query",
|
"playerTicketQuery": "Ticket query",
|
||||||
|
"filterSite": "Site",
|
||||||
|
"filterAllSites": "All sites",
|
||||||
"playerId": "Player ID / account",
|
"playerId": "Player ID / account",
|
||||||
"invalidPlayerId": "Enter a valid player ID or account",
|
"invalidPlayerId": "Enter a valid player ID or account",
|
||||||
"playerIdPlaceholder": "Leave blank for all tickets; enter player ID or account",
|
"playerIdPlaceholder": "Leave blank for all tickets; enter player ID or account",
|
||||||
|
|||||||
@@ -29,7 +29,77 @@
|
|||||||
"jackpotTitle": "奖池",
|
"jackpotTitle": "奖池",
|
||||||
"jackpotDesc": "奖池参数与进账流水",
|
"jackpotDesc": "奖池参数与进账流水",
|
||||||
"riskCapTitle": "限额版本",
|
"riskCapTitle": "限额版本",
|
||||||
"riskCapDesc": "号码赔付封顶与占用视图"
|
"riskCapDesc": "号码赔付封顶与占用视图",
|
||||||
|
"integrationTitle": "主站接入站点",
|
||||||
|
"integrationDesc": "site_code、JWT 密钥、主站钱包 URL 与 iframe 白名单"
|
||||||
|
},
|
||||||
|
"integrationSites": {
|
||||||
|
"title": "主站接入站点",
|
||||||
|
"description": "由运营在后台维护各主站对接参数,并通过权限控制谁能查看或修改。site_code 创建后不可修改。",
|
||||||
|
"create": "新建站点",
|
||||||
|
"edit": "编辑",
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中…",
|
||||||
|
"cancel": "取消",
|
||||||
|
"copy": "复制",
|
||||||
|
"loading": "加载中…",
|
||||||
|
"empty": "暂无接入站点",
|
||||||
|
"loadFailed": "加载接入站点失败",
|
||||||
|
"saveFailed": "保存失败",
|
||||||
|
"createSuccess": "已创建站点 {{code}}",
|
||||||
|
"updateSuccess": "已更新站点 {{code}}",
|
||||||
|
"connectivityTest": "联通检测",
|
||||||
|
"connectivityTitle": "主站钱包联通检测",
|
||||||
|
"connectivityDescription": "使用测试玩家调用站点 {{code}} 的 balance 接口。",
|
||||||
|
"connectivityPlayerId": "测试 site_player_id",
|
||||||
|
"connectivityRun": "开始检测",
|
||||||
|
"connectivityRunning": "检测中…",
|
||||||
|
"connectivitySuccess": "联通成功",
|
||||||
|
"connectivityFailed": "联通失败",
|
||||||
|
"exportParams": "导出参数表",
|
||||||
|
"exportSuccess": "已导出 {{code}} 参数表",
|
||||||
|
"exportFailed": "导出失败",
|
||||||
|
"rotateSecrets": "重置密钥",
|
||||||
|
"rotateSuccess": "已重置站点 {{code}} 的密钥",
|
||||||
|
"rotateFailed": "重置密钥失败",
|
||||||
|
"rotateConfirmTitle": "确认重置密钥?",
|
||||||
|
"rotateConfirmDescription": "将重新生成站点 {{code}} 的 SSO 与钱包密钥,旧密钥立即失效。",
|
||||||
|
"rotateConfirm": "确认重置",
|
||||||
|
"secretsTitle": "请妥善保存密钥",
|
||||||
|
"secretsDescription": "站点 {{code}} 的密钥仅显示一次,关闭后无法再次查看完整内容。",
|
||||||
|
"secretsDismiss": "我已保存",
|
||||||
|
"copied": "已复制 {{field}}",
|
||||||
|
"copyFailed": "复制失败",
|
||||||
|
"noPermission": "当前账号无接入站点查看权限",
|
||||||
|
"codeImmutable": "site_code 创建后不可修改",
|
||||||
|
"statusEnabled": "启用",
|
||||||
|
"statusDisabled": "停用",
|
||||||
|
"dialogCreateTitle": "新建接入站点",
|
||||||
|
"dialogEditTitle": "编辑接入站点",
|
||||||
|
"dialogDescription": "钱包路径使用默认值即可,除非主站 URL 规范不同。",
|
||||||
|
"form": {
|
||||||
|
"required": "请填写站点名称",
|
||||||
|
"codeRequired": "请填写 site_code"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"code": "site_code",
|
||||||
|
"name": "名称",
|
||||||
|
"status": "状态",
|
||||||
|
"walletUrl": "钱包 API",
|
||||||
|
"actions": "操作"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"code": "site_code",
|
||||||
|
"name": "站点名称",
|
||||||
|
"currency": "默认币种",
|
||||||
|
"status": "状态",
|
||||||
|
"walletApiUrl": "主站钱包根 URL",
|
||||||
|
"lotteryH5BaseUrl": "彩票 H5 基址(可选)",
|
||||||
|
"iframeOrigins": "iframe 白名单(每行一个 origin)",
|
||||||
|
"notes": "备注",
|
||||||
|
"ssoSecret": "SSO 密钥",
|
||||||
|
"walletApiKey": "钱包 API 密钥"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"versionStatus": {
|
"versionStatus": {
|
||||||
"active": "生效中",
|
"active": "生效中",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
"listTitle": "玩家列表",
|
"listTitle": "玩家列表",
|
||||||
"createPlayer": "新建玩家",
|
"createPlayer": "新建玩家",
|
||||||
"searchPlaceholder": "按玩家 ID / 用户名 / 昵称搜索",
|
"searchPlaceholder": "按玩家 ID / 用户名 / 昵称搜索",
|
||||||
|
"filterSite": "主站站点",
|
||||||
|
"filterAllSites": "全部站点",
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"loadFailed": "加载玩家列表失败",
|
"loadFailed": "加载玩家列表失败",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"title": "注单列表",
|
"title": "注单列表",
|
||||||
"playerTicketQuery": "注单查询",
|
"playerTicketQuery": "注单查询",
|
||||||
|
"filterSite": "主站站点",
|
||||||
|
"filterAllSites": "全部站点",
|
||||||
"playerId": "玩家 ID / 账号",
|
"playerId": "玩家 ID / 账号",
|
||||||
"invalidPlayerId": "请输入有效玩家 ID 或账号",
|
"invalidPlayerId": "请输入有效玩家 ID 或账号",
|
||||||
"playerIdPlaceholder": "留空显示全部,可输入玩家 ID 或账号",
|
"playerIdPlaceholder": "留空显示全部,可输入玩家 ID 或账号",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const EXACT_ROUTES: Record<string, PageTitleSpec> = {
|
|||||||
"/admin/settings/currencies": { ns: "config", key: "currencies.title" },
|
"/admin/settings/currencies": { ns: "config", key: "currencies.title" },
|
||||||
"/admin/currencies": { ns: "config", key: "currencies.title" },
|
"/admin/currencies": { ns: "config", key: "currencies.title" },
|
||||||
"/admin/config": { ns: "config", key: "hub.title" },
|
"/admin/config": { ns: "config", key: "hub.title" },
|
||||||
|
"/admin/config/integration-sites": { ns: "config", key: "integrationSites.title" },
|
||||||
"/admin/rules/plays": { ns: "config", key: "nav.rulesPlaysTitle" },
|
"/admin/rules/plays": { ns: "config", key: "nav.rulesPlaysTitle" },
|
||||||
"/admin/rules/odds": { ns: "config", key: "nav.rulesOddsTitle" },
|
"/admin/rules/odds": { ns: "config", key: "nav.rulesOddsTitle" },
|
||||||
"/admin/jackpot": { ns: "jackpot", key: "configTitle" },
|
"/admin/jackpot": { ns: "jackpot", key: "configTitle" },
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ const HUB_CARDS: HubCard[] = [
|
|||||||
descKey: "hub.riskCapDesc",
|
descKey: "hub.riskCapDesc",
|
||||||
requiredAny: ["prd.risk_cap.manage", "prd.risk_cap.view"],
|
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() {
|
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,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||||
|
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
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 canFreezePlayers = adminHasAnyPermission(profile?.permissions, [PRD_PLAYER_FREEZE_MANAGE]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(10);
|
const [perPage, setPerPage] = useState(10);
|
||||||
|
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [siteCode, setSiteCode] = useState("");
|
||||||
|
const [appliedSiteCode, setAppliedSiteCode] = useState("");
|
||||||
|
|
||||||
const [items, setItems] = useState<AdminPlayerRow[]>([]);
|
const [items, setItems] = useState<AdminPlayerRow[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -124,6 +128,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
page,
|
page,
|
||||||
per_page: perPage,
|
per_page: perPage,
|
||||||
keyword: query.trim() || undefined,
|
keyword: query.trim() || undefined,
|
||||||
|
site_code: appliedSiteCode.trim() || undefined,
|
||||||
});
|
});
|
||||||
setItems(data.items);
|
setItems(data.items);
|
||||||
setTotal(data.meta.total);
|
setTotal(data.meta.total);
|
||||||
@@ -137,7 +142,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, perPage, query, t]);
|
}, [page, perPage, query, appliedSiteCode, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -297,6 +302,24 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-list-toolbar">
|
<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">
|
<div className="admin-list-field xl:min-w-0">
|
||||||
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
|
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
|
||||||
{t("search")}
|
{t("search")}
|
||||||
@@ -311,6 +334,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setQuery(keyword.trim());
|
setQuery(keyword.trim());
|
||||||
|
setAppliedSiteCode(siteCode.trim());
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -326,6 +350,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setQuery(keyword.trim());
|
setQuery(keyword.trim());
|
||||||
|
setAppliedSiteCode(siteCode.trim());
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("search")}
|
{t("search")}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useExportLabels } from "@/hooks/use-export-labels";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getAdminTicketItems } from "@/api/admin-tickets";
|
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 { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
@@ -19,6 +20,13 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -48,6 +56,7 @@ const TICKET_STATUS_OPTIONS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type TicketFilters = {
|
type TicketFilters = {
|
||||||
|
siteCode: string;
|
||||||
playerQuery: string;
|
playerQuery: string;
|
||||||
drawNo: string;
|
drawNo: string;
|
||||||
numberKeyword: string;
|
numberKeyword: string;
|
||||||
@@ -57,6 +66,7 @@ type TicketFilters = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const emptyTicketFilters: TicketFilters = {
|
const emptyTicketFilters: TicketFilters = {
|
||||||
|
siteCode: "",
|
||||||
playerQuery: "",
|
playerQuery: "",
|
||||||
drawNo: "",
|
drawNo: "",
|
||||||
numberKeyword: "",
|
numberKeyword: "",
|
||||||
@@ -90,6 +100,7 @@ function ticketStatusSummary(statuses: string[], t: TicketTranslateFn): string {
|
|||||||
|
|
||||||
export function PlayerTicketsConsole(): React.ReactElement {
|
export function PlayerTicketsConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["tickets", "common"]);
|
const { t } = useTranslation(["tickets", "common"]);
|
||||||
|
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
|
||||||
const playCodeLabel = useAdminPlayCodeLabel();
|
const playCodeLabel = useAdminPlayCodeLabel();
|
||||||
const exportLabels = useExportLabels("tickets");
|
const exportLabels = useExportLabels("tickets");
|
||||||
const formatTs = useAdminDateTimeFormatter();
|
const formatTs = useAdminDateTimeFormatter();
|
||||||
@@ -118,6 +129,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
page,
|
page,
|
||||||
per_page: perPage,
|
per_page: perPage,
|
||||||
...query,
|
...query,
|
||||||
|
site_code: applied.siteCode.trim() || undefined,
|
||||||
draw_no: applied.drawNo.trim() || undefined,
|
draw_no: applied.drawNo.trim() || undefined,
|
||||||
status: applied.statuses.length > 0 ? applied.statuses : undefined,
|
status: applied.statuses.length > 0 ? applied.statuses : undefined,
|
||||||
number: applied.numberKeyword.trim() || undefined,
|
number: applied.numberKeyword.trim() || undefined,
|
||||||
@@ -143,6 +155,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
setErr(null);
|
setErr(null);
|
||||||
setApplied({
|
setApplied({
|
||||||
...draft,
|
...draft,
|
||||||
|
siteCode: draft.siteCode.trim(),
|
||||||
playerQuery: draft.playerQuery.trim(),
|
playerQuery: draft.playerQuery.trim(),
|
||||||
drawNo: draft.drawNo.trim(),
|
drawNo: draft.drawNo.trim(),
|
||||||
numberKeyword: draft.numberKeyword.trim(),
|
numberKeyword: draft.numberKeyword.trim(),
|
||||||
@@ -173,6 +186,32 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="admin-list-content">
|
<CardContent className="admin-list-content">
|
||||||
<div className="admin-list-toolbar">
|
<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">
|
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
|
||||||
<Label htmlFor="pt-player" className="sm:shrink-0">
|
<Label htmlFor="pt-player" className="sm:shrink-0">
|
||||||
{t("playerId")}
|
{t("playerId")}
|
||||||
|
|||||||
79
src/types/api/admin-integration-site.ts
Normal file
79
src/types/api/admin-integration-site.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
export type AdminIntegrationSiteRow = {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
currency_code: string;
|
||||||
|
status: number;
|
||||||
|
wallet_api_url: string | null;
|
||||||
|
wallet_timeout_seconds: number;
|
||||||
|
has_sso_secret: boolean;
|
||||||
|
has_wallet_api_key: boolean;
|
||||||
|
sso_secret_masked: string | null;
|
||||||
|
wallet_api_key_masked: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminIntegrationSiteDetail = AdminIntegrationSiteRow & {
|
||||||
|
wallet_debit_path: string;
|
||||||
|
wallet_credit_path: string;
|
||||||
|
wallet_balance_path: string;
|
||||||
|
iframe_allowed_origins: string[];
|
||||||
|
lottery_h5_base_url: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
is_default: boolean;
|
||||||
|
created_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminIntegrationSiteSecrets = {
|
||||||
|
sso_jwt_secret: string;
|
||||||
|
wallet_api_key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminIntegrationSiteListData = {
|
||||||
|
items: AdminIntegrationSiteRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminIntegrationSiteCreatePayload = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
currency_code?: string;
|
||||||
|
status?: number;
|
||||||
|
wallet_api_url?: string | null;
|
||||||
|
wallet_debit_path?: string;
|
||||||
|
wallet_credit_path?: string;
|
||||||
|
wallet_balance_path?: string;
|
||||||
|
wallet_timeout_seconds?: number;
|
||||||
|
iframe_allowed_origins?: string[];
|
||||||
|
lottery_h5_base_url?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminIntegrationSiteUpdatePayload = Omit<AdminIntegrationSiteCreatePayload, "code">;
|
||||||
|
|
||||||
|
export type AdminIntegrationSiteWithSecrets = AdminIntegrationSiteDetail & {
|
||||||
|
secrets?: AdminIntegrationSiteSecrets;
|
||||||
|
secrets_display_once?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminIntegrationSiteConnectivityProbe = {
|
||||||
|
success: boolean;
|
||||||
|
main_balance_minor: number | null;
|
||||||
|
currency_code: string;
|
||||||
|
request_url: string;
|
||||||
|
http_status: number | null;
|
||||||
|
message: string | null;
|
||||||
|
response_preview?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminIntegrationSiteConnectivityResult = {
|
||||||
|
site_code: string;
|
||||||
|
site_player_id: string;
|
||||||
|
player_source: "database" | "synthetic";
|
||||||
|
probe: AdminIntegrationSiteConnectivityProbe;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminIntegrationSiteParameterSheet = Record<string, unknown> & {
|
||||||
|
site_code: string;
|
||||||
|
name: string;
|
||||||
|
security_note: string;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user