summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-26 09:57:24 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-26 09:57:24 +0000
commit8b23b471638a155fd1bfa3a8c853b26d9315b272 (patch)
tree47353e9dd342011cb2f1dcd24b09661707a8421b /components
parentd62368d2b68d73da895977e60a18f9b1286b0545 (diff)
(대표님) 권한관리, 문서업로드, rfq첨부, SWP문서룰 등
(최겸) 입찰
Diffstat (limited to 'components')
-rw-r--r--components/ProjectSelector.tsx50
-rw-r--r--components/docu-list-rule/docu-list-rule-client.tsx80
-rw-r--r--components/form-data/add-formTag-dialog.tsx68
-rw-r--r--components/permissions/menu-permission-generator.tsx404
-rw-r--r--components/permissions/menu-permission-manager.tsx552
-rw-r--r--components/permissions/permission-assignment-manager.tsx319
-rw-r--r--components/permissions/permission-crud-manager.tsx562
-rw-r--r--components/permissions/permission-group-manager.tsx799
-rw-r--r--components/permissions/role-permission-manager.tsx178
-rw-r--r--components/permissions/user-permission-manager.tsx573
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