Files
lotteryAdmin/src/modules/draws/draw-review-console.tsx
kang 65eaeecf8c feat(agents, i18n): enhance agent management and settlement features with new translations and UI updates
Added new translations for agent management and settlement features in English, Nepali, and Chinese, improving multi-language support. Updated the agents console to reflect changes in funding modes and player details, enhancing user experience. Refactored the admin permission gate to include new logic for handling bound line agents, ensuring better permission management. Additionally, streamlined the UI for agent-related pages and improved navigation to the settlement center, consolidating related functionalities for better accessibility.
2026-06-04 18:01:05 +08:00

299 lines
11 KiB
TypeScript

"use client";
import { Dices, Rocket, Trash2 } from "lucide-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";
import { toast } from "sonner";
import {
deleteAdminPendingResultBatch,
getAdminDrawResultBatches,
postAdminCreateManualResultBatch,
} from "@/api/admin-draws";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchesData } from "@/types/api/admin-draws";
import { drawStatusLabel } from "./draw-display";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
const RESULT_SLOTS = [
{ prize_type: "first", prize_index: 0, label: "resultSlots.first" },
{ prize_type: "second", prize_index: 0, label: "resultSlots.second" },
{ prize_type: "third", prize_index: 0, label: "resultSlots.third" },
...Array.from({ length: 10 }, (_, i) => ({
prize_type: "starter",
prize_index: i,
label: `resultSlots.starter`,
labelIndex: i + 1,
})),
...Array.from({ length: 10 }, (_, i) => ({
prize_type: "consolation",
prize_index: i,
label: `resultSlots.consolation`,
labelIndex: i + 1,
})),
] as const;
function randomDrawNumber4d(): string {
return String(Math.floor(Math.random() * 10_000)).padStart(4, "0");
}
export function DrawReviewConsole({ drawId }: { drawId: string }) {
const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
PRD_DRAW_RESULT_MANAGE,
]);
const idNum = Number(drawId);
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [savingManual, setSavingManual] = useState(false);
const [discardingBatchId, setDiscardingBatchId] = useState<number | null>(null);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [manualNumbers, setManualNumbers] = useState<string[]>(
() => RESULT_SLOTS.map(() => ""),
);
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError(tRef.current("invalidDrawId"));
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
setData(await getAdminDrawResultBatches(idNum));
} catch (e) {
setData(null);
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
} finally {
setLoading(false);
}
}, [idNum]);
useAsyncEffect(() => {
void load();
}, [idNum]);
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [
data,
]);
function fillRandomManualNumbers(): void {
setManualNumbers(RESULT_SLOTS.map(() => randomDrawNumber4d()));
}
async function discardPendingBatch(batchId: number): Promise<void> {
if (!Number.isFinite(idNum)) return;
setDiscardingBatchId(batchId);
try {
await deleteAdminPendingResultBatch(idNum, batchId);
toast.success(t("discardPendingBatchSuccess"));
await load();
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"),
);
} finally {
setDiscardingBatchId(null);
}
}
async function saveManualDraft(): Promise<void> {
if (!Number.isFinite(idNum)) return;
const invalid = manualNumbers.some((n) => !/^[0-9]{4}$/.test(n));
if (invalid) {
toast.error(t("enter23Numbers"));
return;
}
setSavingManual(true);
try {
const res = await postAdminCreateManualResultBatch(idNum, {
items: RESULT_SLOTS.map((slot, i) => ({
prize_type: slot.prize_type,
prize_index: slot.prize_index,
number_4d: manualNumbers[i],
})),
});
toast.success(t("draftSaved", { version: res.batch.result_version }));
setManualNumbers(RESULT_SLOTS.map(() => ""));
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
} finally {
setSavingManual(false);
}
}
if (loading && !data) {
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
if (error) {
return <p className="text-sm text-destructive">{error}</p>;
}
if (!data) {
return <AdminNoResourceState />;
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">{t("manualResultEntry")}</CardTitle>
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{t("currentStatusLabel")}</span>
<DrawStatusBadge
status={data.draw_status}
label={drawStatusLabel(data.draw_status, t)}
/>
<span>· {t("currentStatusDraftHint")}</span>
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{RESULT_SLOTS.map((slot, i) => (
<label key={`${slot.prize_type}-${slot.prize_index}`} className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
{t(slot.label, { index: "labelIndex" in slot ? slot.labelIndex : undefined })}
</span>
<Input
inputMode="numeric"
maxLength={4}
value={manualNumbers[i]}
disabled={!canManageDraw || savingManual}
placeholder="0000"
className="font-mono"
onChange={(e) => {
const next = e.target.value.replace(/\D/g, "").slice(0, 4);
setManualNumbers((old) => old.map((v, idx) => (idx === i ? next : v)));
}}
/>
</label>
))}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={!canManageDraw || savingManual}
onClick={fillRandomManualNumbers}
>
<Dices className="size-4" aria-hidden />
{t("fillRandomNumbers")}
</Button>
<Button
type="button"
variant="outline"
disabled={savingManual}
onClick={() => setManualNumbers(RESULT_SLOTS.map(() => ""))}
>
{t("clear")}
</Button>
<Button
type="button"
disabled={!canManageDraw || savingManual || !["closed", "review"].includes(data.draw_status)}
onClick={() =>
requestConfirm({
title: t("confirm.saveManualDraftTitle"),
description: t("confirm.saveManualDraftDescription"),
onConfirm: () => saveManualDraft(),
})
}
>
{savingManual ? t("saving") : t("saveDraft")}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">{t("pendingBatches")}</CardTitle>
</CardHeader>
<CardContent>
{pending.length === 0 ? (
<AdminNoResourceState className="py-6" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("batchId")}</TableHead>
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
<TableHead>{t("numberCount")}</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("table.actions", { ns: "common" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pending.map((b) => (
<TableRow key={b.id}>
<TableCell className="font-mono text-xs">{b.id}</TableCell>
<TableCell>v{b.result_version}</TableCell>
<TableCell>{b.items.length}</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{canManageDraw ? (
<AdminRowActionsMenu
busy={discardingBatchId === b.id}
actions={[
{
key: "publish",
label: t("reviewAndPublishAction"),
icon: Rocket,
href: `/admin/draws/${drawId}/publish/${b.id}`,
},
{
key: "discard",
label: t("discardPendingBatch"),
icon: Trash2,
destructive: true,
disabled: discardingBatchId !== null,
onClick: () =>
requestConfirm({
title: t("confirm.discardPendingBatchTitle"),
description: t("confirm.discardPendingBatchDescription"),
confirmVariant: "destructive",
onConfirm: () => discardPendingBatch(b.id),
}),
},
]}
/>
) : (
<span className="text-xs text-muted-foreground">{t("noPublishPermission")}</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<ConfirmDialog />
</div>
);
}