diff options
Diffstat (limited to 'components/permissions/menu-permission-generator.tsx')
| -rw-r--r-- | components/permissions/menu-permission-generator.tsx | 404 |
1 files changed, 404 insertions, 0 deletions
diff --git a/components/permissions/menu-permission-generator.tsx b/components/permissions/menu-permission-generator.tsx new file mode 100644 index 00000000..4c8d60d0 --- /dev/null +++ b/components/permissions/menu-permission-generator.tsx @@ -0,0 +1,404 @@ +// components/permissions/menu-permission-generator-optimized.tsx + +"use client"; + +import { useState, useEffect, useMemo, useCallback } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + RefreshCw, + AlertCircle, + CheckCircle, + Plus, + Search, + ChevronDown, + ChevronUp +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { analyzeMenuPermissions, generateMenuPermissions } from "@/lib/permissions/permission-settings-actions"; + +export function MenuBasedPermissionGenerator() { + const [analysis, setAnalysis] = useState<MenuPermissionAnalysis[]>([]); + const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set()); + const [selectedPermissions, setSelectedPermissions] = useState<Map<string, Set<string>>>(new Map()); + const [loading, setLoading] = useState(false); + const [generating, setGenerating] = useState(false); + + // 필터링과 페이지네이션 + const [searchQuery, setSearchQuery] = useState(""); + const [filterType, setFilterType] = useState<"all" | "configured" | "unconfigured">("unconfigured"); + const [currentPage, setCurrentPage] = useState(1); + const [expandedRow, setExpandedRow] = useState<string | null>(null); // 한 번에 하나만 확장 + const itemsPerPage = 20; + + // 필터링된 데이터 + const filteredAnalysis = useMemo(() => { + let filtered = analysis; + + if (searchQuery) { + filtered = filtered.filter(m => + m.menuTitle.toLowerCase().includes(searchQuery.toLowerCase()) || + m.menuPath.toLowerCase().includes(searchQuery.toLowerCase()) + ); + } + + if (filterType === "configured") { + filtered = filtered.filter(m => m.existingPermissions.length > 0); + } else if (filterType === "unconfigured") { + filtered = filtered.filter(m => m.existingPermissions.length === 0); + } + + return filtered; + }, [analysis, searchQuery, filterType]); + + // 페이지네이션 적용 + const paginatedData = useMemo(() => { + const start = (currentPage - 1) * itemsPerPage; + return filteredAnalysis.slice(start, start + itemsPerPage); + }, [filteredAnalysis, currentPage, itemsPerPage]); + + const totalPages = Math.ceil(filteredAnalysis.length / itemsPerPage); + + // 필터 변경 시 첫 페이지로 + useEffect(() => { + setCurrentPage(1); + }, [searchQuery, filterType]); + + const loadAnalysis = async () => { + setLoading(true); + try { + const data = await analyzeMenuPermissions(); + setAnalysis(data); + + // 초기에는 선택하지 않음 (사용자가 필요한 것만 선택) + setSelectedMenus(new Set()); + setSelectedPermissions(new Map()); + } catch (error) { + toast.error("메뉴 분석에 실패했습니다."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadAnalysis(); + }, []); + + const toggleMenu = useCallback((menuPath: string) => { + setSelectedMenus(prev => { + const newSelected = new Set(prev); + if (newSelected.has(menuPath)) { + newSelected.delete(menuPath); + setSelectedPermissions(perms => { + const newPerms = new Map(perms); + newPerms.delete(menuPath); + return newPerms; + }); + } else { + newSelected.add(menuPath); + const menu = analysis.find(m => m.menuPath === menuPath); + if (menu) { + setSelectedPermissions(perms => { + const newPerms = new Map(perms); + newPerms.set( + menuPath, + new Set(menu.suggestedPermissions.map(p => p.permissionKey)) + ); + return newPerms; + }); + } + } + return newSelected; + }); + }, [analysis]); + + const togglePermission = useCallback((menuPath: string, permissionKey: string) => { + setSelectedPermissions(prev => { + const newPerms = new Map(prev); + const menuPerms = newPerms.get(menuPath) || new Set(); + + if (menuPerms.has(permissionKey)) { + menuPerms.delete(permissionKey); + } else { + menuPerms.add(permissionKey); + } + + newPerms.set(menuPath, menuPerms); + return newPerms; + }); + }, []); + + const handleGenerate = async () => { + const permissionsToGenerate = []; + + for (const [menuPath, permKeys] of selectedPermissions.entries()) { + const menu = analysis.find(m => m.menuPath === menuPath); + if (menu) { + for (const permKey of permKeys) { + const perm = menu.suggestedPermissions.find(p => p.permissionKey === permKey); + if (perm) { + permissionsToGenerate.push({ + ...perm, + menuPath, + }); + } + } + } + } + + if (permissionsToGenerate.length === 0) { + toast.error("생성할 권한을 선택해주세요."); + return; + } + + setGenerating(true); + try { + const result = await generateMenuPermissions(permissionsToGenerate); + toast.success(`${result.created}개의 권한이 생성되었습니다.`); + loadAnalysis(); + } catch (error) { + toast.error("권한 생성에 실패했습니다."); + } finally { + setGenerating(false); + } + }; + + const selectAllInPage = () => { + paginatedData.forEach(menu => { + if (!selectedMenus.has(menu.menuPath)) { + toggleMenu(menu.menuPath); + } + }); + }; + + const deselectAllInPage = () => { + paginatedData.forEach(menu => { + if (selectedMenus.has(menu.menuPath)) { + toggleMenu(menu.menuPath); + } + }); + }; + + const totalSelected = Array.from(selectedPermissions.values()) + .reduce((sum, set) => sum + set.size, 0); + + return ( + <div className="space-y-6"> + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>메뉴 기반 권한 자동 생성</CardTitle> + <CardDescription> + 등록된 메뉴를 분석하여 필요한 권한을 자동으로 생성합니다. + </CardDescription> + </div> + <div className="flex gap-2"> + <Button variant="outline" onClick={loadAnalysis} disabled={loading}> + <RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} /> + 분석 + </Button> + <Button + onClick={handleGenerate} + disabled={totalSelected === 0 || generating} + > + <Plus className="mr-2 h-4 w-4" /> + {totalSelected}개 권한 생성 + </Button> + </div> + </div> + </CardHeader> + <CardContent> + {/* 요약 정보 */} + <div className="grid grid-cols-3 gap-4 mb-6"> + <Card className="cursor-pointer" onClick={() => setFilterType("all")}> + <CardContent className="pt-6"> + <div className="text-2xl font-bold">{analysis.length}</div> + <p className="text-xs text-muted-foreground">전체 메뉴</p> + </CardContent> + </Card> + <Card className="cursor-pointer" onClick={() => setFilterType("configured")}> + <CardContent className="pt-6"> + <div className="text-2xl font-bold"> + {analysis.filter(m => m.existingPermissions.length > 0).length} + </div> + <p className="text-xs text-muted-foreground">권한 설정됨</p> + </CardContent> + </Card> + <Card className="cursor-pointer" onClick={() => setFilterType("unconfigured")}> + <CardContent className="pt-6"> + <div className="text-2xl font-bold text-orange-600"> + {analysis.filter(m => m.existingPermissions.length === 0).length} + </div> + <p className="text-xs text-muted-foreground">권한 미설정</p> + </CardContent> + </Card> + </div> + + {/* 필터 섹션 */} + <div className="flex gap-4 mb-4"> + <div className="flex-1 relative"> + <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="메뉴명, 경로로 검색..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + <Select value={filterType} onValueChange={(v: any) => setFilterType(v)}> + <SelectTrigger className="w-[150px]"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">전체</SelectItem> + <SelectItem value="unconfigured">미설정</SelectItem> + <SelectItem value="configured">설정됨</SelectItem> + </SelectContent> + </Select> + </div> + + {/* 일괄 선택 버튼 */} + <div className="flex justify-between items-center mb-4"> + <span className="text-sm text-muted-foreground"> + {filteredAnalysis.length}개 중 {paginatedData.length}개 표시 + </span> + <div className="flex gap-2"> + <Button size="sm" variant="outline" onClick={selectAllInPage}> + 현재 페이지 전체 선택 + </Button> + <Button size="sm" variant="outline" onClick={deselectAllInPage}> + 현재 페이지 전체 해제 + </Button> + </div> + </div> + + {/* 메뉴 리스트 */} + <div className="border rounded-lg divide-y"> + {paginatedData.map(menu => { + const isSelected = selectedMenus.has(menu.menuPath); + const isExpanded = expandedRow === menu.menuPath; + const menuPermissions = selectedPermissions.get(menu.menuPath); + + return ( + <div key={menu.menuPath} className="p-4"> + <div className="flex items-center gap-4"> + <Checkbox + checked={isSelected} + onCheckedChange={() => toggleMenu(menu.menuPath)} + /> + + <div className="flex-1"> + <div className="font-medium">{menu.menuTitle}</div> + <div className="text-xs text-muted-foreground"> + {menu.menuPath} + </div> + </div> + + <Badge variant="outline">{menu.domain}</Badge> + + {menu.existingPermissions.length > 0 ? ( + <div className="flex items-center gap-1 text-green-600"> + <CheckCircle className="h-4 w-4" /> + <span className="text-sm">{menu.existingPermissions.length}개</span> + </div> + ) : ( + <div className="flex items-center gap-1 text-orange-600"> + <AlertCircle className="h-4 w-4" /> + <span className="text-sm">미설정</span> + </div> + )} + + {isSelected && menu.suggestedPermissions.length > 0 && ( + <Button + size="sm" + variant="ghost" + onClick={() => setExpandedRow(isExpanded ? null : menu.menuPath)} + > + {isExpanded ? <ChevronUp /> : <ChevronDown />} + <span className="ml-1">{menu.suggestedPermissions.length}개 권한</span> + </Button> + )} + </div> + + {/* 권한 상세 (확장 시에만 표시) */} + {isExpanded && isSelected && ( + <div className="mt-4 pl-10 space-y-2"> + {menu.suggestedPermissions.map(perm => ( + <label + key={perm.permissionKey} + className="flex items-center gap-2 cursor-pointer" + > + <Checkbox + checked={menuPermissions?.has(perm.permissionKey) || false} + onCheckedChange={() => togglePermission(menu.menuPath, perm.permissionKey)} + /> + <span className="text-sm">{perm.name}</span> + <Badge variant="outline" className="text-xs"> + {perm.action} + </Badge> + </label> + ))} + </div> + )} + </div> + ); + })} + </div> + + {/* 페이지네이션 */} + {totalPages > 1 && ( + <div className="flex items-center justify-center gap-2 mt-4"> + <Button + size="sm" + variant="outline" + onClick={() => setCurrentPage(1)} + disabled={currentPage === 1} + > + 처음 + </Button> + <Button + size="sm" + variant="outline" + onClick={() => setCurrentPage(p => Math.max(1, p - 1))} + disabled={currentPage === 1} + > + 이전 + </Button> + <span className="px-3 text-sm"> + {currentPage} / {totalPages} + </span> + <Button + size="sm" + variant="outline" + onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} + disabled={currentPage === totalPages} + > + 다음 + </Button> + <Button + size="sm" + variant="outline" + onClick={() => setCurrentPage(totalPages)} + disabled={currentPage === totalPages} + > + 마지막 + </Button> + </div> + )} + </CardContent> + </Card> + </div> + ); +}
\ No newline at end of file |
