diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/ProjectSelector.tsx | 50 | ||||
| -rw-r--r-- | components/docu-list-rule/docu-list-rule-client.tsx | 80 | ||||
| -rw-r--r-- | components/form-data/add-formTag-dialog.tsx | 68 | ||||
| -rw-r--r-- | components/permissions/menu-permission-generator.tsx | 404 | ||||
| -rw-r--r-- | components/permissions/menu-permission-manager.tsx | 552 | ||||
| -rw-r--r-- | components/permissions/permission-assignment-manager.tsx | 319 | ||||
| -rw-r--r-- | components/permissions/permission-crud-manager.tsx | 562 | ||||
| -rw-r--r-- | components/permissions/permission-group-manager.tsx | 799 | ||||
| -rw-r--r-- | components/permissions/role-permission-manager.tsx | 178 | ||||
| -rw-r--r-- | components/permissions/user-permission-manager.tsx | 573 |
10 files changed, 3478 insertions, 107 deletions
diff --git a/components/ProjectSelector.tsx b/components/ProjectSelector.tsx index 652bf77b..773ab7dd 100644 --- a/components/ProjectSelector.tsx +++ b/components/ProjectSelector.tsx @@ -12,12 +12,14 @@ interface ProjectSelectorProps { selectedProjectId?: number | null; onProjectSelect: (project: Project) => void; placeholder?: string; + filterType?: string; // 옵션으로 필터 타입 지정 가능 } export function ProjectSelector({ selectedProjectId, onProjectSelect, - placeholder = "프로젝트 선택..." + placeholder = "프로젝트 선택...", + filterType = "ship" // 기본값을 plant로 설정 }: ProjectSelectorProps) { const [open, setOpen] = React.useState(false) const [searchTerm, setSearchTerm] = React.useState("") @@ -25,17 +27,24 @@ export function ProjectSelector({ const [isLoading, setIsLoading] = React.useState(false) const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) - // 모든 프로젝트 데이터 로드 (한 번만) + // 모든 프로젝트 데이터 로드 후 plant 타입만 필터링 React.useEffect(() => { async function loadAllProjects() { setIsLoading(true); try { const allProjects = await getProjects(); - setProjects(allProjects); + + // filterType이 지정된 경우 해당 타입만 필터링 + const filteredByType = filterType + ? allProjects.filter(p => p.type === filterType) + : allProjects; + + console.log(`Loaded ${filteredByType.length} ${filterType || 'all'} projects`); + setProjects(filteredByType); // 초기 선택된 프로젝트가 있으면 설정 if (selectedProjectId) { - const selected = allProjects.find(p => p.id === selectedProjectId); + const selected = filteredByType.find(p => p.id === selectedProjectId); if (selected) { setSelectedProject(selected); } @@ -48,7 +57,7 @@ export function ProjectSelector({ } loadAllProjects(); - }, [selectedProjectId]); + }, [selectedProjectId, filterType]); // 클라이언트 측에서 검색어로 필터링 const filteredProjects = React.useMemo(() => { @@ -77,10 +86,15 @@ export function ProjectSelector({ role="combobox" aria-expanded={open} className="w-full justify-between" + disabled={isLoading} > - {selectedProject - ? `${selectedProject.projectCode} - ${selectedProject.projectName}` - : placeholder} + {isLoading ? ( + "프로젝트 로딩 중..." + ) : selectedProject ? ( + `${selectedProject.projectCode} - ${selectedProject.projectName}` + ) : ( + placeholder + )} <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> @@ -90,14 +104,22 @@ export function ProjectSelector({ placeholder="프로젝트 코드/이름 검색..." onValueChange={setSearchTerm} /> - <CommandList className="max-h-[300px]" onWheel={(e) => { - e.stopPropagation(); // 이벤트 전파 차단 - const target = e.currentTarget; - target.scrollTop += e.deltaY; // 직접 스크롤 처리 - }}> - <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + <CommandList + className="max-h-[300px]" + onWheel={(e) => { + e.stopPropagation(); + const target = e.currentTarget; + target.scrollTop += e.deltaY; + }} + > {isLoading ? ( <div className="py-6 text-center text-sm">로딩 중...</div> + ) : filteredProjects.length === 0 ? ( + <CommandEmpty> + {searchTerm + ? "검색 결과가 없습니다" + : `${filterType || '해당 타입의'} 프로젝트가 없습니다`} + </CommandEmpty> ) : ( <CommandGroup> {filteredProjects.map((project) => ( diff --git a/components/docu-list-rule/docu-list-rule-client.tsx b/components/docu-list-rule/docu-list-rule-client.tsx index 7e6c2bb1..ae3cdece 100644 --- a/components/docu-list-rule/docu-list-rule-client.tsx +++ b/components/docu-list-rule/docu-list-rule-client.tsx @@ -1,15 +1,7 @@ "use client" import * as React from "react" import { useRouter, useParams } from "next/navigation" - -import { getProjectLists } from "@/lib/projects/service" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" +import { ProjectSelector } from "../ProjectSelector" interface DocuListRuleClientProps { children: React.ReactNode @@ -38,10 +30,6 @@ export default function DocuListRuleClient({ projectIdFromUrl ) - // 프로젝트 목록 상태 - const [projects, setProjects] = React.useState<Array<{ id: number; code: string; name: string; type: string }>>([]) - const [isLoading, setIsLoading] = React.useState(true) - // Update selectedProjectId when URL changes React.useEffect(() => { if (projectIdFromUrl) { @@ -49,40 +37,6 @@ export default function DocuListRuleClient({ } }, [projectIdFromUrl]) - // 프로젝트 목록 로드 - React.useEffect(() => { - const loadProjects = async () => { - try { - setIsLoading(true) - console.log("Loading projects...") - const result = await getProjectLists({ - page: 1, - perPage: 1000, - search: "", - sort: [], - filters: [], - joinOperator: "and", - flags: [], - code: "", - name: "", - type: "" - }) - console.log("Projects result:", result) - if (result.data) { - // plant 타입의 프로젝트만 필터링 - const plantProjects = result.data.filter(project => project.type === 'plant') - console.log("Plant projects:", plantProjects) - setProjects(plantProjects) - } - } catch (error) { - console.error("Failed to load projects:", error) - } finally { - setIsLoading(false) - } - } - loadProjects() - }, []) - // Handle project selection function handleSelectProject(projectId: number) { console.log("Selecting project:", projectId) @@ -107,28 +61,16 @@ export default function DocuListRuleClient({ </div> {/* 오른쪽: ProjectSwitcher */} - <div className="flex items-center space-x-2"> - <Select - value={selectedProjectId ? String(selectedProjectId) : ""} - onValueChange={(value) => { - const projectId = Number(value) - if (projectId) { - handleSelectProject(projectId) - } + <div className="flex items-center space-x-2 max-w-[400px]"> + <ProjectSelector + selectedProjectId={selectedProjectId} + onProjectSelect={(project) => { + handleSelectProject(project.id) }} - disabled={isLoading} - > - <SelectTrigger className="max-w-[300px] whitespace-nowrap overflow-hidden text-ellipsis"> - <SelectValue placeholder={isLoading ? "프로젝트 로딩 중..." : "프로젝트를 선택하세요"} /> - </SelectTrigger> - <SelectContent> - {projects.map((project) => ( - <SelectItem key={project.id} value={String(project.id)}> - {project.code} - {project.name} - </SelectItem> - ))} - </SelectContent> - </Select> + placeholder="프로젝트를 선택하세요" + filterType="plant" // 명시적으로 plant 타입만 (선택사항, 기본값이 plant) + + /> </div> </div> @@ -138,4 +80,4 @@ export default function DocuListRuleClient({ </section> </> ) -} +}
\ No newline at end of file diff --git a/components/form-data/add-formTag-dialog.tsx b/components/form-data/add-formTag-dialog.tsx index 5a462d2b..a5d4db21 100644 --- a/components/form-data/add-formTag-dialog.tsx +++ b/components/form-data/add-formTag-dialog.tsx @@ -52,10 +52,10 @@ import { cn } from "@/lib/utils" import { Badge } from "@/components/ui/badge" import { createTagInForm } from "@/lib/tags/service" -import { - getFormTagTypeMappings, - getTagTypeByDescription, - getSubfieldsByTagTypeForForm +import { + getFormTagTypeMappings, + getTagTypeByDescription, + getSubfieldsByTagTypeForForm } from "@/lib/forms/services" import { useTranslation } from "@/i18n/client"; @@ -100,10 +100,10 @@ interface AddFormTagDialogProps { onOpenChange?: (open: boolean) => void; } -export function AddFormTagDialog({ +export function AddFormTagDialog({ projectId, - formCode, - formName, + formCode, + formName, contractItemId, packageCode, open: externalOpen, @@ -138,7 +138,7 @@ export function AddFormTagDialog({ React.useEffect(() => { const loadMappings = async () => { if (!formCode || !projectId) return; - + setIsLoadingClasses(true); try { const result = await getFormTagTypeMappings(formCode, projectId); @@ -158,7 +158,7 @@ export function AddFormTagDialog({ setIsLoadingClasses(false); } }; - + loadMappings(); }, [formCode, projectId]); @@ -167,7 +167,7 @@ export function AddFormTagDialog({ if (isOpen) { const loadMappings = async () => { if (!formCode || !projectId) return; - + setIsLoadingClasses(true); try { const result = await getFormTagTypeMappings(formCode, projectId); @@ -187,7 +187,7 @@ export function AddFormTagDialog({ setIsLoadingClasses(false); } }; - + loadMappings(); } }, [isOpen, formCode, projectId]); @@ -256,13 +256,13 @@ export function AddFormTagDialog({ // --------------- async function handleSelectClass(classLabel: string) { form.setValue("class", classLabel); - + // Find the mapping for this class const mapping = mappings.find(m => m.classLabel === classLabel); if (mapping) { setSelectedTagTypeLabel(mapping.tagTypeLabel); form.setValue("tagType", mapping.tagTypeLabel); - + // Get the tagTypeCode for this tagTypeLabel try { const tagType = await getTagTypeByDescription(mapping.tagTypeLabel, projectId); @@ -285,28 +285,28 @@ export function AddFormTagDialog({ if (subFields.length === 0) { return; } - + const subscription = form.watch((value) => { if (!value.rows || subFields.length === 0) { return; } - + const rows = [...value.rows]; rows.forEach((row, rowIndex) => { if (!row) return; - + let combined = ""; subFields.forEach((sf, idx) => { const fieldValue = row[sf.name] || ""; - + // delimiter를 앞에 붙이기 (첫 번째 필드가 아니고, 현재 필드에 값이 있고, delimiter가 있는 경우) if (idx > 0 && fieldValue && sf.delimiter) { combined += sf.delimiter; } - + combined += fieldValue; }); - + const currentTagNo = form.getValues(`rows.${rowIndex}.tagNo`); if (currentTagNo !== combined) { form.setValue(`rows.${rowIndex}.tagNo`, combined, { @@ -317,7 +317,7 @@ export function AddFormTagDialog({ } }); }); - + return () => subscription.unsubscribe(); }, [subFields, form]); // --------------- @@ -364,11 +364,16 @@ export function AddFormTagDialog({ try { const res = await createTagInForm(tagData, contractItemId, formCode, packageCode); - if ("error" in res) { + if (res && "error" in res) { failedTags.push({ tag: row.tagNo, error: res.error }); - } else { + } else if (res && res.success) { successfulTags.push(row.tagNo); + } else { + // 예상치 못한 응답 처리 + console.error("Unexpected response:", res); + failedTags.push({ tag: row.tagNo, error: "Unexpected response format" }); } + } catch (err) { failedTags.push({ tag: row.tagNo, error: "Unknown error" }); } @@ -381,7 +386,22 @@ export function AddFormTagDialog({ if (failedTags.length > 0) { console.log("Failed tags:", failedTags); - toast.error(`${failedTags.length}개의 태그 생성에 실패했습니다.`); + + // 전체 에러 메시지 표시 + const errorMessage = failedTags + .map(f => `${f.tag}: ${f.error}`) + .join('\n'); + + toast.error( + <div> + <p>{failedTags.length}개의 태그 생성 실패:</p> + <ul className="text-sm mt-1"> + {failedTags.map((f, idx) => ( + <li key={idx}>• {f.tag}: {f.error}</li> + ))} + </ul> + </div> + ); } // Refresh the page @@ -445,7 +465,7 @@ export function AddFormTagDialog({ // --------------- // Render Class field // --------------- - function renderClassField(field: any) { + function renderClassField(field: any) { const [popoverOpen, setPopoverOpen] = React.useState(false) const buttonId = React.useMemo( 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 diff --git a/components/permissions/menu-permission-manager.tsx b/components/permissions/menu-permission-manager.tsx new file mode 100644 index 00000000..1b771520 --- /dev/null +++ b/components/permissions/menu-permission-manager.tsx @@ -0,0 +1,552 @@ +// components/permissions/menu-permission-manager.tsx + +"use client"; + +import { useState, useEffect } 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 { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Menu, + Search, + Shield, + Lock, + Unlock, + Users, + User, + Settings, + FileText, + Eye, + Edit, + Trash, + Plus, + ChevronRight, + AlertCircle, + CheckCircle, + ExternalLink +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + getMenuPermissions, + updateMenuPermissions, + getMenuManagers, + updateMenuManagers, +} from "@/lib/permissions/service"; + +// 메뉴 구조 타입 +interface MenuItem { + menuPath: string; + menuTitle: string; + menuDescription?: string; + sectionTitle?: string; + menuGroup?: string; + domain: string; + isActive: boolean; + manager1?: { id: number; name: string; email: string; imageUrl?: string }; + manager2?: { id: number; name: string; email: string; imageUrl?: string }; + requiredPermissions: Array<{ + id: number; + permissionKey: string; + name: string; + description?: string; + isRequired: boolean; + }>; + accessCount?: number; // 접근 통계 + lastAccessed?: Date; +} + +// 메뉴 섹션별 그룹화 +interface MenuSection { + title: string; + items: MenuItem[]; +} + +export function MenuPermissionManager() { + const [searchQuery, setSearchQuery] = useState(""); + const [menus, setMenus] = useState<MenuItem[]>([]); + const [selectedMenu, setSelectedMenu] = useState<MenuItem | null>(null); + const [availablePermissions, setAvailablePermissions] = useState<any[]>([]); + const [loading, setLoading] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [selectedDomain, setSelectedDomain] = useState<string>("all"); + + useEffect(() => { + loadMenus(); + }, [selectedDomain]); + + const loadMenus = async () => { + setLoading(true); + try { + const data = await getMenuPermissions(selectedDomain); + setMenus(data.menus); + setAvailablePermissions(data.availablePermissions); + } catch (error) { + toast.error("메뉴 정보를 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + // 메뉴 검색 필터링 + const filteredMenus = menus.filter(menu => + menu.menuTitle.toLowerCase().includes(searchQuery.toLowerCase()) || + menu.menuPath.toLowerCase().includes(searchQuery.toLowerCase()) || + menu.menuDescription?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // 섹션별로 메뉴 그룹화 + const groupedMenus = filteredMenus.reduce((acc, menu) => { + const section = menu.sectionTitle || "기타"; + if (!acc[section]) { + acc[section] = []; + } + acc[section].push(menu); + return acc; + }, {} as Record<string, MenuItem[]>); + + return ( + <div className="grid grid-cols-3 gap-6"> + {/* 메뉴 목록 */} + <Card className="col-span-1"> + <CardHeader> + <CardTitle>메뉴 목록</CardTitle> + <CardDescription>권한을 설정할 메뉴를 선택하세요.</CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {/* 도메인 필터 */} + <div className="flex gap-2"> + <Badge + variant={selectedDomain === "all" ? "default" : "outline"} + className="cursor-pointer" + onClick={() => setSelectedDomain("all")} + > + 전체 + </Badge> + <Badge + variant={selectedDomain === "evcp" ? "default" : "outline"} + className="cursor-pointer" + onClick={() => setSelectedDomain("evcp")} + > + EVCP + </Badge> + <Badge + variant={selectedDomain === "partners" ? "default" : "outline"} + className="cursor-pointer" + onClick={() => setSelectedDomain("partners")} + > + Partners + </Badge> + </div> + + {/* 검색 */} + <div className="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> + + {/* 메뉴 트리 */} + <ScrollArea className="h-[500px]"> + <Accordion type="single" collapsible className="w-full"> + {Object.entries(groupedMenus).map(([section, items]) => ( + <AccordionItem key={section} value={section}> + <AccordionTrigger className="text-sm"> + <div className="flex items-center justify-between flex-1 mr-2"> + <span>{section}</span> + <Badge variant="secondary" className="text-xs"> + {items.length} + </Badge> + </div> + </AccordionTrigger> + <AccordionContent> + <div className="space-y-1"> + {items.map((menu) => ( + <button + key={menu.menuPath} + onClick={() => setSelectedMenu(menu)} + className={cn( + "w-full p-3 rounded-lg text-left transition-colors", + selectedMenu?.menuPath === menu.menuPath + ? "bg-primary/10 border border-primary" + : "hover:bg-muted" + )} + > + <div className="flex items-start justify-between"> + <div className="flex-1"> + <div className="font-medium text-sm">{menu.menuTitle}</div> + <div className="text-xs text-muted-foreground mt-1"> + {menu.menuPath} + </div> + </div> + <div className="flex flex-col items-end gap-1"> + {menu.requiredPermissions.length > 0 && ( + <Badge variant="outline" className="text-xs"> + {menu.requiredPermissions.length}개 권한 + </Badge> + )} + {!menu.isActive && ( + <Badge variant="destructive" className="text-xs"> + 비활성 + </Badge> + )} + </div> + </div> + </button> + ))} + </div> + </AccordionContent> + </AccordionItem> + ))} + </Accordion> + </ScrollArea> + </div> + </CardContent> + </Card> + + {/* 메뉴 상세 및 권한 설정 */} + {selectedMenu ? ( + <Card className="col-span-2"> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle className="flex items-center gap-2"> + {selectedMenu.menuTitle} + {!selectedMenu.isActive && ( + <Badge variant="destructive">비활성</Badge> + )} + </CardTitle> + <CardDescription>{selectedMenu.menuPath}</CardDescription> + {selectedMenu.menuDescription && ( + <p className="text-sm text-muted-foreground mt-2"> + {selectedMenu.menuDescription} + </p> + )} + </div> + <Button + onClick={() => setEditDialogOpen(true)} + size="sm" + > + <Settings className="mr-2 h-4 w-4" /> + 설정 + </Button> + </div> + </CardHeader> + <CardContent> + <div className="space-y-6"> + {/* 담당자 정보 */} + <div> + <h3 className="text-sm font-medium mb-3">담당자</h3> + <div className="space-y-2"> + <ManagerInfo + label="주 담당자" + manager={selectedMenu.manager1} + onEdit={() => {/* 담당자 변경 다이얼로그 */}} + /> + <ManagerInfo + label="부 담당자" + manager={selectedMenu.manager2} + onEdit={() => {/* 담당자 변경 다이얼로그 */}} + /> + </div> + </div> + + <Separator /> + + {/* 필수 권한 */} + <div> + <h3 className="text-sm font-medium mb-3">필수 권한</h3> + {selectedMenu.requiredPermissions.length > 0 ? ( + <div className="space-y-2"> + {selectedMenu.requiredPermissions.map((perm) => ( + <div + key={perm.id} + className="flex items-center justify-between p-3 border rounded-lg" + > + <div> + <div className="font-medium text-sm">{perm.name}</div> + <div className="text-xs text-muted-foreground"> + {perm.permissionKey} + </div> + {perm.description && ( + <div className="text-xs text-muted-foreground mt-1"> + {perm.description} + </div> + )} + </div> + <div className="flex items-center gap-2"> + {perm.isRequired ? ( + <Badge variant="default">필수</Badge> + ) : ( + <Badge variant="outline">선택</Badge> + )} + </div> + </div> + ))} + </div> + ) : ( + <div className="text-center py-8 text-muted-foreground"> + <Unlock className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <p className="text-sm">설정된 권한이 없습니다.</p> + <p className="text-xs mt-1">모든 사용자가 접근 가능합니다.</p> + </div> + )} + </div> + + <Separator /> + + {/* 접근 통계 */} + <div> + <h3 className="text-sm font-medium mb-3">접근 통계</h3> + <div className="grid grid-cols-2 gap-4"> + <div className="p-3 bg-muted/50 rounded-lg"> + <div className="text-2xl font-bold"> + {selectedMenu.accessCount || 0} + </div> + <div className="text-xs text-muted-foreground"> + 총 접근 횟수 + </div> + </div> + <div className="p-3 bg-muted/50 rounded-lg"> + <div className="text-sm font-medium"> + {selectedMenu.lastAccessed + ? new Date(selectedMenu.lastAccessed).toLocaleDateString() + : "-"} + </div> + <div className="text-xs text-muted-foreground"> + 최근 접근일 + </div> + </div> + </div> + </div> + </div> + </CardContent> + </Card> + ) : ( + <Card className="col-span-2 flex items-center justify-center h-[600px]"> + <div className="text-center text-muted-foreground"> + <Menu className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>메뉴를 선택하면 권한 설정이 표시됩니다.</p> + </div> + </Card> + )} + + {/* 메뉴 권한 편집 다이얼로그 */} + {selectedMenu && ( + <MenuPermissionEditDialog + open={editDialogOpen} + onOpenChange={setEditDialogOpen} + menu={selectedMenu} + availablePermissions={availablePermissions} + onSuccess={() => { + loadMenus(); + setEditDialogOpen(false); + }} + /> + )} + </div> + ); +} + +// 담당자 정보 컴포넌트 +function ManagerInfo({ + label, + manager, + onEdit +}: { + label: string; + manager?: { id: number; name: string; email: string; imageUrl?: string }; + onEdit: () => void; +}) { + if (!manager) { + return ( + <div className="flex items-center justify-between p-2 border rounded-lg bg-muted/30"> + <div className="flex items-center gap-2"> + <User className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground">{label}: 미지정</span> + </div> + <Button size="sm" variant="ghost" onClick={onEdit}> + <Plus className="h-4 w-4" /> + </Button> + </div> + ); + } + + return ( + <div className="flex items-center justify-between p-2 border rounded-lg"> + <div className="flex items-center gap-3"> + <Avatar className="h-8 w-8"> + <AvatarImage src={manager.imageUrl} /> + <AvatarFallback>{manager.name[0]}</AvatarFallback> + </Avatar> + <div> + <div className="text-sm font-medium">{manager.name}</div> + <div className="text-xs text-muted-foreground">{manager.email}</div> + </div> + </div> + <Button size="sm" variant="ghost" onClick={onEdit}> + <Edit className="h-4 w-4" /> + </Button> + </div> + ); +} + +// 메뉴 권한 편집 다이얼로그 +function MenuPermissionEditDialog({ + open, + onOpenChange, + menu, + availablePermissions, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + menu: MenuItem; + availablePermissions: any[]; + onSuccess: () => void; +}) { + const [selectedPermissions, setSelectedPermissions] = useState< + Array<{ id: number; isRequired: boolean }> + >(() => menu.requiredPermissions.map(p => ({ id: p.id, isRequired: p.isRequired }))) + + + const [saving, setSaving] = useState(false); + + const handleSave = async () => { + setSaving(true); + try { + await updateMenuPermissions(menu.menuPath, selectedPermissions); + toast.success("메뉴 권한이 업데이트되었습니다."); + onSuccess(); + } catch (error) { + toast.error("권한 업데이트에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + const togglePermission = (permissionId: number) => { + const existing = selectedPermissions.find(p => p.id === permissionId); + if (existing) { + setSelectedPermissions(selectedPermissions.filter(p => p.id !== permissionId)); + } else { + setSelectedPermissions([...selectedPermissions, { id: permissionId, isRequired: true }]); + } + }; + + const toggleRequired = (permissionId: number) => { + setSelectedPermissions( + selectedPermissions.map(p => + p.id === permissionId ? { ...p, isRequired: !p.isRequired } : p + ) + ); + }; + + // 권한을 카테고리별로 그룹화 + const groupedPermissions = availablePermissions.reduce((acc, perm) => { + const category = perm.resource || "기타"; + if (!acc[category]) acc[category] = []; + acc[category].push(perm); + return acc; + }, {} as Record<string, any[]>); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>{menu.menuTitle} 권한 설정</DialogTitle> + <DialogDescription> + 이 메뉴에 접근하기 위한 필수/선택 권한을 설정합니다. + </DialogDescription> + </DialogHeader> + + <ScrollArea className="h-[400px] pr-4"> + <div className="space-y-6"> + {Object.entries(groupedPermissions).map(([category, perms]) => ( + <div key={category}> + <h4 className="text-sm font-medium mb-3">{category}</h4> + <div className="space-y-2"> + {perms.map((permission) => { + const selected = selectedPermissions.find(p => p.id === permission.id); + return ( + <div + key={permission.id} + className={cn( + "flex items-center justify-between p-3 border rounded-lg", + selected && "bg-primary/5 border-primary" + )} + > + <div className="flex items-start gap-3"> + <Checkbox + checked={!!selected} + onCheckedChange={() => togglePermission(permission.id)} + /> + <div className="flex-1"> + <div className="text-sm font-medium">{permission.name}</div> + <div className="text-xs text-muted-foreground"> + {permission.permissionKey} + </div> + {permission.description && ( + <div className="text-xs text-muted-foreground mt-1"> + {permission.description} + </div> + )} + </div> + </div> + + {selected && ( + <div className="flex items-center gap-2"> + <Button + size="sm" + variant={selected.isRequired ? "default" : "outline"} + onClick={() => toggleRequired(permission.id)} + > + {selected.isRequired ? "필수" : "선택"} + </Button> + </div> + )} + </div> + ); + })} + </div> + </div> + ))} + </div> + </ScrollArea> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleSave} disabled={saving}> + {saving ? "저장 중..." : "저장"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/permissions/permission-assignment-manager.tsx b/components/permissions/permission-assignment-manager.tsx new file mode 100644 index 00000000..3649631f --- /dev/null +++ b/components/permissions/permission-assignment-manager.tsx @@ -0,0 +1,319 @@ +// components/permissions/permission-assignment-manager.tsx (업데이트) + +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Users, + User, + Plus, + X, + Search, + Shield, + Loader2 +} from "lucide-react"; +import { toast } from "sonner"; +import { + getPermissionAssignments, + assignPermissionToRoles, + assignPermissionToUsers, + removePermissionFromRole, + removePermissionFromUser, +} from "@/lib/permissions/permission-assignment-actions"; +import { cn } from "@/lib/utils"; + +interface Permission { + id: number; + permissionKey: string; + name: string; + description?: string; + permissionType: string; + resource: string; + action: string; + scope: string; + menuPath?: string; +} + +interface AssignedRole { + id: number; + name: string; + domain: string; + userCount: number; +} + +interface AssignedUser { + id: number; + name: string; + email: string; + imageUrl?: string; + domain: string; + isGrant: boolean; + reason?: string; +} + +export function PermissionAssignmentManager() { + const [permissions, setPermissions] = useState<Permission[]>([]); + const [selectedPermission, setSelectedPermission] = useState<Permission | null>(null); + const [assignedRoles, setAssignedRoles] = useState<AssignedRole[]>([]); + const [assignedUsers, setAssignedUsers] = useState<AssignedUser[]>([]); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + loadPermissions(); + }, []); + + useEffect(() => { + if (selectedPermission) { + loadAssignments(selectedPermission.id); + } + }, [selectedPermission]); + + const loadPermissions = async () => { + setLoading(true); + try { + const data = await getPermissionAssignments(); + setPermissions(data.permissions); + } catch (error) { + toast.error("권한 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const loadAssignments = async (permissionId: number) => { + try { + const data = await getPermissionAssignments(permissionId); + setAssignedRoles(data.roles); + setAssignedUsers(data.users); + } catch (error) { + toast.error("할당 정보를 불러오는데 실패했습니다."); + } + }; + + const handleRemoveRole = async (roleId: number) => { + if (!selectedPermission) return; + + try { + await removePermissionFromRole(selectedPermission.id, roleId); + toast.success("역할에서 권한이 제거되었습니다."); + loadAssignments(selectedPermission.id); + } catch (error) { + toast.error("권한 제거에 실패했습니다."); + } + }; + + const handleRemoveUser = async (userId: number) => { + if (!selectedPermission) return; + + try { + await removePermissionFromUser(selectedPermission.id, userId); + toast.success("사용자에서 권한이 제거되었습니다."); + loadAssignments(selectedPermission.id); + } catch (error) { + toast.error("권한 제거에 실패했습니다."); + } + }; + + // 권한 필터링 + const filteredPermissions = permissions.filter(p => + p.name.toLowerCase().includes(searchQuery.toLowerCase()) || + p.permissionKey.toLowerCase().includes(searchQuery.toLowerCase()) || + p.resource.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // 리소스별 권한 그룹화 + const groupedPermissions = filteredPermissions.reduce((acc, perm) => { + const group = perm.resource; + if (!acc[group]) acc[group] = []; + acc[group].push(perm); + return acc; + }, {} as Record<string, Permission[]>); + + return ( + <div className="grid grid-cols-2 gap-6"> + {/* 권한 목록 */} + <Card> + <CardHeader> + <CardTitle>권한 목록</CardTitle> + <CardDescription>권한을 선택하여 할당을 관리하세요.</CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {/* 검색 */} + <div className="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> + + {/* 권한 목록 */} + {loading ? ( + <div className="flex justify-center py-8"> + <Loader2 className="h-6 w-6 animate-spin" /> + </div> + ) : ( + <ScrollArea className="h-[500px]"> + <div className="space-y-4"> + {Object.entries(groupedPermissions).map(([resource, perms]) => ( + <div key={resource}> + <h4 className="font-medium mb-2 text-sm text-muted-foreground"> + {resource} + </h4> + <div className="space-y-1"> + {perms.map(permission => ( + <button + key={permission.id} + onClick={() => setSelectedPermission(permission)} + className={cn( + "w-full text-left p-3 rounded-lg border transition-colors", + selectedPermission?.id === permission.id + ? "bg-primary/10 border-primary" + : "hover:bg-muted" + )} + > + <div className="flex items-start justify-between"> + <div> + <div className="font-medium text-sm">{permission.name}</div> + <div className="text-xs text-muted-foreground mt-1"> + <code>{permission.permissionKey}</code> + </div> + </div> + <div className="flex gap-1"> + <Badge variant="outline" className="text-xs"> + {permission.permissionType} + </Badge> + <Badge variant="secondary" className="text-xs"> + {permission.scope} + </Badge> + </div> + </div> + </button> + ))} + </div> + </div> + ))} + </div> + </ScrollArea> + )} + </div> + </CardContent> + </Card> + + {/* 할당 관리 */} + {selectedPermission ? ( + <Card> + <CardHeader> + <CardTitle>{selectedPermission.name}</CardTitle> + <CardDescription> + <div className="flex gap-2 mt-2"> + <Badge>{selectedPermission.permissionKey}</Badge> + <Badge variant="outline">{selectedPermission.permissionType}</Badge> + <Badge variant="secondary">{selectedPermission.scope}</Badge> + </div> + </CardDescription> + </CardHeader> + <CardContent> + <Tabs defaultValue="roles"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="roles"> + <Users className="mr-2 h-4 w-4" /> + 역할 ({assignedRoles.length}) + </TabsTrigger> + <TabsTrigger value="users"> + <User className="mr-2 h-4 w-4" /> + 사용자 ({assignedUsers.length}) + </TabsTrigger> + </TabsList> + + <TabsContent value="roles" className="mt-4"> + <div className="space-y-4"> + <Button size="sm" variant="outline"> + <Plus className="mr-2 h-4 w-4" /> + 역할 추가 + </Button> + <div className="space-y-2"> + {assignedRoles.map((role) => ( + <div key={role.id} className="flex items-center justify-between p-3 border rounded-lg"> + <div> + <div className="font-medium">{role.name}</div> + <div className="text-sm text-muted-foreground"> + {role.domain} • {role.userCount}명 사용자 + </div> + </div> + <Button + size="sm" + variant="ghost" + onClick={() => handleRemoveRole(role.id)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </div> + </TabsContent> + + <TabsContent value="users" className="mt-4"> + <div className="space-y-4"> + <Button size="sm" variant="outline"> + <Plus className="mr-2 h-4 w-4" /> + 사용자 추가 + </Button> + <div className="space-y-2"> + {assignedUsers.map((user) => ( + <div key={user.id} className="flex items-center justify-between p-3 border rounded-lg"> + <div className="flex items-center gap-3"> + <Avatar className="h-8 w-8"> + <AvatarImage src={user.imageUrl} /> + <AvatarFallback>{user.name[0]}</AvatarFallback> + </Avatar> + <div> + <div className="font-medium">{user.name}</div> + <div className="text-sm text-muted-foreground">{user.email}</div> + </div> + </div> + <div className="flex items-center gap-2"> + {user.isGrant ? ( + <Badge variant="success">부여</Badge> + ) : ( + <Badge variant="destructive">제한</Badge> + )} + <Button + size="sm" + variant="ghost" + onClick={() => handleRemoveUser(user.id)} + > + <X className="h-4 w-4" /> + </Button> + </div> + </div> + ))} + </div> + </div> + </TabsContent> + </Tabs> + </CardContent> + </Card> + ) : ( + <Card className="flex items-center justify-center"> + <div className="text-center text-muted-foreground"> + <Shield className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>권한을 선택하면 할당 정보가 표시됩니다.</p> + </div> + </Card> + )} + </div> + ); +}
\ No newline at end of file diff --git a/components/permissions/permission-crud-manager.tsx b/components/permissions/permission-crud-manager.tsx new file mode 100644 index 00000000..01c9959f --- /dev/null +++ b/components/permissions/permission-crud-manager.tsx @@ -0,0 +1,562 @@ +// components/permissions/permission-crud-manager.tsx + +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Plus, + Edit, + Trash2, + MoreVertical, + Search, + Filter, + Key, + Shield, + Copy, + CheckCircle +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + getAllPermissions, + createPermission, + updatePermission, + deletePermission, + getPermissionCategories, +} from "@/lib/permissions/permission-settings-actions"; + +interface Permission { + id: number; + permissionKey: string; + name: string; + description?: string; + permissionType: string; + resource: string; + action: string; + scope: string; + menuPath?: string; + uiElement?: string; + isSystem: boolean; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +export function PermissionCrudManager() { + const [permissions, setPermissions] = useState<Permission[]>([]); + const [filteredPermissions, setFilteredPermissions] = useState<Permission[]>([]); + const [categories, setCategories] = useState<{ resource: string; count: number }[]>([]); + const [selectedCategory, setSelectedCategory] = useState<string>("all"); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [editingPermission, setEditingPermission] = useState<Permission | null>(null); + + useEffect(() => { + loadPermissions(); + loadCategories(); + }, []); + + useEffect(() => { + filterPermissions(); + }, [permissions, selectedCategory, searchQuery]); + + const loadPermissions = async () => { + setLoading(true); + try { + const data = await getAllPermissions(); + setPermissions(data); + } catch (error) { + toast.error("권한 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const loadCategories = async () => { + try { + const data = await getPermissionCategories(); + setCategories(data); + } catch (error) { + console.error("카테고리 로드 실패:", error); + } + }; + + const filterPermissions = () => { + let filtered = permissions; + + if (selectedCategory !== "all") { + filtered = filtered.filter(p => p.resource === selectedCategory); + } + + if (searchQuery) { + filtered = filtered.filter(p => + p.name.toLowerCase().includes(searchQuery.toLowerCase()) || + p.permissionKey.toLowerCase().includes(searchQuery.toLowerCase()) || + p.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + } + + setFilteredPermissions(filtered); + }; + + const handleDelete = async (id: number) => { + if (!confirm("이 권한을 삭제하시겠습니까? 관련된 모든 할당이 제거됩니다.")) { + return; + } + + try { + await deletePermission(id); + toast.success("권한이 삭제되었습니다."); + loadPermissions(); + } catch (error) { + toast.error("권한 삭제에 실패했습니다."); + } + }; + + return ( + <div className="space-y-6"> + {/* 헤더 및 필터 */} + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>권한 목록</CardTitle> + <CardDescription> + 시스템에 등록된 모든 권한을 관리합니다. + </CardDescription> + </div> + <Button onClick={() => setCreateDialogOpen(true)}> + <Plus className="mr-2 h-4 w-4" /> + 권한 추가 + </Button> + </div> + </CardHeader> + <CardContent> + <div className="flex gap-4 mb-4"> + {/* 검색 */} + <div className="flex-1"> + <div className="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> + </div> + + {/* 카테고리 필터 */} + <Select value={selectedCategory} onValueChange={setSelectedCategory}> + <SelectTrigger className="w-[200px]"> + <SelectValue placeholder="카테고리 선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">전체 ({permissions.length})</SelectItem> + {categories.map(cat => ( + <SelectItem key={cat.resource} value={cat.resource}> + {cat.resource} ({cat.count}) + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {/* 권한 테이블 */} + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead>권한명</TableHead> + <TableHead>권한 키</TableHead> + <TableHead>타입</TableHead> + <TableHead>리소스</TableHead> + <TableHead>액션</TableHead> + <TableHead>범위</TableHead> + <TableHead>상태</TableHead> + <TableHead className="text-right">작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {filteredPermissions.map(permission => ( + <TableRow key={permission.id}> + <TableCell> + <div> + <div className="font-medium">{permission.name}</div> + {permission.description && ( + <div className="text-xs text-muted-foreground"> + {permission.description} + </div> + )} + </div> + </TableCell> + <TableCell> + <code className="text-xs bg-muted px-1 py-0.5 rounded"> + {permission.permissionKey} + </code> + </TableCell> + <TableCell> + <Badge variant="outline">{permission.permissionType}</Badge> + </TableCell> + <TableCell>{permission.resource}</TableCell> + <TableCell>{permission.action}</TableCell> + <TableCell> + <Badge variant="secondary">{permission.scope}</Badge> + </TableCell> + <TableCell> + <div className="flex items-center gap-2"> + {permission.isActive ? ( + <Badge variant="success">활성</Badge> + ) : ( + <Badge variant="destructive">비활성</Badge> + )} + {permission.isSystem && ( + <Badge variant="outline">시스템</Badge> + )} + </div> + </TableCell> + <TableCell className="text-right"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <MoreVertical className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={() => { + navigator.clipboard.writeText(permission.permissionKey); + toast.success("권한 키가 복사되었습니다."); + }} + > + <Copy className="mr-2 h-4 w-4" /> + 키 복사 + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => setEditingPermission(permission)} + > + <Edit className="mr-2 h-4 w-4" /> + 수정 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => handleDelete(permission.id)} + className="text-destructive" + disabled={permission.isSystem} + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </CardContent> + </Card> + + {/* 권한 생성/수정 다이얼로그 */} + <PermissionFormDialog + open={createDialogOpen || !!editingPermission} + onOpenChange={(open) => { + if (!open) { + setCreateDialogOpen(false); + setEditingPermission(null); + } + }} + permission={editingPermission} + onSuccess={() => { + setCreateDialogOpen(false); + setEditingPermission(null); + loadPermissions(); + }} + /> + </div> + ); +} + +// 권한 생성/수정 폼 다이얼로그 +function PermissionFormDialog({ + open, + onOpenChange, + permission, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + permission?: Permission | null; + onSuccess: () => void; +}) { + const [formData, setFormData] = useState({ + permissionKey: "", + name: "", + description: "", + permissionType: "action", + resource: "", + action: "", + scope: "own", + menuPath: "", + uiElement: "", + isActive: true, + }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (permission) { + setFormData({ + permissionKey: permission.permissionKey, + name: permission.name, + description: permission.description || "", + permissionType: permission.permissionType, + resource: permission.resource, + action: permission.action, + scope: permission.scope, + menuPath: permission.menuPath || "", + uiElement: permission.uiElement || "", + isActive: permission.isActive, + }); + } else { + setFormData({ + permissionKey: "", + name: "", + description: "", + permissionType: "action", + resource: "", + action: "", + scope: "own", + menuPath: "", + uiElement: "", + isActive: true, + }); + } + }, [permission]); + + const handleSubmit = async () => { + if (!formData.permissionKey || !formData.name || !formData.resource || !formData.action) { + toast.error("필수 항목을 입력해주세요."); + return; + } + + setSaving(true); + try { + if (permission) { + await updatePermission(permission.id, formData); + toast.success("권한이 수정되었습니다."); + } else { + await createPermission(formData); + toast.success("권한이 생성되었습니다."); + } + onSuccess(); + } catch (error: any) { + toast.error(error.message || "권한 저장에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + // 권한 키 자동 생성 + const generatePermissionKey = () => { + if (formData.resource && formData.action) { + const key = `${formData.resource}.${formData.action}`.toLowerCase().replace(/\s+/g, '_'); + setFormData({ ...formData, permissionKey: key }); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>{permission ? "권한 수정" : "권한 생성"}</DialogTitle> + <DialogDescription> + 새로운 권한을 생성하거나 기존 권한을 수정합니다. + </DialogDescription> + </DialogHeader> + + <div className="grid gap-4 py-4"> + <div className="grid grid-cols-2 gap-4"> + <div> + <Label>권한 키*</Label> + <div className="flex gap-2"> + <Input + value={formData.permissionKey} + onChange={(e) => setFormData({ ...formData, permissionKey: e.target.value })} + placeholder="예: rfq.vendor.create" + /> + {!permission && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={generatePermissionKey} + > + 자동 + </Button> + )} + </div> + </div> + <div> + <Label>권한명*</Label> + <Input + value={formData.name} + onChange={(e) => setFormData({ ...formData, name: e.target.value })} + placeholder="예: RFQ 벤더 추가" + /> + </div> + </div> + + <div> + <Label>설명</Label> + <Textarea + value={formData.description} + onChange={(e) => setFormData({ ...formData, description: e.target.value })} + placeholder="권한에 대한 상세 설명" + /> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div> + <Label>권한 타입*</Label> + <Select + value={formData.permissionType} + onValueChange={(v) => setFormData({ ...formData, permissionType: v })} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="menu_access">메뉴 접근</SelectItem> + <SelectItem value="action">액션 실행</SelectItem> + <SelectItem value="data_read">데이터 읽기</SelectItem> + <SelectItem value="data_write">데이터 쓰기</SelectItem> + <SelectItem value="data_delete">데이터 삭제</SelectItem> + <SelectItem value="approve">승인</SelectItem> + <SelectItem value="export">내보내기</SelectItem> + <SelectItem value="import">가져오기</SelectItem> + </SelectContent> + </Select> + </div> + <div> + <Label>범위*</Label> + <Select + value={formData.scope} + onValueChange={(v) => setFormData({ ...formData, scope: v })} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">전체</SelectItem> + <SelectItem value="domain">도메인</SelectItem> + <SelectItem value="assigned">담당</SelectItem> + <SelectItem value="own">본인</SelectItem> + <SelectItem value="department">부서</SelectItem> + <SelectItem value="company">회사</SelectItem> + </SelectContent> + </Select> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div> + <Label>리소스*</Label> + <Input + value={formData.resource} + onChange={(e) => setFormData({ ...formData, resource: e.target.value })} + placeholder="예: rfq_vendor" + /> + </div> + <div> + <Label>액션*</Label> + <Input + value={formData.action} + onChange={(e) => setFormData({ ...formData, action: e.target.value })} + placeholder="예: create" + /> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div> + <Label>메뉴 경로</Label> + <Input + value={formData.menuPath} + onChange={(e) => setFormData({ ...formData, menuPath: e.target.value })} + placeholder="예: /evcp/rfq-last" + /> + </div> + <div> + <Label>UI 요소</Label> + <Input + value={formData.uiElement} + onChange={(e) => setFormData({ ...formData, uiElement: e.target.value })} + placeholder="예: btn-add-vendor" + /> + </div> + </div> + + <div className="flex items-center gap-2"> + <input + type="checkbox" + id="isActive" + checked={formData.isActive} + onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })} + /> + <Label htmlFor="isActive">활성 상태</Label> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleSubmit} disabled={saving}> + {saving ? "저장 중..." : permission ? "수정" : "생성"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/permissions/permission-group-manager.tsx b/components/permissions/permission-group-manager.tsx new file mode 100644 index 00000000..11aac6cf --- /dev/null +++ b/components/permissions/permission-group-manager.tsx @@ -0,0 +1,799 @@ +// components/permissions/permission-group-manager.tsx + +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Shield, + Plus, + Edit, + Trash2, + Copy, + Users, + Key, + MoreVertical, + Package, + ChevronRight, + Loader2, + Search +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + getPermissionGroups, + createPermissionGroup, + updatePermissionGroup, + deletePermissionGroup, + getGroupPermissions, + updateGroupPermissions, + clonePermissionGroup, + getGroupAssignments, +} from "@/lib/permissions/permission-group-actions"; + +interface PermissionGroup { + id: number; + groupKey: string; + name: string; + description?: string; + domain?: string; + isActive: boolean; + permissionCount: number; + roleCount: number; + userCount: number; + createdAt: Date; + updatedAt: Date; +} + +interface Permission { + id: number; + permissionKey: string; + name: string; + description?: string; + resource: string; + action: string; + permissionType: string; + scope: string; +} + +export function PermissionGroupManager() { + const [groups, setGroups] = useState<PermissionGroup[]>([]); + const [selectedGroup, setSelectedGroup] = useState<PermissionGroup | null>(null); + const [groupPermissions, setGroupPermissions] = useState<Permission[]>([]); + const [availablePermissions, setAvailablePermissions] = useState<Permission[]>([]); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [editingGroup, setEditingGroup] = useState<PermissionGroup | null>(null); + const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); + + useEffect(() => { + loadGroups(); + }, []); + + useEffect(() => { + if (selectedGroup) { + loadGroupPermissions(selectedGroup.id); + } + }, [selectedGroup]); + + const loadGroups = async () => { + setLoading(true); + try { + const data = await getPermissionGroups(); + setGroups(data); + } catch (error) { + toast.error("권한 그룹을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const loadGroupPermissions = async (groupId: number) => { + try { + const data = await getGroupPermissions(groupId); + setGroupPermissions(data.permissions); + setAvailablePermissions(data.availablePermissions); + } catch (error) { + toast.error("권한 정보를 불러오는데 실패했습니다."); + } + }; + + const handleDelete = async (id: number) => { + if (!confirm("이 권한 그룹을 삭제하시겠습니까? 관련된 모든 할당이 제거됩니다.")) { + return; + } + + try { + await deletePermissionGroup(id); + toast.success("권한 그룹이 삭제되었습니다."); + if (selectedGroup?.id === id) { + setSelectedGroup(null); + } + loadGroups(); + } catch (error) { + toast.error("권한 그룹 삭제에 실패했습니다."); + } + }; + + const handleClone = async (group: PermissionGroup) => { + try { + const cloned = await clonePermissionGroup(group.id); + toast.success(`"${cloned.name}" 그룹이 생성되었습니다.`); + loadGroups(); + } catch (error) { + toast.error("권한 그룹 복제에 실패했습니다."); + } + }; + + // 검색 필터링 + const filteredGroups = groups.filter(group => + group.name.toLowerCase().includes(searchQuery.toLowerCase()) || + group.groupKey.toLowerCase().includes(searchQuery.toLowerCase()) || + group.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( + <div className="space-y-6"> + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>권한 그룹</CardTitle> + <CardDescription> + 관련된 권한들을 그룹으로 묶어 효율적으로 관리합니다. + </CardDescription> + </div> + <Button onClick={() => setCreateDialogOpen(true)}> + <Plus className="mr-2 h-4 w-4" /> + 그룹 생성 + </Button> + </div> + </CardHeader> + <CardContent> + {/* 검색 */} + <div className="mb-4"> + <div className="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> + </div> + + {/* 그룹 목록 */} + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + {filteredGroups.map(group => ( + <GroupCard + key={group.id} + group={group} + isSelected={selectedGroup?.id === group.id} + onSelect={() => setSelectedGroup(group)} + onEdit={() => setEditingGroup(group)} + onClone={() => handleClone(group)} + onDelete={() => handleDelete(group.id)} + onManagePermissions={() => { + setSelectedGroup(group); + setPermissionDialogOpen(true); + }} + /> + ))} + </div> + </CardContent> + </Card> + + {/* 선택된 그룹 상세 */} + {selectedGroup && ( + <GroupDetailCard + group={selectedGroup} + permissions={groupPermissions} + onEditPermissions={() => setPermissionDialogOpen(true)} + /> + )} + + {/* 그룹 생성/수정 다이얼로그 */} + <GroupFormDialog + open={createDialogOpen || !!editingGroup} + onOpenChange={(open) => { + if (!open) { + setCreateDialogOpen(false); + setEditingGroup(null); + } + }} + group={editingGroup} + onSuccess={() => { + setCreateDialogOpen(false); + setEditingGroup(null); + loadGroups(); + }} + /> + + {/* 권한 관리 다이얼로그 */} + {selectedGroup && ( + <GroupPermissionsDialog + open={permissionDialogOpen} + onOpenChange={setPermissionDialogOpen} + group={selectedGroup} + groupPermissions={groupPermissions} + availablePermissions={availablePermissions} + onSuccess={() => { + loadGroupPermissions(selectedGroup.id); + loadGroups(); + }} + /> + )} + </div> + ); +} + +// 그룹 카드 컴포넌트 +function GroupCard({ + group, + isSelected, + onSelect, + onEdit, + onClone, + onDelete, + onManagePermissions, +}: { + group: PermissionGroup; + isSelected: boolean; + onSelect: () => void; + onEdit: () => void; + onClone: () => void; + onDelete: () => void; + onManagePermissions: () => void; +}) { + return ( + <Card + className={cn( + "cursor-pointer transition-colors", + isSelected && "ring-2 ring-primary" + )} + onClick={onSelect} + > + <CardHeader className="pb-3"> + <div className="flex items-start justify-between"> + <div> + <CardTitle className="text-lg">{group.name}</CardTitle> + <div className="flex items-center gap-2 mt-1"> + <code className="text-xs bg-muted px-1 py-0.5 rounded"> + {group.groupKey} + </code> + {group.domain && ( + <Badge variant="outline" className="text-xs"> + {group.domain} + </Badge> + )} + </div> + </div> + <DropdownMenu> + <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> + <Button variant="ghost" className="h-8 w-8 p-0"> + <MoreVertical className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={(e) => { + e.stopPropagation(); + onManagePermissions(); + }}> + <Key className="mr-2 h-4 w-4" /> + 권한 관리 + </DropdownMenuItem> + <DropdownMenuItem onClick={(e) => { + e.stopPropagation(); + onEdit(); + }}> + <Edit className="mr-2 h-4 w-4" /> + 수정 + </DropdownMenuItem> + <DropdownMenuItem onClick={(e) => { + e.stopPropagation(); + onClone(); + }}> + <Copy className="mr-2 h-4 w-4" /> + 복제 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={(e) => { + e.stopPropagation(); + onDelete(); + }} + className="text-destructive" + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + </CardHeader> + <CardContent> + {group.description && ( + <p className="text-sm text-muted-foreground mb-3"> + {group.description} + </p> + )} + <div className="flex items-center gap-4 text-sm"> + <div className="flex items-center gap-1"> + <Key className="h-3 w-3" /> + <span>{group.permissionCount}개 권한</span> + </div> + <div className="flex items-center gap-1"> + <Users className="h-3 w-3" /> + <span>{group.roleCount}개 역할</span> + </div> + <div className="flex items-center gap-1"> + <Shield className="h-3 w-3" /> + <span>{group.userCount}명</span> + </div> + </div> + </CardContent> + </Card> + ); +} + +// 그룹 상세 카드 +function GroupDetailCard({ + group, + permissions, + onEditPermissions, +}: { + group: PermissionGroup; + permissions: Permission[]; + onEditPermissions: () => void; +}) { + // 리소스별로 권한 그룹화 + const groupedPermissions = permissions.reduce((acc, perm) => { + const resource = perm.resource; + if (!acc[resource]) acc[resource] = []; + acc[resource].push(perm); + return acc; + }, {} as Record<string, Permission[]>); + + return ( + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>{group.name} 권한 목록</CardTitle> + <CardDescription>이 그룹에 포함된 모든 권한입니다.</CardDescription> + </div> + <Button onClick={onEditPermissions}> + <Edit className="mr-2 h-4 w-4" /> + 권한 편집 + </Button> + </div> + </CardHeader> + <CardContent> + <Accordion type="multiple" className="w-full"> + {Object.entries(groupedPermissions).map(([resource, perms]) => ( + <AccordionItem key={resource} value={resource}> + <AccordionTrigger> + <div className="flex items-center justify-between flex-1 mr-2"> + <span className="font-medium">{resource}</span> + <Badge variant="secondary">{perms.length}개</Badge> + </div> + </AccordionTrigger> + <AccordionContent> + <div className="space-y-2"> + {perms.map(permission => ( + <div key={permission.id} className="flex items-start gap-3 p-2"> + <Badge variant="outline" className="mt-0.5"> + {permission.action} + </Badge> + <div className="flex-1"> + <div className="text-sm font-medium">{permission.name}</div> + <div className="text-xs text-muted-foreground"> + {permission.permissionKey} + </div> + {permission.description && ( + <div className="text-xs text-muted-foreground mt-1"> + {permission.description} + </div> + )} + </div> + <Badge variant="secondary" className="text-xs"> + {permission.scope} + </Badge> + </div> + ))} + </div> + </AccordionContent> + </AccordionItem> + ))} + </Accordion> + </CardContent> + </Card> + ); +} + +// 그룹 생성/수정 폼 다이얼로그 +function GroupFormDialog({ + open, + onOpenChange, + group, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + group?: PermissionGroup | null; + onSuccess: () => void; +}) { + const [formData, setFormData] = useState({ + groupKey: "", + name: "", + description: "", + domain: "", + isActive: true, + }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (group) { + setFormData({ + groupKey: group.groupKey, + name: group.name, + description: group.description || "", + domain: group.domain || "", + isActive: group.isActive, + }); + } else { + setFormData({ + groupKey: "", + name: "", + description: "", + domain: "", + isActive: true, + }); + } + }, [group]); + + const handleSubmit = async () => { + if (!formData.groupKey || !formData.name) { + toast.error("필수 항목을 입력해주세요."); + return; + } + + setSaving(true); + try { + if (group) { + await updatePermissionGroup(group.id, formData); + toast.success("권한 그룹이 수정되었습니다."); + } else { + await createPermissionGroup(formData); + toast.success("권한 그룹이 생성되었습니다."); + } + onSuccess(); + } catch (error: any) { + toast.error(error.message || "권한 그룹 저장에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>{group ? "권한 그룹 수정" : "권한 그룹 생성"}</DialogTitle> + <DialogDescription> + 권한 그룹 정보를 입력하세요. + </DialogDescription> + </DialogHeader> + + <div className="grid gap-4 py-4"> + <div> + <Label>그룹 키*</Label> + <Input + value={formData.groupKey} + onChange={(e) => setFormData({ ...formData, groupKey: e.target.value })} + placeholder="예: rfq_manager" + /> + </div> + + <div> + <Label>그룹명*</Label> + <Input + value={formData.name} + onChange={(e) => setFormData({ ...formData, name: e.target.value })} + placeholder="예: RFQ 관리자 권한" + /> + </div> + + <div> + <Label>설명</Label> + <Textarea + value={formData.description} + onChange={(e) => setFormData({ ...formData, description: e.target.value })} + placeholder="그룹에 대한 설명" + /> + </div> + + <div> + <Label>도메인</Label> + <Select + value={formData.domain} + onValueChange={(v) => setFormData({ ...formData, domain: v })} + > + <SelectTrigger> + <SelectValue placeholder="도메인 선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="">전체</SelectItem> + <SelectItem value="evcp">EVCP</SelectItem> + <SelectItem value="partners">Partners</SelectItem> + <SelectItem value="procurement">Procurement</SelectItem> + <SelectItem value="sales">Sales</SelectItem> + <SelectItem value="engineering">Engineering</SelectItem> + </SelectContent> + </Select> + </div> + + <div className="flex items-center gap-2"> + <Checkbox + id="isActive" + checked={formData.isActive} + onCheckedChange={(v) => setFormData({ ...formData, isActive: !!v })} + /> + <Label htmlFor="isActive">활성 상태</Label> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleSubmit} disabled={saving}> + {saving ? "저장 중..." : group ? "수정" : "생성"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +// 그룹 권한 관리 다이얼로그 +function GroupPermissionsDialog({ + open, + onOpenChange, + group, + groupPermissions, + availablePermissions, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + group: PermissionGroup; + groupPermissions: Permission[]; + availablePermissions: Permission[]; + onSuccess: () => void; +}) { + const [selectedPermissions, setSelectedPermissions] = useState<Set<number>>( + new Set(groupPermissions.map(p => p.id)) + ); + const [saving, setSaving] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + + // 검색 필터링 + const filteredPermissions = availablePermissions.filter(p => + p.name.toLowerCase().includes(searchQuery.toLowerCase()) || + p.permissionKey.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // 리소스별로 권한 그룹화 + const groupedPermissions = filteredPermissions.reduce((acc, perm) => { + const resource = perm.resource; + if (!acc[resource]) acc[resource] = []; + acc[resource].push(perm); + return acc; + }, {} as Record<string, Permission[]>); + + const handleSave = async () => { + setSaving(true); + try { + await updateGroupPermissions(group.id, Array.from(selectedPermissions)); + toast.success("권한이 업데이트되었습니다."); + onSuccess(); + onOpenChange(false); + } catch (error) { + toast.error("권한 업데이트에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + const togglePermission = (permissionId: number) => { + const newSet = new Set(selectedPermissions); + if (newSet.has(permissionId)) { + newSet.delete(permissionId); + } else { + newSet.add(permissionId); + } + setSelectedPermissions(newSet); + }; + + const toggleResource = (resource: string) => { + const resourcePerms = groupedPermissions[resource] || []; + const allSelected = resourcePerms.every(p => selectedPermissions.has(p.id)); + + const newSet = new Set(selectedPermissions); + resourcePerms.forEach(p => { + if (allSelected) { + newSet.delete(p.id); + } else { + newSet.add(p.id); + } + }); + setSelectedPermissions(newSet); + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>{group.name} 권한 설정</DialogTitle> + <DialogDescription> + 이 그룹에 포함할 권한을 선택하세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 검색 */} + <div className="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> + + {/* 선택 정보 */} + <div className="flex items-center justify-between p-2 bg-muted rounded"> + <span className="text-sm"> + {selectedPermissions.size}개 권한 선택됨 + </span> + <div className="flex gap-2"> + <Button + size="sm" + variant="outline" + onClick={() => setSelectedPermissions(new Set())} + > + 전체 해제 + </Button> + <Button + size="sm" + variant="outline" + onClick={() => setSelectedPermissions(new Set(availablePermissions.map(p => p.id)))} + > + 전체 선택 + </Button> + </div> + </div> + + {/* 권한 목록 */} + <ScrollArea className="h-[400px] pr-4"> + <Accordion type="multiple" className="w-full"> + {Object.entries(groupedPermissions).map(([resource, perms]) => { + const allSelected = perms.every(p => selectedPermissions.has(p.id)); + const someSelected = perms.some(p => selectedPermissions.has(p.id)); + + return ( + <AccordionItem key={resource} value={resource}> + <AccordionTrigger> + <div className="flex items-center gap-2 flex-1"> + <Checkbox + checked={allSelected} + indeterminate={!allSelected && someSelected} + onCheckedChange={() => toggleResource(resource)} + onClick={(e) => e.stopPropagation()} + /> + <span className="font-medium">{resource}</span> + <Badge variant="secondary" className="ml-auto mr-2"> + {perms.filter(p => selectedPermissions.has(p.id)).length}/{perms.length} + </Badge> + </div> + </AccordionTrigger> + <AccordionContent> + <div className="space-y-2 pl-6"> + {perms.map(permission => ( + <label + key={permission.id} + className="flex items-start gap-3 cursor-pointer p-2 hover:bg-muted rounded" + > + <Checkbox + checked={selectedPermissions.has(permission.id)} + onCheckedChange={() => togglePermission(permission.id)} + /> + <div className="flex-1"> + <div className="text-sm font-medium">{permission.name}</div> + <div className="text-xs text-muted-foreground"> + {permission.permissionKey} + </div> + {permission.description && ( + <div className="text-xs text-muted-foreground mt-1"> + {permission.description} + </div> + )} + </div> + <div className="flex gap-1"> + <Badge variant="outline" className="text-xs"> + {permission.permissionType} + </Badge> + <Badge variant="secondary" className="text-xs"> + {permission.scope} + </Badge> + </div> + </label> + ))} + </div> + </AccordionContent> + </AccordionItem> + ); + })} + </Accordion> + </ScrollArea> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleSave} disabled={saving}> + {saving ? "저장 중..." : "저장"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/permissions/role-permission-manager.tsx b/components/permissions/role-permission-manager.tsx new file mode 100644 index 00000000..b229ec57 --- /dev/null +++ b/components/permissions/role-permission-manager.tsx @@ -0,0 +1,178 @@ +// components/permissions/role-permission-manager.tsx + +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "sonner"; +import { assignPermissionsToRole, getRolePermissions } from "@/lib/permissions/service"; + +export function RolePermissionManager() { + const [selectedRole, setSelectedRole] = useState<string>(""); + const [permissions, setPermissions] = useState<any[]>([]); + const [selectedPermissions, setSelectedPermissions] = useState<Set<number>>(new Set()); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (selectedRole) { + loadRolePermissions(selectedRole); + } + }, [selectedRole]); + + const loadRolePermissions = async (roleId: string) => { + try { + const data = await getRolePermissions(parseInt(roleId)); + setPermissions(data.permissions); + setSelectedPermissions(new Set(data.assignedPermissionIds)); + } catch (error) { + toast.error("권한 목록을 불러오는데 실패했습니다."); + } + }; + + const handleSave = async () => { + if (!selectedRole) { + toast.error("역할을 선택해주세요."); + return; + } + + try { + setLoading(true); + await assignPermissionsToRole( + parseInt(selectedRole), + Array.from(selectedPermissions) + ); + toast.success("권한이 성공적으로 저장되었습니다."); + } catch (error) { + toast.error("권한 저장에 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const togglePermission = (permissionId: number) => { + const newSet = new Set(selectedPermissions); + if (newSet.has(permissionId)) { + newSet.delete(permissionId); + } else { + newSet.add(permissionId); + } + setSelectedPermissions(newSet); + }; + + // 권한 그룹별로 정리 + const groupedPermissions = permissions.reduce((acc, perm) => { + const group = perm.menuPath || "기타"; + if (!acc[group]) acc[group] = []; + acc[group].push(perm); + return acc; + }, {} as Record<string, any[]>); + + return ( + <Card> + <CardHeader> + <CardTitle>역할별 권한 설정</CardTitle> + <CardDescription> + 역할을 선택하고 해당 역할에 부여할 권한을 선택하세요. + </CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-6"> + {/* 역할 선택 */} + <div className="flex items-center gap-4"> + <Select value={selectedRole} onValueChange={setSelectedRole}> + <SelectTrigger className="w-[300px]"> + <SelectValue placeholder="역할 선택..." /> + </SelectTrigger> + <SelectContent> + <SelectItem value="1">EVCP Admin</SelectItem> + <SelectItem value="2">EVCP Manager</SelectItem> + <SelectItem value="3">EVCP User</SelectItem> + <SelectItem value="4">Partner Admin</SelectItem> + <SelectItem value="5">Partner User</SelectItem> + </SelectContent> + </Select> + + <Button + onClick={handleSave} + disabled={!selectedRole || loading} + > + 권한 저장 + </Button> + </div> + + {/* 권한 목록 */} + {selectedRole && ( + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px]">선택</TableHead> + <TableHead>메뉴/그룹</TableHead> + <TableHead>권한명</TableHead> + <TableHead>타입</TableHead> + <TableHead>범위</TableHead> + <TableHead>설명</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {Object.entries(groupedPermissions).map(([group, perms]) => ( + <> + <TableRow key={group} className="bg-muted/50"> + <TableCell colSpan={6} className="font-medium"> + {group} + </TableCell> + </TableRow> + {perms.map((permission) => ( + <TableRow key={permission.id}> + <TableCell> + <Checkbox + checked={selectedPermissions.has(permission.id)} + onCheckedChange={() => togglePermission(permission.id)} + /> + </TableCell> + <TableCell> + <Badge variant="outline">{permission.resource}</Badge> + </TableCell> + <TableCell className="font-medium"> + {permission.name} + </TableCell> + <TableCell> + <Badge>{permission.permissionType}</Badge> + </TableCell> + <TableCell> + <Badge variant="secondary">{permission.scope}</Badge> + </TableCell> + <TableCell className="text-sm text-muted-foreground"> + {permission.description} + </TableCell> + </TableRow> + ))} + </> + ))} + </TableBody> + </Table> + </div> + )} + </div> + </CardContent> + </Card> + ); +}
\ No newline at end of file diff --git a/components/permissions/user-permission-manager.tsx b/components/permissions/user-permission-manager.tsx new file mode 100644 index 00000000..9c23b122 --- /dev/null +++ b/components/permissions/user-permission-manager.tsx @@ -0,0 +1,573 @@ +// components/permissions/user-permission-manager.tsx + +"use client"; + +import { useState, useEffect, useTransition } 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 { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Calendar } from "@/components/ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Search, + UserPlus, + Shield, + Clock, + AlertTriangle, + CheckCircle, + XCircle, + CalendarIcon, + Plus, + Minus, + Settings, + Key, + Users, + Building, + ChevronRight +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + getUserPermissionDetails, + grantPermissionToUser, + revokePermissionFromUser, + searchUsers, + getUserRoles, +} from "@/lib/permissions/service"; + +interface User { + id: number; + name: string; + email: string; + imageUrl?: string; + domain: string; + companyName?: string; + roles: { id: number; name: string }[]; +} + +interface Permission { + id: number; + permissionKey: string; + name: string; + description?: string; + permissionType: string; + resource: string; + action: string; + scope: string; + menuPath?: string; +} + +interface UserPermission extends Permission { + source: "role" | "direct"; + roleName?: string; + grantedBy?: string; + grantedAt?: Date; + expiresAt?: Date; + reason?: string; + isGrant: boolean; +} + +export function UserPermissionManager() { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedUser, setSelectedUser] = useState<User | null>(null); + const [users, setUsers] = useState<User[]>([]); + const [userPermissions, setUserPermissions] = useState<UserPermission[]>([]); + const [availablePermissions, setAvailablePermissions] = useState<Permission[]>([]); + const [loading, setLoading] = useState(false); + const [isPending, startTransition] = useTransition(); + const [addPermissionDialogOpen, setAddPermissionDialogOpen] = useState(false); + + // 사용자 검색 + useEffect(() => { + const timer = setTimeout(() => { + if (searchQuery) { + searchUsersData(searchQuery); + } + }, 300); + return () => clearTimeout(timer); + }, [searchQuery]); + + // 선택된 사용자의 권한 로드 + useEffect(() => { + if (selectedUser) { + loadUserPermissions(selectedUser.id); + } + }, [selectedUser]); + + const searchUsersData = async (query: string) => { + try { + const data = await searchUsers(query); + setUsers(data); + } catch (error) { + toast.error("사용자 검색에 실패했습니다."); + } + }; + + const loadUserPermissions = async (userId: number) => { + setLoading(true); + try { + const data = await getUserPermissionDetails(userId); + setUserPermissions(data.permissions); + setAvailablePermissions(data.availablePermissions); + } catch (error) { + toast.error("권한 정보를 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + // 역할 기반 권한과 직접 부여 권한 분리 + const rolePermissions = userPermissions.filter(p => p.source === "role"); + const directPermissions = userPermissions.filter(p => p.source === "direct"); + + return ( + <div className="grid grid-cols-3 gap-6"> + {/* 사용자 검색 및 선택 */} + <Card className="col-span-1"> + <CardHeader> + <CardTitle>사용자 선택</CardTitle> + <CardDescription>권한을 관리할 사용자를 검색하세요.</CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {/* 검색 입력 */} + <div className="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> + + {/* 사용자 목록 */} + <ScrollArea className="h-[500px]"> + <div className="space-y-2"> + {users.map((user) => ( + <button + key={user.id} + onClick={() => setSelectedUser(user)} + className={cn( + "w-full p-3 rounded-lg border text-left transition-colors", + selectedUser?.id === user.id + ? "bg-primary/10 border-primary" + : "hover:bg-muted" + )} + > + <div className="flex items-center gap-3"> + <Avatar> + <AvatarImage src={user.imageUrl} /> + <AvatarFallback>{user.name[0]}</AvatarFallback> + </Avatar> + <div className="flex-1 min-w-0"> + <div className="font-medium truncate">{user.name}</div> + <div className="text-sm text-muted-foreground truncate"> + {user.email} + </div> + <div className="flex items-center gap-2 mt-1"> + <Badge variant="outline" className="text-xs"> + {user.domain} + </Badge> + {user.companyName && ( + <span className="text-xs text-muted-foreground"> + {user.companyName} + </span> + )} + </div> + </div> + </div> + </button> + ))} + </div> + </ScrollArea> + </div> + </CardContent> + </Card> + + {/* 권한 상세 */} + {selectedUser ? ( + <Card className="col-span-2"> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>{selectedUser.name}의 권한</CardTitle> + <CardDescription> + {selectedUser.email} • {selectedUser.domain} + </CardDescription> + </div> + <AddPermissionDialog + userId={selectedUser.id} + availablePermissions={availablePermissions.filter( + p => !userPermissions.some(up => up.id === p.id) + )} + onSuccess={() => loadUserPermissions(selectedUser.id)} + /> + </div> + </CardHeader> + <CardContent> + <Tabs defaultValue="all" className="w-full"> + <TabsList> + <TabsTrigger value="all"> + 전체 권한 ({userPermissions.length}) + </TabsTrigger> + <TabsTrigger value="role"> + <Users className="mr-2 h-4 w-4" /> + 역할 기반 ({rolePermissions.length}) + </TabsTrigger> + <TabsTrigger value="direct"> + <Shield className="mr-2 h-4 w-4" /> + 직접 부여 ({directPermissions.length}) + </TabsTrigger> + </TabsList> + + <TabsContent value="all" className="mt-4"> + <PermissionList + permissions={userPermissions} + userId={selectedUser.id} + onRevoke={() => loadUserPermissions(selectedUser.id)} + /> + </TabsContent> + + <TabsContent value="role" className="mt-4"> + <div className="space-y-4"> + {/* 역할 표시 */} + <div className="p-4 bg-muted/50 rounded-lg"> + <h4 className="text-sm font-medium mb-2">보유 역할</h4> + <div className="flex flex-wrap gap-2"> + {selectedUser.roles.map(role => ( + <Badge key={role.id} variant="secondary"> + {role.name} + </Badge> + ))} + </div> + </div> + <PermissionList + permissions={rolePermissions} + userId={selectedUser.id} + readOnly + /> + </div> + </TabsContent> + + <TabsContent value="direct" className="mt-4"> + <PermissionList + permissions={directPermissions} + userId={selectedUser.id} + onRevoke={() => loadUserPermissions(selectedUser.id)} + /> + </TabsContent> + </Tabs> + </CardContent> + </Card> + ) : ( + <Card className="col-span-2 flex items-center justify-center h-[600px]"> + <div className="text-center text-muted-foreground"> + <Shield className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>사용자를 선택하면 권한 정보가 표시됩니다.</p> + </div> + </Card> + )} + </div> + ); +} + +// 권한 목록 컴포넌트 +function PermissionList({ + permissions, + userId, + readOnly = false, + onRevoke +}: { + permissions: UserPermission[]; + userId: number; + readOnly?: boolean; + onRevoke?: () => void; +}) { + const [revoking, setRevoking] = useState<number | null>(null); + + const handleRevoke = async (permissionId: number) => { + if (!confirm("이 권한을 제거하시겠습니까?")) return; + + setRevoking(permissionId); + try { + await revokePermissionFromUser(userId, permissionId); + toast.success("권한이 제거되었습니다."); + onRevoke?.(); + } catch (error) { + toast.error("권한 제거에 실패했습니다."); + } finally { + setRevoking(null); + } + }; + + // 권한을 리소스별로 그룹화 + const groupedPermissions = permissions.reduce((acc, perm) => { + const group = perm.resource; + if (!acc[group]) acc[group] = []; + acc[group].push(perm); + return acc; + }, {} as Record<string, UserPermission[]>); + + return ( + <div className="space-y-4"> + {Object.entries(groupedPermissions).map(([resource, perms]) => ( + <div key={resource} className="border rounded-lg"> + <div className="px-4 py-2 bg-muted/30 font-medium text-sm"> + {resource} + </div> + <div className="divide-y"> + {perms.map((permission) => ( + <div key={permission.id} className="p-4"> + <div className="flex items-start justify-between"> + <div className="space-y-1"> + <div className="flex items-center gap-2"> + <span className="font-medium">{permission.name}</span> + {permission.source === "role" && ( + <Badge variant="outline" className="text-xs"> + 역할: {permission.roleName} + </Badge> + )} + {permission.isGrant === false && ( + <Badge variant="destructive" className="text-xs"> + 제한 + </Badge> + )} + </div> + <div className="text-sm text-muted-foreground"> + {permission.description} + </div> + <div className="flex items-center gap-4 text-xs text-muted-foreground"> + <span>타입: {permission.permissionType}</span> + <span>범위: {permission.scope}</span> + {permission.expiresAt && ( + <span className="text-orange-600"> + <Clock className="inline h-3 w-3 mr-1" /> + {format(new Date(permission.expiresAt), "yyyy-MM-dd")} 만료 + </span> + )} + </div> + {permission.reason && ( + <div className="text-xs text-muted-foreground mt-2"> + 사유: {permission.reason} + </div> + )} + </div> + + {!readOnly && permission.source === "direct" && ( + <Button + variant="ghost" + size="sm" + onClick={() => handleRevoke(permission.id)} + disabled={revoking === permission.id} + > + <XCircle className="h-4 w-4" /> + </Button> + )} + </div> + </div> + ))} + </div> + </div> + ))} + </div> + ); +} + +// 권한 추가 다이얼로그 +function AddPermissionDialog({ + userId, + availablePermissions, + onSuccess +}: { + userId: number; + availablePermissions: Permission[]; + onSuccess: () => void; +}) { + const [open, setOpen] = useState(false); + const [selectedPermissions, setSelectedPermissions] = useState<number[]>([]); + const [reason, setReason] = useState(""); + const [expiresAt, setExpiresAt] = useState<Date | undefined>(); + const [isGrant, setIsGrant] = useState(true); + const [saving, setSaving] = useState(false); + + const handleSubmit = async () => { + if (selectedPermissions.length === 0) { + toast.error("권한을 선택해주세요."); + return; + } + + setSaving(true); + try { + await grantPermissionToUser({ + userId, + permissionIds: selectedPermissions, + isGrant, + reason, + expiresAt, + }); + toast.success("권한이 추가되었습니다."); + setOpen(false); + onSuccess(); + // Reset form + setSelectedPermissions([]); + setReason(""); + setExpiresAt(undefined); + setIsGrant(true); + } catch (error) { + toast.error("권한 추가에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button> + <Plus className="mr-2 h-4 w-4" /> + 권한 추가 + </Button> + </DialogTrigger> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>권한 추가</DialogTitle> + <DialogDescription> + 사용자에게 직접 권한을 부여하거나 제한합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + {/* 권한 타입 선택 */} + <div className="flex items-center space-x-4"> + <Label>권한 타입</Label> + <div className="flex gap-4"> + <label className="flex items-center gap-2"> + <input + type="radio" + checked={isGrant} + onChange={() => setIsGrant(true)} + /> + <span className="text-sm">부여</span> + </label> + <label className="flex items-center gap-2"> + <input + type="radio" + checked={!isGrant} + onChange={() => setIsGrant(false)} + /> + <span className="text-sm text-destructive">제한</span> + </label> + </div> + </div> + + {/* 권한 선택 */} + <div> + <Label>권한 선택</Label> + <ScrollArea className="h-[200px] border rounded-md p-4 mt-2"> + <div className="space-y-2"> + {availablePermissions.map(permission => ( + <label + key={permission.id} + className="flex items-start gap-2 cursor-pointer" + > + <Checkbox + checked={selectedPermissions.includes(permission.id)} + onCheckedChange={(checked) => { + if (checked) { + setSelectedPermissions([...selectedPermissions, permission.id]); + } else { + setSelectedPermissions( + selectedPermissions.filter(id => id !== permission.id) + ); + } + }} + /> + <div className="flex-1"> + <div className="text-sm font-medium">{permission.name}</div> + <div className="text-xs text-muted-foreground"> + {permission.description} + </div> + </div> + </label> + ))} + </div> + </ScrollArea> + </div> + + {/* 사유 */} + <div> + <Label>사유</Label> + <Textarea + placeholder="권한 부여/제한 사유를 입력하세요." + value={reason} + onChange={(e) => setReason(e.target.value)} + className="mt-2" + /> + </div> + + {/* 만료일 */} + <div> + <Label>만료일 (선택)</Label> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + className={cn( + "w-full justify-start text-left font-normal mt-2", + !expiresAt && "text-muted-foreground" + )} + > + <CalendarIcon className="mr-2 h-4 w-4" /> + {expiresAt ? format(expiresAt, "PPP", { locale: ko }) : "만료일 선택"} + </Button> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={expiresAt} + onSelect={setExpiresAt} + initialFocus + /> + </PopoverContent> + </Popover> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => setOpen(false)}> + 취소 + </Button> + <Button onClick={handleSubmit} disabled={saving}> + {saving ? "저장 중..." : "권한 추가"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file |
