Some checks failed
lotteryadmin CI / build (push) Has been cancelled
- Simplified share rate calculation in AgentsConsole by removing unnecessary checks and directly setting the profile share rate. - Updated the use of `profileParentCaps` to always return total share rate in the agent profile. - Removed unused variables and memoized calculations for improved performance. - Cleaned up imports in various files, removing unused components and optimizing the code structure. - Added `tRef` dependency to several useEffect hooks to ensure proper reactivity to translation changes. - Enhanced report preview tables with better label handling for various statuses and actions. - Updated wallet filter options to align with player-side transaction types. - Introduced new properties in types for better type safety and clarity.
323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { ChevronDown, ChevronRight, 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 { Input } from "@/components/ui/input";
|
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
|
import { AdminLoadingState } 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 { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
|
|
import { DrawPublishDialog } from "@/modules/draws/draw-publish-dialog";
|
|
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
|
|
|
|
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
|
|
|
|
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 }): React.ReactElement {
|
|
const { t } = useTranslation(["draws", "common"]);
|
|
const tRef = useTranslationRef(["draws", "common"]);
|
|
const profile = useAdminProfile();
|
|
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
|
|
const { refresh: refreshDraw } = useDrawDetail();
|
|
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 [manualOpen, setManualOpen] = useState(false);
|
|
const [publishBatch, setPublishBatch] = useState<AdminDrawBatchRow | 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, tRef]);
|
|
|
|
useAsyncEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
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();
|
|
await refreshDraw();
|
|
} 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(() => ""));
|
|
setManualOpen(false);
|
|
await load();
|
|
await refreshDraw();
|
|
} catch (e) {
|
|
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
|
} finally {
|
|
setSavingManual(false);
|
|
}
|
|
}
|
|
|
|
async function onPublishFlowDone(): Promise<void> {
|
|
await load();
|
|
await refreshDraw();
|
|
}
|
|
|
|
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-4">
|
|
<div className="rounded-lg border border-border/60">
|
|
<div className="border-b border-border/60 px-3 py-2.5">
|
|
<h2 className="text-sm font-semibold">{t("pendingBatches")}</h2>
|
|
</div>
|
|
<div className="p-3">
|
|
{pending.length === 0 ? (
|
|
<AdminNoResourceState className="py-6" message={t("noPendingBatches")} />
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
|
|
<TableHead>{t("numberCount")}</TableHead>
|
|
<TableHead className="w-14 text-center">{t("table.actions", { ns: "common" })}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{pending.map((b) => (
|
|
<TableRow key={b.id}>
|
|
<TableCell>v{b.result_version}</TableCell>
|
|
<TableCell className="tabular-nums">{b.items.length}</TableCell>
|
|
<TableCell className="text-center">
|
|
{canManageDraw ? (
|
|
<AdminRowActionsMenu
|
|
busy={discardingBatchId === b.id}
|
|
actions={[
|
|
{
|
|
key: "publish",
|
|
label: t("reviewAndPublishAction"),
|
|
icon: Rocket,
|
|
onClick: () => setPublishBatch(b),
|
|
},
|
|
{
|
|
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">—</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{canManageDraw ? (
|
|
<div className="rounded-lg border border-border/60">
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between px-3 py-2.5 text-left text-sm font-semibold"
|
|
onClick={() => setManualOpen((open) => !open)}
|
|
>
|
|
{t("manualResultEntry")}
|
|
{manualOpen ? (
|
|
<ChevronDown className="size-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="size-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
{manualOpen ? (
|
|
<div className="space-y-3 border-t border-border/60 px-3 py-3">
|
|
<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">
|
|
<span className="text-xs text-muted-foreground">
|
|
{t(slot.label, { index: "labelIndex" in slot ? slot.labelIndex : undefined })}
|
|
</span>
|
|
<Input
|
|
inputMode="numeric"
|
|
maxLength={4}
|
|
value={manualNumbers[i]}
|
|
disabled={savingManual}
|
|
placeholder="0000"
|
|
className="h-8 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 justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8"
|
|
disabled={savingManual}
|
|
onClick={fillRandomManualNumbers}
|
|
>
|
|
<Dices className="size-3.5" aria-hidden />
|
|
{t("fillRandomNumbers")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8"
|
|
disabled={savingManual}
|
|
onClick={() => setManualNumbers(RESULT_SLOTS.map(() => ""))}
|
|
>
|
|
{t("clear")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="h-8"
|
|
disabled={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>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<DrawPublishDialog
|
|
open={publishBatch != null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setPublishBatch(null);
|
|
}
|
|
}}
|
|
drawId={idNum}
|
|
batch={publishBatch}
|
|
onPublished={() => void onPublishFlowDone()}
|
|
onDiscarded={() => void onPublishFlowDone()}
|
|
/>
|
|
<ConfirmDialog />
|
|
</div>
|
|
);
|
|
} |