feat(admin): add draw management features including create, update, delete, and batch delete functionalities

- Implemented API functions for creating, updating, and deleting draws.
- Enhanced the admin draws console with UI components for managing draws.
- Added internationalization support for new draw management actions and messages.
This commit is contained in:
2026-05-25 18:00:43 +08:00
parent eb02252431
commit f080e6ba8e
15 changed files with 948 additions and 24 deletions

View File

@@ -0,0 +1,134 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { postAdminCreateDraw } from "@/api/admin-draws";
import { AdminDateTimeField } from "@/components/admin/admin-datetime-field";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
import { LotteryApiBizError } from "@/types/api/errors";
type DrawCreateDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
scheduleTimezone?: string;
onCreated: () => void | Promise<void>;
};
function resetFormState(): {
drawTime: string;
closeTime: string;
startTime: string;
drawNo: string;
} {
return { drawTime: "", closeTime: "", startTime: "", drawNo: "" };
}
export function DrawCreateDialog({
open,
onOpenChange,
scheduleTimezone,
onCreated,
}: DrawCreateDialogProps) {
const { t } = useTranslation(["draws", "common"]);
const [form, setForm] = useState(resetFormState);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) {
setForm(resetFormState());
}
}, [open]);
async function submit(): Promise<void> {
if (!form.drawTime.trim()) {
toast.error(t("createDraw.drawTimeRequired"));
return;
}
setSaving(true);
try {
await postAdminCreateDraw({
draw_time: form.drawTime.trim(),
close_time: form.closeTime.trim() || undefined,
start_time: form.startTime.trim() || undefined,
draw_no: form.drawNo.trim() || undefined,
});
toast.success(t("createDraw.success"));
setForm(resetFormState());
onOpenChange(false);
await onCreated();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("createDraw.failed"));
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton className="max-w-lg gap-4">
<DialogHeader>
<DialogTitle>{t("createDraw.title")}</DialogTitle>
<DialogDescription>
{t("createDraw.description", { tz: "Local" })}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<AdminDateTimeField
id="draw-create-draw-time"
label={t("drawTime")}
value={form.drawTime}
onChange={(drawTime) => setForm((prev) => ({ ...prev, drawTime }))}
required
/>
<AdminDateTimeField
id="draw-create-close-time"
label={t("closeTime")}
value={form.closeTime}
onChange={(closeTime) => setForm((prev) => ({ ...prev, closeTime }))}
optional
/>
<AdminDateTimeField
id="draw-create-start-time"
label={t("startTime")}
value={form.startTime}
onChange={(startTime) => setForm((prev) => ({ ...prev, startTime }))}
optional
/>
<div className="grid gap-1.5">
<Label htmlFor="draw-create-draw-no">{t("drawNo")}</Label>
<Input
id="draw-create-draw-no"
placeholder="20260526-008"
value={form.drawNo}
onChange={(e) => setForm((prev) => ({ ...prev, drawNo: e.target.value }))}
/>
</div>
<p className="text-xs text-muted-foreground">{t("createDraw.hint")}</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t("actions.cancel", { ns: "common" })}
</Button>
<Button type="button" disabled={saving} onClick={() => void submit()}>
{saving ? t("createDraw.saving") : t("createDraw.submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { putAdminUpdateDraw } from "@/api/admin-draws";
import { AdminDateTimeField } from "@/components/admin/admin-datetime-field";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { formatAdminInstant } from "@/lib/admin-datetime";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawListItem } from "@/types/api/admin-draws";
type DrawEditDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
draw: AdminDrawListItem | null;
scheduleTimezone?: string;
onSaved: () => void | Promise<void>;
};
function isoToScheduleValue(iso: string | null): string {
return formatAdminInstant(iso, {
locale: getAdminRequestLocale(),
});
}
export function DrawEditDialog({
open,
onOpenChange,
draw,
scheduleTimezone,
onSaved,
}: DrawEditDialogProps) {
const { t } = useTranslation(["draws", "common"]);
const [drawTime, setDrawTime] = useState("");
const [closeTime, setCloseTime] = useState("");
const [startTime, setStartTime] = useState("");
const [drawNo, setDrawNo] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open || draw == null) {
return;
}
setDrawTime(isoToScheduleValue(draw.draw_time));
setCloseTime(isoToScheduleValue(draw.close_time));
setStartTime(isoToScheduleValue(draw.start_time));
setDrawNo(draw.draw_no);
}, [open, draw]);
async function submit(): Promise<void> {
if (draw == null) {
return;
}
if (!drawTime.trim()) {
toast.error(t("createDraw.drawTimeRequired"));
return;
}
setSaving(true);
try {
await putAdminUpdateDraw(draw.id, {
draw_time: drawTime.trim(),
close_time: closeTime.trim() || undefined,
start_time: startTime.trim() || undefined,
draw_no: drawNo.trim() || undefined,
});
toast.success(t("editDraw.success"));
onOpenChange(false);
await onSaved();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("editDraw.failed"));
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton className="max-w-lg gap-4">
<DialogHeader>
<DialogTitle>{t("editDraw.title")}</DialogTitle>
<DialogDescription>
{t("editDraw.description", {
tz: "Local",
drawNo: draw?.draw_no ?? "",
})}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<AdminDateTimeField
id="draw-edit-draw-time"
label={t("drawTime")}
value={drawTime}
onChange={setDrawTime}
required
/>
<AdminDateTimeField
id="draw-edit-close-time"
label={t("closeTime")}
value={closeTime}
onChange={setCloseTime}
optional
/>
<AdminDateTimeField
id="draw-edit-start-time"
label={t("startTime")}
value={startTime}
onChange={setStartTime}
optional
/>
<div className="grid gap-1.5">
<Label htmlFor="draw-edit-draw-no">{t("drawNo")}</Label>
<Input
id="draw-edit-draw-no"
value={drawNo}
onChange={(e) => setDrawNo(e.target.value)}
/>
</div>
<p className="text-xs text-muted-foreground">{t("createDraw.hint")}</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t("actions.cancel", { ns: "common" })}
</Button>
<Button type="button" disabled={saving || draw == null} onClick={() => void submit()}>
{saving ? t("editDraw.saving") : t("editDraw.submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,25 @@
import type { AdminDrawListItem } from "@/types/api/admin-draws";
const CANCELLABLE_STATUSES = new Set(["pending", "open", "closing", "closed"]);
export function drawHasNoBets(row: AdminDrawListItem): boolean {
return (row.total_bet_minor ?? 0) === 0;
}
export function canEditDrawRow(row: AdminDrawListItem): boolean {
if (!drawHasNoBets(row)) {
return false;
}
return row.status === "pending" || row.status === "open";
}
export function canDeleteDrawRow(row: AdminDrawListItem): boolean {
return row.status === "pending" && drawHasNoBets(row);
}
export function canCancelDrawRow(row: AdminDrawListItem): boolean {
if (!drawHasNoBets(row)) {
return false;
}
return CANCELLABLE_STATUSES.has(row.status);
}

View File

@@ -5,13 +5,22 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getAdminDraws, postAdminGenerateDrawPlan } from "@/api/admin-draws";
import {
deleteAdminDraw,
getAdminDraws,
postAdminBatchDestroyDraws,
postAdminCancelDraw,
postAdminGenerateDrawPlan,
} from "@/api/admin-draws";
import { formatAdminInstant } from "@/lib/admin-datetime";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { Button, buttonVariants } from "@/components/ui/button";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
@@ -28,7 +37,13 @@ import {
TableRow,
} from "@/components/ui/table";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { DrawCreateDialog } from "./draw-create-dialog";
import { DrawEditDialog } from "./draw-edit-dialog";
import {
canCancelDrawRow,
canDeleteDrawRow,
canEditDrawRow,
} from "./draw-list-actions";
import { formatAdminMinorUnits } from "@/lib/money";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
@@ -72,12 +87,18 @@ export function DrawsIndexConsole() {
const { t } = useTranslation(["draws", "common"]);
const exportLabels = useExportLabels("drawsList");
useAdminCurrencyCatalog();
const formatDt = useAdminDateTimeFormatter();
const defaultCurrency = "NPR";
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [data, setData] = useState<AdminDrawListData | null>(null);
const formatDt = useCallback(
(iso: string | null | undefined) =>
formatAdminInstant(iso, {
locale: getAdminRequestLocale(),
}),
[],
);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [draftDrawNo, setDraftDrawNo] = useState("");
@@ -87,6 +108,10 @@ export function DrawsIndexConsole() {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState<number>(10);
const [generating, setGenerating] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editDraw, setEditDraw] = useState<AdminDrawListItem | null>(null);
const [selectedDrawIds, setSelectedDrawIds] = useState<Set<number>>(new Set());
const [batchDeleting, setBatchDeleting] = useState(false);
const drawStatusTriggerLabel = useMemo(
() =>
@@ -149,25 +174,114 @@ export function DrawsIndexConsole() {
return () => window.clearTimeout(timer);
}, [load]);
const handleSelectAll = useCallback((checked: boolean) => {
if (checked && data) {
const deletableIds = data.items
.filter((row) => canDeleteDrawRow(row))
.map((row) => row.id);
setSelectedDrawIds(new Set(deletableIds));
} else {
setSelectedDrawIds(new Set());
}
}, [data]);
const handleSelectRow = useCallback((drawId: number, checked: boolean) => {
setSelectedDrawIds((prev) => {
const next = new Set(prev);
if (checked) {
next.add(drawId);
} else {
next.delete(drawId);
}
return next;
});
}, []);
const isAllSelected = useMemo(() => {
if (!data) return false;
const deletableIds = data.items.filter((row) => canDeleteDrawRow(row)).map((row) => row.id);
return deletableIds.length > 0 && deletableIds.every((id) => selectedDrawIds.has(id));
}, [data, selectedDrawIds]);
const isSomeSelected = useMemo(() => {
if (!data) return false;
const deletableIds = data.items.filter((row) => canDeleteDrawRow(row)).map((row) => row.id);
return deletableIds.some((id) => selectedDrawIds.has(id));
}, [data, selectedDrawIds]);
async function handleBatchDelete(): Promise<void> {
if (selectedDrawIds.size === 0) return;
setBatchDeleting(true);
try {
const result = await postAdminBatchDestroyDraws(Array.from(selectedDrawIds));
if (result.failed.length > 0) {
toast.error(
t("batchDelete.partialFailed", {
success: result.success.length,
failed: result.failed.length,
}),
);
result.failed.forEach((f) => {
console.error(`Failed to delete draw ${f.id}: ${f.reason}`);
});
} else {
toast.success(
t("batchDelete.success", { count: result.success.length }),
);
}
setSelectedDrawIds(new Set());
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("batchDelete.failed"));
} finally {
setBatchDeleting(false);
}
}
return (
<>
<Card className="admin-list-card">
<CardHeader className="admin-list-header flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="admin-list-title">{t("statusListTitle")}</CardTitle>
{canManageDraw ? (
<Button
type="button"
onClick={() =>
requestConfirm({
title: t("confirm.generatePlanTitle"),
description: t("confirm.generatePlanDescription"),
onConfirm: () => generatePlan(),
})
}
disabled={generating}
>
{generating ? t("generating") : t("generatePlan")}
</Button>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(true)}>
{t("createDraw.open")}
</Button>
<Button
type="button"
onClick={() =>
requestConfirm({
title: t("confirm.generatePlanTitle"),
description: t("confirm.generatePlanDescription"),
onConfirm: () => generatePlan(),
})
}
disabled={generating}
>
{generating ? t("generating") : t("generatePlan")}
</Button>
{selectedDrawIds.size > 0 && (
<Button
type="button"
variant="destructive"
onClick={() =>
requestConfirm({
title: t("batchDelete.confirmTitle"),
description: t("batchDelete.confirmDescription", { count: selectedDrawIds.size }),
confirmVariant: "destructive",
onConfirm: () => handleBatchDelete(),
})
}
disabled={batchDeleting}
>
{batchDeleting ? t("batchDelete.deleting") : t("batchDelete.action", { count: selectedDrawIds.size })}
</Button>
)}
</div>
) : null}
</CardHeader>
<CardContent className="admin-list-content">
@@ -247,6 +361,18 @@ export function DrawsIndexConsole() {
</div>
</div>
{data?.schedule ? (
<div className="space-y-1 text-xs text-muted-foreground">
<p>
{t("scheduleTimezoneHint", {
tz: "Local",
interval: data.schedule.interval_minutes,
})}
</p>
{canManageDraw ? <p>{t("listActionsHint")}</p> : null}
</div>
) : null}
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : null}
@@ -255,6 +381,15 @@ export function DrawsIndexConsole() {
<Table id="draws-index-table">
<TableHeader>
<TableRow>
<TableHead className="w-12">
{canManageDraw && data && data.items.some((row) => canDeleteDrawRow(row)) ? (
<Checkbox
checked={isAllSelected}
onCheckedChange={(checked) => handleSelectAll(checked === true)}
indeterminate={isSomeSelected && !isAllSelected}
/>
) : null}
</TableHead>
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("startTime")}</TableHead>
<TableHead>{t("closeTime")}</TableHead>
@@ -269,19 +404,27 @@ export function DrawsIndexConsole() {
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="text-muted-foreground">
<TableCell colSpan={10} className="text-muted-foreground">
{t("states.loading", { ns: "common" })}
</TableCell>
</TableRow>
) : data === null || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-muted-foreground">
<TableCell colSpan={10} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
data.items.map((row: AdminDrawListItem) => (
<TableRow key={row.id}>
<TableCell className="w-12">
{canManageDraw && canDeleteDrawRow(row) ? (
<Checkbox
checked={selectedDrawIds.has(row.id)}
onCheckedChange={(checked) => handleSelectRow(row.id, checked === true)}
/>
) : null}
</TableCell>
<TableCell className="font-mono text-xs">{row.draw_no}</TableCell>
<TableCell className="text-sm">{formatDt(row.start_time)}</TableCell>
<TableCell className="text-sm">{formatDt(row.close_time)}</TableCell>
@@ -313,12 +456,85 @@ export function DrawsIndexConsole() {
: "—"}
</TableCell>
<TableCell className="text-right">
<Link
href={`/admin/draws/${row.id}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("viewDetails")}
</Link>
<div className="flex flex-wrap items-center justify-end gap-1.5">
<Link
href={`/admin/draws/${row.id}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("viewDetails")}
</Link>
{canManageDraw && canEditDrawRow(row) ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setEditDraw(row)}
>
{t("editDraw.action")}
</Button>
) : null}
{canManageDraw && canDeleteDrawRow(row) ? (
<Button
type="button"
variant="destructive"
size="sm"
onClick={() =>
requestConfirm({
title: t("deleteDraw.title"),
description: t("deleteDraw.description", { drawNo: row.draw_no }),
confirmVariant: "destructive",
onConfirm: async () => {
try {
await deleteAdminDraw(row.id);
toast.success(t("deleteDraw.success"));
await load();
} catch (e) {
toast.error(
e instanceof LotteryApiBizError
? e.message
: t("deleteDraw.failed"),
);
}
},
})
}
>
{t("deleteDraw.action")}
</Button>
) : null}
{canManageDraw &&
canCancelDrawRow(row) &&
!canDeleteDrawRow(row) ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
requestConfirm({
title: t("cancelFromList.title"),
description: t("cancelFromList.description", {
drawNo: row.draw_no,
}),
onConfirm: async () => {
try {
await postAdminCancelDraw(row.id);
toast.success(t("actionSuccess", { name: t("cancelDraw") }));
await load();
} catch (e) {
toast.error(
e instanceof LotteryApiBizError
? e.message
: t("actionFailed", { name: t("cancelDraw") }),
);
}
},
})
}
>
{t("cancelFromList.action")}
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))
@@ -344,6 +560,25 @@ export function DrawsIndexConsole() {
) : null}
</CardContent>
</Card>
{canManageDraw ? (
<DrawCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={load}
/>
) : null}
{canManageDraw ? (
<DrawEditDialog
open={editDraw != null}
onOpenChange={(open) => {
if (!open) {
setEditDraw(null);
}
}}
draw={editDraw}
onSaved={load}
/>
) : null}
<ConfirmDialog />
</>
);