From 1fcefb1d2ba4c8594cedede66a0724c30e541078 Mon Sep 17 00:00:00 2001 From: kent Date: Tue, 30 Dec 2025 17:17:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EA=B6=8C=ED=95=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20UI=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PermissionDetailClient 역할별 권한 설정 기능 강화 - 권한 관리 메인 페이지 API 연동 완료 - 타입 정의 확장 및 actions 추가 - 시스템 역할/사용자 역할 구분 UI --- .../PermissionDetailClient.tsx | 839 +++++++++--------- .../settings/PermissionManagement/actions.ts | 221 +++++ .../settings/PermissionManagement/index.tsx | 308 +++---- .../settings/PermissionManagement/types.ts | 99 ++- 4 files changed, 869 insertions(+), 598 deletions(-) create mode 100644 src/components/settings/PermissionManagement/actions.ts diff --git a/src/components/settings/PermissionManagement/PermissionDetailClient.tsx b/src/components/settings/PermissionManagement/PermissionDetailClient.tsx index 9ff86830..71476422 100644 --- a/src/components/settings/PermissionManagement/PermissionDetailClient.tsx +++ b/src/components/settings/PermissionManagement/PermissionDetailClient.tsx @@ -2,19 +2,24 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; -import { ChevronDown, ChevronRight, Shield, ArrowLeft, Trash2, Save } from 'lucide-react'; +import { + ChevronDown, + ChevronRight, + Shield, + ArrowLeft, + Trash2, + Save, + CheckCircle2, + XCircle, + RotateCcw, + Loader2, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; import { Table, TableBody, @@ -35,23 +40,27 @@ import { } from '@/components/ui/alert-dialog'; import { PageHeader } from '@/components/organisms/PageHeader'; import { PageLayout } from '@/components/organisms/PageLayout'; -import type { Permission, MenuPermission, PermissionType } from './types'; +import { toast } from 'sonner'; +import type { Role, MenuTreeItem, PermissionMatrix, PermissionType } from './types'; +import { + fetchRole, + createRole, + updateRole, + deleteRole, + fetchPermissionMenus, + fetchPermissionMatrix, + togglePermission, + allowAllPermissions, + denyAllPermissions, + resetPermissions, +} from './actions'; interface PermissionDetailClientProps { permissionId: string; isNew?: boolean; } -// 메뉴 구조 타입 (localStorage user.menu와 동일한 구조) -interface SerializableMenuItem { - id: string; - label: string; - iconName: string; - path: string; - children?: SerializableMenuItem[]; -} - -// 권한 타입 (기획서 기준: 전체 제외) +// 권한 타입 const PERMISSION_TYPES: PermissionType[] = ['view', 'create', 'update', 'delete', 'approve', 'export', 'manage']; const PERMISSION_LABELS_MAP: Record = { view: '조회', @@ -63,243 +72,76 @@ const PERMISSION_LABELS_MAP: Record = { manage: '관리', }; -// 제외할 메뉴 ID 목록 (시스템 설정 하위 메뉴) -const EXCLUDED_MENU_IDS = ['database', 'system-monitoring', 'security-management', 'system-settings']; - -// 메뉴 필터링 함수 (제외 목록에 있는 메뉴 제거) -const filterExcludedMenus = (menus: SerializableMenuItem[]): SerializableMenuItem[] => { - return menus - .filter(menu => !EXCLUDED_MENU_IDS.includes(menu.id)) - .map(menu => { - if (menu.children && menu.children.length > 0) { - const filteredChildren = menu.children.filter( - child => !EXCLUDED_MENU_IDS.includes(child.id) - ); - // 자식이 모두 필터링되면 부모도 제거 - if (filteredChildren.length === 0) { - return null; - } - return { ...menu, children: filteredChildren }; - } - return menu; - }) - .filter((menu): menu is SerializableMenuItem => menu !== null); -}; - -// localStorage에서 메뉴 데이터 가져오기 -const getMenuFromLocalStorage = (): SerializableMenuItem[] => { - if (typeof window === 'undefined') return []; - - try { - const userDataStr = localStorage.getItem('user'); - if (userDataStr) { - const userData = JSON.parse(userDataStr); - if (userData.menu && Array.isArray(userData.menu)) { - // 제외 목록 필터링 적용 - return filterExcludedMenus(userData.menu); - } - } - } catch (error) { - console.error('Failed to load menu from localStorage:', error); - } - - // 기본 메뉴 (user.menu가 없는 경우) - return [ - { id: 'dashboard', label: '대시보드', iconName: 'layout-dashboard', path: '/dashboard' }, - { - id: 'sales', - label: '판매관리', - iconName: 'shopping-cart', - path: '#', - children: [ - { id: 'customer-management', label: '거래처관리', iconName: 'building-2', path: '/sales/client-management-sales-admin' }, - { id: 'quote-management', label: '견적관리', iconName: 'receipt', path: '/sales/quote-management' }, - { id: 'pricing-management', label: '단가관리', iconName: 'dollar-sign', path: '/sales/pricing-management' }, - ], - }, - { - id: 'master-data', - label: '기준정보', - iconName: 'settings', - path: '#', - children: [ - { id: 'item-master', label: '품목기준관리', iconName: 'package', path: '/master-data/item-master-data-management' }, - ], - }, - { - id: 'hr', - label: '인사관리', - iconName: 'users', - path: '#', - children: [ - { id: 'vacation-management', label: '휴가관리', iconName: 'calendar', path: '/hr/vacation-management' }, - { id: 'salary-management', label: '급여관리', iconName: 'wallet', path: '/hr/salary-management' }, - ], - }, - { - id: 'settings', - label: '기준정보 설정', - iconName: 'settings-2', - path: '#', - children: [ - { id: 'ranks', label: '직급관리', iconName: 'badge', path: '/settings/ranks' }, - { id: 'titles', label: '직책관리', iconName: 'user-cog', path: '/settings/titles' }, - { id: 'permissions', label: '권한관리', iconName: 'shield', path: '/settings/permissions' }, - ], - }, - ]; -}; - -// 메뉴 구조를 flat한 MenuPermission 배열로 변환 -const convertMenuToPermissions = ( - menus: SerializableMenuItem[], - existingPermissions: MenuPermission[] -): MenuPermission[] => { - const result: MenuPermission[] = []; - - const processMenu = (menu: SerializableMenuItem, parentId?: string) => { - // 기존 권한 데이터 찾기 - const existing = existingPermissions.find(ep => ep.menuId === menu.id); - - result.push({ - menuId: menu.id, - menuName: menu.label, - parentMenuId: parentId, - permissions: existing?.permissions || {}, - }); - - // 자식 메뉴 처리 - if (menu.children && menu.children.length > 0) { - menu.children.forEach(child => processMenu(child, menu.id)); - } - }; - - menus.forEach(menu => processMenu(menu)); - return result; -}; - -// localStorage 키 -const PERMISSIONS_STORAGE_KEY = 'buddy_permissions'; - -// 기본 권한 데이터 -const defaultPermissions: Permission[] = [ - { - id: 1, - name: '관리자', - status: 'active', - menuPermissions: [], - createdAt: '2025-01-01T00:00:00Z', - }, - { - id: 2, - name: '일반사용자', - status: 'active', - menuPermissions: [], - createdAt: '2025-01-15T00:00:00Z', - }, - { - id: 3, - name: '인사담당자', - status: 'active', - menuPermissions: [], - createdAt: '2025-02-01T00:00:00Z', - }, - { - id: 4, - name: '결재담당자', - status: 'active', - menuPermissions: [], - createdAt: '2025-02-15T00:00:00Z', - }, - { - id: 5, - name: '게스트', - status: 'hidden', - menuPermissions: [], - createdAt: '2025-03-01T00:00:00Z', - }, -]; - -// localStorage에서 권한 데이터 로드 -const loadPermissions = (): Permission[] => { - if (typeof window === 'undefined') return defaultPermissions; - - try { - const stored = localStorage.getItem(PERMISSIONS_STORAGE_KEY); - if (stored) { - return JSON.parse(stored); - } - } catch (error) { - console.error('Failed to load permissions:', error); - } - return defaultPermissions; -}; - -// localStorage에 권한 데이터 저장 -const savePermissions = (permissions: Permission[]) => { - if (typeof window === 'undefined') return; - - try { - localStorage.setItem(PERMISSIONS_STORAGE_KEY, JSON.stringify(permissions)); - // 커스텀 이벤트 발생 (목록 페이지에서 감지) - window.dispatchEvent(new CustomEvent('permissionsUpdated', { detail: permissions })); - } catch (error) { - console.error('Failed to save permissions:', error); - } -}; - export function PermissionDetailClient({ permissionId, isNew = false }: PermissionDetailClientProps) { const router = useRouter(); - const [permission, setPermission] = useState(null); + + // 역할 데이터 + const [role, setRole] = useState(null); const [name, setName] = useState(''); - const [status, setStatus] = useState<'active' | 'hidden'>('active'); - const [menuStructure, setMenuStructure] = useState([]); - const [menuPermissions, setMenuPermissions] = useState([]); - const [expandedMenus, setExpandedMenus] = useState>(new Set()); + const [description, setDescription] = useState(''); + const [isHidden, setIsHidden] = useState(false); + + // 권한 매트릭스 데이터 + const [menuTree, setMenuTree] = useState([]); + const [permissionTypes, setPermissionTypes] = useState([]); + const [matrix, setMatrix] = useState(null); + + // UI 상태 + const [expandedMenus, setExpandedMenus] = useState>(new Set()); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(true); - const [isSaved, setIsSaved] = useState(!isNew); // 새 권한은 저장 전 상태 + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isTogglingPermission, setIsTogglingPermission] = useState(false); - // 권한 데이터 로드 - useEffect(() => { - const menus = getMenuFromLocalStorage(); - setMenuStructure(menus); + // 데이터 로드 + const loadData = useCallback(async () => { + setIsLoading(true); - if (isNew) { - // 새 권한 등록 모드 - setName(''); - setStatus('active'); - const emptyPermissions = convertMenuToPermissions(menus, []); - setMenuPermissions(emptyPermissions); - setPermission(null); + try { + // 메뉴 트리 로드 + const menusResult = await fetchPermissionMenus(); + if (menusResult.success && menusResult.data) { + setMenuTree(menusResult.data.menus); + setPermissionTypes(menusResult.data.permission_types); + } + + if (!isNew) { + // 기존 역할 로드 + const roleResult = await fetchRole(parseInt(permissionId)); + if (roleResult.success && roleResult.data) { + setRole(roleResult.data); + setName(roleResult.data.name); + setDescription(roleResult.data.description || ''); + setIsHidden(roleResult.data.is_hidden); + } else { + toast.error(roleResult.error || '역할 조회 실패'); + } + + // 권한 매트릭스 로드 + const matrixResult = await fetchPermissionMatrix(parseInt(permissionId)); + if (matrixResult.success && matrixResult.data) { + setMatrix(matrixResult.data); + } + } + } catch (error) { + toast.error(error instanceof Error ? error.message : '데이터 로드 실패'); + } finally { setIsLoading(false); - return; } - - // 기존 권한 수정 모드 - const permissions = loadPermissions(); - const found = permissions.find(p => p.id.toString() === permissionId); - - if (found) { - setPermission(found); - setName(found.name); - setStatus(found.status); - - // 기존 권한 데이터와 메뉴 구조 병합 - const mergedPermissions = convertMenuToPermissions(menus, found.menuPermissions); - setMenuPermissions(mergedPermissions); - } - - setIsLoading(false); }, [permissionId, isNew]); + useEffect(() => { + loadData(); + }, [loadData]); + // 뒤로가기 const handleBack = useCallback(() => { router.push('/settings/permissions'); }, [router]); - // 부모 메뉴 접기/펼치기 - const toggleMenuExpand = (menuId: string) => { + // 메뉴 접기/펼치기 + const toggleMenuExpand = (menuId: number) => { setExpandedMenus(prev => { const newSet = new Set(prev); if (newSet.has(menuId)) { @@ -311,153 +153,198 @@ export function PermissionDetailClient({ permissionId, isNew = false }: Permissi }); }; - // 권한 저장 (기존 권한 수정 시) - const savePermission = useCallback((updatedPermission: Permission) => { - const permissions = loadPermissions(); - const updatedPermissions = permissions.map(p => - p.id === updatedPermission.id ? updatedPermission : p - ); - savePermissions(updatedPermissions); - setPermission(updatedPermission); - }, []); - - // 새 권한 저장 - const handleSaveNew = useCallback(() => { + // 새 역할 저장 + const handleSaveNew = async () => { if (!name.trim()) { - alert('권한명을 입력해주세요.'); + toast.error('역할명을 입력해주세요.'); return; } - const permissions = loadPermissions(); - const newId = Math.max(...permissions.map(p => p.id), 0) + 1; - const newPermission: Permission = { - id: newId, - name: name.trim(), - status, - menuPermissions, - createdAt: new Date().toISOString(), - }; + setIsSaving(true); + try { + const result = await createRole({ + name: name.trim(), + description: description.trim() || undefined, + is_hidden: isHidden, + }); - const updatedPermissions = [...permissions, newPermission]; - savePermissions(updatedPermissions); - setPermission(newPermission); - setIsSaved(true); - - // 저장 후 상세 페이지로 이동 (URL 변경) - router.replace(`/settings/permissions/${newId}`); - }, [name, status, menuPermissions, router]); - - // 권한 토글 (기존 권한은 자동 저장, 새 권한은 상태만 업데이트) - const handlePermissionToggle = (menuId: string, permType: PermissionType) => { - const newMenuPermissions = menuPermissions.map(mp => - mp.menuId === menuId - ? { - ...mp, - permissions: { - ...mp.permissions, - [permType]: !mp.permissions[permType], - }, - } - : mp - ); - setMenuPermissions(newMenuPermissions); - - // 기존 권한인 경우에만 자동 저장 - if (permission && isSaved) { - const updatedPermission = { - ...permission, - name, - status, - menuPermissions: newMenuPermissions, - updatedAt: new Date().toISOString(), - }; - savePermission(updatedPermission); + if (result.success && result.data) { + toast.success('역할이 생성되었습니다.'); + router.replace(`/settings/permissions/${result.data.id}`); + } else { + toast.error(result.error || '역할 생성 실패'); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : '저장 중 오류 발생'); + } finally { + setIsSaving(false); } }; - // 전체 선택/해제 (열 기준) - 헤더 체크박스 - const handleColumnSelectAll = (permType: PermissionType, checked: boolean) => { - const newMenuPermissions = menuPermissions.map(mp => ({ - ...mp, - permissions: { - ...mp.permissions, - [permType]: checked, - }, - })); - setMenuPermissions(newMenuPermissions); + // 역할 정보 업데이트 + const handleUpdateRole = async () => { + if (!role) return; - // 기존 권한인 경우에만 자동 저장 - if (permission && isSaved) { - const updatedPermission = { - ...permission, - name, - status, - menuPermissions: newMenuPermissions, - updatedAt: new Date().toISOString(), - }; - savePermission(updatedPermission); + setIsSaving(true); + try { + const result = await updateRole(role.id, { + name: name.trim(), + description: description.trim() || undefined, + is_hidden: isHidden, + }); + + if (result.success && result.data) { + setRole(result.data); + toast.success('역할 정보가 수정되었습니다.'); + } else { + toast.error(result.error || '역할 수정 실패'); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : '수정 중 오류 발생'); + } finally { + setIsSaving(false); } }; - // 기본 정보 변경 - const handleNameBlur = () => { - // 새 권한 모드에서는 저장하지 않음 - if (!permission || !isSaved || name === permission.name) return; + // 권한 토글 + const handlePermissionToggle = async (menuId: number, permType: string) => { + if (!role || isTogglingPermission) return; - const updatedPermission = { - ...permission, - name, - status, - menuPermissions, - updatedAt: new Date().toISOString(), - }; - savePermission(updatedPermission); + setIsTogglingPermission(true); + try { + const result = await togglePermission(role.id, menuId, permType); + + if (result.success && result.data) { + // 매트릭스 새로고침 + const matrixResult = await fetchPermissionMatrix(role.id); + if (matrixResult.success && matrixResult.data) { + setMatrix(matrixResult.data); + } + + // 하위 메뉴 전파 알림 + if (result.data.propagated_to && result.data.propagated_to.length > 0) { + toast.info(`하위 ${result.data.propagated_to.length}개 메뉴에도 적용되었습니다.`); + } + } else { + toast.error(result.error || '권한 변경 실패'); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : '권한 변경 중 오류 발생'); + } finally { + setIsTogglingPermission(false); + } }; - const handleStatusChange = (newStatus: 'active' | 'hidden') => { - setStatus(newStatus); + // 전체 허용 + const handleAllowAll = async () => { + if (!role) return; - // 기존 권한인 경우에만 자동 저장 - if (permission && isSaved) { - const updatedPermission = { - ...permission, - name, - status: newStatus, - menuPermissions, - updatedAt: new Date().toISOString(), - }; - savePermission(updatedPermission); + setIsSaving(true); + try { + const result = await allowAllPermissions(role.id); + if (result.success) { + toast.success('모든 권한이 허용되었습니다.'); + const matrixResult = await fetchPermissionMatrix(role.id); + if (matrixResult.success && matrixResult.data) { + setMatrix(matrixResult.data); + } + } else { + toast.error(result.error || '전체 허용 실패'); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : '전체 허용 중 오류 발생'); + } finally { + setIsSaving(false); + } + }; + + // 전체 거부 + const handleDenyAll = async () => { + if (!role) return; + + setIsSaving(true); + try { + const result = await denyAllPermissions(role.id); + if (result.success) { + toast.success('모든 권한이 거부되었습니다.'); + const matrixResult = await fetchPermissionMatrix(role.id); + if (matrixResult.success && matrixResult.data) { + setMatrix(matrixResult.data); + } + } else { + toast.error(result.error || '전체 거부 실패'); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : '전체 거부 중 오류 발생'); + } finally { + setIsSaving(false); + } + }; + + // 기본값 초기화 + const handleReset = async () => { + if (!role) return; + + setIsSaving(true); + try { + const result = await resetPermissions(role.id); + if (result.success) { + toast.success('기본 권한으로 초기화되었습니다. (조회만 허용)'); + const matrixResult = await fetchPermissionMatrix(role.id); + if (matrixResult.success && matrixResult.data) { + setMatrix(matrixResult.data); + } + } else { + toast.error(result.error || '초기화 실패'); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : '초기화 중 오류 발생'); + } finally { + setIsSaving(false); } }; // 삭제 const handleDelete = () => setDeleteDialogOpen(true); - const confirmDelete = () => { - if (!permission) return; + const confirmDelete = async () => { + if (!role) return; - const permissions = loadPermissions(); - const updatedPermissions = permissions.filter(p => p.id !== permission.id); - savePermissions(updatedPermissions); - setDeleteDialogOpen(false); - handleBack(); + setIsDeleting(true); + try { + const result = await deleteRole(role.id); + if (result.success) { + toast.success('역할이 삭제되었습니다.'); + handleBack(); + } else { + toast.error(result.error || '역할 삭제 실패'); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : '삭제 중 오류 발생'); + } finally { + setIsDeleting(false); + setDeleteDialogOpen(false); + } }; - // 메뉴 행 렌더링 (재귀적으로 부모-자식 처리) - const renderMenuRows = () => { + // 메뉴의 권한 상태 가져오기 + const getMenuPermission = (menuId: number, permType: string): boolean => { + if (!matrix) return false; + const menuPerm = matrix.menus.find(m => m.menu_id === menuId); + return menuPerm?.permissions[permType as PermissionType] || false; + }; + + // 메뉴 행 렌더링 (재귀) + const renderMenuRows = (menus: MenuTreeItem[], depth = 0): React.ReactElement[] => { const rows: React.ReactElement[] = []; - menuStructure.forEach(menu => { - const mp = menuPermissions.find(p => p.menuId === menu.id); - if (!mp) return; - + menus.forEach(menu => { const hasChildren = menu.children && menu.children.length > 0; const isExpanded = expandedMenus.has(menu.id); - // 부모 메뉴 행 rows.push( - +
{hasChildren && (
{PERMISSION_TYPES.map(pt => ( handlePermissionToggle(menu.id, pt)} + disabled={isTogglingPermission || isNew} /> ))}
); - // 자식 메뉴 행 (펼쳐진 경우에만) - if (hasChildren && isExpanded) { - menu.children?.forEach(child => { - const childMp = menuPermissions.find(p => p.menuId === child.id); - if (!childMp) return; - - rows.push( - - - - {child.label} - - {PERMISSION_TYPES.map(pt => ( - - handlePermissionToggle(child.id, pt)} - /> - - ))} - - ); - }); + // 자식 메뉴 + if (hasChildren && isExpanded && menu.children) { + rows.push(...renderMenuRows(menu.children, depth + 1)); } }); return rows; }; + // 로딩 상태 if (isLoading) { return (
-
로딩 중...
+ + 로딩 중...
); } - // 새 권한 등록 모드가 아닌데 권한을 찾지 못한 경우 - if (!permission && !isNew) { + // 역할을 찾지 못한 경우 + if (!isNew && !role) { return (
-
권한을 찾을 수 없습니다.
+
역할을 찾을 수 없습니다.
) : ( - + <> + + + )}
@@ -572,83 +468,144 @@ export function PermissionDetailClient({ permissionId, isNew = false }: Permissi

기본 정보

-
+
- + setName(e.target.value)} - onBlur={handleNameBlur} + placeholder="역할명을 입력하세요" />
- - + + setDescription(e.target.value)} + placeholder="역할 설명을 입력하세요" + /> +
+
+ +
+ + + {isHidden ? '숨김 (사용자에게 표시 안함)' : '공개'} + +
- {/* 메뉴별 권한 설정 테이블 */} - - -

메뉴

-
- - - - 메뉴 - {PERMISSION_TYPES.map(pt => ( - -
+ {/* 권한 매트릭스 (기존 역할만 표시) */} + {!isNew && ( + + +
+

메뉴별 권한

+
+ + + +
+
+
+
+ + + 메뉴 + {PERMISSION_TYPES.map(pt => ( + {PERMISSION_LABELS_MAP[pt]} - 0 && menuPermissions.every(mp => mp.permissions[pt])} - onCheckedChange={(checked) => handleColumnSelectAll(pt, !!checked)} - /> - - - ))} - - - - {renderMenuRows()} - -
-
-
-
+ + ))} + + + + {renderMenuRows(menuTree)} + + +
+ {isTogglingPermission && ( +
+ +
+ )} +
+
+ )} + + {/* 새 역할 안내 */} + {isNew && ( + + +
+

역할을 저장한 후 메뉴별 권한을 설정할 수 있습니다.

+
+
+
+ )} {/* 삭제 확인 다이얼로그 */} - {!isNew && permission && ( + {!isNew && role && ( - 권한 삭제 + 역할 삭제 - "{permission.name}" 권한을 삭제하시겠습니까? + "{role.name}" 역할을 삭제하시겠습니까?
- 이 권한을 사용 중인 사원이 있으면 해당 사원의 권한이 초기화됩니다. + 이 역할을 사용 중인 사원이 있으면 해당 사원의 역할이 초기화됩니다.
- 취소 + 취소 - 삭제 + {isDeleting ? ( + <> + + 삭제 중... + + ) : ( + '삭제' + )}
@@ -656,4 +613,4 @@ export function PermissionDetailClient({ permissionId, isNew = false }: Permissi )}
); -} +} \ No newline at end of file diff --git a/src/components/settings/PermissionManagement/actions.ts b/src/components/settings/PermissionManagement/actions.ts new file mode 100644 index 00000000..7eb3a925 --- /dev/null +++ b/src/components/settings/PermissionManagement/actions.ts @@ -0,0 +1,221 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { apiClient } from '@/lib/api/client'; +import type { Role, RoleStats, PermissionMatrix, MenuTreeItem, ApiResponse, PaginatedResponse } from './types'; + +// ========== Role CRUD ========== + +/** + * 역할 목록 조회 + */ +export async function fetchRoles(params?: { + page?: number; + size?: number; + q?: string; + is_hidden?: boolean; +}): Promise>> { + try { + const response = await apiClient.get>('/v1/roles', { params }); + return { success: true, data: response }; + } catch (error) { + console.error('Failed to fetch roles:', error); + return { success: false, error: error instanceof Error ? error.message : '역할 목록 조회 실패' }; + } +} + +/** + * 역할 상세 조회 + */ +export async function fetchRole(id: number): Promise> { + try { + const response = await apiClient.get(`/v1/roles/${id}`); + return { success: true, data: response }; + } catch (error) { + console.error('Failed to fetch role:', error); + return { success: false, error: error instanceof Error ? error.message : '역할 조회 실패' }; + } +} + +/** + * 역할 생성 + */ +export async function createRole(data: { + name: string; + description?: string; + is_hidden?: boolean; +}): Promise> { + try { + const response = await apiClient.post('/v1/roles', data); + revalidatePath('/settings/permissions'); + return { success: true, data: response }; + } catch (error) { + console.error('Failed to create role:', error); + return { success: false, error: error instanceof Error ? error.message : '역할 생성 실패' }; + } +} + +/** + * 역할 수정 + */ +export async function updateRole( + id: number, + data: { + name?: string; + description?: string; + is_hidden?: boolean; + } +): Promise> { + try { + const response = await apiClient.patch(`/v1/roles/${id}`, data); + revalidatePath('/settings/permissions'); + revalidatePath(`/settings/permissions/${id}`); + return { success: true, data: response }; + } catch (error) { + console.error('Failed to update role:', error); + return { success: false, error: error instanceof Error ? error.message : '역할 수정 실패' }; + } +} + +/** + * 역할 삭제 + */ +export async function deleteRole(id: number): Promise> { + try { + await apiClient.delete(`/v1/roles/${id}`); + revalidatePath('/settings/permissions'); + return { success: true }; + } catch (error) { + console.error('Failed to delete role:', error); + return { success: false, error: error instanceof Error ? error.message : '역할 삭제 실패' }; + } +} + +/** + * 역할 통계 조회 + */ +export async function fetchRoleStats(): Promise> { + try { + const response = await apiClient.get('/v1/roles/stats'); + return { success: true, data: response }; + } catch (error) { + console.error('Failed to fetch role stats:', error); + return { success: false, error: error instanceof Error ? error.message : '역할 통계 조회 실패' }; + } +} + +/** + * 활성 역할 목록 (드롭다운용) + */ +export async function fetchActiveRoles(): Promise> { + try { + const response = await apiClient.get('/v1/roles/active'); + return { success: true, data: response }; + } catch (error) { + console.error('Failed to fetch active roles:', error); + return { success: false, error: error instanceof Error ? error.message : '활성 역할 목록 조회 실패' }; + } +} + +// ========== Permission Matrix ========== + +/** + * 권한 매트릭스용 메뉴 트리 조회 + */ +export async function fetchPermissionMenus(): Promise> { + try { + const response = await apiClient.get<{ + menus: MenuTreeItem[]; + permission_types: string[]; + }>('/v1/role-permissions/menus'); + return { success: true, data: response }; + } catch (error) { + console.error('Failed to fetch permission menus:', error); + return { success: false, error: error instanceof Error ? error.message : '메뉴 트리 조회 실패' }; + } +} + +/** + * 역할의 권한 매트릭스 조회 + */ +export async function fetchPermissionMatrix(roleId: number): Promise> { + try { + const response = await apiClient.get(`/v1/roles/${roleId}/permissions/matrix`); + return { success: true, data: response }; + } catch (error) { + console.error('Failed to fetch permission matrix:', error); + return { success: false, error: error instanceof Error ? error.message : '권한 매트릭스 조회 실패' }; + } +} + +/** + * 특정 메뉴의 특정 권한 토글 + */ +export async function togglePermission( + roleId: number, + menuId: number, + permissionType: string +): Promise> { + try { + const response = await apiClient.post<{ + granted: boolean; + propagated_to: number[]; + }>(`/v1/roles/${roleId}/permissions/toggle`, { + menu_id: menuId, + permission_type: permissionType, + }); + revalidatePath(`/settings/permissions/${roleId}`); + return { success: true, data: response }; + } catch (error) { + console.error('Failed to toggle permission:', error); + return { success: false, error: error instanceof Error ? error.message : '권한 토글 실패' }; + } +} + +/** + * 모든 권한 허용 + */ +export async function allowAllPermissions(roleId: number): Promise> { + try { + const response = await apiClient.post<{ count: number }>(`/v1/roles/${roleId}/permissions/allow-all`); + revalidatePath(`/settings/permissions/${roleId}`); + return { success: true, data: response }; + } catch (error) { + console.error('Failed to allow all permissions:', error); + return { success: false, error: error instanceof Error ? error.message : '전체 허용 실패' }; + } +} + +/** + * 모든 권한 거부 + */ +export async function denyAllPermissions(roleId: number): Promise> { + try { + const response = await apiClient.post<{ count: number }>(`/v1/roles/${roleId}/permissions/deny-all`); + revalidatePath(`/settings/permissions/${roleId}`); + return { success: true, data: response }; + } catch (error) { + console.error('Failed to deny all permissions:', error); + return { success: false, error: error instanceof Error ? error.message : '전체 거부 실패' }; + } +} + +/** + * 기본 권한으로 초기화 (view만 허용) + */ +export async function resetPermissions(roleId: number): Promise> { + try { + const response = await apiClient.post<{ count: number }>(`/v1/roles/${roleId}/permissions/reset`); + revalidatePath(`/settings/permissions/${roleId}`); + return { success: true, data: response }; + } catch (error) { + console.error('Failed to reset permissions:', error); + return { success: false, error: error instanceof Error ? error.message : '권한 초기화 실패' }; + } +} \ No newline at end of file diff --git a/src/components/settings/PermissionManagement/index.tsx b/src/components/settings/PermissionManagement/index.tsx index 6335f29b..aa3ee101 100644 --- a/src/components/settings/PermissionManagement/index.tsx +++ b/src/components/settings/PermissionManagement/index.tsx @@ -11,6 +11,8 @@ import { Settings, Eye, EyeOff, + Users, + Loader2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; @@ -33,77 +35,9 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; -import type { Permission } from './types'; - -// localStorage 키 -const PERMISSIONS_STORAGE_KEY = 'buddy_permissions'; - -/** - * 기본 권한 데이터 (PDF 54페이지 기준) - */ -const defaultPermissions: Permission[] = [ - { - id: 1, - name: '관리자', - status: 'active', - menuPermissions: [], - createdAt: '2025-01-01T00:00:00Z', - }, - { - id: 2, - name: '일반사용자', - status: 'active', - menuPermissions: [], - createdAt: '2025-01-15T00:00:00Z', - }, - { - id: 3, - name: '인사담당자', - status: 'active', - menuPermissions: [], - createdAt: '2025-02-01T00:00:00Z', - }, - { - id: 4, - name: '결재담당자', - status: 'active', - menuPermissions: [], - createdAt: '2025-02-15T00:00:00Z', - }, - { - id: 5, - name: '게스트', - status: 'hidden', - menuPermissions: [], - createdAt: '2025-03-01T00:00:00Z', - }, -]; - -// localStorage에서 권한 데이터 로드 -const loadPermissions = (): Permission[] => { - if (typeof window === 'undefined') return defaultPermissions; - - try { - const stored = localStorage.getItem(PERMISSIONS_STORAGE_KEY); - if (stored) { - return JSON.parse(stored); - } - } catch (error) { - console.error('Failed to load permissions:', error); - } - return defaultPermissions; -}; - -// localStorage에 권한 데이터 저장 -const savePermissions = (permissions: Permission[]) => { - if (typeof window === 'undefined') return; - - try { - localStorage.setItem(PERMISSIONS_STORAGE_KEY, JSON.stringify(permissions)); - } catch (error) { - console.error('Failed to save permissions:', error); - } -}; +import { toast } from 'sonner'; +import type { Role, RoleStats } from './types'; +import { fetchRoles, fetchRoleStats, deleteRole } from './actions'; export function PermissionManagement() { const router = useRouter(); @@ -114,30 +48,49 @@ export function PermissionManagement() { const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 20; - // 권한 데이터 - const [permissions, setPermissions] = useState(defaultPermissions); + // 역할 데이터 + const [roles, setRoles] = useState([]); + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); // 삭제 확인 다이얼로그 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [permissionToDelete, setPermissionToDelete] = useState(null); + const [roleToDelete, setRoleToDelete] = useState(null); const [isBulkDelete, setIsBulkDelete] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); - // localStorage에서 초기 데이터 로드 - useEffect(() => { - setPermissions(loadPermissions()); + // API에서 데이터 로드 + const loadData = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const [rolesResult, statsResult] = await Promise.all([ + fetchRoles({ size: 1000 }), // 전체 로드 후 클라이언트 필터링 + fetchRoleStats(), + ]); + + if (rolesResult.success && rolesResult.data) { + setRoles(rolesResult.data.data); + } else { + setError(rolesResult.error || '역할 목록 조회 실패'); + } + + if (statsResult.success && statsResult.data) { + setStats(statsResult.data); + } + } catch (err) { + setError(err instanceof Error ? err.message : '데이터 로드 실패'); + } finally { + setIsLoading(false); + } }, []); - // 권한 변경 감지 (상세 페이지에서 변경 시) + // 초기 데이터 로드 useEffect(() => { - const handlePermissionsUpdated = (event: CustomEvent) => { - setPermissions(event.detail); - }; - - window.addEventListener('permissionsUpdated', handlePermissionsUpdated as EventListener); - return () => { - window.removeEventListener('permissionsUpdated', handlePermissionsUpdated as EventListener); - }; - }, []); + loadData(); + }, [loadData]); // ===== 탭 상태 ===== const [activeTab, setActiveTab] = useState('all'); @@ -162,24 +115,25 @@ export function PermissionManagement() { // ===== 필터링된 데이터 ===== const filteredData = useMemo(() => { - let result = permissions; + let result = roles; // 탭 필터 - if (activeTab === 'active') { - result = result.filter(item => item.status === 'active'); + if (activeTab === 'visible') { + result = result.filter(item => !item.is_hidden); } else if (activeTab === 'hidden') { - result = result.filter(item => item.status === 'hidden'); + result = result.filter(item => item.is_hidden); } // 검색 필터 if (searchQuery) { result = result.filter(item => - item.name.toLowerCase().includes(searchQuery.toLowerCase()) + item.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (item.description?.toLowerCase().includes(searchQuery.toLowerCase())) ); } return result; - }, [permissions, searchQuery, activeTab]); + }, [roles, searchQuery, activeTab]); const paginatedData = useMemo(() => { const startIndex = (currentPage - 1) * itemsPerPage; @@ -190,24 +144,21 @@ export function PermissionManagement() { // ===== 핸들러 ===== const handleAdd = () => { - // 새 권한 등록 페이지로 이동 (저장 전까지 목록에 추가 안됨) router.push('/settings/permissions/new'); }; - const handleEdit = (permission: Permission, e?: React.MouseEvent) => { + const handleEdit = (role: Role, e?: React.MouseEvent) => { e?.stopPropagation(); - // 상세 페이지로 라우팅 - router.push(`/settings/permissions/${permission.id}`); + router.push(`/settings/permissions/${role.id}`); }; - const handleViewDetail = (permission: Permission) => { - // 상세 페이지로 라우팅 - router.push(`/settings/permissions/${permission.id}`); + const handleViewDetail = (role: Role) => { + router.push(`/settings/permissions/${role.id}`); }; - const handleDelete = (permission: Permission, e?: React.MouseEvent) => { + const handleDelete = (role: Role, e?: React.MouseEvent) => { e?.stopPropagation(); - setPermissionToDelete(permission); + setRoleToDelete(role); setIsBulkDelete(false); setDeleteDialogOpen(true); }; @@ -218,20 +169,41 @@ export function PermissionManagement() { setDeleteDialogOpen(true); }; - const confirmDelete = () => { - let updatedPermissions: Permission[]; - if (isBulkDelete) { - updatedPermissions = permissions.filter(p => !selectedItems.has(p.id.toString())); - setSelectedItems(new Set()); - } else if (permissionToDelete) { - updatedPermissions = permissions.filter(p => p.id !== permissionToDelete.id); - } else { - return; + const confirmDelete = async () => { + setIsDeleting(true); + + try { + if (isBulkDelete) { + // 일괄 삭제 + const deletePromises = Array.from(selectedItems).map(id => deleteRole(parseInt(id))); + const results = await Promise.all(deletePromises); + const failedCount = results.filter(r => !r.success).length; + + if (failedCount > 0) { + toast.error(`${failedCount}개 역할 삭제 실패`); + } else { + toast.success(`${selectedItems.size}개 역할 삭제 완료`); + } + setSelectedItems(new Set()); + } else if (roleToDelete) { + // 단일 삭제 + const result = await deleteRole(roleToDelete.id); + if (result.success) { + toast.success(`"${roleToDelete.name}" 역할 삭제 완료`); + } else { + toast.error(result.error || '역할 삭제 실패'); + } + } + + // 데이터 새로고침 + await loadData(); + } catch (err) { + toast.error(err instanceof Error ? err.message : '삭제 중 오류 발생'); + } finally { + setIsDeleting(false); + setDeleteDialogOpen(false); + setRoleToDelete(null); } - setPermissions(updatedPermissions); - savePermissions(updatedPermissions); - setDeleteDialogOpen(false); - setPermissionToDelete(null); }; // ===== 날짜 포맷 ===== @@ -241,63 +213,65 @@ export function PermissionManagement() { // ===== 탭 설정 ===== const tabs: TabOption[] = useMemo(() => { - const activeCount = permissions.filter(p => p.status === 'active').length; - const hiddenCount = permissions.filter(p => p.status === 'hidden').length; + const visibleCount = roles.filter(r => !r.is_hidden).length; + const hiddenCount = roles.filter(r => r.is_hidden).length; return [ - { value: 'all', label: '전체', count: permissions.length, color: 'blue' }, - { value: 'active', label: '공개', count: activeCount, color: 'green' }, + { value: 'all', label: '전체', count: roles.length, color: 'blue' }, + { value: 'visible', label: '공개', count: visibleCount, color: 'green' }, { value: 'hidden', label: '숨김', count: hiddenCount, color: 'gray' }, ]; - }, [permissions]); + }, [roles]); // ===== 통계 카드 ===== const statCards: StatCard[] = useMemo(() => { - const totalCount = permissions.length; - const activeCount = permissions.filter(p => p.status === 'active').length; - const hiddenCount = permissions.filter(p => p.status === 'hidden').length; - return [ { - label: '전체 권한', - value: totalCount, + label: '전체 역할', + value: stats?.total_roles ?? roles.length, icon: Shield, iconColor: 'text-blue-500', }, { label: '공개', - value: activeCount, + value: stats?.visible_roles ?? roles.filter(r => !r.is_hidden).length, icon: Eye, iconColor: 'text-green-500', }, { label: '숨김', - value: hiddenCount, + value: stats?.hidden_roles ?? roles.filter(r => r.is_hidden).length, icon: EyeOff, iconColor: 'text-gray-500', }, + { + label: '사용 중', + value: stats?.roles_with_users ?? 0, + icon: Users, + iconColor: 'text-purple-500', + }, ]; - }, [permissions]); + }, [roles, stats]); // ===== 테이블 컬럼 ===== const tableColumns: TableColumn[] = useMemo(() => { const baseColumns: TableColumn[] = [ { key: 'index', label: '번호', className: 'text-center w-[80px]' }, - { key: 'name', label: '권한', className: 'flex-1' }, - { key: 'status', label: '상태', className: 'text-center flex-1' }, - { key: 'createdAt', label: '등록일시', className: 'text-center flex-1' }, + { key: 'name', label: '역할', className: 'flex-1' }, + { key: 'description', label: '설명', className: 'flex-1' }, + { key: 'status', label: '상태', className: 'text-center w-[100px]' }, + { key: 'createdAt', label: '등록일', className: 'text-center w-[120px]' }, ]; - // 체크박스 선택 시에만 작업 컬럼 표시 if (selectedItems.size > 0) { - baseColumns.push({ key: 'action', label: '작업', className: 'text-center flex-1' }); + baseColumns.push({ key: 'action', label: '작업', className: 'text-center w-[150px]' }); } return baseColumns; }, [selectedItems.size]); // ===== 테이블 행 렌더링 ===== - const renderTableRow = useCallback((item: Permission, index: number, globalIndex: number) => { + const renderTableRow = useCallback((item: Role, index: number, globalIndex: number) => { const isSelected = selectedItems.has(item.id.toString()); const hasSelection = selectedItems.size > 0; @@ -317,12 +291,13 @@ export function PermissionManagement() { {globalIndex} {item.name} + {item.description || '-'} - - {item.status === 'active' ? '공개' : '숨김'} + + {item.is_hidden ? '숨김' : '공개'} - {formatDate(item.createdAt)} + {formatDate(item.created_at)} {hasSelection && ( e.stopPropagation()}>
@@ -362,7 +337,7 @@ export function PermissionManagement() { // ===== 모바일 카드 렌더링 ===== const renderMobileCard = useCallback(( - item: Permission, + item: Role, index: number, globalIndex: number, isSelected: boolean, @@ -373,16 +348,16 @@ export function PermissionManagement() { id={item.id.toString()} title={item.name} headerBadges={ - - {item.status === 'active' ? '공개' : '숨김'} + + {item.is_hidden ? '숨김' : '공개'} } isSelected={isSelected} onToggleSelection={onToggle} infoGrid={
- - + +
} actions={ @@ -420,23 +395,42 @@ export function PermissionManagement() { )}
); + // ===== 로딩/에러 상태 ===== + if (isLoading) { + return ( +
+ + 데이터를 불러오는 중... +
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + // ===== 목록 화면 ===== return ( <> item.id.toString()} + getItemId={(item: Role) => item.id.toString()} onBulkDelete={handleBulkDelete} renderTableRow={renderTableRow} renderMobileCard={renderMobileCard} @@ -464,25 +458,33 @@ export function PermissionManagement() { - 권한 삭제 + 역할 삭제 {isBulkDelete - ? `선택한 ${selectedItems.size}개의 권한을 삭제하시겠습니까?` - : `"${permissionToDelete?.name}" 권한을 삭제하시겠습니까?` + ? `선택한 ${selectedItems.size}개의 역할을 삭제하시겠습니까?` + : `"${roleToDelete?.name}" 역할을 삭제하시겠습니까?` }
- 이 권한을 사용 중인 사원이 있으면 해당 사원의 권한이 초기화됩니다. + 이 역할을 사용 중인 사원이 있으면 해당 사원의 역할이 초기화됩니다.
- 취소 + 취소 - 삭제 + {isDeleting ? ( + <> + + 삭제 중... + + ) : ( + '삭제' + )}
diff --git a/src/components/settings/PermissionManagement/types.ts b/src/components/settings/PermissionManagement/types.ts index 30b71d4f..2d2bd103 100644 --- a/src/components/settings/PermissionManagement/types.ts +++ b/src/components/settings/PermissionManagement/types.ts @@ -1,11 +1,83 @@ /** - * 권한 타입 정의 (PDF 54-55페이지 기준) + * 권한 관리 타입 정의 */ -// 권한 유형 +// ========== API 응답 공통 타입 ========== + +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; +} + +export interface PaginatedResponse { + data: T[]; + meta: { + current_page: number; + last_page: number; + per_page: number; + total: number; + }; +} + +// ========== Role (역할) 타입 ========== + +export interface Role { + id: number; + name: string; + description?: string; + is_hidden: boolean; + guard_name: string; + created_at: string; + updated_at: string; + permissions_count?: number; + users_count?: number; +} + +export interface RoleStats { + total_roles: number; + visible_roles: number; + hidden_roles: number; + roles_with_users: number; +} + +// ========== Permission (권한) 타입 ========== + export type PermissionType = 'view' | 'create' | 'update' | 'delete' | 'approve' | 'export' | 'manage'; -// 메뉴별 권한 설정 +// 메뉴 트리 아이템 (권한 매트릭스용) +export interface MenuTreeItem { + id: number; + name: string; + code: string; + parent_id: number | null; + depth: number; + sort_order: number; + children?: MenuTreeItem[]; +} + +// 권한 매트릭스 응답 +export interface PermissionMatrix { + role_id: number; + role_name: string; + menus: MenuPermissionItem[]; + permission_types: PermissionType[]; +} + +// 메뉴별 권한 상태 +export interface MenuPermissionItem { + menu_id: number; + menu_name: string; + menu_code: string; + depth: number; + permissions: { + [key in PermissionType]?: boolean; + }; +} + +// ========== Legacy 타입 (기존 호환성) ========== + +// 메뉴별 권한 설정 (기존 호환) export interface MenuPermission { menuId: string; menuName: string; @@ -15,7 +87,7 @@ export interface MenuPermission { }; } -// 권한 그룹 +// 권한 그룹 (기존 호환, Role로 대체 권장) export interface Permission { id: number; name: string; @@ -25,6 +97,17 @@ export interface Permission { updatedAt?: string; } +// ========== Dialog Props ========== + +export interface RoleDialogProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + mode: 'add' | 'edit'; + role?: Role; + onSubmit: (data: { name: string; description?: string; is_hidden: boolean }) => void; +} + +// 기존 호환성 export interface PermissionDialogProps { isOpen: boolean; onOpenChange: (open: boolean) => void; @@ -33,6 +116,8 @@ export interface PermissionDialogProps { onSubmit: (data: { name: string; status: 'active' | 'hidden' }) => void; } +// ========== 상수 ========== + // 권한 라벨 매핑 export const PERMISSION_LABELS: Record = { view: '조회', @@ -43,3 +128,9 @@ export const PERMISSION_LABELS: Record = { export: '내보내기', manage: '관리', }; + +// 역할 상태 라벨 +export const ROLE_STATUS_LABELS = { + visible: '공개', + hidden: '숨김', +} as const;