From 8b23b471638a155fd1bfa3a8c853b26d9315b272 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 26 Sep 2025 09:57:24 +0000 Subject: (대표님) 권한관리, 문서업로드, rfq첨부, SWP문서룰 등 (최겸) 입찰 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/ProjectSelector.tsx | 50 +- .../docu-list-rule/docu-list-rule-client.tsx | 80 +-- components/form-data/add-formTag-dialog.tsx | 68 +- .../permissions/menu-permission-generator.tsx | 404 +++++++++++ components/permissions/menu-permission-manager.tsx | 552 ++++++++++++++ .../permissions/permission-assignment-manager.tsx | 319 ++++++++ components/permissions/permission-crud-manager.tsx | 562 +++++++++++++++ .../permissions/permission-group-manager.tsx | 799 +++++++++++++++++++++ components/permissions/role-permission-manager.tsx | 178 +++++ components/permissions/user-permission-manager.tsx | 573 +++++++++++++++ 10 files changed, 3478 insertions(+), 107 deletions(-) create mode 100644 components/permissions/menu-permission-generator.tsx create mode 100644 components/permissions/menu-permission-manager.tsx create mode 100644 components/permissions/permission-assignment-manager.tsx create mode 100644 components/permissions/permission-crud-manager.tsx create mode 100644 components/permissions/permission-group-manager.tsx create mode 100644 components/permissions/role-permission-manager.tsx create mode 100644 components/permissions/user-permission-manager.tsx (limited to 'components') 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(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 + )} @@ -90,14 +104,22 @@ export function ProjectSelector({ placeholder="프로젝트 코드/이름 검색..." onValueChange={setSearchTerm} /> - { - e.stopPropagation(); // 이벤트 전파 차단 - const target = e.currentTarget; - target.scrollTop += e.deltaY; // 직접 스크롤 처리 - }}> - 검색 결과가 없습니다 + { + e.stopPropagation(); + const target = e.currentTarget; + target.scrollTop += e.deltaY; + }} + > {isLoading ? (
로딩 중...
+ ) : filteredProjects.length === 0 ? ( + + {searchTerm + ? "검색 결과가 없습니다" + : `${filterType || '해당 타입의'} 프로젝트가 없습니다`} + ) : ( {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>([]) - 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({ {/* 오른쪽: ProjectSwitcher */} -
- + placeholder="프로젝트를 선택하세요" + filterType="plant" // 명시적으로 plant 타입만 (선택사항, 기본값이 plant) + + />
@@ -138,4 +80,4 @@ export default function DocuListRuleClient({ ) -} +} \ 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( +
+

{failedTags.length}개의 태그 생성 실패:

+
    + {failedTags.map((f, idx) => ( +
  • • {f.tag}: {f.error}
  • + ))} +
+
+ ); } // 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([]); + const [selectedMenus, setSelectedMenus] = useState>(new Set()); + const [selectedPermissions, setSelectedPermissions] = useState>>(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(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 ( +
+ + +
+
+ 메뉴 기반 권한 자동 생성 + + 등록된 메뉴를 분석하여 필요한 권한을 자동으로 생성합니다. + +
+
+ + +
+
+
+ + {/* 요약 정보 */} +
+ setFilterType("all")}> + +
{analysis.length}
+

전체 메뉴

+
+
+ setFilterType("configured")}> + +
+ {analysis.filter(m => m.existingPermissions.length > 0).length} +
+

권한 설정됨

+
+
+ setFilterType("unconfigured")}> + +
+ {analysis.filter(m => m.existingPermissions.length === 0).length} +
+

권한 미설정

+
+
+
+ + {/* 필터 섹션 */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> +
+ +
+ + {/* 일괄 선택 버튼 */} +
+ + {filteredAnalysis.length}개 중 {paginatedData.length}개 표시 + +
+ + +
+
+ + {/* 메뉴 리스트 */} +
+ {paginatedData.map(menu => { + const isSelected = selectedMenus.has(menu.menuPath); + const isExpanded = expandedRow === menu.menuPath; + const menuPermissions = selectedPermissions.get(menu.menuPath); + + return ( +
+
+ toggleMenu(menu.menuPath)} + /> + +
+
{menu.menuTitle}
+
+ {menu.menuPath} +
+
+ + {menu.domain} + + {menu.existingPermissions.length > 0 ? ( +
+ + {menu.existingPermissions.length}개 +
+ ) : ( +
+ + 미설정 +
+ )} + + {isSelected && menu.suggestedPermissions.length > 0 && ( + + )} +
+ + {/* 권한 상세 (확장 시에만 표시) */} + {isExpanded && isSelected && ( +
+ {menu.suggestedPermissions.map(perm => ( + + ))} +
+ )} +
+ ); + })} +
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+ + + + {currentPage} / {totalPages} + + + +
+ )} +
+
+
+ ); +} \ 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([]); + const [selectedMenu, setSelectedMenu] = useState(null); + const [availablePermissions, setAvailablePermissions] = useState([]); + const [loading, setLoading] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [selectedDomain, setSelectedDomain] = useState("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); + + return ( +
+ {/* 메뉴 목록 */} + + + 메뉴 목록 + 권한을 설정할 메뉴를 선택하세요. + + +
+ {/* 도메인 필터 */} +
+ setSelectedDomain("all")} + > + 전체 + + setSelectedDomain("evcp")} + > + EVCP + + setSelectedDomain("partners")} + > + Partners + +
+ + {/* 검색 */} +
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> +
+ + {/* 메뉴 트리 */} + + + {Object.entries(groupedMenus).map(([section, items]) => ( + + +
+ {section} + + {items.length} + +
+
+ +
+ {items.map((menu) => ( + + ))} +
+
+
+ ))} +
+
+
+
+
+ + {/* 메뉴 상세 및 권한 설정 */} + {selectedMenu ? ( + + +
+
+ + {selectedMenu.menuTitle} + {!selectedMenu.isActive && ( + 비활성 + )} + + {selectedMenu.menuPath} + {selectedMenu.menuDescription && ( +

+ {selectedMenu.menuDescription} +

+ )} +
+ +
+
+ +
+ {/* 담당자 정보 */} +
+

담당자

+
+ {/* 담당자 변경 다이얼로그 */}} + /> + {/* 담당자 변경 다이얼로그 */}} + /> +
+
+ + + + {/* 필수 권한 */} +
+

필수 권한

+ {selectedMenu.requiredPermissions.length > 0 ? ( +
+ {selectedMenu.requiredPermissions.map((perm) => ( +
+
+
{perm.name}
+
+ {perm.permissionKey} +
+ {perm.description && ( +
+ {perm.description} +
+ )} +
+
+ {perm.isRequired ? ( + 필수 + ) : ( + 선택 + )} +
+
+ ))} +
+ ) : ( +
+ +

설정된 권한이 없습니다.

+

모든 사용자가 접근 가능합니다.

+
+ )} +
+ + + + {/* 접근 통계 */} +
+

접근 통계

+
+
+
+ {selectedMenu.accessCount || 0} +
+
+ 총 접근 횟수 +
+
+
+
+ {selectedMenu.lastAccessed + ? new Date(selectedMenu.lastAccessed).toLocaleDateString() + : "-"} +
+
+ 최근 접근일 +
+
+
+
+
+
+
+ ) : ( + +
+ +

메뉴를 선택하면 권한 설정이 표시됩니다.

+
+
+ )} + + {/* 메뉴 권한 편집 다이얼로그 */} + {selectedMenu && ( + { + loadMenus(); + setEditDialogOpen(false); + }} + /> + )} +
+ ); +} + +// 담당자 정보 컴포넌트 +function ManagerInfo({ + label, + manager, + onEdit +}: { + label: string; + manager?: { id: number; name: string; email: string; imageUrl?: string }; + onEdit: () => void; +}) { + if (!manager) { + return ( +
+
+ + {label}: 미지정 +
+ +
+ ); + } + + return ( +
+
+ + + {manager.name[0]} + +
+
{manager.name}
+
{manager.email}
+
+
+ +
+ ); +} + +// 메뉴 권한 편집 다이얼로그 +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); + + return ( + + + + {menu.menuTitle} 권한 설정 + + 이 메뉴에 접근하기 위한 필수/선택 권한을 설정합니다. + + + + +
+ {Object.entries(groupedPermissions).map(([category, perms]) => ( +
+

{category}

+
+ {perms.map((permission) => { + const selected = selectedPermissions.find(p => p.id === permission.id); + return ( +
+
+ togglePermission(permission.id)} + /> +
+
{permission.name}
+
+ {permission.permissionKey} +
+ {permission.description && ( +
+ {permission.description} +
+ )} +
+
+ + {selected && ( +
+ +
+ )} +
+ ); + })} +
+
+ ))} +
+
+ + + + + +
+
+ ); +} \ 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([]); + const [selectedPermission, setSelectedPermission] = useState(null); + const [assignedRoles, setAssignedRoles] = useState([]); + const [assignedUsers, setAssignedUsers] = useState([]); + 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); + + return ( +
+ {/* 권한 목록 */} + + + 권한 목록 + 권한을 선택하여 할당을 관리하세요. + + +
+ {/* 검색 */} +
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> +
+ + {/* 권한 목록 */} + {loading ? ( +
+ +
+ ) : ( + +
+ {Object.entries(groupedPermissions).map(([resource, perms]) => ( +
+

+ {resource} +

+
+ {perms.map(permission => ( + + ))} +
+
+ ))} +
+
+ )} +
+
+
+ + {/* 할당 관리 */} + {selectedPermission ? ( + + + {selectedPermission.name} + +
+ {selectedPermission.permissionKey} + {selectedPermission.permissionType} + {selectedPermission.scope} +
+
+
+ + + + + + 역할 ({assignedRoles.length}) + + + + 사용자 ({assignedUsers.length}) + + + + +
+ +
+ {assignedRoles.map((role) => ( +
+
+
{role.name}
+
+ {role.domain} • {role.userCount}명 사용자 +
+
+ +
+ ))} +
+
+
+ + +
+ +
+ {assignedUsers.map((user) => ( +
+
+ + + {user.name[0]} + +
+
{user.name}
+
{user.email}
+
+
+
+ {user.isGrant ? ( + 부여 + ) : ( + 제한 + )} + +
+
+ ))} +
+
+
+
+
+
+ ) : ( + +
+ +

권한을 선택하면 할당 정보가 표시됩니다.

+
+
+ )} +
+ ); +} \ 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([]); + const [filteredPermissions, setFilteredPermissions] = useState([]); + const [categories, setCategories] = useState<{ resource: string; count: number }[]>([]); + const [selectedCategory, setSelectedCategory] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [editingPermission, setEditingPermission] = useState(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 ( +
+ {/* 헤더 및 필터 */} + + +
+
+ 권한 목록 + + 시스템에 등록된 모든 권한을 관리합니다. + +
+ +
+
+ +
+ {/* 검색 */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> +
+
+ + {/* 카테고리 필터 */} + +
+ + {/* 권한 테이블 */} +
+ + + + 권한명 + 권한 키 + 타입 + 리소스 + 액션 + 범위 + 상태 + 작업 + + + + {filteredPermissions.map(permission => ( + + +
+
{permission.name}
+ {permission.description && ( +
+ {permission.description} +
+ )} +
+
+ + + {permission.permissionKey} + + + + {permission.permissionType} + + {permission.resource} + {permission.action} + + {permission.scope} + + +
+ {permission.isActive ? ( + 활성 + ) : ( + 비활성 + )} + {permission.isSystem && ( + 시스템 + )} +
+
+ + + + + + + { + navigator.clipboard.writeText(permission.permissionKey); + toast.success("권한 키가 복사되었습니다."); + }} + > + + 키 복사 + + setEditingPermission(permission)} + > + + 수정 + + + handleDelete(permission.id)} + className="text-destructive" + disabled={permission.isSystem} + > + + 삭제 + + + + +
+ ))} +
+
+
+
+
+ + {/* 권한 생성/수정 다이얼로그 */} + { + if (!open) { + setCreateDialogOpen(false); + setEditingPermission(null); + } + }} + permission={editingPermission} + onSuccess={() => { + setCreateDialogOpen(false); + setEditingPermission(null); + loadPermissions(); + }} + /> +
+ ); +} + +// 권한 생성/수정 폼 다이얼로그 +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 ( + + + + {permission ? "권한 수정" : "권한 생성"} + + 새로운 권한을 생성하거나 기존 권한을 수정합니다. + + + +
+
+
+ +
+ setFormData({ ...formData, permissionKey: e.target.value })} + placeholder="예: rfq.vendor.create" + /> + {!permission && ( + + )} +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="예: RFQ 벤더 추가" + /> +
+
+ +
+ +