import React, { useEffect, useMemo, useRef, useState } from "react"; import { Edit, MoreHorizontal, Plus, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Label } from "../ui/label"; import { Switch } from "../ui/switch"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../ui/dialog"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../ui/table"; import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "../ui/pagination"; import { createMenu, deleteMenu, getMenus, updateMenu } from "../../services/menuService"; import type { MenuCreateInput, MenuDto } from "../../types/menu"; function toDisplay(v: string | null | undefined): string { const s = (v ?? "").trim(); return s ? s : "N/A"; } function toNumberOrNull(v: string): number | null { const s = v.trim(); if (!s) return null; const n = Number(s); return Number.isFinite(n) ? n : null; } function formatDateTime(v: string | null | undefined): string { const s = (v ?? "").trim(); if (!s) return "N/A"; const d = new Date(s); if (Number.isNaN(d.getTime())) return s; return d.toLocaleString(); } export function MenuManagementView() { const [menus, setMenus] = useState([]); const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); const [refreshSeq, setRefreshSeq] = useState(0); const [actionsOpenForId, setActionsOpenForId] = useState(null); const [keyword, setKeyword] = useState(""); const keywordTimerRef = useRef(null); const [debouncedKeyword, setDebouncedKeyword] = useState(""); const [pageIndex, setPageIndex] = useState(1); const [pageSize] = useState(10); const [isCreateOpen, setIsCreateOpen] = useState(false); const [isEditOpen, setIsEditOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); const abortRef = useRef(null); useEffect(() => { if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); keywordTimerRef.current = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300); return () => { if (keywordTimerRef.current) window.clearTimeout(keywordTimerRef.current); }; }, [keyword]); useEffect(() => { setPageIndex(1); }, [debouncedKeyword]); const totalPages = Math.max(1, Math.ceil(total / pageSize)); useEffect(() => { const run = async () => { abortRef.current?.abort(); const ac = new AbortController(); abortRef.current = ac; setLoading(true); try { const skipCount = (pageIndex - 1) * pageSize; const res = await getMenus( { skipCount, maxResultCount: pageSize, keyword: debouncedKeyword || undefined, }, ac.signal, ); setMenus(res.items ?? []); setTotal(res.totalCount ?? 0); } catch (e: any) { if (e?.name === "AbortError") return; toast.error("Failed to load menus.", { description: e?.message ? String(e.message) : "Please try again.", }); setMenus([]); setTotal(0); } finally { setLoading(false); } }; run(); return () => abortRef.current?.abort(); }, [debouncedKeyword, pageIndex, pageSize, refreshSeq]); const refreshList = () => setRefreshSeq((x) => x + 1); const openEdit = (m: MenuDto) => { setActionsOpenForId(null); setEditing(m); setIsEditOpen(true); }; const openDelete = (m: MenuDto) => { setActionsOpenForId(null); setDeleting(m); setIsDeleteOpen(true); }; return (
setKeyword(e.target.value)} style={{ height: 40, boxSizing: "border-box" }} className="border border-gray-300 rounded-md w-40 shrink-0 bg-white placeholder:text-gray-500" />
Name Path Icon Order Parent ID Enabled Created At Actions {menus.length === 0 ? ( {loading ? "Loading..." : "No data"} ) : ( menus.map((m) => ( {toDisplay(m.name)} {toDisplay(m.path)} {toDisplay(m.icon)} {m.order ?? "N/A"} {toDisplay(m.parentId)} {m.isEnabled ? "Yes" : "No"} {formatDateTime(m.createdAt)} setActionsOpenForId(open ? m.id : null)} >
)) )}
{total === 0 ? "0 results" : `${total} results`}
{ e.preventDefault(); setPageIndex((p) => Math.max(1, p - 1)); }} /> {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { const page = i + 1; return ( { e.preventDefault(); setPageIndex(page); }} > {page} ); })} { e.preventDefault(); setPageIndex((p) => Math.min(totalPages, p + 1)); }} />
setIsCreateOpen(open)} onSaved={refreshList} /> setIsEditOpen(open)} onSaved={refreshList} /> setIsDeleteOpen(open)} onDeleted={refreshList} />
); } function CreateOrEditMenuDialog({ mode, open, menu, onOpenChange, onSaved, }: { mode: "create" | "edit"; open: boolean; menu: MenuDto | null; onOpenChange: (open: boolean) => void; onSaved: () => void; }) { const isEdit = mode === "edit"; const [submitting, setSubmitting] = useState(false); const [name, setName] = useState(""); const [path, setPath] = useState(""); const [icon, setIcon] = useState(""); const [order, setOrder] = useState(""); const [parentId, setParentId] = useState(""); const [isEnabled, setIsEnabled] = useState(true); useEffect(() => { if (!open) return; setName(menu?.name ?? ""); setPath(menu?.path ?? ""); setIcon(menu?.icon ?? ""); setOrder(menu?.order === null || menu?.order === undefined ? "" : String(menu.order)); setParentId(menu?.parentId ?? ""); setIsEnabled(menu?.isEnabled ?? true); }, [open, menu]); const canSubmit = useMemo(() => { return Boolean(name.trim() && path.trim()); }, [name, path]); const submit = async () => { if (!canSubmit) { toast.error("Please fill in required fields.", { description: "Name and Path are required.", }); return; } setSubmitting(true); try { const payload: MenuCreateInput = { name: name.trim(), path: path.trim(), icon: icon.trim() ? icon.trim() : null, order: toNumberOrNull(order), parentId: parentId.trim() ? parentId.trim() : null, isEnabled, }; if (isEdit) { if (!menu?.id) throw new Error("Missing menu id."); await updateMenu(menu.id, payload); toast.success("Menu updated.", { description: "Changes have been saved successfully." }); } else { await createMenu(payload); toast.success("Menu created.", { description: "A new menu has been created successfully." }); } onOpenChange(false); onSaved(); } catch (e: any) { toast.error(isEdit ? "Failed to update menu." : "Failed to create menu.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; return ( {isEdit ? "Edit Menu" : "New Menu"} {isEdit ? "Update menu fields and save changes." : "Fill out the form to create a new menu."}
setName(e.target.value)} placeholder="e.g. Dashboard" />
setPath(e.target.value)} placeholder="e.g. /dashboard" />
setIcon(e.target.value)} placeholder="e.g. LayoutDashboard" />
setOrder(e.target.value)} placeholder="e.g. 10" />
setParentId(e.target.value)} placeholder="Optional" />
Enabled
); } function DeleteMenuDialog({ open, menu, onOpenChange, onDeleted, }: { open: boolean; menu: MenuDto | null; onOpenChange: (open: boolean) => void; onDeleted: () => void; }) { const [submitting, setSubmitting] = useState(false); const name = useMemo(() => { const n = (menu?.name ?? "").trim(); const p = (menu?.path ?? "").trim(); if (n && p) return `${n} (${p})`; return n || p || "this menu"; }, [menu?.name, menu?.path]); const submit = async () => { if (!menu?.id) return; setSubmitting(true); try { await deleteMenu(menu.id); toast.success("Menu deleted.", { description: "The menu has been removed successfully." }); onOpenChange(false); onDeleted(); } catch (e: any) { toast.error("Failed to delete menu.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; return ( Delete Menu This action cannot be undone.
Are you sure you want to delete {name}?
); }