feat(api, i18n): add admin report job functionalities and enhance locale support
- Introduced new API functions for managing admin report jobs, including download and post operations. - Updated English, Nepali, and Chinese locale files to include new messages related to report job actions and rollback confirmations. - Enhanced user experience by providing clearer instructions and feedback in the admin interface. - Refactored related components to integrate new functionalities and improve overall usability.
This commit is contained in:
275
src/modules/config/risk-cap-runtime-panel.tsx
Normal file
275
src/modules/config/risk-cap-runtime-panel.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
import { getAdminRiskPools } from "@/api/admin-risk";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ConfigSection } from "@/modules/config/config-section";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDrawListItem } from "@/types/api/admin-draws";
|
||||
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
|
||||
|
||||
type PoolFilter = "all" | "sold_out" | "high_risk";
|
||||
|
||||
export function RiskCapRuntimePanel() {
|
||||
const { t } = useTranslation(["config", "risk", "draws", "common"]);
|
||||
const [draws, setDraws] = useState<AdminDrawListItem[]>([]);
|
||||
const [drawsLoading, setDrawsLoading] = useState(true);
|
||||
const [drawId, setDrawId] = useState<string>("");
|
||||
|
||||
const [numberQ, setNumberQ] = useState("");
|
||||
const [appliedNumber, setAppliedNumber] = useState("");
|
||||
const [poolFilter, setPoolFilter] = useState<PoolFilter>("all");
|
||||
|
||||
const [pools, setPools] = useState<AdminRiskPoolRow[]>([]);
|
||||
const [currencyCode, setCurrencyCode] = useState<string | null>(null);
|
||||
const [poolsLoading, setPoolsLoading] = useState(false);
|
||||
const [poolsError, setPoolsError] = useState<string | null>(null);
|
||||
|
||||
const selectedDraw = useMemo(
|
||||
() => draws.find((d) => String(d.id) === drawId) ?? null,
|
||||
[draws, drawId],
|
||||
);
|
||||
|
||||
const loadDraws = useCallback(async () => {
|
||||
setDrawsLoading(true);
|
||||
try {
|
||||
const data = await getAdminDraws({ page: 1, per_page: 50 });
|
||||
setDraws(data.items);
|
||||
if (data.items.length > 0) {
|
||||
setDrawId((prev) => (prev === "" ? String(data.items[0].id) : prev));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setDraws([]);
|
||||
} finally {
|
||||
setDrawsLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const loadPools = useCallback(async () => {
|
||||
if (!drawId) {
|
||||
setPools([]);
|
||||
return;
|
||||
}
|
||||
const id = Number(drawId);
|
||||
if (!Number.isFinite(id)) {
|
||||
return;
|
||||
}
|
||||
setPoolsLoading(true);
|
||||
setPoolsError(null);
|
||||
try {
|
||||
const data = await getAdminRiskPools(id, {
|
||||
page: 1,
|
||||
per_page: 200,
|
||||
normalized_number: appliedNumber.trim() || undefined,
|
||||
sold_out_only: poolFilter === "sold_out",
|
||||
high_risk_only: poolFilter === "high_risk",
|
||||
sort: poolFilter === "high_risk" ? "usage_desc" : "number_asc",
|
||||
});
|
||||
setPools(data.items);
|
||||
setCurrencyCode(data.currency_code);
|
||||
} catch (e) {
|
||||
setPoolsError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||
setPools([]);
|
||||
} finally {
|
||||
setPoolsLoading(false);
|
||||
}
|
||||
}, [appliedNumber, drawId, poolFilter, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDraws();
|
||||
}, [loadDraws]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPools();
|
||||
}, [loadPools]);
|
||||
|
||||
const riskBase = drawId ? `/admin/draws/${drawId}/risk` : null;
|
||||
|
||||
return (
|
||||
<ConfigSection
|
||||
title={t("riskCap.runtime.title", { ns: "config" })}
|
||||
description={t("riskCap.runtime.description", { ns: "config" })}
|
||||
>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="grid min-w-[12rem] flex-1 gap-1.5">
|
||||
<Label htmlFor="risk-cap-draw">{t("riskCap.runtime.drawLabel", { ns: "config" })}</Label>
|
||||
<Select
|
||||
value={drawId || undefined}
|
||||
onValueChange={(v) => setDrawId(v ?? "")}
|
||||
disabled={drawsLoading || draws.length === 0}
|
||||
>
|
||||
<SelectTrigger id="risk-cap-draw" className="font-mono">
|
||||
<SelectValue placeholder={t("riskCap.runtime.drawPlaceholder", { ns: "config" })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{draws.map((d) => (
|
||||
<SelectItem key={d.id} value={String(d.id)}>
|
||||
{d.draw_no} · {d.status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{riskBase ? (
|
||||
<div className="flex flex-wrap gap-2 pb-0.5">
|
||||
<Link href={`${riskBase}/pools`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{t("subnav.riskPools", { ns: "draws" })}
|
||||
</Link>
|
||||
<Link href={`${riskBase}/hot`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{t("subnav.riskHot", { ns: "draws" })}
|
||||
</Link>
|
||||
<Link href={`${riskBase}/sold-out`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{t("subnav.riskSoldOut", { ns: "draws" })}
|
||||
</Link>
|
||||
<Link href={`${riskBase}/occupancy`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{t("subnav.riskLockLogs", { ns: "draws" })}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{drawId ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="risk-cap-number-q">{t("riskCap.occupancy.searchLabel", { ns: "config" })}</Label>
|
||||
<Input
|
||||
id="risk-cap-number-q"
|
||||
className="w-[140px] font-mono"
|
||||
placeholder={t("riskCap.occupancy.searchPlaceholder", { ns: "config" })}
|
||||
value={numberQ}
|
||||
onChange={(e) => setNumberQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
{ id: "all", label: t("riskCap.runtime.filterAll", { ns: "config" }) },
|
||||
{ id: "sold_out", label: t("riskCap.runtime.filterSoldOut", { ns: "config" }) },
|
||||
{ id: "high_risk", label: t("riskCap.runtime.filterHighRisk", { ns: "config" }) },
|
||||
] as const
|
||||
).map((f) => (
|
||||
<Button
|
||||
key={f.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={poolFilter === f.id ? "default" : "outline"}
|
||||
onClick={() => setPoolFilter(f.id)}
|
||||
>
|
||||
{f.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setAppliedNumber(numberQ.trim());
|
||||
}}
|
||||
>
|
||||
{t("actions.search", { ns: "common" })}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" disabled={poolsLoading} onClick={() => void loadPools()}>
|
||||
{t("versionActions.refresh", { ns: "config" })}
|
||||
</Button>
|
||||
{pools.length > 0 ? (
|
||||
<AdminTableExportButton
|
||||
tableId="risk-cap-runtime-pools"
|
||||
filename={`risk-pools-${selectedDraw?.draw_no ?? drawId}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{poolsError ? <p className="text-sm text-destructive">{poolsError}</p> : null}
|
||||
|
||||
<div className="admin-table-shell">
|
||||
<Table id="risk-cap-runtime-pools">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||
<TableHead className="text-center">{t("riskCap.table.used", { ns: "config" })}</TableHead>
|
||||
<TableHead className="text-center">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
|
||||
<TableHead className="text-center">{t("riskCap.table.ratio", { ns: "config" })}</TableHead>
|
||||
<TableHead className="text-center">{t("riskCap.table.soldOut", { ns: "config" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{poolsLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-muted-foreground">
|
||||
{t("states.loading", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : pools.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pools.map((row) => (
|
||||
<TableRow
|
||||
key={row.normalized_number}
|
||||
className={cn(
|
||||
row.is_sold_out && "bg-destructive/5",
|
||||
!row.is_sold_out && (row.usage_ratio ?? 0) >= 0.8 && "bg-amber-500/10",
|
||||
)}
|
||||
>
|
||||
<TableCell className="font-mono text-sm">{row.normalized_number}</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.locked_amount, currencyCode ?? undefined)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.remaining_amount, currencyCode ?? undefined)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{row.usage_ratio != null ? `${Math.round(row.usage_ratio * 100)}%` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs">
|
||||
{row.is_sold_out
|
||||
? t("riskCap.runtime.soldYes", { ns: "config" })
|
||||
: t("riskCap.runtime.soldNo", { ns: "config" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("riskCap.runtime.manageHint", { ns: "config" })}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("riskCap.runtime.noDraws", { ns: "config" })}</p>
|
||||
)}
|
||||
</ConfigSection>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user