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.
This commit is contained in:
2026-06-04 09:17:55 +08:00
parent 59b0684ea1
commit cbc499e5b2
79 changed files with 3468 additions and 1406 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
@@ -15,25 +15,18 @@ import {
postAdminRunDrawRng,
} from "@/api/admin-draws";
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
import { Button, buttonVariants } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawShowData } from "@/types/api/admin-draws";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
import { cn } from "@/lib/utils";
import {
drawResultSourceLabel,
drawStatusLabel,
hallPreviewDiffersFromDbStatus,
} from "./draw-display";
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "./draw-display";
import { DrawStatusBadge } from "./draw-status-badge";
import {
PRD_DRAW_REOPEN_MANAGE,
@@ -42,12 +35,31 @@ import {
PRD_PAYOUT_REVIEW,
} from "./draw-prd";
function Field({ label, children }: { label: string; children: React.ReactNode }) {
type ScheduleStep = {
key: string;
label: string;
at: string | null | undefined;
};
function ScheduleTimeline({ steps }: { steps: ScheduleStep[] }) {
const formatDt = useAdminDateTimeFormatter();
return (
<div className="grid gap-1 sm:grid-cols-[10rem_1fr] sm:items-start">
<Label className="text-muted-foreground">{label}</Label>
<div className="text-sm">{children}</div>
</div>
<ol className="grid gap-3 sm:grid-cols-3">
{steps.map((step, index) => (
<li
key={step.key}
className={cn(
"relative rounded-lg border bg-muted/20 px-3 py-2.5",
index < steps.length - 1 &&
"sm:after:absolute sm:after:top-1/2 sm:after:left-full sm:after:h-px sm:after:w-3 sm:after:-translate-y-1/2 sm:after:bg-border",
)}
>
<p className="text-xs font-medium text-muted-foreground">{step.label}</p>
<p className="mt-1 font-mono text-sm tabular-nums">{formatDt(step.at)}</p>
</li>
))}
</ol>
);
}
@@ -62,7 +74,6 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
PRD_PAYOUT_MANAGE,
PRD_PAYOUT_REVIEW,
]);
const formatDt = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminDrawShowData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -105,6 +116,99 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
void load();
}, [idNum]);
const scheduleSteps = useMemo((): ScheduleStep[] => {
if (!data) return [];
const steps: ScheduleStep[] = [
{ key: "start", label: t("startTime"), at: data.start_time },
{ key: "close", label: t("closeTime"), at: data.close_time },
{ key: "draw", label: t("plannedDraw"), at: data.draw_time },
];
if (data.cooling_end_time) {
steps.push({
key: "cooling",
label: t("coolingEndTime"),
at: data.cooling_end_time,
});
}
return steps;
}, [data, t]);
const availableActions = useMemo(() => {
if (!data) return [];
type ActionDef = {
key: string;
label: string;
variant: "outline" | "destructive";
enabled: boolean;
onConfirm: () => Promise<void>;
confirmTitle: string;
confirmDescription: string;
confirmVariant?: "default" | "destructive";
};
const defs: ActionDef[] = [];
if (canManageDraw) {
defs.push(
{
key: "manualClose",
label: t("manualClose"),
variant: "outline",
enabled: ["pending", "open"].includes(data.status),
onConfirm: () => postAdminManualCloseDraw(idNum),
confirmTitle: t("confirm.manualCloseTitle"),
confirmDescription: t("confirm.manualCloseDescription"),
},
{
key: "cancel",
label: t("cancelBeforeDraw"),
variant: "outline",
enabled: ["pending", "open", "closing", "closed"].includes(data.status),
onConfirm: () => postAdminCancelDraw(idNum),
confirmTitle: t("confirm.cancelDrawTitle"),
confirmDescription: t("confirm.cancelDrawDescription"),
},
{
key: "rng",
label: t("rngAutoGenerate"),
variant: "outline",
enabled: data.status === "closed",
onConfirm: () => postAdminRunDrawRng(idNum),
confirmTitle: t("confirm.rngDrawTitle"),
confirmDescription: t("confirm.rngDrawDescription"),
},
);
}
if (canReopenDraw) {
defs.push({
key: "reopen",
label: t("cooldownReopen"),
variant: "destructive",
enabled: data.status === "cooldown",
onConfirm: () => postAdminReopenDraw(idNum),
confirmTitle: t("confirm.reopenTitle"),
confirmDescription: t("confirm.reopenDescription"),
confirmVariant: "destructive",
});
}
if (canRunSettlement) {
defs.push({
key: "settlement",
label: t("runSettlement"),
variant: "outline",
enabled: data.status === "settling",
onConfirm: () => postAdminRunDrawSettlement(idNum),
confirmTitle: t("confirm.runSettlementTitle"),
confirmDescription: t("confirm.runSettlementDescription"),
});
}
return defs.filter((d) => d.enabled);
}, [canManageDraw, canReopenDraw, canRunSettlement, data, idNum, t]);
if (loading && !data) {
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
@@ -113,164 +217,122 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
}
const batch = data.result_batch_counts;
const hasResultActivity = batch.total > 0 || batch.pending_review > 0 || batch.published > 0;
const showActions =
availableActions.length > 0 && (canManageDraw || canReopenDraw || canRunSettlement);
return (
<div className="space-y-6">
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 pb-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="text-xl">{data.draw_no}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">{t("drawDetail")}</p>
<div className="space-y-4">
<Card>
<CardHeader className="pb-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="font-mono text-xl tracking-tight">{data.draw_no}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{t("detailSubtitle", {
date: data.business_date,
seq: data.sequence_no,
})}
</p>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<div className="flex flex-wrap items-center gap-2">
<DrawStatusBadge
status={data.status}
label={drawStatusLabel(data.status, t)}
/>
{hallPreviewDiffersFromDbStatus(data.status, data.hall_preview_status) ? (
<p className="flex flex-wrap items-center justify-end gap-2 text-sm text-muted-foreground">
<span>{t("hallPreviewStatusLabel")}</span>
<>
<span className="text-xs text-muted-foreground">{t("hallPreviewStatusLabel")}</span>
<DrawStatusBadge
status={data.hall_preview_status}
label={drawStatusLabel(data.hall_preview_status, t)}
/>
</p>
</>
) : null}
</div>
</div>
</CardHeader>
<CardContent className="grid gap-6 p-6 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label={t("businessDate")}>{data.business_date}</Field>
<Field label={t("sequenceNo")}>{data.sequence_no}</Field>
<Field label={t("startTime")}>{formatDt(data.start_time)}</Field>
<Field label={t("closeTime")}>{formatDt(data.close_time)}</Field>
<Field label={t("plannedDraw")}>{formatDt(data.draw_time)}</Field>
<Field label={t("coolingEndTime")}>{formatDt(data.cooling_end_time)}</Field>
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<Field label={t("resultSource")}>{drawResultSourceLabel(data.result_source, t)}</Field>
<Field label={t("currentResultVersion")}>{data.current_result_version}</Field>
<Field label={t("settleVersion")}>{data.settle_version}</Field>
<Field label={t("isReopened")}>{data.is_reopened ? t("yes") : t("no")}</Field>
</div>
</div>
<div className="rounded-xl border bg-muted/20 p-4">
<p className="text-sm font-medium text-muted-foreground">{t("batchStats")}</p>
<div className="mt-3 grid gap-3 text-sm">
<div className="flex items-center justify-between rounded-lg bg-background px-3 py-2">
<span>{t("batchTotal")}</span>
<span className="font-semibold">{data.result_batch_counts.total}</span>
<CardContent className="space-y-6 border-t pt-6">
<section className="space-y-3">
<h3 className="text-sm font-medium">{t("scheduleTitle")}</h3>
<ScheduleTimeline steps={scheduleSteps} />
</section>
<section className="space-y-3">
<h3 className="text-sm font-medium">{t("resultBatchesTitle")}</h3>
{hasResultActivity ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="rounded-md bg-muted px-2.5 py-1">
{t("batchSummaryTotal", { count: batch.total })}
</span>
{batch.pending_review > 0 ? (
<Link
href={`/admin/draws/${drawId}/review`}
className="rounded-md bg-amber-500/15 px-2.5 py-1 font-medium text-amber-800 dark:text-amber-200"
>
{t("batchSummaryPending", { count: batch.pending_review })}
</Link>
) : (
<span className="rounded-md bg-muted px-2.5 py-1 text-muted-foreground">
{t("batchSummaryPending", { count: 0 })}
</span>
)}
{batch.published > 0 ? (
<Link
href={`/admin/draws/${drawId}/results`}
className="rounded-md bg-emerald-500/15 px-2.5 py-1 font-medium text-emerald-800 dark:text-emerald-200"
>
{t("batchSummaryPublished", { count: batch.published })}
</Link>
) : (
<span className="rounded-md bg-muted px-2.5 py-1 text-muted-foreground">
{t("batchSummaryPublished", { count: 0 })}
</span>
)}
</div>
<div className="flex items-center justify-between rounded-lg bg-background px-3 py-2 text-amber-600 dark:text-amber-400">
<span>{t("pendingReview")}</span>
<span className="font-semibold">{data.result_batch_counts.pending_review}</span>
) : (
<p className="text-sm text-muted-foreground">
{t("noResultBatchesYet")}{" "}
<Link
href={`/admin/draws/${drawId}/review`}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{t("goToReviewTab")}
</Link>
</p>
)}
</section>
{showActions ? (
<section className="space-y-3 border-t pt-6">
<h3 className="text-sm font-medium">{t("drawActions")}</h3>
<div className="flex flex-wrap gap-2">
{availableActions.map((action) => (
<Button
key={action.key}
type="button"
size="sm"
variant={action.variant}
disabled={acting !== null}
onClick={() =>
requestConfirm({
title: action.confirmTitle,
description: action.confirmDescription,
confirmVariant: action.confirmVariant,
onConfirm: () => runAction(action.label, action.onConfirm),
})
}
>
{acting === action.label ? t("processing") : action.label}
</Button>
))}
</div>
<div className="flex items-center justify-between rounded-lg bg-background px-3 py-2 text-emerald-600 dark:text-emerald-400">
<span>{t("published")}</span>
<span className="font-semibold">{data.result_batch_counts.published}</span>
</div>
</div>
<Link
href={`/admin/draws/${drawId}/finance`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "mt-4 w-full")}
>
{t("viewFinance")}
</Link>
</div>
</section>
) : null}
</CardContent>
</Card>
{(canManageDraw || canReopenDraw || canRunSettlement) ? (
<Card>
<CardHeader>
<CardTitle className="text-base">{t("drawActions")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={!canManageDraw || acting !== null || !["pending", "open"].includes(data.status)}
onClick={() =>
requestConfirm({
title: t("confirm.manualCloseTitle"),
description: t("confirm.manualCloseDescription"),
onConfirm: () => runAction(t("manualClose"), () => postAdminManualCloseDraw(idNum)),
})
}
>
{acting === t("manualClose") ? t("processing") : t("manualClose")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={!canManageDraw || acting !== null || !["pending", "open", "closing", "closed"].includes(data.status)}
onClick={() =>
requestConfirm({
title: t("confirm.cancelDrawTitle"),
description: t("confirm.cancelDrawDescription"),
onConfirm: () => runAction(t("cancelDraw"), () => postAdminCancelDraw(idNum)),
})
}
>
{acting === t("cancelDraw") ? t("processing") : t("cancelBeforeDraw")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={!canManageDraw || acting !== null || data.status !== "closed"}
onClick={() =>
requestConfirm({
title: t("confirm.rngDrawTitle"),
description: t("confirm.rngDrawDescription"),
onConfirm: () => runAction(t("rngDraw"), () => postAdminRunDrawRng(idNum)),
})
}
>
{acting === t("rngDraw") ? t("generating") : t("rngAutoGenerate")}
</Button>
{canReopenDraw ? (
<Button
type="button"
variant="destructive"
size="sm"
disabled={acting !== null || data.status !== "cooldown"}
onClick={() =>
requestConfirm({
title: t("confirm.reopenTitle"),
description: t("confirm.reopenDescription"),
confirmVariant: "destructive",
onConfirm: () => runAction(t("reopen"), () => postAdminReopenDraw(idNum)),
})
}
>
{acting === t("reopen") ? t("processing") : t("cooldownReopen")}
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
disabled={!canRunSettlement || acting !== null || data.status !== "settling"}
onClick={() =>
requestConfirm({
title: t("confirm.runSettlementTitle"),
description: t("confirm.runSettlementDescription"),
onConfirm: () => runAction(t("runSettlement"), () => postAdminRunDrawSettlement(idNum)),
})
}
>
{acting === t("runSettlement") ? t("processing") : t("runSettlement")}
</Button>
</CardContent>
</Card>
) : null}
<ConfirmDialog />
</div>
);