diff --git a/src/api/admin-draws.ts b/src/api/admin-draws.ts index d00aaab..3fd8a6e 100644 --- a/src/api/admin-draws.ts +++ b/src/api/admin-draws.ts @@ -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(`${A}/draws/generate-plan`); } +export async function postAdminCreateDraw( + payload: AdminDrawCreatePayload, +): Promise { + return adminRequest.post(`${A}/draws`, payload); +} + +export async function putAdminUpdateDraw( + drawId: number, + payload: AdminDrawCreatePayload, +): Promise { + return adminRequest.put(`${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 { return adminRequest.post(`${A}/draws/${drawId}/manual-close`); } diff --git a/src/components/admin/admin-datetime-field.tsx b/src/components/admin/admin-datetime-field.tsx new file mode 100644 index 0000000..15d2433 --- /dev/null +++ b/src/components/admin/admin-datetime-field.tsx @@ -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 ( +
+ +
+ + + + {dateSummary} + + + { + if (!d) { + onChange(""); + return; + } + onChange(joinDateTime(format(d, "yyyy-MM-dd"), time)); + setOpen(false); + }} + /> + {optional ? ( +
+ +
+ ) : null} +
+
+ +
+ + { + onChange(joinDateTime(date, e.target.value)); + }} + /> +
+
+ {optional ? ( +

+ {t("datetime.optionalHint", { defaultValue: "Leave empty to auto-fill from server config." })} +

+ ) : null} +
+ ); +} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index e72613a..03295b1 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -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", diff --git a/src/i18n/locales/en/draws.json b/src/i18n/locales/en/draws.json index 2a681a6..ae4cdeb 100644 --- a/src/i18n/locales/en/draws.json +++ b/src/i18n/locales/en/draws.json @@ -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" } } diff --git a/src/i18n/locales/ne/common.json b/src/i18n/locales/ne/common.json index 7b33aaf..9885e72 100644 --- a/src/i18n/locales/ne/common.json +++ b/src/i18n/locales/ne/common.json @@ -80,6 +80,9 @@ "exportDone": "निर्यात भयो", "exportFailed": "निर्यात असफल" }, + "datetime": { + "optionalHint": "खाली छोड्नुहोस् — सर्वर सेटिङबाट स्वचालित" + }, "date": { "placeholder": "मिति छान्नुहोस्", "rangePlaceholder": "मिति दायरा छान्नुहोस्", diff --git a/src/i18n/locales/ne/draws.json b/src/i18n/locales/ne/draws.json index 90a898e..086fd6a 100644 --- a/src/i18n/locales/ne/draws.json +++ b/src/i18n/locales/ne/draws.json @@ -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}} असफल" } } diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 5bb9d28..9327e63 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -80,6 +80,9 @@ "exportDone": "已导出", "exportFailed": "导出失败" }, + "datetime": { + "optionalHint": "留空则按服务器配置自动推算" + }, "date": { "placeholder": "选择日期", "rangePlaceholder": "选择日期范围", diff --git a/src/i18n/locales/zh/draws.json b/src/i18n/locales/zh/draws.json index 3b526a1..25509cb 100644 --- a/src/i18n/locales/zh/draws.json +++ b/src/i18n/locales/zh/draws.json @@ -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}} 个" } } diff --git a/src/lib/admin-datetime.ts b/src/lib/admin-datetime.ts index 41f3886..39c184f 100644 --- a/src/lib/admin-datetime.ts +++ b/src/lib/admin-datetime.ts @@ -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, diff --git a/src/lib/lottery-schedule-timezone.ts b/src/lib/lottery-schedule-timezone.ts new file mode 100644 index 0000000..231277d --- /dev/null +++ b/src/lib/lottery-schedule-timezone.ts @@ -0,0 +1,5 @@ +/** + * PRD / 界面文档:服务器时区 GMT,与 UTC 等效。 + * @see docs/01-界面文档.md §1.4 + */ +export const LOTTERY_SCHEDULE_TIMEZONE = "UTC"; diff --git a/src/modules/draws/draw-create-dialog.tsx b/src/modules/draws/draw-create-dialog.tsx new file mode 100644 index 0000000..d994473 --- /dev/null +++ b/src/modules/draws/draw-create-dialog.tsx @@ -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; +}; + +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 { + 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 ( + + + + {t("createDraw.title")} + + {t("createDraw.description", { tz: "Local" })} + + + +
+ setForm((prev) => ({ ...prev, drawTime }))} + required + /> + setForm((prev) => ({ ...prev, closeTime }))} + optional + /> + setForm((prev) => ({ ...prev, startTime }))} + optional + /> +
+ + setForm((prev) => ({ ...prev, drawNo: e.target.value }))} + /> +
+

{t("createDraw.hint")}

+
+ + + + + +
+
+ ); +} diff --git a/src/modules/draws/draw-edit-dialog.tsx b/src/modules/draws/draw-edit-dialog.tsx new file mode 100644 index 0000000..02842d0 --- /dev/null +++ b/src/modules/draws/draw-edit-dialog.tsx @@ -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; +}; + +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 { + 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 ( + + + + {t("editDraw.title")} + + {t("editDraw.description", { + tz: "Local", + drawNo: draw?.draw_no ?? "", + })} + + + +
+ + + +
+ + setDrawNo(e.target.value)} + /> +
+

{t("createDraw.hint")}

+
+ + + + + +
+
+ ); +} diff --git a/src/modules/draws/draw-list-actions.ts b/src/modules/draws/draw-list-actions.ts new file mode 100644 index 0000000..9a03bf2 --- /dev/null +++ b/src/modules/draws/draw-list-actions.ts @@ -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); +} diff --git a/src/modules/draws/draws-index-console.tsx b/src/modules/draws/draws-index-console.tsx index 8c793ca..dacb745 100644 --- a/src/modules/draws/draws-index-console.tsx +++ b/src/modules/draws/draws-index-console.tsx @@ -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(null); + const formatDt = useCallback( + (iso: string | null | undefined) => + formatAdminInstant(iso, { + locale: getAdminRequestLocale(), + }), + [], + ); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [draftDrawNo, setDraftDrawNo] = useState(""); @@ -87,6 +108,10 @@ export function DrawsIndexConsole() { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); const [generating, setGenerating] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [editDraw, setEditDraw] = useState(null); + const [selectedDrawIds, setSelectedDrawIds] = useState>(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 { + 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 ( <> {t("statusListTitle")} {canManageDraw ? ( - +
+ + + {selectedDrawIds.size > 0 && ( + + )} +
) : null}
@@ -247,6 +361,18 @@ export function DrawsIndexConsole() { + {data?.schedule ? ( +
+

+ {t("scheduleTimezoneHint", { + tz: "Local", + interval: data.schedule.interval_minutes, + })} +

+ {canManageDraw ?

{t("listActionsHint")}

: null} +
+ ) : null} + {error ? (

{error}

) : null} @@ -255,6 +381,15 @@ export function DrawsIndexConsole() { + + {canManageDraw && data && data.items.some((row) => canDeleteDrawRow(row)) ? ( + handleSelectAll(checked === true)} + indeterminate={isSomeSelected && !isAllSelected} + /> + ) : null} + {t("drawNo")} {t("startTime")} {t("closeTime")} @@ -269,19 +404,27 @@ export function DrawsIndexConsole() { {loading ? ( - + {t("states.loading", { ns: "common" })} ) : data === null || data.items.length === 0 ? ( - + {t("states.noData", { ns: "common" })} ) : ( data.items.map((row: AdminDrawListItem) => ( + + {canManageDraw && canDeleteDrawRow(row) ? ( + handleSelectRow(row.id, checked === true)} + /> + ) : null} + {row.draw_no} {formatDt(row.start_time)} {formatDt(row.close_time)} @@ -313,12 +456,85 @@ export function DrawsIndexConsole() { : "—"} - - {t("viewDetails")} - +
+ + {t("viewDetails")} + + {canManageDraw && canEditDrawRow(row) ? ( + + ) : null} + {canManageDraw && canDeleteDrawRow(row) ? ( + + ) : null} + {canManageDraw && + canCancelDrawRow(row) && + !canDeleteDrawRow(row) ? ( + + ) : null} +
)) @@ -344,6 +560,25 @@ export function DrawsIndexConsole() { ) : null} + {canManageDraw ? ( + + ) : null} + {canManageDraw ? ( + { + if (!open) { + setEditDraw(null); + } + }} + draw={editDraw} + onSaved={load} + /> + ) : null} ); diff --git a/src/types/api/admin-draws.ts b/src/types/api/admin-draws.ts index 2d186c7..ce1a61e 100644 --- a/src/types/api/admin-draws.ts +++ b/src/types/api/admin-draws.ts @@ -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 = {