diff options
Diffstat (limited to 'lib/menu-list/table')
| -rw-r--r-- | lib/menu-list/table/initialize-button.tsx | 42 | ||||
| -rw-r--r-- | lib/menu-list/table/manager-select.tsx | 192 | ||||
| -rw-r--r-- | lib/menu-list/table/menu-list-table.tsx | 280 |
3 files changed, 514 insertions, 0 deletions
diff --git a/lib/menu-list/table/initialize-button.tsx b/lib/menu-list/table/initialize-button.tsx new file mode 100644 index 00000000..1b8a2458 --- /dev/null +++ b/lib/menu-list/table/initialize-button.tsx @@ -0,0 +1,42 @@ +// app/evcp/menu-list/components/initialize-button.tsx + +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { RefreshCw } from "lucide-react"; +import { toast } from "sonner"; +import { initializeMenuAssignments } from "../servcie"; + +export function InitializeButton() { + const [isLoading, setIsLoading] = useState(false); + + const handleInitialize = async () => { + setIsLoading(true); + try { + const result = await initializeMenuAssignments(); + + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error("메뉴 초기화 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( + <Button + onClick={handleInitialize} + disabled={isLoading} + variant="outline" + size="sm" + > + <RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} /> + 메뉴 초기화 + </Button> + ); +}
\ No newline at end of file diff --git a/lib/menu-list/table/manager-select.tsx b/lib/menu-list/table/manager-select.tsx new file mode 100644 index 00000000..a4bcccd7 --- /dev/null +++ b/lib/menu-list/table/manager-select.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useState } from "react"; +import { Check, ChevronsUpDown, Search, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { toast } from "sonner"; +import { updateMenuManager } from "../servcie"; + +interface User { + id: number; + name: string; + email: string; + domain: string; +} + +interface ManagerSelectProps { + menuPath: string; + currentManagerId?: number | null; + users: User[]; + placeholder: string; + otherManagerId?: number | null; // 다른 담당자 ID (중복 선택 방지용) + type: "manager1" | "manager2"; +} + +export function ManagerSelect({ + menuPath, + currentManagerId, + users, + placeholder, + otherManagerId, + type +}: ManagerSelectProps) { + const [open, setOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + // 선택 가능한 사용자 필터링 (다른 담당자로 이미 선택된 사용자 제외) + const availableUsers = users.filter(user => + user.id !== otherManagerId || user.id === currentManagerId + ); + + // 현재 선택된 사용자 찾기 + const selectedUser = availableUsers.find(user => user.id === currentManagerId); + + const handleSelect = async (userId: number | null) => { + setIsLoading(true); + setOpen(false); + + try { + // 현재 담당자 정보 구성 + const updateData = type === "manager1" + ? { manager1Id: userId, manager2Id: otherManagerId } + : { manager1Id: otherManagerId, manager2Id: userId }; + + const result = await updateMenuManager( + menuPath, + updateData.manager1Id, + updateData.manager2Id + ); + + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error("담당자 업데이트 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleClear = () => { + handleSelect(null); + }; + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-full justify-between text-left font-normal" + disabled={isLoading} + > + {selectedUser ? ( + <div className="flex items-center justify-between w-full"> + <div className="flex flex-col min-w-0 flex-1"> + <span className="font-medium truncate">{selectedUser.name}</span> + <span className="text-xs text-muted-foreground truncate"> + {selectedUser.email} + </span> + </div> + {!isLoading && ( + <Button + variant="ghost" + size="sm" + className="h-auto p-1 hover:bg-transparent" + onClick={(e) => { + e.stopPropagation(); + handleClear(); + }} + > + <X className="h-3 w-3" /> + </Button> + )} + </div> + ) : ( + <span className="text-muted-foreground">{placeholder}</span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + + <PopoverContent className="w-[400px] p-0" align="start"> + <Command> + <CommandInput + placeholder="사용자 검색..." + className="h-9" + /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {/* 담당자 없음 옵션 */} + <CommandItem + value="none" + onSelect={() => handleSelect(null)} + className="flex items-center gap-2" + > + <Check + className={cn( + "mr-2 h-4 w-4", + !selectedUser ? "opacity-100" : "opacity-0" + )} + /> + <div className="flex flex-col"> + <span className="font-medium">담당자 없음</span> + <span className="text-xs text-muted-foreground"> + 담당자를 지정하지 않습니다 + </span> + </div> + </CommandItem> + + {/* 사용자 목록 */} + {availableUsers.map((user) => ( + <CommandItem + key={user.id} + value={`${user.name} ${user.email}`} // 검색을 위해 이름과 이메일 모두 포함 + onSelect={() => handleSelect(user.id)} + className="flex items-center gap-2" + disabled={user.id === otherManagerId && user.id !== currentManagerId} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedUser?.id === user.id ? "opacity-100" : "opacity-0" + )} + /> + <div className="flex flex-col min-w-0 flex-1"> + <span className="font-medium truncate">{user.name}</span> + <span className="text-xs text-muted-foreground truncate"> + {user.email} + </span> + </div> + {user.id === otherManagerId && user.id !== currentManagerId && ( + <span className="text-xs text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded"> + 다른 담당자로 선택됨 + </span> + )} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +}
\ No newline at end of file diff --git a/lib/menu-list/table/menu-list-table.tsx b/lib/menu-list/table/menu-list-table.tsx new file mode 100644 index 00000000..097be082 --- /dev/null +++ b/lib/menu-list/table/menu-list-table.tsx @@ -0,0 +1,280 @@ +// app/evcp/menu-list/components/menu-list-table.tsx + +"use client"; + +import { useState, useMemo } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { Search, Filter, ExternalLink } from "lucide-react"; +import { toast } from "sonner"; +import { ManagerSelect } from "./manager-select"; +import { toggleMenuActive } from "../servcie"; + +interface MenuAssignment { + id: number; + menuPath: string; + menuTitle: string; + menuDescription?: string | null; + menuGroup?: string | null; + sectionTitle: string; + domain: string; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + manager1Id?: number | null; + manager2Id?: number | null; + manager1Name?: string | null; + manager1Email?: string | null; + manager2Name?: string | null; + manager2Email?: string | null; +} + +interface User { + id: number; + name: string; + email: string; + domain: string; +} + +interface MenuListTableProps { + initialMenus: MenuAssignment[]; + initialUsers: User[]; +} + +export function MenuListTable({ initialMenus, initialUsers }: MenuListTableProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [domainFilter, setDomainFilter] = useState<string>("all"); + const [sectionFilter, setSectionFilter] = useState<string>("all"); + const [statusFilter, setStatusFilter] = useState<string>("all"); + + // 필터링된 메뉴 데이터 + const filteredMenus = useMemo(() => { + return initialMenus.filter((menu) => { + const matchesSearch = + menu.menuTitle.toLowerCase().includes(searchQuery.toLowerCase()) || + menu.menuPath.toLowerCase().includes(searchQuery.toLowerCase()) || + menu.sectionTitle.toLowerCase().includes(searchQuery.toLowerCase()) || + (menu.menuDescription?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false); + + const matchesDomain = domainFilter === "all" || menu.domain === domainFilter; + const matchesSection = sectionFilter === "all" || menu.sectionTitle === sectionFilter; + const matchesStatus = statusFilter === "all" || + (statusFilter === "active" && menu.isActive) || + (statusFilter === "inactive" && !menu.isActive); + + return matchesSearch && matchesDomain && matchesSection && matchesStatus; + }); + }, [initialMenus, searchQuery, domainFilter, sectionFilter, statusFilter]); + + // 섹션 리스트 추출 + const sections = useMemo(() => { + const sectionSet = new Set(initialMenus.map(menu => menu.sectionTitle)); + return Array.from(sectionSet).sort(); + }, [initialMenus]); + + // 도메인별 사용자 필터링 + const getFilteredUsers = (domain: string) => { + return initialUsers.filter(user => user.domain === domain); + }; + + // 메뉴 활성화/비활성화 토글 + const handleToggleActive = async (menuPath: string, isActive: boolean) => { + try { + const result = await toggleMenuActive(menuPath, isActive); + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error("메뉴 상태 변경 중 오류가 발생했습니다."); + } + }; + + return ( + <div className="space-y-4"> + {/* 필터 영역 */} + <div className="flex flex-col sm:flex-row gap-4"> + <div className="flex-1"> + <div className="relative"> + <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="메뉴명, 경로, 설명으로 검색..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + </div> + + <div className="flex gap-2"> + <Select value={domainFilter} onValueChange={setDomainFilter}> + <SelectTrigger className="w-[120px]"> + <SelectValue placeholder="도메인" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">전체 도메인</SelectItem> + <SelectItem value="evcp">EVCP</SelectItem> + <SelectItem value="partners">Partners</SelectItem> + </SelectContent> + </Select> + + <Select value={sectionFilter} onValueChange={setSectionFilter}> + <SelectTrigger className="w-[150px]"> + <SelectValue placeholder="섹션" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">전체 섹션</SelectItem> + {sections.map((section) => ( + <SelectItem key={section} value={section}> + {section} + </SelectItem> + ))} + </SelectContent> + </Select> + + <Select value={statusFilter} onValueChange={setStatusFilter}> + <SelectTrigger className="w-[100px]"> + <SelectValue placeholder="상태" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">전체</SelectItem> + <SelectItem value="active">활성</SelectItem> + <SelectItem value="inactive">비활성</SelectItem> + </SelectContent> + </Select> + </div> + </div> + + {/* 결과 요약 */} + <div className="flex items-center justify-between text-sm text-muted-foreground"> + <span> + 총 {filteredMenus.length}개의 메뉴 + {searchQuery && ` (${initialMenus.length}개 중 검색 결과)`} + </span> + </div> + + {/* 테이블 */} + <div className="rounded-md border"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px]">상태</TableHead> + <TableHead>메뉴 정보</TableHead> + <TableHead className="w-[100px]">도메인</TableHead> + <TableHead className="w-[200px]">담당자 1</TableHead> + <TableHead className="w-[200px]">담당자 2</TableHead> + {/* <TableHead className="w-[80px]">동작</TableHead> */} + </TableRow> + </TableHeader> + <TableBody> + {filteredMenus.length === 0 ? ( + <TableRow> + <TableCell colSpan={6} className="text-center py-8 text-muted-foreground"> + 조건에 맞는 메뉴가 없습니다. + </TableCell> + </TableRow> + ) : ( + filteredMenus.map((menu) => { + const domainUsers = getFilteredUsers(menu.domain); + + return ( + <TableRow key={menu.id}> + <TableCell> + <Switch + checked={menu.isActive} + onCheckedChange={(checked) => handleToggleActive(menu.menuPath, checked)} + /> + </TableCell> + + <TableCell> + <div className="space-y-1"> + <div className="flex items-center gap-2"> + <span className="font-medium">{menu.menuTitle}</span> + <Badge variant="outline" className="text-xs"> + {menu.sectionTitle} + </Badge> + {menu.menuGroup && ( + <Badge variant="secondary" className="text-xs"> + {menu.menuGroup} + </Badge> + )} + </div> + <div className="text-xs text-muted-foreground font-mono"> + {menu.menuPath} + </div> + {menu.menuDescription && ( + <div className="text-xs text-muted-foreground"> + {menu.menuDescription} + </div> + )} + </div> + </TableCell> + + <TableCell> + <Badge + variant={menu.domain === "evcp" ? "default" : "secondary"} + className="text-xs" + > + {menu.domain.toUpperCase()} + </Badge> + </TableCell> + + <TableCell> + <ManagerSelect + menuPath={menu.menuPath} + currentManagerId={menu.manager1Id} + users={domainUsers} + placeholder="담당자 1 선택" + otherManagerId={menu.manager2Id} + type="manager1" + /> + </TableCell> + + <TableCell> + <ManagerSelect + menuPath={menu.menuPath} + currentManagerId={menu.manager2Id} + users={domainUsers} + placeholder="담당자 2 선택" + otherManagerId={menu.manager1Id} + type="manager2" + /> + </TableCell> + + {/* <TableCell> + <Button + variant="ghost" + size="sm" + onClick={() => window.open(menu.menuPath, '_blank')} + > + <ExternalLink className="h-3 w-3" /> + </Button> + </TableCell> */} + </TableRow> + ); + }) + )} + </TableBody> + </Table> + </div> + </div> + ); +}
\ No newline at end of file |
