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.
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
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 {
|
|
getAdminDraw,
|
|
postAdminCancelDraw,
|
|
postAdminManualCloseDraw,
|
|
postAdminReopenDraw,
|
|
postAdminRunDrawRng,
|
|
} from "@/api/admin-draws";
|
|
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-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 { canManageDrawResults } from "@/lib/draw-access";
|
|
import { useAdminProfile } from "@/stores/admin-session";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "./draw-display";
|
|
import { DrawStatusBadge } from "./draw-status-badge";
|
|
import {
|
|
PRD_DRAW_REOPEN_MANAGE,
|
|
PRD_DRAW_RESULT_MANAGE,
|
|
PRD_PAYOUT_MANAGE,
|
|
PRD_PAYOUT_REVIEW,
|
|
} from "./draw-prd";
|
|
|
|
type ScheduleStep = {
|
|
key: string;
|
|
label: string;
|
|
at: string | null | undefined;
|
|
};
|
|
|
|
function ScheduleTimeline({ steps }: { steps: ScheduleStep[] }) {
|
|
const formatDt = useAdminDateTimeFormatter();
|
|
|
|
return (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
|
const { t } = useTranslation(["draws", "common"]);
|
|
const tRef = useTranslationRef(["draws", "common"]);
|
|
const idNum = Number(drawId);
|
|
const profile = useAdminProfile();
|
|
const canManageDraw = canManageDrawResults(profile?.permissions);
|
|
const canReopenDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_REOPEN_MANAGE]);
|
|
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [
|
|
PRD_PAYOUT_MANAGE,
|
|
PRD_PAYOUT_REVIEW,
|
|
]);
|
|
const [data, setData] = useState<AdminDrawShowData | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [acting, setActing] = useState<string | null>(null);
|
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
|
|
|
const load = useCallback(async () => {
|
|
if (!Number.isFinite(idNum)) {
|
|
setError(tRef.current("invalidDrawId"));
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
setData(await getAdminDraw(idNum));
|
|
} catch (e) {
|
|
setData(null);
|
|
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [idNum]);
|
|
|
|
async function runAction(name: string, action: () => Promise<unknown>): Promise<void> {
|
|
if (!Number.isFinite(idNum)) return;
|
|
setActing(name);
|
|
try {
|
|
await action();
|
|
toast.success(t("actionSuccess", { name }));
|
|
await load();
|
|
} catch (e) {
|
|
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name }));
|
|
} finally {
|
|
setActing(null);
|
|
}
|
|
}
|
|
|
|
useAsyncEffect(() => {
|
|
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" />;
|
|
}
|
|
|
|
if (error) {
|
|
return <p className="text-sm text-destructive">{error}</p>;
|
|
}
|
|
if (!data) {
|
|
return <AdminNoResourceState />;
|
|
}
|
|
|
|
const batch = data.result_batch_counts;
|
|
const pendingReview = batch.pending_review ?? 0;
|
|
const totalBatches = batch.total ?? batch.published;
|
|
const hasResultActivity =
|
|
(canManageDraw && (totalBatches > 0 || pendingReview > 0)) || batch.published > 0;
|
|
const showActions =
|
|
availableActions.length > 0 && (canManageDraw || canReopenDraw || canRunSettlement);
|
|
|
|
return (
|
|
<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-wrap items-center gap-2">
|
|
<DrawStatusBadge
|
|
status={data.status}
|
|
label={drawStatusLabel(data.status, t)}
|
|
/>
|
|
{hallPreviewDiffersFromDbStatus(data.status, data.hall_preview_status) ? (
|
|
<>
|
|
<span className="text-xs text-muted-foreground">{t("hallPreviewStatusLabel")}</span>
|
|
<DrawStatusBadge
|
|
status={data.hall_preview_status}
|
|
label={drawStatusLabel(data.hall_preview_status, t)}
|
|
/>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<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">
|
|
{canManageDraw ? (
|
|
<span className="rounded-md bg-muted px-2.5 py-1">
|
|
{t("batchSummaryTotal", { count: totalBatches })}
|
|
</span>
|
|
) : null}
|
|
{canManageDraw ? (
|
|
pendingReview > 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: pendingReview })}
|
|
</Link>
|
|
) : (
|
|
<span className="rounded-md bg-muted px-2.5 py-1 text-muted-foreground">
|
|
{t("batchSummaryPending", { count: 0 })}
|
|
</span>
|
|
)
|
|
) : null}
|
|
{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>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("noResultBatchesYet")}
|
|
{canManageDraw ? (
|
|
<>
|
|
{" "}
|
|
<Link
|
|
href={`/admin/draws/${drawId}/review`}
|
|
className="font-medium text-primary underline-offset-4 hover:underline"
|
|
>
|
|
{t("goToReviewTab")}
|
|
</Link>
|
|
</>
|
|
) : null}
|
|
</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>
|
|
</section>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
<ConfirmDialog />
|
|
</div>
|
|
);
|
|
}
|