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

@@ -12,6 +12,8 @@ import type {
AdminDrawPublishResponse, AdminDrawPublishResponse,
AdminDrawPlanGenerateResponse, AdminDrawPlanGenerateResponse,
AdminDrawShowData, AdminDrawShowData,
AdminDrawCreatePayload,
AdminDrawCreateResponse,
} from "@/types/api/admin-draws"; } from "@/types/api/admin-draws";
const A = `${API_V1_PREFIX}/admin`; const A = `${API_V1_PREFIX}/admin`;
@@ -56,6 +58,32 @@ export async function postAdminGenerateDrawPlan(): Promise<AdminDrawPlanGenerate
return adminRequest.post<AdminDrawPlanGenerateResponse>(`${A}/draws/generate-plan`); return adminRequest.post<AdminDrawPlanGenerateResponse>(`${A}/draws/generate-plan`);
} }
export async function postAdminCreateDraw(
payload: AdminDrawCreatePayload,
): Promise<AdminDrawCreateResponse> {
return adminRequest.post<AdminDrawCreateResponse>(`${A}/draws`, payload);
}
export async function putAdminUpdateDraw(
drawId: number,
payload: AdminDrawCreatePayload,
): Promise<AdminDrawCreateResponse> {
return adminRequest.put<AdminDrawCreateResponse>(`${A}/draws/${drawId}`, payload);
}
export async function deleteAdminDraw(drawId: number): Promise<{ deleted: boolean }> {
return adminRequest.delete<{ deleted: boolean }>(`${A}/draws/${drawId}`);
}
export async function postAdminBatchDestroyDraws(
drawIds: number[],
): Promise<{ success: number[]; failed: Array<{ id: number; reason: string }> }> {
return adminRequest.post<{ success: number[]; failed: Array<{ id: number; reason: string }> }>(
`${A}/draws/batch-destroy`,
{ draw_ids: drawIds },
);
}
export async function postAdminManualCloseDraw(drawId: number): Promise<AdminDrawActionResponse> { export async function postAdminManualCloseDraw(drawId: number): Promise<AdminDrawActionResponse> {
return adminRequest.post<AdminDrawActionResponse>(`${A}/draws/${drawId}/manual-close`); return adminRequest.post<AdminDrawActionResponse>(`${A}/draws/${drawId}/manual-close`);
} }

View File

@@ -0,0 +1,184 @@
"use client";
import * as React from "react";
import { format, parse } from "date-fns";
import { enUS } from "date-fns/locale";
import { CalendarIcon, Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button, buttonVariants } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
/** `yyyy-MM-dd HH:mm:ss` */
export type AdminDateTimeValue = string;
function splitDateTime(value: AdminDateTimeValue): { date: string; time: string } {
const trimmed = value.trim();
const match = /^(\d{4}-\d{2}-\d{2})(?: (\d{2}:\d{2}(?::\d{2})?))?$/.exec(trimmed);
if (!match) {
return { date: "", time: "" };
}
const time = match[2] ?? "";
if (/^\d{2}:\d{2}$/.test(time)) {
return { date: match[1], time: `${time}:00` };
}
return { date: match[1], time };
}
function normalizeTimeForInput(time: string): string {
if (!time) {
return "";
}
if (/^\d{2}:\d{2}:\d{2}$/.test(time)) {
return time;
}
if (/^\d{2}:\d{2}$/.test(time)) {
return `${time}:00`;
}
return "";
}
function normalizeTimeForApi(time: string): string {
if (!time) {
return "00:00:00";
}
if (/^\d{2}:\d{2}$/.test(time)) {
return `${time}:00`;
}
if (/^\d{2}:\d{2}:\d{2}$/.test(time)) {
return time;
}
return "00:00:00";
}
function joinDateTime(date: string, time: string): AdminDateTimeValue {
if (!date) {
return "";
}
return `${date} ${normalizeTimeForApi(time)}`;
}
export function AdminDateTimeField({
id,
label,
value,
onChange,
optional = false,
required = false,
}: {
id: string;
label: string;
/** `yyyy-MM-dd HH:mm:ss` or empty */
value: AdminDateTimeValue;
onChange: (next: AdminDateTimeValue) => void;
/** 允许留空(开始/封盘等可选字段) */
optional?: boolean;
required?: boolean;
}) {
const { t } = useTranslation(["common"]);
const [open, setOpen] = React.useState(false);
const { date, time } = splitDateTime(value);
const parsedDate = React.useMemo(() => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return undefined;
}
const d = parse(date, "yyyy-MM-dd", new Date());
return Number.isNaN(d.getTime()) ? undefined : d;
}, [date]);
const dateSummary = parsedDate
? format(parsedDate, "yyyy-MM-dd", { locale: enUS })
: t("date.placeholder", { defaultValue: "Select date" });
const timeInputId = `${id}-time`;
return (
<div className="grid gap-1.5">
<Label htmlFor={id}>
{label}
{required ? <span className="text-destructive"> *</span> : null}
</Label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Popover
modal={false}
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger
type="button"
id={id}
className={cn(
buttonVariants({ variant: "outline", size: "default" }),
"h-8 w-full justify-start gap-2 px-2.5 font-normal sm:flex-1 md:text-sm",
!parsedDate && "text-muted-foreground",
)}
>
<CalendarIcon className="pointer-events-none size-4 shrink-0 opacity-70" aria-hidden />
<span className="min-w-0 flex-1 truncate text-left">{dateSummary}</span>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-auto min-w-fit p-0">
<Calendar
mode="single"
locale={enUS}
captionLayout="dropdown"
selected={parsedDate}
defaultMonth={parsedDate}
onSelect={(d) => {
if (!d) {
onChange("");
return;
}
onChange(joinDateTime(format(d, "yyyy-MM-dd"), time));
setOpen(false);
}}
/>
{optional ? (
<div className="flex justify-end border-t px-2 py-1">
<Button
type="button"
variant="ghost"
size="xs"
className="h-7 px-2"
onClick={() => {
onChange("");
setOpen(false);
}}
>
{t("actions.clear", { defaultValue: "Clear" })}
</Button>
</div>
) : null}
</PopoverContent>
</Popover>
<div className="relative w-full sm:w-[9.5rem] sm:shrink-0">
<Clock
className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 opacity-70"
aria-hidden
/>
<Input
id={timeInputId}
type="time"
step={1}
disabled={!date}
value={normalizeTimeForInput(time)}
className="h-8 pl-8 font-mono text-sm tabular-nums"
onChange={(e) => {
onChange(joinDateTime(date, e.target.value));
}}
/>
</div>
</div>
{optional ? (
<p className="text-xs text-muted-foreground">
{t("datetime.optionalHint", { defaultValue: "Leave empty to auto-fill from server config." })}
</p>
) : null}
</div>
);
}

View File

@@ -80,6 +80,9 @@
"exportDone": "Exported", "exportDone": "Exported",
"exportFailed": "Export failed" "exportFailed": "Export failed"
}, },
"datetime": {
"optionalHint": "Leave empty to auto-fill from server config"
},
"date": { "date": {
"placeholder": "Select date", "placeholder": "Select date",
"rangePlaceholder": "Select date range", "rangePlaceholder": "Select date range",

View File

@@ -5,6 +5,18 @@
"generating": "Generating…", "generating": "Generating…",
"generateSuccess": "Generated {{created}} draws, buffer {{upcoming}}/{{target}}", "generateSuccess": "Generated {{created}} draws, buffer {{upcoming}}/{{target}}",
"generateFailed": "Generation failed", "generateFailed": "Generation failed",
"scheduleTimezoneHint": "Times in server timezone {{tz}} (GMT, per UI spec); draw interval {{interval}} min (LOTTERY_DRAW_INTERVAL_MINUTES).",
"createDraw": {
"open": "New draw",
"title": "Create draw manually",
"description": "Enter date and time in {{tz}} (not your browser local zone). If only draw time is set, start/close are derived from server config.",
"hint": "Start < close < draw. Draw number optional; sequence auto-assigned by UTC business date.",
"drawTimeRequired": "Draw time is required",
"submit": "Create",
"saving": "Creating…",
"success": "Draw created",
"failed": "Create failed"
},
"drawNo": "Draw no.", "drawNo": "Draw no.",
"status": "Status", "status": "Status",
"startTime": "Start time", "startTime": "Start time",
@@ -18,6 +30,28 @@
"reset": "Reset", "reset": "Reset",
"fuzzyDrawNo": "Fuzzy draw no.", "fuzzyDrawNo": "Fuzzy draw no.",
"viewDetails": "View details", "viewDetails": "View details",
"editDraw": {
"action": "Edit",
"title": "Edit draw",
"description": "Draw {{drawNo}} · edit times in {{tz}}",
"submit": "Save",
"saving": "Saving…",
"success": "Draw updated",
"failed": "Update failed"
},
"deleteDraw": {
"action": "Delete",
"title": "Delete draw",
"description": "Delete draw {{drawNo}}? Only for pending draws with no bets. This cannot be undone.",
"success": "Draw deleted",
"failed": "Delete failed"
},
"cancelFromList": {
"action": "Cancel",
"title": "Cancel draw",
"description": "Cancel draw {{drawNo}}? Status becomes cancelled; the row is kept."
},
"listActionsHint": "Pending with no bets: edit or delete. Open/closing/closed with no bets: cancel from list or use detail page.",
"invalidDrawId": "Invalid draw ID", "invalidDrawId": "Invalid draw ID",
"loadFailed": "Failed to load. Check login and API configuration.", "loadFailed": "Failed to load. Check login and API configuration.",
"drawDetail": "Draw details", "drawDetail": "Draw details",
@@ -162,5 +196,14 @@
"publishDescription": "Results become visible to players and may trigger settlement.", "publishDescription": "Results become visible to players and may trigger settlement.",
"generatePlanTitle": "Confirm generate draw plan?", "generatePlanTitle": "Confirm generate draw plan?",
"generatePlanDescription": "Future bettable draws will be created per system rules." "generatePlanDescription": "Future bettable draws will be created per system rules."
},
"batchDelete": {
"action": "Delete {{count}} draws",
"deleting": "Deleting…",
"confirmTitle": "Confirm batch delete draws?",
"confirmDescription": "Will delete {{count}} pending draws with no bets. This cannot be undone.",
"success": "Successfully deleted {{count}} draws",
"failed": "Batch delete failed",
"partialFailed": "Partial failure: {{success}} succeeded, {{failed}} failed"
} }
} }

View File

@@ -80,6 +80,9 @@
"exportDone": "निर्यात भयो", "exportDone": "निर्यात भयो",
"exportFailed": "निर्यात असफल" "exportFailed": "निर्यात असफल"
}, },
"datetime": {
"optionalHint": "खाली छोड्नुहोस् — सर्वर सेटिङबाट स्वचालित"
},
"date": { "date": {
"placeholder": "मिति छान्नुहोस्", "placeholder": "मिति छान्नुहोस्",
"rangePlaceholder": "मिति दायरा छान्नुहोस्", "rangePlaceholder": "मिति दायरा छान्नुहोस्",

View File

@@ -5,6 +5,18 @@
"generating": "सिर्जना हुँदैछ…", "generating": "सिर्जना हुँदैछ…",
"generateSuccess": "{{created}} ड्रअ सिर्जना भयो, बफर {{upcoming}}/{{target}}", "generateSuccess": "{{created}} ड्रअ सिर्जना भयो, बफर {{upcoming}}/{{target}}",
"generateFailed": "सिर्जना असफल भयो", "generateFailed": "सिर्जना असफल भयो",
"scheduleTimezoneHint": "सूची समय सर्भर समयक्षेत्र {{tz}} (GMT); ड्र अन्तराल {{interval}} मिनेट।",
"createDraw": {
"open": "नयाँ ड्रअ",
"title": "म्यानुअल ड्रअ सिर्जना",
"description": "{{tz}} मा मिति र समय प्रविष्ट गर्नुहोस् (ब्राउजर स्थानीय समय होइन)।",
"hint": "सुरु < बन्द < ड्रअ। ड्रअ नम्बर वैकल्पिक।",
"drawTimeRequired": "ड्रअ समय आवश्यक छ",
"submit": "सिर्जना",
"saving": "सिर्जना हुँदैछ…",
"success": "ड्रअ सिर्जना भयो",
"failed": "सिर्जना असफल"
},
"drawNo": "ड्रअ नं.", "drawNo": "ड्रअ नं.",
"status": "स्थिति", "status": "स्थिति",
"startTime": "सुरु समय", "startTime": "सुरु समय",
@@ -18,6 +30,28 @@
"reset": "रिसेट", "reset": "रिसेट",
"fuzzyDrawNo": "फजी ड्रअ नं.", "fuzzyDrawNo": "फजी ड्रअ नं.",
"viewDetails": "विवरण हेर्नुहोस्", "viewDetails": "विवरण हेर्नुहोस्",
"editDraw": {
"action": "सम्पादन",
"title": "ड्रअ सम्पादन",
"description": "ड्रअ {{drawNo}} · {{tz}}",
"submit": "सेभ",
"saving": "सेभ हुँदैछ…",
"success": "ड्रअ अद्यावधिक भयो",
"failed": "अद्यावधिक असफल"
},
"deleteDraw": {
"action": "मेटाउनुहोस्",
"title": "ड्रअ मेटाउनुहोस्",
"description": "ड्रअ {{drawNo}} मेटाउने? केवल pending र बेट नभएको।",
"success": "ड्रअ मेटाइयो",
"failed": "मेटाउन असफल"
},
"cancelFromList": {
"action": "रद्द",
"title": "ड्रअ रद्द",
"description": "ड्रअ {{drawNo}} रद्द गर्ने?"
},
"listActionsHint": "बेट नभएको pending: सम्पादन/मेटाउन; अन्य: विवरण पृष्ठ।",
"invalidDrawId": "अवैध ड्रअ ID", "invalidDrawId": "अवैध ड्रअ ID",
"loadFailed": "लोड असफल भयो। लगइन र API कन्फिग जाँच गर्नुहोस्।", "loadFailed": "लोड असफल भयो। लगइन र API कन्फिग जाँच गर्नुहोस्।",
"drawDetail": "ड्रअ विवरण", "drawDetail": "ड्रअ विवरण",
@@ -162,5 +196,14 @@
"publishDescription": "खेलाडीहरूले नतिजा देख्नेछन्।", "publishDescription": "खेलाडीहरूले नतिजा देख्नेछन्।",
"generatePlanTitle": "ड्रअ योजना सिर्जना पुष्टि?", "generatePlanTitle": "ड्रअ योजना सिर्जना पुष्टि?",
"generatePlanDescription": "भविष्यका ड्रअहरू सिर्जना हुनेछन्।" "generatePlanDescription": "भविष्यका ड्रअहरू सिर्जना हुनेछन्।"
},
"batchDelete": {
"action": "{{count}} ड्रअ मेटाउनुहोस्",
"deleting": "मेटाउँदै…",
"confirmTitle": "ब्याच मेटाउने पुष्टि?",
"confirmDescription": "{{count}} विना दांवका ड्रअहरू मेटाउनेछ। यो पूर्ववत गर्न सकिँदैन।",
"success": "सफलतापूर्वक {{count}} ड्रअ मेटाइयो",
"failed": "ब्याच मेटाउन असफल",
"partialFailed": "आंशिक असफल: {{success}} सफल, {{failed}} असफल"
} }
} }

View File

@@ -80,6 +80,9 @@
"exportDone": "已导出", "exportDone": "已导出",
"exportFailed": "导出失败" "exportFailed": "导出失败"
}, },
"datetime": {
"optionalHint": "留空则按服务器配置自动推算"
},
"date": { "date": {
"placeholder": "选择日期", "placeholder": "选择日期",
"rangePlaceholder": "选择日期范围", "rangePlaceholder": "选择日期范围",

View File

@@ -5,6 +5,18 @@
"generating": "生成中…", "generating": "生成中…",
"generateSuccess": "已生成 {{created}} 期,当前缓冲 {{upcoming}}/{{target}}", "generateSuccess": "已生成 {{created}} 期,当前缓冲 {{upcoming}}/{{target}}",
"generateFailed": "生成失败", "generateFailed": "生成失败",
"scheduleTimezoneHint": "列表时间为服务器时区 {{tz}}GMT与界面文档一致开奖间隔 {{interval}} 分钟LOTTERY_DRAW_INTERVAL_MINUTES。",
"createDraw": {
"open": "新建期号",
"title": "手动创建期号",
"description": "日期与时间按 {{tz}} 填写(勿用浏览器本地时区)。仅填开奖时间时,开始/封盘按系统配置自动推算。",
"hint": "开始 < 封盘 < 开奖。期号可留空,将按 UTC 业务日自动生成流水号。",
"drawTimeRequired": "请填写开奖时间",
"submit": "创建",
"saving": "创建中…",
"success": "期号已创建",
"failed": "创建失败"
},
"drawNo": "期号", "drawNo": "期号",
"status": "状态", "status": "状态",
"startTime": "开始时间", "startTime": "开始时间",
@@ -18,6 +30,28 @@
"reset": "重置", "reset": "重置",
"fuzzyDrawNo": "模糊匹配期号", "fuzzyDrawNo": "模糊匹配期号",
"viewDetails": "查看详情", "viewDetails": "查看详情",
"editDraw": {
"action": "编辑",
"title": "编辑期号",
"description": "期号 {{drawNo}} · 时间按 {{tz}} 编辑",
"submit": "保存",
"saving": "保存中…",
"success": "期号已更新",
"failed": "更新失败"
},
"deleteDraw": {
"action": "删除",
"title": "删除期号",
"description": "确定删除期号 {{drawNo}}?仅适用于未开始且无注单的记录,删除后不可恢复。",
"success": "期号已删除",
"failed": "删除失败"
},
"cancelFromList": {
"action": "取消",
"title": "取消期号",
"description": "确定取消期号 {{drawNo}}?取消后状态为「已取消」,记录仍保留。"
},
"listActionsHint": "未开始且无注单:可编辑、删除;可下注/封盘/待开奖且无注单:可取消(见详情页更多操作)。",
"invalidDrawId": "无效的期号 ID", "invalidDrawId": "无效的期号 ID",
"loadFailed": "加载失败,请检查登录与 API 配置", "loadFailed": "加载失败,请检查登录与 API 配置",
"drawDetail": "开奖详情", "drawDetail": "开奖详情",
@@ -162,5 +196,14 @@
"publishDescription": "发布后将对玩家可见并可能触发结算,请再次核对号码。", "publishDescription": "发布后将对玩家可见并可能触发结算,请再次核对号码。",
"generatePlanTitle": "确认批量生成期号计划?", "generatePlanTitle": "确认批量生成期号计划?",
"generatePlanDescription": "将按系统规则补充未来可下注期号。" "generatePlanDescription": "将按系统规则补充未来可下注期号。"
},
"batchDelete": {
"action": "删除 {{count}} 期",
"deleting": "删除中…",
"confirmTitle": "确认批量删除期号?",
"confirmDescription": "将删除 {{count}} 个未开始且无注单的期号,删除后不可恢复。",
"success": "已成功删除 {{count}} 个期号",
"failed": "批量删除失败",
"partialFailed": "部分删除失败:成功 {{success}} 个,失败 {{failed}} 个"
} }
} }

View File

@@ -54,6 +54,7 @@ export function formatAdminCalendarToday(locale: AdminApiLocale, weekdayLabel: s
/** /**
* 将接口返回的 ISO 时间串格式化为浏览器本地时区下的 `YYYY-MM-DD HH:mm:ss`。 * 将接口返回的 ISO 时间串格式化为浏览器本地时区下的 `YYYY-MM-DD HH:mm:ss`。
* 期号相关列表请使用 {@link formatAdminInstantInTimeZone} 并传入 UTC。
*/ */
export function formatAdminInstant( export function formatAdminInstant(
iso: string | null | undefined, iso: string | null | undefined,

View File

@@ -0,0 +1,5 @@
/**
* PRD / 界面文档:服务器时区 GMT与 UTC 等效。
* @see docs/01-界面文档.md §1.4
*/
export const LOTTERY_SCHEDULE_TIMEZONE = "UTC";

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 { useTranslation } from "react-i18next";
import { toast } from "sonner"; 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 { Button, buttonVariants } from "@/components/ui/button";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -28,7 +37,13 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; 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 { formatAdminMinorUnits } from "@/lib/money";
import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels"; import { useExportLabels } from "@/hooks/use-export-labels";
@@ -72,12 +87,18 @@ export function DrawsIndexConsole() {
const { t } = useTranslation(["draws", "common"]); const { t } = useTranslation(["draws", "common"]);
const exportLabels = useExportLabels("drawsList"); const exportLabels = useExportLabels("drawsList");
useAdminCurrencyCatalog(); useAdminCurrencyCatalog();
const formatDt = useAdminDateTimeFormatter();
const defaultCurrency = "NPR"; const defaultCurrency = "NPR";
const profile = useAdminProfile(); const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]); const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [data, setData] = useState<AdminDrawListData | null>(null); const [data, setData] = useState<AdminDrawListData | null>(null);
const formatDt = useCallback(
(iso: string | null | undefined) =>
formatAdminInstant(iso, {
locale: getAdminRequestLocale(),
}),
[],
);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [draftDrawNo, setDraftDrawNo] = useState(""); const [draftDrawNo, setDraftDrawNo] = useState("");
@@ -87,6 +108,10 @@ export function DrawsIndexConsole() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState<number>(10); const [perPage, setPerPage] = useState<number>(10);
const [generating, setGenerating] = useState(false); 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( const drawStatusTriggerLabel = useMemo(
() => () =>
@@ -149,25 +174,114 @@ export function DrawsIndexConsole() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [load]); }, [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 ( return (
<> <>
<Card className="admin-list-card"> <Card className="admin-list-card">
<CardHeader className="admin-list-header flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <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> <CardTitle className="admin-list-title">{t("statusListTitle")}</CardTitle>
{canManageDraw ? ( {canManageDraw ? (
<Button <div className="flex flex-wrap gap-2">
type="button" <Button type="button" variant="outline" onClick={() => setCreateOpen(true)}>
onClick={() => {t("createDraw.open")}
requestConfirm({ </Button>
title: t("confirm.generatePlanTitle"), <Button
description: t("confirm.generatePlanDescription"), type="button"
onConfirm: () => generatePlan(), onClick={() =>
}) requestConfirm({
} title: t("confirm.generatePlanTitle"),
disabled={generating} description: t("confirm.generatePlanDescription"),
> onConfirm: () => generatePlan(),
{generating ? t("generating") : t("generatePlan")} })
</Button> }
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} ) : null}
</CardHeader> </CardHeader>
<CardContent className="admin-list-content"> <CardContent className="admin-list-content">
@@ -247,6 +361,18 @@ export function DrawsIndexConsole() {
</div> </div>
</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 ? ( {error ? (
<p className="text-sm text-destructive">{error}</p> <p className="text-sm text-destructive">{error}</p>
) : null} ) : null}
@@ -255,6 +381,15 @@ export function DrawsIndexConsole() {
<Table id="draws-index-table"> <Table id="draws-index-table">
<TableHeader> <TableHeader>
<TableRow> <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("drawNo")}</TableHead>
<TableHead>{t("startTime")}</TableHead> <TableHead>{t("startTime")}</TableHead>
<TableHead>{t("closeTime")}</TableHead> <TableHead>{t("closeTime")}</TableHead>
@@ -269,19 +404,27 @@ export function DrawsIndexConsole() {
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={9} className="text-muted-foreground"> <TableCell colSpan={10} className="text-muted-foreground">
{t("states.loading", { ns: "common" })} {t("states.loading", { ns: "common" })}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : data === null || data.items.length === 0 ? ( ) : data === null || data.items.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={9} className="text-muted-foreground"> <TableCell colSpan={10} className="text-muted-foreground">
{t("states.noData", { ns: "common" })} {t("states.noData", { ns: "common" })}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
data.items.map((row: AdminDrawListItem) => ( data.items.map((row: AdminDrawListItem) => (
<TableRow key={row.id}> <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="font-mono text-xs">{row.draw_no}</TableCell>
<TableCell className="text-sm">{formatDt(row.start_time)}</TableCell> <TableCell className="text-sm">{formatDt(row.start_time)}</TableCell>
<TableCell className="text-sm">{formatDt(row.close_time)}</TableCell> <TableCell className="text-sm">{formatDt(row.close_time)}</TableCell>
@@ -313,12 +456,85 @@ export function DrawsIndexConsole() {
: "—"} : "—"}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Link <div className="flex flex-wrap items-center justify-end gap-1.5">
href={`/admin/draws/${row.id}`} <Link
className={cn(buttonVariants({ variant: "outline", size: "sm" }))} href={`/admin/draws/${row.id}`}
> className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
{t("viewDetails")} >
</Link> {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> </TableCell>
</TableRow> </TableRow>
)) ))
@@ -344,6 +560,25 @@ export function DrawsIndexConsole() {
) : null} ) : null}
</CardContent> </CardContent>
</Card> </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 /> <ConfirmDialog />
</> </>
); );

View File

@@ -25,9 +25,37 @@ export type AdminDrawListMeta = {
last_page: number; last_page: number;
}; };
export type AdminDrawScheduleMeta = {
timezone: string;
interval_minutes: number;
betting_window_seconds: number;
close_before_draw_seconds: number;
};
export type AdminDrawListData = { export type AdminDrawListData = {
items: AdminDrawListItem[]; items: AdminDrawListItem[];
meta: AdminDrawListMeta; meta: AdminDrawListMeta;
schedule?: AdminDrawScheduleMeta;
};
export type AdminDrawCreatePayload = {
draw_time: string;
start_time?: string;
close_time?: string;
draw_no?: string;
business_date?: string;
sequence_no?: number;
};
export type AdminDrawCreateResponse = {
id: number;
draw_no: string;
business_date: string;
sequence_no: number;
status: string;
start_time: string | null;
close_time: string | null;
draw_time: string | null;
}; };
export type AdminDrawShowData = { export type AdminDrawShowData = {