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:
@@ -12,6 +12,8 @@ import type {
|
||||
AdminDrawPublishResponse,
|
||||
AdminDrawPlanGenerateResponse,
|
||||
AdminDrawShowData,
|
||||
AdminDrawCreatePayload,
|
||||
AdminDrawCreateResponse,
|
||||
} from "@/types/api/admin-draws";
|
||||
|
||||
const A = `${API_V1_PREFIX}/admin`;
|
||||
@@ -56,6 +58,32 @@ export async function postAdminGenerateDrawPlan(): Promise<AdminDrawPlanGenerate
|
||||
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> {
|
||||
return adminRequest.post<AdminDrawActionResponse>(`${A}/draws/${drawId}/manual-close`);
|
||||
}
|
||||
|
||||
184
src/components/admin/admin-datetime-field.tsx
Normal file
184
src/components/admin/admin-datetime-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -80,6 +80,9 @@
|
||||
"exportDone": "Exported",
|
||||
"exportFailed": "Export failed"
|
||||
},
|
||||
"datetime": {
|
||||
"optionalHint": "Leave empty to auto-fill from server config"
|
||||
},
|
||||
"date": {
|
||||
"placeholder": "Select date",
|
||||
"rangePlaceholder": "Select date range",
|
||||
|
||||
@@ -5,6 +5,18 @@
|
||||
"generating": "Generating…",
|
||||
"generateSuccess": "Generated {{created}} draws, buffer {{upcoming}}/{{target}}",
|
||||
"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.",
|
||||
"status": "Status",
|
||||
"startTime": "Start time",
|
||||
@@ -18,6 +30,28 @@
|
||||
"reset": "Reset",
|
||||
"fuzzyDrawNo": "Fuzzy draw no.",
|
||||
"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",
|
||||
"loadFailed": "Failed to load. Check login and API configuration.",
|
||||
"drawDetail": "Draw details",
|
||||
@@ -162,5 +196,14 @@
|
||||
"publishDescription": "Results become visible to players and may trigger settlement.",
|
||||
"generatePlanTitle": "Confirm generate draw plan?",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
"exportDone": "निर्यात भयो",
|
||||
"exportFailed": "निर्यात असफल"
|
||||
},
|
||||
"datetime": {
|
||||
"optionalHint": "खाली छोड्नुहोस् — सर्वर सेटिङबाट स्वचालित"
|
||||
},
|
||||
"date": {
|
||||
"placeholder": "मिति छान्नुहोस्",
|
||||
"rangePlaceholder": "मिति दायरा छान्नुहोस्",
|
||||
|
||||
@@ -5,6 +5,18 @@
|
||||
"generating": "सिर्जना हुँदैछ…",
|
||||
"generateSuccess": "{{created}} ड्रअ सिर्जना भयो, बफर {{upcoming}}/{{target}}",
|
||||
"generateFailed": "सिर्जना असफल भयो",
|
||||
"scheduleTimezoneHint": "सूची समय सर्भर समयक्षेत्र {{tz}} (GMT); ड्र अन्तराल {{interval}} मिनेट।",
|
||||
"createDraw": {
|
||||
"open": "नयाँ ड्रअ",
|
||||
"title": "म्यानुअल ड्रअ सिर्जना",
|
||||
"description": "{{tz}} मा मिति र समय प्रविष्ट गर्नुहोस् (ब्राउजर स्थानीय समय होइन)।",
|
||||
"hint": "सुरु < बन्द < ड्रअ। ड्रअ नम्बर वैकल्पिक।",
|
||||
"drawTimeRequired": "ड्रअ समय आवश्यक छ",
|
||||
"submit": "सिर्जना",
|
||||
"saving": "सिर्जना हुँदैछ…",
|
||||
"success": "ड्रअ सिर्जना भयो",
|
||||
"failed": "सिर्जना असफल"
|
||||
},
|
||||
"drawNo": "ड्रअ नं.",
|
||||
"status": "स्थिति",
|
||||
"startTime": "सुरु समय",
|
||||
@@ -18,6 +30,28 @@
|
||||
"reset": "रिसेट",
|
||||
"fuzzyDrawNo": "फजी ड्रअ नं.",
|
||||
"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",
|
||||
"loadFailed": "लोड असफल भयो। लगइन र API कन्फिग जाँच गर्नुहोस्।",
|
||||
"drawDetail": "ड्रअ विवरण",
|
||||
@@ -162,5 +196,14 @@
|
||||
"publishDescription": "खेलाडीहरूले नतिजा देख्नेछन्।",
|
||||
"generatePlanTitle": "ड्रअ योजना सिर्जना पुष्टि?",
|
||||
"generatePlanDescription": "भविष्यका ड्रअहरू सिर्जना हुनेछन्।"
|
||||
},
|
||||
"batchDelete": {
|
||||
"action": "{{count}} ड्रअ मेटाउनुहोस्",
|
||||
"deleting": "मेटाउँदै…",
|
||||
"confirmTitle": "ब्याच मेटाउने पुष्टि?",
|
||||
"confirmDescription": "{{count}} विना दांवका ड्रअहरू मेटाउनेछ। यो पूर्ववत गर्न सकिँदैन।",
|
||||
"success": "सफलतापूर्वक {{count}} ड्रअ मेटाइयो",
|
||||
"failed": "ब्याच मेटाउन असफल",
|
||||
"partialFailed": "आंशिक असफल: {{success}} सफल, {{failed}} असफल"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
"exportDone": "已导出",
|
||||
"exportFailed": "导出失败"
|
||||
},
|
||||
"datetime": {
|
||||
"optionalHint": "留空则按服务器配置自动推算"
|
||||
},
|
||||
"date": {
|
||||
"placeholder": "选择日期",
|
||||
"rangePlaceholder": "选择日期范围",
|
||||
|
||||
@@ -5,6 +5,18 @@
|
||||
"generating": "生成中…",
|
||||
"generateSuccess": "已生成 {{created}} 期,当前缓冲 {{upcoming}}/{{target}}",
|
||||
"generateFailed": "生成失败",
|
||||
"scheduleTimezoneHint": "列表时间为服务器时区 {{tz}}(GMT,与界面文档一致);开奖间隔 {{interval}} 分钟(LOTTERY_DRAW_INTERVAL_MINUTES)。",
|
||||
"createDraw": {
|
||||
"open": "新建期号",
|
||||
"title": "手动创建期号",
|
||||
"description": "日期与时间按 {{tz}} 填写(勿用浏览器本地时区)。仅填开奖时间时,开始/封盘按系统配置自动推算。",
|
||||
"hint": "开始 < 封盘 < 开奖。期号可留空,将按 UTC 业务日自动生成流水号。",
|
||||
"drawTimeRequired": "请填写开奖时间",
|
||||
"submit": "创建",
|
||||
"saving": "创建中…",
|
||||
"success": "期号已创建",
|
||||
"failed": "创建失败"
|
||||
},
|
||||
"drawNo": "期号",
|
||||
"status": "状态",
|
||||
"startTime": "开始时间",
|
||||
@@ -18,6 +30,28 @@
|
||||
"reset": "重置",
|
||||
"fuzzyDrawNo": "模糊匹配期号",
|
||||
"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",
|
||||
"loadFailed": "加载失败,请检查登录与 API 配置",
|
||||
"drawDetail": "开奖详情",
|
||||
@@ -162,5 +196,14 @@
|
||||
"publishDescription": "发布后将对玩家可见并可能触发结算,请再次核对号码。",
|
||||
"generatePlanTitle": "确认批量生成期号计划?",
|
||||
"generatePlanDescription": "将按系统规则补充未来可下注期号。"
|
||||
},
|
||||
"batchDelete": {
|
||||
"action": "删除 {{count}} 期",
|
||||
"deleting": "删除中…",
|
||||
"confirmTitle": "确认批量删除期号?",
|
||||
"confirmDescription": "将删除 {{count}} 个未开始且无注单的期号,删除后不可恢复。",
|
||||
"success": "已成功删除 {{count}} 个期号",
|
||||
"failed": "批量删除失败",
|
||||
"partialFailed": "部分删除失败:成功 {{success}} 个,失败 {{failed}} 个"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export function formatAdminCalendarToday(locale: AdminApiLocale, weekdayLabel: s
|
||||
|
||||
/**
|
||||
* 将接口返回的 ISO 时间串格式化为浏览器本地时区下的 `YYYY-MM-DD HH:mm:ss`。
|
||||
* 期号相关列表请使用 {@link formatAdminInstantInTimeZone} 并传入 UTC。
|
||||
*/
|
||||
export function formatAdminInstant(
|
||||
iso: string | null | undefined,
|
||||
|
||||
5
src/lib/lottery-schedule-timezone.ts
Normal file
5
src/lib/lottery-schedule-timezone.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* PRD / 界面文档:服务器时区 GMT,与 UTC 等效。
|
||||
* @see docs/01-界面文档.md §1.4
|
||||
*/
|
||||
export const LOTTERY_SCHEDULE_TIMEZONE = "UTC";
|
||||
134
src/modules/draws/draw-create-dialog.tsx
Normal file
134
src/modules/draws/draw-create-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
src/modules/draws/draw-edit-dialog.tsx
Normal file
146
src/modules/draws/draw-edit-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/modules/draws/draw-list-actions.ts
Normal file
25
src/modules/draws/draw-list-actions.ts
Normal 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);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -25,9 +25,37 @@ export type AdminDrawListMeta = {
|
||||
last_page: number;
|
||||
};
|
||||
|
||||
export type AdminDrawScheduleMeta = {
|
||||
timezone: string;
|
||||
interval_minutes: number;
|
||||
betting_window_seconds: number;
|
||||
close_before_draw_seconds: number;
|
||||
};
|
||||
|
||||
export type AdminDrawListData = {
|
||||
items: AdminDrawListItem[];
|
||||
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 = {
|
||||
|
||||
Reference in New Issue
Block a user