Files
lotteryAdmin/src/modules/draws/draw-detail-console.tsx
kang af982bb9f7 feat(api, agents, i18n): enhance settlement features and multi-language support
Added new types and API functions for settlement period summaries and credit ledgers, improving the management of agent settlements. Updated the admin console to reflect these changes, enhancing user experience with better navigation and data presentation. Additionally, expanded multi-language support by incorporating new translations in English, Nepali, and Chinese for settlement-related terms, ensuring consistency across the platform.
2026-06-05 18:00:59 +08:00

357 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 { adminHasAnyPermission } from "@/lib/admin-permissions";
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>
);
}