From 4614210aa9878922cfa1e424ce677ef893a1b6b2 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 29 Sep 2025 13:31:40 +0000 Subject: (대표님) 구매 권한설정, data room 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/file-manager/FileManager.tsx | 1215 +++++++++++++------- components/file-manager/SecurePDFViewer.tsx | 350 ++++++ components/form-data/spreadJS-dialog.tsx | 27 +- components/permissions/permission-crud-manager.tsx | 92 +- .../permission-group-assignment-manager.tsx | 666 +++++++++++ .../permissions/permission-group-manager.tsx | 294 +++-- components/permissions/role-permission-manager.tsx | 32 +- components/permissions/role-selector.tsx | 227 ++++ components/project/ProjectDashboard.tsx | 465 ++++++-- components/project/ProjectList.tsx | 221 ++-- components/project/ProjectNav.tsx | 30 +- components/vendor-data/vendor-data-container.tsx | 22 +- 12 files changed, 2899 insertions(+), 742 deletions(-) create mode 100644 components/file-manager/SecurePDFViewer.tsx create mode 100644 components/permissions/permission-group-assignment-manager.tsx create mode 100644 components/permissions/role-selector.tsx (limited to 'components') diff --git a/components/file-manager/FileManager.tsx b/components/file-manager/FileManager.tsx index 483ef773..587beb22 100644 --- a/components/file-manager/FileManager.tsx +++ b/components/file-manager/FileManager.tsx @@ -1,12 +1,12 @@ 'use client'; import React, { useState, useEffect, useCallback } from 'react'; -import { - Folder, - File, - FolderPlus, - Upload, - Trash2, +import { + Folder, + File, + FolderPlus, + Upload, + Trash2, Edit2, Download, Share2, @@ -93,6 +93,8 @@ import { } from "@/components/ui/file-list"; import { decryptWithServerAction } from '@/components/drm/drmUtils'; import { Progress } from '@/components/ui/progress'; +// Import the secure viewer component +import { SecurePDFViewer } from './SecurePDFViewer'; interface FileItem { id: string; @@ -126,12 +128,12 @@ interface FileManagerProps { projectId: string; } -// 카테고리별 아이콘과 색상 +// Category configuration with icons and colors const categoryConfig = { - public: { icon: Globe, color: 'text-green-500', label: '공개' }, - restricted: { icon: Eye, color: 'text-yellow-500', label: '제한' }, - confidential: { icon: Lock, color: 'text-red-500', label: '기밀' }, - internal: { icon: Shield, color: 'text-blue-500', label: '내부' }, + public: { icon: Globe, color: 'text-green-500', label: 'Public' }, + restricted: { icon: Eye, color: 'text-yellow-500', label: 'Restricted' }, + confidential: { icon: Lock, color: 'text-red-500', label: 'Confidential' }, + internal: { icon: Shield, color: 'text-blue-500', label: 'Internal' }, }; // Tree Item Component @@ -143,172 +145,183 @@ const TreeItem: React.FC<{ onToggleExpand: (id: string) => void; onSelectItem: (id: string) => void; onDoubleClick: (item: FileItem) => void; + onView: (item: FileItem) => void; onDownload: (item: FileItem) => void; onDownloadFolder: (item: FileItem) => void; onDelete: (ids: string[]) => void; onShare: (item: FileItem) => void; onRename: (item: FileItem) => void; isInternalUser: boolean; -}> = ({ - item, - level, - expandedFolders, - selectedItems, - onToggleExpand, +}> = ({ + item, + level, + expandedFolders, + selectedItems, + onToggleExpand, onSelectItem, onDoubleClick, + onView, onDownload, onDownloadFolder, onDelete, onShare, onRename, - isInternalUser + isInternalUser }) => { - const hasChildren = item.type === 'folder' && item.children && item.children.length > 0; - const isExpanded = expandedFolders.has(item.id); - const isSelected = selectedItems.has(item.id); - const CategoryIcon = categoryConfig[item.category].icon; - const categoryColor = categoryConfig[item.category].color; - const categoryLabel = categoryConfig[item.category].label; + const hasChildren = item.type === 'folder' && item.children && item.children.length > 0; + const isExpanded = expandedFolders.has(item.id); + const isSelected = selectedItems.has(item.id); + const CategoryIcon = categoryConfig[item.category].icon; + const categoryColor = categoryConfig[item.category].color; + const categoryLabel = categoryConfig[item.category].label; - const formatFileSize = (bytes?: number) => { - if (!bytes) return '-'; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; - }; + const formatFileSize = (bytes?: number) => { + if (!bytes) return '-'; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; + }; - return ( - <> -
onSelectItem(item.id)} - onDoubleClick={() => onDoubleClick(item)} - > -
- {item.type === 'folder' && ( - + return ( + <> +
- )} -
- - {item.type === 'folder' ? ( - - ) : ( - - )} - - {item.name} - - - - {categoryLabel} - - - - {formatFileSize(item.size)} - - - {new Date(item.updatedAt).toLocaleDateString()} - - - - - - - - {item.type === 'file' && item.permissions?.canDownload && ( - onDownload(item)}> - - 다운로드 - - )} - + style={{ paddingLeft: `${level * 24 + 8}px` }} + onClick={() => onSelectItem(item.id)} + onDoubleClick={() => onDoubleClick(item)} + > +
{item.type === 'folder' && ( - onDownloadFolder(item)}> - - 폴더 전체 다운로드 - - )} - - {isInternalUser && ( - <> - onShare(item)}> - - 공유 - - - {item.permissions?.canEdit && ( - onRename(item)}> - - 이름 변경 - + )} - - {item.permissions?.canDelete && ( - <> - - onDelete([item.id])} - > - - 삭제 - - + {item.type === 'file' && ( +
)} - - -
- - {item.type === 'folder' && isExpanded && item.children && ( -
- {item.children.map((child) => ( - - ))} +
+ + {item.type === 'folder' ? ( + + ) : ( + + )} + + {item.name} + + + + {categoryLabel} + + + + {formatFileSize(item.size)} + + + {new Date(item.updatedAt).toLocaleDateString()} + + + + + + + + {item.type === 'file' && ( + <> + onView(item)}> + + View + + {item.permissions?.canDownload && ( + onDownload(item)}> + + Download + + )} + + )} + + {item.type === 'folder' && ( + onDownloadFolder(item)}> + + Download Folder + + )} + + {isInternalUser && ( + <> + onShare(item)}> + + Share + + + {item.permissions?.canEdit && ( + onRename(item)}> + + Rename + + )} + + )} + + {item.permissions?.canDelete && ( + <> + + onDelete([item.id])} + > + + Delete + + + )} + +
- )} - - ); -}; + + {item.type === 'folder' && isExpanded && item.children && ( +
+ {item.children.map((child) => ( + + ))} +
+ )} + + ); + }; export function FileManager({ projectId }: FileManagerProps) { const { data: session } = useSession(); @@ -321,19 +334,23 @@ export function FileManager({ projectId }: FileManagerProps) { const [viewMode, setViewMode] = useState<'grid' | 'list'>('list'); const [searchQuery, setSearchQuery] = useState(''); const [loading, setLoading] = useState(false); - - // 업로드 상태 + + console.log(items,"items") + + // Upload states const [uploadDialogOpen, setUploadDialogOpen] = useState(false); const [uploadingFiles, setUploadingFiles] = useState([]); const [uploadCategory, setUploadCategory] = useState('confidential'); - - // 다이얼로그 상태 + + // Dialog states const [folderDialogOpen, setFolderDialogOpen] = useState(false); const [shareDialogOpen, setShareDialogOpen] = useState(false); const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); const [renameDialogOpen, setRenameDialogOpen] = useState(false); - - // 다이얼로그 데이터 + const [viewerDialogOpen, setViewerDialogOpen] = useState(false); + const [viewerFileUrl, setViewerFileUrl] = useState(null); + + // Dialog data const [dialogValue, setDialogValue] = useState(''); const [selectedCategory, setSelectedCategory] = useState('confidential'); const [selectedFile, setSelectedFile] = useState(null); @@ -343,76 +360,76 @@ export function FileManager({ projectId }: FileManagerProps) { expiresAt: '', maxDownloads: '', }); - + const { toast } = useToast(); - // 사용자가 내부 사용자인지 확인 + // Check if user is internal const isInternalUser = session?.user?.domain !== 'partners'; - // 트리 구조 생성 함수 + // Build tree structure function const buildTree = (flatItems: FileItem[]): FileItem[] => { const itemMap = new Map(); const rootItems: FileItem[] = []; - - // 모든 아이템을 맵에 저장 (children 초기화) + + // Store all items in map (initialize children) flatItems.forEach(item => { itemMap.set(item.id, { ...item, children: [] }); }); - - // 부모-자식 관계 설정 + + // Set parent-child relationships flatItems.forEach(item => { const mappedItem = itemMap.get(item.id)!; - + if (!item.parentId) { - // parentId가 없으면 루트 아이템 + // No parentId means root item rootItems.push(mappedItem); } else { - // parentId가 있으면 부모의 children에 추가 + // Has parentId, add to parent's children const parent = itemMap.get(item.parentId); if (parent) { if (!parent.children) parent.children = []; parent.children.push(mappedItem); } else { - // 부모를 찾을 수 없으면 루트로 처리 + // Can't find parent, treat as root rootItems.push(mappedItem); } } }); - + return rootItems; }; - // 파일 목록 가져오기 + // Fetch file list const fetchItems = useCallback(async () => { setLoading(true); try { const params = new URLSearchParams(); - - // 트리 뷰일 때는 전체 목록을 가져옴 + + // For tree view, get entire list if (viewMode === 'list') { params.append('viewMode', 'tree'); - // 트리 뷰에서도 현재 경로 정보는 유지 (하이라이팅 등에 사용) + // Keep current path info for tree view (used for highlighting, etc.) if (currentParentId) params.append('currentParentId', currentParentId); } else { - // 그리드 뷰일 때는 현재 폴더의 내용만 가져옴 + // For grid view, only get current folder contents if (currentParentId) params.append('parentId', currentParentId); } - + const response = await fetch(`/api/data-room/${projectId}?${params}`); if (!response.ok) throw new Error('Failed to fetch files'); - + const data = await response.json(); setItems(data); - - // 트리 구조 생성 + + // Build tree structure if (viewMode === 'list') { const tree = buildTree(data); setTreeItems(tree); } } catch (error) { toast({ - title: '오류', - description: '파일을 불러오는데 실패했습니다.', + title: 'Error', + description: 'Failed to load files.', variant: 'destructive', }); } finally { @@ -424,7 +441,7 @@ export function FileManager({ projectId }: FileManagerProps) { fetchItems(); }, [fetchItems]); - // 폴더 생성 + // Create folder const createFolder = async () => { try { const response = await fetch(`/api/data-room/${projectId}`, { @@ -446,66 +463,66 @@ export function FileManager({ projectId }: FileManagerProps) { await fetchItems(); setFolderDialogOpen(false); setDialogValue(''); - + toast({ - title: '성공', - description: '폴더가 생성되었습니다.', + title: 'Success', + description: 'Folder created successfully.', }); } catch (error: any) { toast({ - title: '오류', - description: error.message || '폴더 생성에 실패했습니다.', + title: 'Error', + description: error.message || 'Failed to create folder.', variant: 'destructive', }); } }; - // 파일 업로드 처리 + // Handle file upload const handleFileUpload = async (files: FileList | File[]) => { const fileArray = Array.from(files); - - // 업로드 파일 목록 초기화 + + // Initialize uploading file list const newUploadingFiles: UploadingFile[] = fileArray.map(file => ({ file, progress: 0, status: 'pending' as const })); - + setUploadingFiles(newUploadingFiles); - - // 각 파일 업로드 처리 + + // Process each file upload for (let i = 0; i < fileArray.length; i++) { const file = fileArray[i]; - + try { - // 상태 업데이트: 업로드 중 - setUploadingFiles(prev => prev.map((f, idx) => + // Update status: uploading + setUploadingFiles(prev => prev.map((f, idx) => idx === i ? { ...f, status: 'uploading', progress: 20 } : f )); - // DRM 복호화 - setUploadingFiles(prev => prev.map((f, idx) => + // DRM decryption + setUploadingFiles(prev => prev.map((f, idx) => idx === i ? { ...f, status: 'processing', progress: 40 } : f )); - + const decryptedData = await decryptWithServerAction(file); - - // FormData 생성 + + // Create FormData const formData = new FormData(); const blob = new Blob([decryptedData], { type: file.type }); formData.append('file', blob, file.name); formData.append('category', uploadCategory); - formData.append('fileSize', file.size.toString()); // 파일 크기 전달 + formData.append('fileSize', file.size.toString()); // Pass file size if (currentParentId) { formData.append('parentId', currentParentId); } - - // 업로드 진행률 업데이트 - setUploadingFiles(prev => prev.map((f, idx) => + + // Update upload progress + setUploadingFiles(prev => prev.map((f, idx) => idx === i ? { ...f, progress: 60 } : f )); - // API 호출 + // API call const response = await fetch(`/api/data-room/${projectId}/upload`, { method: 'POST', body: formData, @@ -515,99 +532,99 @@ export function FileManager({ projectId }: FileManagerProps) { const error = await response.json(); throw new Error(error.error || 'Upload failed'); } - - // 성공 - setUploadingFiles(prev => prev.map((f, idx) => + + // Success + setUploadingFiles(prev => prev.map((f, idx) => idx === i ? { ...f, status: 'completed', progress: 100 } : f )); - + } catch (error: any) { - // 실패 - setUploadingFiles(prev => prev.map((f, idx) => - idx === i ? { - ...f, - status: 'error', - error: error.message || '업로드 실패' + // Failure + setUploadingFiles(prev => prev.map((f, idx) => + idx === i ? { + ...f, + status: 'error', + error: error.message || 'Upload failed' } : f )); } } - - // 모든 업로드 완료 후 목록 새로고침 + + // Refresh list after all uploads complete await fetchItems(); - - // 성공한 파일이 있으면 토스트 표시 + + // Show toast if any files succeeded const successCount = newUploadingFiles.filter(f => f.status === 'completed').length; if (successCount > 0) { toast({ - title: '업로드 완료', - description: `${successCount}개 파일이 업로드되었습니다.`, + title: 'Upload Complete', + description: `${successCount} file(s) uploaded successfully.`, }); } }; - // 폴더 다운로드 + // Download folder const downloadFolder = async (folder: FileItem) => { if (folder.type !== 'folder') return; - + try { toast({ - title: '권한 확인 중', - description: '폴더 내 파일들의 다운로드 권한을 확인하고 있습니다...', + title: 'Checking Permissions', + description: 'Verifying download permissions for folder contents...', }); - // 폴더 다운로드 API 호출 + // Call folder download API const response = await fetch(`/api/data-room/${projectId}/download-folder/${folder.id}`, { method: 'GET', }); - + if (!response.ok) { const error = await response.json(); - - // 권한이 없는 파일이 있는 경우 상세 정보 제공 + + // If there are files without permission, provide details if (error.unauthorizedFiles) { toast({ - title: '다운로드 권한 부족', - description: `${error.unauthorizedFiles.length}개 파일에 대한 권한이 없습니다: ${error.unauthorizedFiles.join(', ')}`, + title: 'Insufficient Permissions', + description: `No permission for ${error.unauthorizedFiles.length} file(s): ${error.unauthorizedFiles.join(', ')}`, variant: 'destructive', }); return; } - - throw new Error(error.error || '폴더 다운로드 실패'); + + throw new Error(error.error || 'Folder download failed'); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); - - // 폴더명을 파일명에 포함 + + // Include folder name in filename const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const fileName = `${folder.name}_${timestamp}.zip`; - + const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); - + window.URL.revokeObjectURL(url); document.body.removeChild(a); toast({ - title: '다운로드 완료', - description: `${folder.name} 폴더가 다운로드되었습니다.`, + title: 'Download Complete', + description: `${folder.name} folder downloaded successfully.`, }); } catch (error: any) { toast({ - title: '오류', - description: error.message || '폴더 다운로드에 실패했습니다.', + title: 'Error', + description: error.message || 'Failed to download folder.', variant: 'destructive', }); } }; - // 파일 공유 + // Share file const shareFile = async () => { if (!selectedFile) return; @@ -626,45 +643,45 @@ export function FileManager({ projectId }: FileManagerProps) { } const data = await response.json(); - - // 공유 링크 복사 + + // Copy share link to clipboard await navigator.clipboard.writeText(data.shareUrl); - + toast({ - title: '공유 링크 생성됨', - description: '링크가 클립보드에 복사되었습니다.', + title: 'Share Link Created', + description: 'Link copied to clipboard.', }); - + setShareDialogOpen(false); setSelectedFile(null); } catch (error) { toast({ - title: '오류', - description: '공유 링크 생성에 실패했습니다.', + title: 'Error', + description: 'Failed to create share link.', variant: 'destructive', }); } }; - // 다중 파일 다운로드 + // Download multiple files const downloadMultipleFiles = async (itemIds: string[]) => { - // 선택된 파일들 중 실제 파일만 필터링 (폴더 제외) - const filesToDownload = items.filter(item => - itemIds.includes(item.id) && - item.type === 'file' && - item.permissions?.canDownload + // Filter only actual files (exclude folders) that can be downloaded + const filesToDownload = items.filter(item => + itemIds.includes(item.id) && + item.type === 'file' && + item.permissions?.canDownload === 'true' ); if (filesToDownload.length === 0) { toast({ - title: '알림', - description: '다운로드 가능한 파일이 없습니다.', + title: 'Notice', + description: 'No downloadable files selected.', variant: 'default', }); return; } - // 단일 파일인 경우 일반 다운로드 사용 + // Use regular download for single file if (filesToDownload.length === 1) { await downloadFile(filesToDownload[0]); return; @@ -672,98 +689,119 @@ export function FileManager({ projectId }: FileManagerProps) { try { toast({ - title: '다운로드 준비 중', - description: `${filesToDownload.length}개 파일을 압축하고 있습니다...`, + title: 'Preparing Download', + description: `Compressing ${filesToDownload.length} files...`, }); - // 여러 파일 다운로드 API 호출 + // Call multiple files download API const response = await fetch(`/api/data-room/${projectId}/download-multiple`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileIds: filesToDownload.map(f => f.id) }) }); - + if (!response.ok) { - throw new Error('다운로드 실패'); + throw new Error('Download failed'); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); - - // 현재 날짜시간을 파일명에 포함 + + // Include timestamp in filename const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const fileName = `files_${timestamp}.zip`; - + const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); - + window.URL.revokeObjectURL(url); document.body.removeChild(a); toast({ - title: '다운로드 완료', - description: `${filesToDownload.length}개 파일이 다운로드되었습니다.`, + title: 'Download Complete', + description: `${filesToDownload.length} files downloaded successfully.`, }); } catch (error) { - console.error('다중 다운로드 오류:', error); - - // 실패 시 개별 다운로드 옵션 제공 + console.error('Multiple download error:', error); + + // Offer individual downloads on failure toast({ - title: '압축 다운로드 실패', - description: '개별 다운로드를 시도하시겠습니까?', + title: 'Batch Download Failed', + description: 'Would you like to try individual downloads?', action: ( ), }); } }; - // 파일 다운로드 + // View file with PDFTron + const viewFile = async (file: FileItem) => { + try { + + + + setViewerFileUrl(file.filePath); + setSelectedFile(file); + setViewerDialogOpen(true); + + + + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to open file for viewing.', + variant: 'destructive', + }); + } + }; + + // Download file const downloadFile = async (file: FileItem) => { try { const response = await fetch(`/api/data-room/${projectId}/${file.id}/download`); - + if (!response.ok) { throw new Error('Download failed'); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); - + const a = document.createElement('a'); a.href = url; a.download = file.name; document.body.appendChild(a); a.click(); - + window.URL.revokeObjectURL(url); document.body.removeChild(a); } catch (error) { toast({ - title: '오류', - description: '다운로드에 실패했습니다.', + title: 'Error', + description: 'Download failed.', variant: 'destructive', }); } }; - // 파일 삭제 + // Delete files const deleteItems = async (itemIds: string[]) => { try { await Promise.all( @@ -774,27 +812,106 @@ export function FileManager({ projectId }: FileManagerProps) { await fetchItems(); setSelectedItems(new Set()); - + + toast({ + title: 'Success', + description: 'Selected items deleted successfully.', + }); + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to delete items.', + variant: 'destructive', + }); + } + }; + + // Rename item + const renameItem = async () => { + if (!selectedFile) return; + + try { + const response = await fetch( + `/api/data-room/${projectId}/${selectedFile.id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: dialogValue }), + } + ); + + if (!response.ok) { + throw new Error('Failed to rename'); + } + + await fetchItems(); + setRenameDialogOpen(false); + setSelectedFile(null); + setDialogValue(''); + + toast({ + title: 'Success', + description: 'Item renamed successfully.', + }); + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to rename item.', + variant: 'destructive', + }); + } + }; + + // Change category + const changeCategory = async ( + itemId: string, + newCategory: string, + applyToChildren: boolean = false + ) => { + try { + const response = await fetch( + `/api/data-room/${projectId}/${itemId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + category: newCategory, + applyToChildren + }), + } + ); + + if (!response.ok) { + throw new Error('Failed to change category'); + } + + await fetchItems(); + toast({ - title: '성공', - description: '선택한 항목이 삭제되었습니다.', + title: 'Success', + description: 'Category updated successfully.', }); } catch (error) { toast({ - title: '오류', - description: '삭제에 실패했습니다.', + title: 'Error', + description: 'Failed to change category.', variant: 'destructive', }); } }; - // 폴더 더블클릭 처리 + // Category change dialog states + const [categoryDialogOpen, setCategoryDialogOpen] = useState(false); + const [applyToChildren, setApplyToChildren] = useState(false); + const [newCategory, setNewCategory] = useState('confidential'); + + // Handle folder double click const handleFolderOpen = (folder: FileItem) => { if (viewMode === 'grid') { setCurrentPath([...currentPath, folder.name]); setCurrentParentId(folder.id); } else { - // 트리 뷰에서는 expand/collapse + // In tree view, expand/collapse const newExpanded = new Set(expandedFolders); if (newExpanded.has(folder.id)) { newExpanded.delete(folder.id); @@ -806,7 +923,7 @@ export function FileManager({ projectId }: FileManagerProps) { setSelectedItems(new Set()); }; - // 폴더 확장 토글 + // Toggle folder expansion const toggleFolderExpand = (folderId: string) => { const newExpanded = new Set(expandedFolders); if (newExpanded.has(folderId)) { @@ -817,7 +934,7 @@ export function FileManager({ projectId }: FileManagerProps) { setExpandedFolders(newExpanded); }; - // 아이템 선택 + // Toggle item selection const toggleItemSelection = (itemId: string) => { const newSelected = new Set(selectedItems); if (newSelected.has(itemId)) { @@ -828,18 +945,18 @@ export function FileManager({ projectId }: FileManagerProps) { setSelectedItems(newSelected); }; - // 경로 탐색 + // Navigate to path const navigateToPath = (index: number) => { if (index === -1) { setCurrentPath([]); setCurrentParentId(null); } else { setCurrentPath(currentPath.slice(0, index + 1)); - // parentId 업데이트 로직 필요 + // Need to update parentId logic } }; - // 필터링된 아이템 + // Filtered items const filteredItems = items.filter(item => item.name.toLowerCase().includes(searchQuery.toLowerCase()) ); @@ -848,7 +965,7 @@ export function FileManager({ projectId }: FileManagerProps) { item.name.toLowerCase().includes(searchQuery.toLowerCase()) ); - // 파일 크기 포맷 + // Format file size const formatFileSize = (bytes?: number) => { if (!bytes) return '-'; const sizes = ['B', 'KB', 'MB', 'GB']; @@ -858,7 +975,7 @@ export function FileManager({ projectId }: FileManagerProps) { return (
- {/* 툴바 */} + {/* Toolbar */}
@@ -869,40 +986,40 @@ export function FileManager({ projectId }: FileManagerProps) { onClick={() => setFolderDialogOpen(true)} > - 새 폴더 + New Folder - )} - + {selectedItems.size > 0 && ( <> - {/* 다중 다운로드 버튼 */} - {items.filter(item => - selectedItems.has(item.id) && - item.type === 'file' && - item.permissions?.canDownload + {/* Multiple download button */} + {items.filter(item => + selectedItems.has(item.id) && + item.type === 'file' && + item.permissions?.canDownload ==='true' ).length > 0 && ( - - )} - - {/* 삭제 버튼 */} + + )} + + {/* Delete button */} {items.find(item => selectedItems.has(item.id))?.permissions?.canDelete && ( )} )} - + {!isInternalUser && ( - 외부 사용자 + External User )}
- +
setSearchQuery(e.target.value)} />
- +
- {/* 파일 목록 */} + {/* File List */} {loading ? (
-
로딩 중...
+
Loading...
) : filteredItems.length === 0 ? (
-

비어있음

+

Empty

) : viewMode === 'grid' ? (
{filteredItems.map((item) => { const CategoryIcon = categoryConfig[item.category].icon; const categoryColor = categoryConfig[item.category].color; - + return ( @@ -1006,11 +1123,11 @@ export function FileManager({ projectId }: FileManagerProps) { )}
- + {item.name} - + {item.viewCount !== undefined && (
@@ -1027,45 +1144,66 @@ export function FileManager({ projectId }: FileManagerProps) { )}
- + {item.type === 'folder' && ( <> handleFolderOpen(item)}> - 열기 + Open downloadFolder(item)}> - 폴더 전체 다운로드 + Download Folder )} - - {item.type === 'file' && item.permissions?.canDownload && ( - downloadFile(item)}> - - 다운로드 - + + {item.type === 'file' && ( + <> + viewFile(item)}> + + View + + {item.permissions?.canDownload === 'true' && ( + downloadFile(item)}> + + Download + + )} + )} - + {isInternalUser && ( <> - 카테고리 변경 + Change Category {Object.entries(categoryConfig).map(([key, config]) => ( - + { + if (item.type === 'folder') { + // Show dialog for folders + setSelectedFile(item); + setNewCategory(key); + setCategoryDialogOpen(true); + } else { + // Change immediately for files + changeCategory(item.id, key, false); + } + }} + > {config.label} ))} - + { setSelectedFile(item); @@ -1073,9 +1211,9 @@ export function FileManager({ projectId }: FileManagerProps) { }} > - 공유 + Share - + {item.permissions?.canEdit && ( { setSelectedFile(item); @@ -1083,12 +1221,12 @@ export function FileManager({ projectId }: FileManagerProps) { setRenameDialogOpen(true); }}> - 이름 변경 + Rename )} )} - + {item.permissions?.canDelete && ( <> @@ -1097,7 +1235,7 @@ export function FileManager({ projectId }: FileManagerProps) { onClick={() => deleteItems([item.id])} > - 삭제 + Delete )} @@ -1119,6 +1257,7 @@ export function FileManager({ projectId }: FileManagerProps) { onToggleExpand={toggleFolderExpand} onSelectItem={toggleItemSelection} onDoubleClick={handleFolderOpen} + onView={viewFile} onDownload={downloadFile} onDownloadFolder={downloadFolder} onDelete={deleteItems} @@ -1138,20 +1277,20 @@ export function FileManager({ projectId }: FileManagerProps) { )}
- {/* 업로드 다이얼로그 */} + {/* Upload Dialog */} - 파일 업로드 + Upload Files - 파일을 드래그 앤 드롭하거나 클릭하여 선택하세요. + Drag and drop files or click to select. - +
- {/* 카테고리 선택 */} + {/* Category Selection */}
- + setDialogValue(e.target.value)} - placeholder="폴더 이름 입력" + placeholder="Enter folder name" />
- +
- +
- + - +
- {/* 파일 공유 다이얼로그 */} + {/* File Share Dialog */} - 파일 공유 + Share File - {selectedFile?.name}을(를) 공유합니다. + Sharing {selectedFile?.name}. - + - 링크 공유 - 권한 설정 + Link Sharing + Permission Settings - +
- - setShareSettings({ ...shareSettings, accessLevel: value })} > @@ -1347,101 +1486,305 @@ export function FileManager({ projectId }: FileManagerProps) {
- 보기만 가능 + View Only
- 보기 + 다운로드 + View + Download
- +
- + setShareSettings({...shareSettings, password: e.target.value})} - placeholder="비밀번호 입력" + onChange={(e) => setShareSettings({ ...shareSettings, password: e.target.value })} + placeholder="Enter password" />
- +
- + setShareSettings({...shareSettings, expiresAt: e.target.value})} + onChange={(e) => setShareSettings({ ...shareSettings, expiresAt: e.target.value })} />
- +
- + setShareSettings({...shareSettings, maxDownloads: e.target.value})} - placeholder="무제한" + onChange={(e) => setShareSettings({ ...shareSettings, maxDownloads: e.target.value })} + placeholder="Unlimited" />
- +
- +
- +
- +
- +
- +
- +
- +
- + + +
+
+ + {/* Rename Dialog */} + + + + Rename + + {selectedFile?.type === 'file' + ? 'Enter the file name. (Extension will be preserved automatically)' + : 'Enter the folder name.' + } + + + +
+ + setDialogValue(e.target.value)} + placeholder={ + selectedFile?.type === 'file' + ? selectedFile.name.substring(0, selectedFile.name.lastIndexOf('.')) + : selectedFile?.name + } + onKeyDown={(e) => { + if (e.key === 'Enter') { + renameItem(); + } + }} + /> + {selectedFile?.type === 'file' && ( +

+ Extension: {selectedFile.name.substring(selectedFile.name.lastIndexOf('.'))} +

+ )} +
+ + + + + +
+
+ + {/* Category Change Dialog (for folders) */} + + + + Change Category + + Changing category for {selectedFile?.name} folder. + + + +
+
+ +
+ {Object.entries(categoryConfig).map(([key, config]) => ( +
setNewCategory(key)} + > + +
+
{config.label}
+
+ {key === 'public' && 'External users can access freely'} + {key === 'restricted' && 'External users can only view'} + {key === 'confidential' && 'External users cannot access'} + {key === 'internal' && 'Internal use only'} +
+
+
+ ))} +
+
+ + {selectedFile?.type === 'folder' && ( +
+ + +
+ )} +
+ + + +
+ + {/* Secure Document Viewer Dialog */} + { + if (!open) { + setViewerDialogOpen(false); + setViewerFileUrl(null); + setSelectedFile(null); + } + }} + > + + + +
+ + Secure Document Viewer +
+
+ + View Only Mode +
+
+ +
+ Viewing: {selectedFile?.name} + + + Protected Document - No Download/Copy/Print + +
+
+
+ +
+ {viewerFileUrl && selectedFile && ( + { + setViewerDialogOpen(false); + setViewerFileUrl(null); + setSelectedFile(null); + }} + /> + )} +
+ +
+
+
+ Viewer: {session?.user?.email} + Time: {new Date().toLocaleString()} + IP logged for security +
+ +
+
+
+
); } \ No newline at end of file diff --git a/components/file-manager/SecurePDFViewer.tsx b/components/file-manager/SecurePDFViewer.tsx new file mode 100644 index 00000000..cd7c081a --- /dev/null +++ b/components/file-manager/SecurePDFViewer.tsx @@ -0,0 +1,350 @@ +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; +import { useSession } from 'next-auth/react'; +import { WebViewerInstance } from '@pdftron/webviewer'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +interface SecurePDFViewerProps { + documentUrl: string; + fileName: string; + onClose?: () => void; +} + +export function SecurePDFViewer({ documentUrl, fileName, onClose }: SecurePDFViewerProps) { + const viewerRef = useRef(null); + const instanceRef = useRef(null); + const initialized = useRef(false); + const isCancelled = useRef(false); + const { data: session } = useSession(); + const [isLoading, setIsLoading] = useState(true); + + // WebViewer 초기화 + useEffect(() => { + if (!initialized.current && viewerRef.current) { + initialized.current = true; + isCancelled.current = false; + + requestAnimationFrame(() => { + if (viewerRef.current) { + import('@pdftron/webviewer').then(({ default: WebViewer }) => { + if (isCancelled.current) { + console.log('📛 WebViewer 초기화 취소됨'); + return; + } + + const viewerElement = viewerRef.current; + if (!viewerElement) return; + + WebViewer( + { + path: '/pdftronWeb', // BasicContractTemplateViewer와 동일한 경로 + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, // 동일한 라이센스 키 + fullAPI: true, + enableFilePicker: false, + enableMeasurement: false, + enableRedaction: false, + enableAnnotations: false, + enableTextSelection: false, + enableFormFilling: false, + enablePrint: false, + enableDownload: false, + }, + viewerElement + ).then(async (instance: WebViewerInstance) => { + instanceRef.current = instance; + + try { + const { disableElements } = instance.UI; + + // 보안을 위해 모든 도구 비활성화 + disableElements([ + 'toolsHeader', + 'viewControlsButton', + 'panToolButton', + 'selectToolButton', + 'menuButton', + 'leftPanel', + 'leftPanelButton', + 'searchButton', + 'notesPanel', + 'notesPanelButton', + 'toolbarGroup-Annotate', + 'toolbarGroup-Shapes', + 'toolbarGroup-Edit', + 'toolbarGroup-Insert', + 'toolbarGroup-FillAndSign', + 'toolbarGroup-Forms', + 'toolsOverlay', + 'printButton', + 'downloadButton', + 'saveAsButton', + 'filePickerHandler', + 'textPopup', + 'contextMenuPopup', + 'pageManipulationOverlay', + 'documentControl', + 'header', + 'ribbons', + 'toggleNotesButton' + ]); + + // CSS 적용으로 추가 보안 + const iframeWindow = instance.UI.iframeWindow; + if (iframeWindow && iframeWindow.document) { + const style = iframeWindow.document.createElement('style'); + style.textContent = ` + /* Hide all toolbars and buttons */ + .HeaderToolsContainer, + .Header, + .ToolsHeader, + .LeftHeader, + .RightHeader, + .DocumentContainer > div:first-child { + display: none !important; + } + + /* Disable right-click context menu */ + * { + -webkit-user-select: none !important; + -moz-user-select: none !important; + -ms-user-select: none !important; + user-select: none !important; + } + + /* Hide page controls */ + .DocumentContainer .PageControls { + display: none !important; + } + + /* Disable text selection cursor */ + .pageContainer { + cursor: default !important; + } + + /* Prevent drag and drop */ + * { + -webkit-user-drag: none !important; + -khtml-user-drag: none !important; + -moz-user-drag: none !important; + -o-user-drag: none !important; + user-drag: none !important; + } + `; + iframeWindow.document.head.appendChild(style); + } + + console.log('📝 WebViewer 초기화 완료'); + + // 문서 로드 + await loadSecureDocument(instance, documentUrl, fileName); + + } catch (uiError) { + console.warn('⚠️ UI 설정 중 오류:', uiError); + toast.error('뷰어 설정 중 오류가 발생했습니다.'); + } + }).catch((error) => { + console.error('❌ WebViewer 초기화 실패:', error); + setIsLoading(false); + toast.error('뷰어 초기화에 실패했습니다.'); + }); + }); + } + }); + } + + return () => { + if (instanceRef.current) { + instanceRef.current.UI.dispose(); + instanceRef.current = null; + } + isCancelled.current = true; + }; + }, []); + + // 문서 로드 함수 + const loadSecureDocument = async ( + instance: WebViewerInstance, + documentPath: string, + docFileName: string + ) => { + setIsLoading(true); + try { + // 절대 URL로 변환 + const fullPath = documentPath.startsWith('http') + ? documentPath + : `${window.location.origin}${documentPath}`; + + console.log('📄 보안 문서 로드 시작:', fullPath); + + // 문서 로드 + await instance.UI.loadDocument(fullPath, { + filename: docFileName, + extension: docFileName.split('.').pop()?.toLowerCase(), + }); + + const { documentViewer, annotationManager } = instance.Core; + + // 문서 로드 완료 이벤트 + documentViewer.addEventListener('documentLoaded', async () => { + setIsLoading(false); + + // 워터마크 추가 + const watermarkText = `${session?.user?.email || 'CONFIDENTIAL'}\n${new Date().toLocaleString()}`; + + // 대각선 워터마크 + documentViewer.setWatermark({ + text: watermarkText, + fontSize: 30, + fontFamily: 'Arial', + color: 'rgba(255, 0, 0, 0.3)', + opacity: 30, + diagonal: true, + }); + + // 각 페이지에 커스텀 워터마크 추가 + const pageCount = documentViewer.getPageCount(); + for (let i = 1; i <= pageCount; i++) { + const pageInfo = documentViewer.getDocument().getPageInfo(i); + const { width, height } = pageInfo; + + // FreeTextAnnotation 생성 + const watermarkAnnot = new instance.Core.Annotations.FreeTextAnnotation(); + watermarkAnnot.PageNumber = i; + watermarkAnnot.X = width / 4; + watermarkAnnot.Y = height / 2; + watermarkAnnot.Width = width / 2; + watermarkAnnot.Height = 100; + watermarkAnnot.setContents( + `${session?.user?.email}\n${docFileName}\n${new Date().toLocaleDateString()}` + ); + watermarkAnnot.FillColor = new instance.Core.Annotations.Color(255, 0, 0, 0.1); + watermarkAnnot.TextColor = new instance.Core.Annotations.Color(255, 0, 0, 0.3); + watermarkAnnot.FontSize = '24pt'; + watermarkAnnot.TextAlign = 'center'; + watermarkAnnot.Rotation = -45; + watermarkAnnot.ReadOnly = true; + watermarkAnnot.Locked = true; + watermarkAnnot.Printable = true; + + annotationManager.addAnnotation(watermarkAnnot); + } + + annotationManager.drawAnnotations(documentViewer.getCurrentPage()); + + // Pan 모드로 설정 (텍스트 선택 불가) + documentViewer.setToolMode(documentViewer.getTool('Pan')); + + // 페이지 이동 로깅 + documentViewer.addEventListener('pageNumberUpdated', (pageNumber: number) => { + console.log(`Page ${pageNumber} viewed at ${new Date().toISOString()}`); + // 서버로 감사 로그 전송 가능 + }); + + console.log('✅ 보안 문서 로드 완료'); + toast.success('문서가 안전하게 로드되었습니다.'); + }); + + // 에러 처리 + documentViewer.addEventListener('error', (error: any) => { + console.error('Document loading error:', error); + setIsLoading(false); + toast.error('문서 로드 중 오류가 발생했습니다.'); + }); + + } catch (err) { + console.error('❌ 문서 로딩 중 오류:', err); + toast.error(`문서 로드 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`); + setIsLoading(false); + } + }; + + // 키보드 단축키 차단 + useEffect(() => { + const preventShortcuts = (e: KeyboardEvent) => { + // Ctrl+C, Ctrl+A, Ctrl+P, Ctrl+S, F12 등 차단 + if ( + (e.ctrlKey && ['c', 'a', 'p', 's', 'x', 'v'].includes(e.key.toLowerCase())) || + (e.metaKey && ['c', 'a', 'p', 's', 'x', 'v'].includes(e.key.toLowerCase())) || + e.key === 'F12' || + (e.ctrlKey && e.shiftKey && ['I', 'C', 'J'].includes(e.key)) + ) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + }; + + const preventContextMenu = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }; + + const preventDrag = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }; + + document.addEventListener('keydown', preventShortcuts); + document.addEventListener('contextmenu', preventContextMenu); + document.addEventListener('dragstart', preventDrag); + + return () => { + document.removeEventListener('keydown', preventShortcuts); + document.removeEventListener('contextmenu', preventContextMenu); + document.removeEventListener('dragstart', preventDrag); + }; + }, []); + + return ( +
+
{ + e.preventDefault(); + e.stopPropagation(); + return false; + }} + onCut={(e) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }} + onPaste={(e) => { + e.preventDefault(); + e.stopPropagation(); + return false; + }} + > + {isLoading && ( +
+ +

보안 문서 로딩 중...

+
+ )} +
+ + {/* 보안 오버레이 */} +
+
+ ); +} \ No newline at end of file diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx index 19c9a616..d001463e 100644 --- a/components/form-data/spreadJS-dialog.tsx +++ b/components/form-data/spreadJS-dialog.tsx @@ -1055,8 +1055,18 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat if (templateType === 'SPREAD_LIST' && tableData.length > 0) { updateProgress('Processing data rows...', 60, 100); - - dataSheets.forEach(dataSheet => { + + const activeSheetName = workingTemplate.SPR_LST_SETUP?.ACT_SHEET; + + const matchingDataSheets = dataSheets.filter(ds => + ds.SHEET_NAME === activeSheetName + ); + + if (activeSheetName && spread.getSheetFromName(activeSheetName)) { + spread.setActiveSheet(activeSheetName); + } + + matchingDataSheets.forEach(dataSheet => { if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) { dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { const { ATT_ID, IN } = mapping; @@ -1115,8 +1125,17 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat } else if (templateType === 'SPREAD_ITEM' && selectedRow) { updateProgress('Setting up form fields...', 60, 100); - - dataSheets.forEach(dataSheet => { + const activeSheetName = workingTemplate.SPR_ITM_LST_SETUP?.ACT_SHEET; + + const matchingDataSheets = dataSheets.filter(ds => + ds.SHEET_NAME === activeSheetName + ); + + if (activeSheetName && spread.getSheetFromName(activeSheetName)) { + spread.setActiveSheet(activeSheetName); + } + + matchingDataSheets.forEach(dataSheet => { dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => { const { ATT_ID, IN } = mapping; const cellPos = parseCellAddress(IN); diff --git a/components/permissions/permission-crud-manager.tsx b/components/permissions/permission-crud-manager.tsx index 01c9959f..a9b2f64e 100644 --- a/components/permissions/permission-crud-manager.tsx +++ b/components/permissions/permission-crud-manager.tsx @@ -25,6 +25,16 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Table, TableBody, @@ -52,7 +62,8 @@ import { Key, Shield, Copy, - CheckCircle + CheckCircle, + AlertTriangle } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; @@ -90,6 +101,8 @@ export function PermissionCrudManager() { const [loading, setLoading] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [editingPermission, setEditingPermission] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deletingPermission, setDeletingPermission] = useState(null); useEffect(() => { loadPermissions(); @@ -139,20 +152,25 @@ export function PermissionCrudManager() { setFilteredPermissions(filtered); }; - const handleDelete = async (id: number) => { - if (!confirm("이 권한을 삭제하시겠습니까? 관련된 모든 할당이 제거됩니다.")) { - return; - } - + const handleDelete = async () => { + if (!deletingPermission) return; + try { - await deletePermission(id); + await deletePermission(deletingPermission.id); toast.success("권한이 삭제되었습니다."); loadPermissions(); + setDeleteDialogOpen(false); + setDeletingPermission(null); } catch (error) { toast.error("권한 삭제에 실패했습니다."); } }; + const openDeleteDialog = (permission: Permission) => { + setDeletingPermission(permission); + setDeleteDialogOpen(true); + }; + return (
{/* 헤더 및 필터 */} @@ -280,7 +298,7 @@ export function PermissionCrudManager() { handleDelete(permission.id)} + onClick={() => openDeleteDialog(permission)} className="text-destructive" disabled={permission.isSystem} > @@ -314,6 +332,64 @@ export function PermissionCrudManager() { loadPermissions(); }} /> + + {/* 삭제 확인 다이얼로그 */} + + + + +
+ + 권한 삭제 확인 +
+
+ + {deletingPermission && ( +
+

+ "{deletingPermission.name}" 권한을 삭제하시겠습니까? +

+ +
+
+ 권한 키: + {deletingPermission.permissionKey} +
+
+ 리소스: + {deletingPermission.resource} +
+
+ 액션: + {deletingPermission.action} +
+
+ +
+

+ ⚠️ 주의: 이 작업은 되돌릴 수 없습니다 +

+

+ 이 권한과 관련된 모든 역할 및 사용자 할당이 제거됩니다. +

+
+
+ )} +
+
+ + setDeletingPermission(null)}> + 취소 + + + 삭제 + + +
+
); } diff --git a/components/permissions/permission-group-assignment-manager.tsx b/components/permissions/permission-group-assignment-manager.tsx new file mode 100644 index 00000000..cd7531a0 --- /dev/null +++ b/components/permissions/permission-group-assignment-manager.tsx @@ -0,0 +1,666 @@ +// components/permissions/permission-group-assignment-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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +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 { + Users, + User, + Plus, + X, + Search, + Package, + Shield, + Loader2, + UserPlus, + ChevronRight +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + getPermissionGroupAssignments, + assignGroupToRoles, + assignGroupToUsers, + removeGroupFromRole, + removeGroupFromUser, + searchRoles, + searchUsers, +} from "@/lib/permissions/permission-group-assignment-actions"; + +interface PermissionGroup { + id: number; + groupKey: string; + name: string; + description?: string; + domain?: string; + permissionCount: number; + isActive: boolean; +} + +interface AssignedRole { + id: number; + name: string; + domain: string; + userCount: number; + assignedAt: Date; + assignedBy?: string; +} + +interface AssignedUser { + id: number; + name: string; + email: string; + imageUrl?: string; + domain: string; + companyName?: string; + assignedAt: Date; + assignedBy?: string; +} + +export function PermissionGroupAssignmentManager() { + const [groups, setGroups] = useState([]); + const [selectedGroup, setSelectedGroup] = useState(null); + const [assignedRoles, setAssignedRoles] = useState([]); + const [assignedUsers, setAssignedUsers] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [addRoleDialogOpen, setAddRoleDialogOpen] = useState(false); + const [addUserDialogOpen, setAddUserDialogOpen] = useState(false); + + useEffect(() => { + loadGroups(); + }, []); + + useEffect(() => { + if (selectedGroup) { + loadAssignments(selectedGroup.id); + } + }, [selectedGroup]); + + const loadGroups = async () => { + setLoading(true); + try { + const data = await getPermissionGroupAssignments(); + setGroups(data.groups); + } catch (error) { + toast.error("권한 그룹을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const loadAssignments = async (groupId: number) => { + try { + const data = await getPermissionGroupAssignments(groupId); + setAssignedRoles(data.roles); + setAssignedUsers(data.users); + } catch (error) { + toast.error("할당 정보를 불러오는데 실패했습니다."); + } + }; + + const handleRemoveRole = async (roleId: number) => { + if (!selectedGroup) return; + + try { + await removeGroupFromRole(selectedGroup.id, roleId); + toast.success("역할에서 권한 그룹이 제거되었습니다."); + loadAssignments(selectedGroup.id); + } catch (error) { + toast.error("권한 그룹 제거에 실패했습니다."); + } + }; + + const handleRemoveUser = async (userId: number) => { + if (!selectedGroup) return; + + try { + await removeGroupFromUser(selectedGroup.id, userId); + toast.success("사용자에서 권한 그룹이 제거되었습니다."); + loadAssignments(selectedGroup.id); + } catch (error) { + toast.error("권한 그룹 제거에 실패했습니다."); + } + }; + + // 그룹 필터링 + const filteredGroups = groups.filter(g => + g.name.toLowerCase().includes(searchQuery.toLowerCase()) || + g.groupKey.toLowerCase().includes(searchQuery.toLowerCase()) || + g.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( +
+ {/* 권한 그룹 목록 */} + + + 권한 그룹 + 할당을 관리할 권한 그룹을 선택하세요. + + +
+ {/* 검색 */} +
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> +
+ + {/* 그룹 목록 */} + {loading ? ( +
+ +
+ ) : ( + +
+ {filteredGroups.map(group => ( + + ))} +
+
+ )} +
+
+
+ + {/* 할당 관리 */} + {selectedGroup ? ( + + +
+ {selectedGroup.name} + +
+ {selectedGroup.groupKey} + {selectedGroup.domain && ( + {selectedGroup.domain} + )} + {selectedGroup.permissionCount}개 권한 +
+
+
+
+ + + + + + 역할 ({assignedRoles.length}) + + + + 사용자 ({assignedUsers.length}) + + + + +
+ + +
+ {assignedRoles.map((role) => ( +
+
+
{role.name}
+
+ {role.domain} • {role.userCount}명 사용자 +
+
+ {new Date(role.assignedAt).toLocaleDateString()} 할당 + {role.assignedBy && ` • ${role.assignedBy}`} +
+
+ +
+ ))} + + {assignedRoles.length === 0 && ( +
+ +

할당된 역할이 없습니다.

+
+ )} +
+
+
+ + +
+ + +
+ {assignedUsers.map((user) => ( +
+
+ + + {user.name[0]} + +
+
{user.name}
+
{user.email}
+
+ + {user.domain} + + {user.companyName && ( + + {user.companyName} + + )} +
+
+
+ +
+ ))} + + {assignedUsers.length === 0 && ( +
+ +

할당된 사용자가 없습니다.

+
+ )} +
+
+
+
+
+
+ ) : ( + +
+ +

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

+
+
+ )} + + {/* 역할 추가 다이얼로그 */} + {selectedGroup && ( + { + setAddRoleDialogOpen(false); + loadAssignments(selectedGroup.id); + }} + /> + )} + + {/* 사용자 추가 다이얼로그 */} + {selectedGroup && ( + { + setAddUserDialogOpen(false); + loadAssignments(selectedGroup.id); + }} + /> + )} +
+ ); +} + +// 역할 추가 다이얼로그 +function AddRoleDialog({ + open, + onOpenChange, + group, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + group: PermissionGroup; + onSuccess: () => void; +}) { + const [availableRoles, setAvailableRoles] = useState([]); + const [selectedRoles, setSelectedRoles] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) { + loadAvailableRoles(); + } + }, [open]); + + const loadAvailableRoles = async () => { + setLoading(true); + try { + const data = await searchRoles(group.id); + setAvailableRoles(data); + } catch (error) { + toast.error("역할 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async () => { + if (selectedRoles.length === 0) { + toast.error("역할을 선택해주세요."); + return; + } + + setSaving(true); + try { + await assignGroupToRoles(group.id, selectedRoles); + toast.success("역할에 권한 그룹이 추가되었습니다."); + onSuccess(); + } catch (error) { + toast.error("권한 그룹 추가에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + return ( + + + + 역할 추가 + + "{group.name}" 그룹을 할당할 역할을 선택하세요. + + + + {loading ? ( +
+ +
+ ) : ( +
+ +
+ {availableRoles.map((role) => ( + + ))} +
+
+ +
+ {selectedRoles.length}개 역할 선택됨 +
+
+ )} + + + + + +
+
+ ); +} + +// 사용자 추가 다이얼로그 +function AddUserDialog({ + open, + onOpenChange, + group, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + group: PermissionGroup; + onSuccess: () => void; +}) { + const [searchQuery, setSearchQuery] = useState(""); + const [availableUsers, setAvailableUsers] = useState([]); + const [selectedUsers, setSelectedUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => { + if (searchQuery && open) { + searchUsersData(searchQuery); + } + }, 300); + return () => clearTimeout(timer); + }, [searchQuery, open]); + + const searchUsersData = async (query: string) => { + setLoading(true); + try { + const data = await searchUsers(query, group.id); + setAvailableUsers(data); + } catch (error) { + toast.error("사용자 검색에 실패했습니다."); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async () => { + if (selectedUsers.length === 0) { + toast.error("사용자를 선택해주세요."); + return; + } + + setSaving(true); + try { + await assignGroupToUsers(group.id, selectedUsers); + toast.success("사용자에게 권한 그룹이 추가되었습니다."); + onSuccess(); + } catch (error) { + toast.error("권한 그룹 추가에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + return ( + + + + 사용자 추가 + + "{group.name}" 그룹을 할당할 사용자를 검색하고 선택하세요. + + + +
+ {/* 검색 */} +
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> +
+ + {/* 사용자 목록 */} + {loading ? ( +
+ +
+ ) : ( + <> + +
+ {availableUsers.map((user) => ( + + ))} +
+
+ + {availableUsers.length > 0 && ( +
+ {selectedUsers.length}명 선택됨 +
+ )} + + )} +
+ + + + + +
+
+ ); +} \ No newline at end of file diff --git a/components/permissions/permission-group-manager.tsx b/components/permissions/permission-group-manager.tsx index 11aac6cf..ff7bef7f 100644 --- a/components/permissions/permission-group-manager.tsx +++ b/components/permissions/permission-group-manager.tsx @@ -3,6 +3,9 @@ "use client"; import { useState, useEffect } from "react"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -19,6 +22,16 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Select, SelectContent, @@ -47,6 +60,15 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { Shield, Plus, @@ -99,6 +121,19 @@ interface Permission { scope: string; } +// 폼 스키마 정의 +const permissionGroupFormSchema = z.object({ + groupKey: z.string() + .min(1, "그룹 키는 필수입니다.") + .regex(/^[a-z0-9_]+$/, "소문자, 숫자, 언더스코어만 사용 가능합니다."), + name: z.string().min(1, "그룹명은 필수입니다."), + description: z.string().optional(), + domain: z.string().optional(), + isActive: z.boolean().default(true), +}); + +type PermissionGroupFormValues = z.infer; + export function PermissionGroupManager() { const [groups, setGroups] = useState([]); const [selectedGroup, setSelectedGroup] = useState(null); @@ -109,6 +144,7 @@ export function PermissionGroupManager() { const [createDialogOpen, setCreateDialogOpen] = useState(false); const [editingGroup, setEditingGroup] = useState(null); const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); + const [deletingGroupId, setDeletingGroupId] = useState(null); useEffect(() => { loadGroups(); @@ -143,19 +179,23 @@ export function PermissionGroupManager() { }; const handleDelete = async (id: number) => { - if (!confirm("이 권한 그룹을 삭제하시겠습니까? 관련된 모든 할당이 제거됩니다.")) { - return; - } + setDeletingGroupId(id); + }; + + const confirmDelete = async () => { + if (!deletingGroupId) return; try { - await deletePermissionGroup(id); + await deletePermissionGroup(deletingGroupId); toast.success("권한 그룹이 삭제되었습니다."); - if (selectedGroup?.id === id) { + if (selectedGroup?.id === deletingGroupId) { setSelectedGroup(null); } loadGroups(); } catch (error) { toast.error("권한 그룹 삭제에 실패했습니다."); + } finally { + setDeletingGroupId(null); } }; @@ -268,6 +308,24 @@ export function PermissionGroupManager() { }} /> )} + + {/* 삭제 확인 다이얼로그 */} + !open && setDeletingGroupId(null)}> + + + 권한 그룹 삭제 + + 이 권한 그룹을 삭제하시겠습니까? 관련된 모든 할당이 제거되며, 이 작업은 되돌릴 수 없습니다. + + + + setDeletingGroupId(null)}>취소 + + 삭제 + + + +
); } @@ -290,6 +348,9 @@ function GroupCard({ onDelete: () => void; onManagePermissions: () => void; }) { + + console.log(group,"group") + return ( void; }) { - const [formData, setFormData] = useState({ - groupKey: "", - name: "", - description: "", - domain: "", - isActive: true, - }); const [saving, setSaving] = useState(false); + + const form = useForm({ + resolver: zodResolver(permissionGroupFormSchema), + defaultValues: { + groupKey: "", + name: "", + description: "", + domain: undefined, + isActive: true, + }, + }); + + console.log(form.getValues()) useEffect(() => { if (group) { - setFormData({ + form.reset({ groupKey: group.groupKey, name: group.name, description: group.description || "", - domain: group.domain || "", + domain: group.domain || undefined, isActive: group.isActive, }); } else { - setFormData({ + form.reset({ groupKey: "", name: "", description: "", - domain: "", + domain: undefined, isActive: true, }); } - }, [group]); - - const handleSubmit = async () => { - if (!formData.groupKey || !formData.name) { - toast.error("필수 항목을 입력해주세요."); - return; - } + }, [group, form]); + const onSubmit = async (values: PermissionGroupFormValues) => { setSaving(true); try { + // domain이 undefined인 경우 빈 문자열로 변환 + const submitData = { + ...values, + domain: values.domain || "", + }; + if (group) { - await updatePermissionGroup(group.id, formData); + await updatePermissionGroup(group.id, submitData); toast.success("권한 그룹이 수정되었습니다."); } else { - await createPermissionGroup(formData); + await createPermissionGroup(submitData); toast.success("권한 그룹이 생성되었습니다."); } onSuccess(); @@ -530,72 +598,131 @@ function GroupFormDialog({ -
-
- - setFormData({ ...formData, groupKey: e.target.value })} - placeholder="예: rfq_manager" +
+ + ( + + 그룹 키 * + + + + + 소문자, 숫자, 언더스코어만 사용 가능합니다. + + + + )} /> -
-
- - setFormData({ ...formData, name: e.target.value })} - placeholder="예: RFQ 관리자 권한" + ( + + 그룹명 * + + + + + + )} /> -
-
- -