Files
lotteryAdmin/src/modules/config/doc/risk-cap-doc-screen.tsx
kang cbc499e5b2 feat(api, agents): add agent node profile retrieval and update functionality
Implemented new API functions to fetch and update agent node profiles, enhancing the management capabilities for agent data. This addition improves the overall functionality of the admin agents console, allowing for better user interaction with agent profiles. Updated related types for improved type safety and clarity in the codebase.
2026-06-04 09:17:55 +08:00

634 lines
22 KiB
TypeScript

"use client";
import { Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
deleteRiskCapVersion,
getAllConfigVersions,
getRiskCapVersion,
getRiskCapVersions,
postRiskCapVersion,
publishRiskCapVersion,
putRiskCapItems,
} from "@/api/admin-config";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
import {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import { ConfigSection } from "@/modules/config/config-section";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { RiskCapRuntimePanel } from "@/modules/config/risk-cap-runtime-panel";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
import { PRD_RISK_CAP_MANAGE, PRD_RISK_CAP_VIEW } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
import type {
ConfigVersionSummary,
RiskCapItemRow,
RiskCapVersionDetail,
} from "@/types/api/admin-config";
type DraftRiskRow = Omit<RiskCapItemRow, "id"> & { clientKey: string };
function newRow(): DraftRiskRow {
return {
clientKey: `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
draw_id: null,
normalized_number: "0000",
cap_amount: 0,
cap_type: "per_number",
};
}
function isDefaultRiskRow(row: DraftRiskRow): boolean {
return row.cap_type === "default";
}
function defaultRiskRowFromAmount(amount: number): DraftRiskRow {
return {
clientKey: `default-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
draw_id: null,
normalized_number: "0000",
cap_amount: amount,
cap_type: "default",
};
}
export function RiskCapDocScreen() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const tRef = useTranslationRef(["config", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_RISK_CAP_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState<RiskCapVersionDetail | null>(null);
const [draftRows, setDraftRows] = useState<DraftRiskRow[]>([]);
const [loadingList, setLoadingList] = useState(true);
const [loadingDetail, setLoadingDetail] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [defaultCapStr, setDefaultCapStr] = useState("");
const [syncOpen, setSyncOpen] = useState(false);
const [rollbackOpen, setRollbackOpen] = useState(false);
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
const amountCurrencyCode = "NPR";
const refreshList = useCallback(async () => {
setLoadingList(true);
setError(null);
try {
const d = await getAllConfigVersions(getRiskCapVersions);
setList(d.items);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" });
setError(msg);
setList([]);
} finally {
setLoadingList(false);
}
}, []);
useAsyncEffect(() => {
void refreshList();
}, []);
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
const defaultRow = rows.find(isDefaultRiskRow);
if (!defaultRow) {
setDefaultCapStr("");
return;
}
setDefaultCapStr(formatAdminMinorDecimal(defaultRow.cap_amount, amountCurrencyCode));
}
const loadDetail = useCallback(async (id: number) => {
setLoadingDetail(true);
try {
const d = await getRiskCapVersion(id);
setDetail(d);
const mapped = d.items.map((it) => ({
clientKey: `srv-${it.id}`,
draw_id: it.draw_id,
normalized_number: it.normalized_number,
cap_amount: it.cap_amount,
cap_type: it.cap_type,
}));
setDraftRows(mapped);
syncDefaultCapFromRows(mapped);
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setDetail(null);
setDraftRows([]);
syncDefaultCapFromRows([]);
} finally {
setLoadingDetail(false);
}
}, []);
useEffect(() => {
if (list.length === 0) {
if (selectedId !== "") {
queueMicrotask(() => {
setSelectedId("");
setDetail(null);
setDraftRows([]);
syncDefaultCapFromRows([]);
});
}
return;
}
if (selectedId !== "" && list.some((x) => String(x.id) === selectedId)) {
return;
}
queueMicrotask(() => {
const pickId = pickDefaultConfigVersionId(list);
if (pickId) {
setSelectedId(pickId);
}
});
}, [list, selectedId]);
useEffect(() => {
if (selectedId === "") {
return;
}
const id = Number(selectedId);
if (!Number.isFinite(id)) {
return;
}
queueMicrotask(() => {
void loadDetail(id);
});
}, [selectedId, loadDetail]);
const selectedVersionSummary = useMemo(
() => list.find((x) => String(x.id) === selectedId) ?? null,
[list, selectedId],
);
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
const isDraft = selectedStatus === "draft";
const canEditDraft = isDraft && canManage;
const updateRow = (idx: number, patch: Partial<DraftRiskRow>) => {
setDraftRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
};
function removeRow(idx: number) {
setDraftRows((prev) => prev.filter((_, i) => i !== idx));
}
async function handleSave() {
if (!detail || !canEditDraft) {
return;
}
if (draftRows.length === 0) {
toast.error(t("riskCap.validation.requireAtLeastOne", { ns: "config" }));
return;
}
for (const r of draftRows) {
if (isDefaultRiskRow(r)) {
if (r.cap_amount <= 0) {
toast.error(t("riskCap.validation.defaultGreaterThanZero", { ns: "config" }));
return;
}
continue;
}
if (!/^[0-9]{4}$/.test(r.normalized_number)) {
toast.error(t("riskCap.validation.numberMustBe4Digits", { ns: "config", number: r.normalized_number }));
return;
}
}
setSaving(true);
try {
const payload = draftRows.map((r) => ({
draw_id: r.draw_id && r.draw_id > 0 ? r.draw_id : null,
normalized_number: r.normalized_number,
cap_amount: r.cap_amount,
cap_type: r.cap_type,
}));
const d = await putRiskCapItems(detail.id, payload);
setDetail(d);
const saved = d.items.map((it) => ({
clientKey: `srv-${it.id}`,
draw_id: it.draw_id,
normalized_number: it.normalized_number,
cap_amount: it.cap_amount,
cap_type: it.cap_type,
}));
setDraftRows(saved);
syncDefaultCapFromRows(saved);
toast.success(t("versionActions.saveDraft", { ns: "config" }));
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.saveFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
async function handlePublish() {
if (!detail || !canEditDraft) {
return;
}
setSaving(true);
try {
const d = await publishRiskCapVersion(detail.id);
setDetail(d);
const pub = d.items.map((it) => ({
clientKey: `srv-${it.id}`,
draw_id: it.draw_id,
normalized_number: it.normalized_number,
cap_amount: it.cap_amount,
cap_type: it.cap_type,
}));
setDraftRows(pub);
syncDefaultCapFromRows(pub);
toast.success(t("versionActions.publishCurrent", { ns: "config" }));
void refreshList();
setSelectedId(String(d.id));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.publishFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
async function handleNewDraft() {
setSaving(true);
try {
const active = list.find((x) => x.status === "active");
const d = await postRiskCapVersion({
reason: `draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null,
});
toast.success(t("riskCap.createDraftSuccess", { ns: "config", version: d.version_no }));
await refreshList();
setSelectedId(String(d.id));
setDetail(d);
const nd = d.items.map((it) => ({
clientKey: `srv-${it.id}`,
draw_id: it.draw_id,
normalized_number: it.normalized_number,
cap_amount: it.cap_amount,
cap_type: it.cap_type,
}));
setDraftRows(nd);
syncDefaultCapFromRows(nd);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.createDraftFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
function applyDefaultCap() {
const n = parseAdminMajorToMinor(defaultCapStr, amountCurrencyCode);
if (n == null || !Number.isFinite(n) || n <= 0) {
toast.error(t("riskCap.validation.enterValidCapAmount", { ns: "config" }));
return;
}
setDraftRows((prev) => {
const next = prev.filter((row) => !isDefaultRiskRow(row));
return [defaultRiskRowFromAmount(n), ...next];
});
setSyncOpen(false);
toast.message(t("riskCap.savedLocalDraft", { ns: "config" }));
}
const specialRows = useMemo(
() => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)),
[draftRows],
);
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteRiskCapVersion(row.id);
toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.deleteFailed", { ns: "config" }));
throw e;
}
}
function requestRollback(row: ConfigVersionSummary) {
setRollbackTarget(row);
setRollbackOpen(true);
}
async function handleRollback() {
if (!rollbackTarget) {
return;
}
setSaving(true);
try {
const d = await postRiskCapVersion({
reason: `rollback from v${rollbackTarget.version_no}`,
clone_from_version_id: rollbackTarget.id,
});
toast.success(
t("versionActions.rollbackSuccess", {
ns: "config",
fromVersion: rollbackTarget.version_no,
version: d.version_no,
}),
);
await refreshList();
setSelectedId(String(d.id));
setDetail(d);
const mapped = d.items.map((it) => ({
clientKey: `srv-${it.id}`,
draw_id: it.draw_id,
normalized_number: it.normalized_number,
cap_amount: it.cap_amount,
cap_type: it.cap_type,
}));
setDraftRows(mapped);
syncDefaultCapFromRows(mapped);
setRollbackOpen(false);
setRollbackTarget(null);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.rollbackFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
return (
<ConfigDocPage
title={t("nav.items.risk-cap", { ns: "config" })}
titleSuffix={detail ? `· v${detail.version_no}` : undefined}
toolbar={
<ConfigDocToolbar
switcher={
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle={`${t("nav.items.risk-cap", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}
/>
}
actions={
<ConfigVersionActions
isDraft={isDraft}
canManage={canManage}
loadingList={loadingList}
loadingDetail={loadingDetail}
saving={saving}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
onPublish={() =>
requestConfirm({
title: t("riskCap.publishDialog.title", { ns: "config" }),
description: t("riskCap.publishDialog.description", { ns: "config" }),
confirmLabel: t("riskCap.publishDialog.confirm", { ns: "config" }),
confirmVariant: "destructive",
onConfirm: () => handlePublish(),
})
}
/>
}
footer={
detail ? (
<ConfigVersionToolbarMeta emphasis={!isDraft}>
<span>
{t("riskCap.effectiveAt", {
ns: "config",
value: detail.effective_at ? formatDt(detail.effective_at) : "—",
})}
</span>
{!isDraft ? (
<ConfigVersionToolbarMetaEmphasis>
{t("riskCap.readOnlyHint", { ns: "config" })}
</ConfigVersionToolbarMetaEmphasis>
) : (
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
)}
</ConfigVersionToolbarMeta>
) : null
}
/>
}
contentClassName="space-y-8"
>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<ConfigSection title={t("riskCap.defaultCap.title", { ns: "config" })}>
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1">
<Label htmlFor="default-cap">{t("riskCap.defaultCap.fieldLabel", { ns: "config" })}</Label>
{canEditDraft ? (
<Input
id="default-cap"
type="number"
min={0}
className="w-[220px] font-mono tabular-nums"
disabled={saving}
value={defaultCapStr}
placeholder={t("riskCap.placeholders.defaultCap", { ns: "config" })}
onChange={(e) => setDefaultCapStr(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono className="w-[220px]">
{defaultCapStr || formatAdminMinorDecimal(0, amountCurrencyCode)}
</ConfigReadonlyValue>
)}
</div>
{canEditDraft ? (
<Button type="button" variant="secondary" disabled={saving} onClick={() => setSyncOpen(true)}>
{t("riskCap.actions.update", { ns: "config" })}
</Button>
) : null}
</div>
</ConfigSection>
<ConfigSection
title={t("riskCap.specialCaps.title", { ns: "config" })}
actions={
canEditDraft ? (
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => setDraftRows((prev) => [...prev, newRow()])}
>
{t("riskCap.actions.addSpecialCap", { ns: "config" })}
</Button>
) : null
}
>
{loadingDetail ? (
<AdminLoadingState minHeight="6rem" className="py-4" label={t("riskCap.loadingDetails", { ns: "config" })} />
) : specialRows.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{specialRows.map(({ row: r, index: idx }) => (
<TableRow key={r.clientKey}>
<TableCell>
{canEditDraft ? (
<Input
className="h-8 font-mono tabular-nums"
maxLength={4}
disabled={saving}
value={r.normalized_number}
placeholder={t("riskCap.placeholders.number", { ns: "config" })}
onChange={(e) =>
updateRow(idx, {
normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4),
})
}
/>
) : (
<ConfigReadonlyValue mono>{r.normalized_number}</ConfigReadonlyValue>
)}
</TableCell>
<TableCell>
{canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="h-8 font-mono tabular-nums"
disabled={saving}
value={formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
placeholder={t("riskCap.placeholders.capAmount", { ns: "config" })}
onChange={(e) =>
updateRow(idx, {
cap_amount:
parseAdminMajorToMinor(e.target.value, amountCurrencyCode) ?? 0,
})
}
/>
) : (
<ConfigReadonlyValue mono>
{formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{canEditDraft ? (
<AdminRowActionsMenu
busy={saving}
actions={[
{
key: "delete",
label: t("actions.delete", { ns: "adminUsers" }),
icon: Trash2,
destructive: true,
onClick: () => removeRow(idx),
},
]}
/>
) : (
<span className="text-sm text-muted-foreground">{t("riskCap.readOnly", { ns: "config" })}</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</ConfigSection>
<RiskCapRuntimePanel />
<Dialog open={syncOpen} onOpenChange={setSyncOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("riskCap.syncDialog.title", { ns: "config" })}</DialogTitle>
<DialogDescription>
{t("riskCap.syncDialog.description", { ns: "config", value: defaultCapStr || "(empty)" })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setSyncOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button type="button" onClick={applyDefaultCap}>
{t("riskCap.syncDialog.confirm", { ns: "config" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("versionActions.rollbackDialog.title", { ns: "config" })}</DialogTitle>
<DialogDescription>
{t("versionActions.rollbackDialog.description", {
ns: "config",
version: rollbackTarget?.version_no ?? "—",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRollbackOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button type="button" onClick={() => void handleRollback()} disabled={!rollbackTarget || saving}>
{t("versionActions.rollbackDialog.confirm", { ns: "config" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog />
</ConfigDocPage>
);
}