Files
lotteryAdmin/src/modules/draws/draw-detail-console.tsx
kang 6ea0a6feec feat(agents, config, dashboard, i18n): add agent line provision wizard, site deletion, and site dashboard with multi-language support
Added agent line provision wizard page with permission gating, replacing redirect placeholder. Introduced site deletion API and UI with confirmation dialog in integration sites management. Added new site-scoped dashboard panel showing bet metrics, P/L trends, active players, and quick links. Enhanced chart tooltip to support custom formatters and fix indicator color
2026-06-12 20:47:53 +08:00

423 lines
15 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,
getAdminDrawFinanceSummary,
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 { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
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 { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { formatAdminMinorUnits } from "@/lib/money";
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "./draw-display";
import { DrawStatusBadge } from "./draw-status-badge";
import {
PRD_DRAW_REOPEN_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 [financeSummary, setFinanceSummary] = useState<AdminDrawFinanceSummaryData | 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 {
const draw = await getAdminDraw(idNum);
setData(draw);
if (draw.capabilities?.can_view_draw_finance !== false) {
try {
setFinanceSummary(await getAdminDrawFinanceSummary(idNum));
} catch {
setFinanceSummary(null);
}
} else {
setFinanceSummary(null);
}
} catch (e) {
setData(null);
setFinanceSummary(null);
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
} finally {
setLoading(false);
}
}, [idNum, tRef]);
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: async () => { await 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: async () => { await postAdminCancelDraw(idNum); },
confirmTitle: t("confirm.cancelDrawTitle"),
confirmDescription: t("confirm.cancelDrawDescription"),
},
{
key: "rng",
label: t("rngAutoGenerate"),
variant: "outline",
enabled: data.status === "closed",
onConfirm: async () => { await 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: async () => { await 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: async () => { await 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 financeCurrency = financeSummary?.currency_code ?? "NPR";
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">
<div className="mb-3">
<Link
href="/admin/draws"
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
>
{t("backToList")}
</Link>
</div>
<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("overviewTitle")}</h3>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
<p className="text-xs font-medium text-muted-foreground">{t("overviewBetTotal")}</p>
<p className="mt-1 font-mono text-sm tabular-nums">
{formatAdminMinorUnits(
financeSummary?.total_bet_minor ?? data.total_bet_minor ?? 0,
financeCurrency,
)}
</p>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
<p className="text-xs font-medium text-muted-foreground">{t("overviewPayoutTotal")}</p>
<p className="mt-1 font-mono text-sm tabular-nums">
{formatAdminMinorUnits(
financeSummary?.total_payout_minor ?? data.total_payout_minor ?? 0,
financeCurrency,
)}
</p>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2.5">
<p className="text-xs font-medium text-muted-foreground">{t("overviewProfitLoss")}</p>
<p
className={cn(
"mt-1 font-mono text-sm tabular-nums",
signedMoneyClass(
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
true,
),
)}
>
{formatAdminMinorUnits(
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
financeCurrency,
)}
</p>
</div>
</div>
</section>
<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")}
<span className="ml-1">{t("reviewQueueHint")}</span>
{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>
);
}