diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-17 08:09:35 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-17 08:09:35 +0000 |
| commit | bea9853efe30c393b0d030bc552c1f5bbb838835 (patch) | |
| tree | 820e12543f847bbcdc8d55b575016c535fb299c6 | |
| parent | 1540eac291761ffd8fc1947ed626e4e4a4407922 (diff) | |
(대표님) 데이터룸 관련 개발사항
| -rw-r--r-- | app/[lng]/evcp/data-room/[projectId]/members/page.tsx | 41 | ||||
| -rw-r--r-- | app/[lng]/evcp/data-room/[projectId]/settings/page.tsx | 20 | ||||
| -rw-r--r-- | components/file-manager/FileManager.tsx | 874 | ||||
| -rw-r--r-- | components/layout/HeaderDataroom.tsx | 24 | ||||
| -rw-r--r-- | components/layout/HeaderSimple.tsx | 4 | ||||
| -rw-r--r-- | components/project/ProjectDashboard.tsx | 4 | ||||
| -rw-r--r-- | components/project/ProjectNav.tsx | 14 | ||||
| -rw-r--r-- | i18n/locales/en/menu.json | 4 | ||||
| -rw-r--r-- | i18n/locales/ko/menu.json | 4 | ||||
| -rw-r--r-- | lib/forms/services.ts | 7 | ||||
| -rw-r--r-- | lib/gtc-contract/service.ts | 1 | ||||
| -rw-r--r-- | lib/owner-companies/service.ts | 4 |
12 files changed, 695 insertions, 306 deletions
diff --git a/app/[lng]/evcp/data-room/[projectId]/members/page.tsx b/app/[lng]/evcp/data-room/[projectId]/members/page.tsx index 18442c0e..dbd5e37d 100644 --- a/app/[lng]/evcp/data-room/[projectId]/members/page.tsx +++ b/app/[lng]/evcp/data-room/[projectId]/members/page.tsx @@ -105,7 +105,7 @@ export default function ProjectMembersPage({ const [roleFilter, setRoleFilter] = useState<string>('all'); const [addMemberOpen, setAddMemberOpen] = useState(false); const [editingMember, setEditingMember] = useState<Member | null>(null); - + // 사용자 선택 관련 상태 const [availableUsers, setAvailableUsers] = useState<User[]>([]); const [selectedUser, setSelectedUser] = useState<User | null>(null); @@ -113,12 +113,12 @@ export default function ProjectMembersPage({ const [userPopoverOpen, setUserPopoverOpen] = useState(false); const [loadingUsers, setLoadingUsers] = useState(false); const [isExternalUser, setIsExternalUser] = useState(false); // 외부 사용자 여부 - + const [newMemberRole, setNewMemberRole] = useState<string>('viewer'); const [currentUserRole, setCurrentUserRole] = useState<string>('viewer'); const [page, setPage] = useState(1); const pageSize = 20; - + // Command component key management const userOptionIdsRef = useRef<Record<number, string>>({}); const popoverContentId = `popover-content-${Date.now()}`; @@ -284,7 +284,7 @@ export default function ProjectMembersPage({ const handleSelectUser = (user: User) => { setSelectedUser(user); setUserPopoverOpen(false); - + // 외부 사용자(partners)인 경우 역할을 viewer로 고정 if (user.domain === 'partners') { setIsExternalUser(true); @@ -319,6 +319,7 @@ export default function ProjectMembersPage({ user.email.toLowerCase().includes(userSearchTerm.toLowerCase()) ); + const canManageMembers = currentUserRole === 'owner' || currentUserRole === 'admin'; const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize)); @@ -577,7 +578,7 @@ export default function ProjectMembersPage({ <TabsContent value="internal" className="space-y-4 mt-4"> <div className="space-y-2"> <Label htmlFor="internal-user">사용자 선택</Label> - + {loadingUsers ? ( <div className="flex items-center justify-center py-4"> <Loader2 className="h-4 w-4 animate-spin" /> @@ -665,8 +666,8 @@ export default function ProjectMembersPage({ <div className="space-y-2"> <Label htmlFor="internal-role">역할</Label> - <Select - value={newMemberRole} + <Select + value={newMemberRole} onValueChange={setNewMemberRole} disabled={!selectedUser || isExternalUser} > @@ -685,14 +686,14 @@ export default function ProjectMembersPage({ <TabsContent value="external" className="space-y-4 mt-4"> <div className="rounded-lg bg-amber-50 border border-amber-200 p-3 mb-4"> <p className="text-sm text-amber-800"> - <strong>보안 정책 안내</strong><br/> + <strong>보안 정책 안내</strong><br /> 외부 사용자(파트너)는 보안 정책상 Viewer 권한만 부여 가능합니다. </p> </div> <div className="space-y-2"> <Label htmlFor="external-user">파트너 선택</Label> - + {loadingUsers ? ( <div className="flex items-center justify-center py-4"> <Loader2 className="h-4 w-4 animate-spin" /> @@ -723,7 +724,7 @@ export default function ProjectMembersPage({ <PopoverContent className="w-[460px] p-0"> <Command> <CommandInput - placeholder="이름으로 검색..." + placeholder="이름 또는 이메일로 검색..." value={userSearchTerm} onValueChange={setUserSearchTerm} /> @@ -738,7 +739,7 @@ export default function ProjectMembersPage({ <CommandEmpty>파트너를 찾을 수 없습니다.</CommandEmpty> <CommandGroup heading="파트너 목록"> {filteredUsers - .filter(u => u.domain === 'partners') + .filter(u => u.ownerCompanyId !== null) .map((user) => ( <CommandItem key={user.id} @@ -748,12 +749,14 @@ export default function ProjectMembersPage({ setIsExternalUser(true); setNewMemberRole('viewer'); }} - value={user.name} + value={`${user.name} ${user.email}`} className="truncate" > - <Users className="mr-2 h-4 w-4 text-amber-600" /> - <span className="truncate flex-1">{user.name}</span> - <Badge variant="outline" className="text-xs mx-2">파트너</Badge> + <Users className="mr-2 h-4 w-4 text-amber-600 flex-shrink-0" /> + <div className="flex-1 truncate"> + <div className="font-medium truncate">{user.name}</div> + <div className="text-xs text-muted-foreground truncate">{user.email}</div> + </div> <Check className={cn( "ml-auto h-4 w-4 flex-shrink-0", @@ -785,8 +788,8 @@ export default function ProjectMembersPage({ </Tabs> <DialogFooter> - <Button - variant="outline" + <Button + variant="outline" onClick={() => { setAddMemberOpen(false); setSelectedUser(null); @@ -797,8 +800,8 @@ export default function ProjectMembersPage({ > 취소 </Button> - <Button - onClick={addMember} + <Button + onClick={addMember} disabled={!selectedUser} > 추가하기 diff --git a/app/[lng]/evcp/data-room/[projectId]/settings/page.tsx b/app/[lng]/evcp/data-room/[projectId]/settings/page.tsx index aa0f3b52..fc132e65 100644 --- a/app/[lng]/evcp/data-room/[projectId]/settings/page.tsx +++ b/app/[lng]/evcp/data-room/[projectId]/settings/page.tsx @@ -2,7 +2,7 @@ // app/projects/[projectId]/settings/page.tsx 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect ,use} from 'react'; import { Settings, Shield, @@ -59,8 +59,12 @@ interface ProjectSettings { export default function ProjectSettingsPage({ params }: { - params: { projectId: string } + params: Promise<{ projectId: string }> }) { + + const { projectId } = use(params); + + const [settings, setSettings] = useState<ProjectSettings | null>(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -74,12 +78,12 @@ export default function ProjectSettingsPage({ useEffect(() => { fetchSettings(); checkUserRole(); - }, [params.projectId]); + }, [projectId]); const fetchSettings = async () => { try { setLoading(true); - const response = await fetch(`/api/projects/${params.projectId}/settings`); + const response = await fetch(`/api/projects/${projectId}/settings`); if (!response.ok) { throw new Error('설정을 불러올 수 없습니다'); @@ -100,7 +104,7 @@ export default function ProjectSettingsPage({ const checkUserRole = async () => { try { - const response = await fetch(`/api/projects/${params.projectId}/access`); + const response = await fetch(`/api/projects/${projectId}/access`); const data = await response.json(); setCurrentUserRole(data.role); } catch (error) { @@ -113,7 +117,7 @@ export default function ProjectSettingsPage({ try { setSaving(true); - const response = await fetch(`/api/projects/${params.projectId}/settings`, { + const response = await fetch(`/api/projects/${projectId}/settings`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings), @@ -138,7 +142,7 @@ export default function ProjectSettingsPage({ const deleteProject = async () => { try { - const response = await fetch(`/api/projects/${params.projectId}`, { + const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE', }); @@ -161,7 +165,7 @@ export default function ProjectSettingsPage({ const archiveProject = async () => { try { - const response = await fetch(`/api/projects/${params.projectId}/archive`, { + const response = await fetch(`/api/projects/${projectId}/archive`, { method: 'POST', }); diff --git a/components/file-manager/FileManager.tsx b/components/file-manager/FileManager.tsx index f92f6b04..1af29e74 100644 --- a/components/file-manager/FileManager.tsx +++ b/components/file-manager/FileManager.tsx @@ -115,6 +115,7 @@ interface FileItem { viewCount?: number; parentId?: string | null; children?: FileItem[]; + filePath?: string; } interface UploadingFile { @@ -151,6 +152,7 @@ const TreeItem: React.FC<{ onDelete: (ids: string[]) => void; onShare: (item: FileItem) => void; onRename: (item: FileItem) => void; + onCategoryChange: (item: FileItem) => void; isInternalUser: boolean; }> = ({ item, @@ -166,6 +168,7 @@ const TreeItem: React.FC<{ onDelete, onShare, onRename, + onCategoryChange, isInternalUser }) => { const hasChildren = item.type === 'folder' && item.children && item.children.length > 0; @@ -184,113 +187,180 @@ const TreeItem: React.FC<{ return ( <> - <div - className={cn( - "flex items-center p-2 rounded-lg cursor-pointer transition-colors", - "hover:bg-accent", - isSelected && "bg-accent" - )} - style={{ paddingLeft: `${level * 24 + 8}px` }} - onClick={() => onSelectItem(item.id)} - onDoubleClick={() => onDoubleClick(item)} - > - <div className="flex items-center mr-2"> - {item.type === 'folder' && ( - <button - onClick={(e) => { - e.stopPropagation(); - onToggleExpand(item.id); - }} - className="p-0.5 hover:bg-gray-200 rounded" - > - {isExpanded ? ( - <ChevronDown className="h-4 w-4" /> - ) : ( - <ChevronRight className="h-4 w-4" /> + <ContextMenu> + <ContextMenuTrigger> + <div + className={cn( + "flex items-center p-2 rounded-lg cursor-pointer transition-colors", + "hover:bg-accent", + isSelected && "bg-accent" + )} + style={{ paddingLeft: `${level * 24 + 8}px` }} + onClick={() => onSelectItem(item.id)} + onDoubleClick={() => onDoubleClick(item)} + > + <div className="flex items-center mr-2"> + {item.type === 'folder' && ( + <button + onClick={(e) => { + e.stopPropagation(); + onToggleExpand(item.id); + }} + className="p-0.5 hover:bg-gray-200 rounded" + > + {isExpanded ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + </button> )} - </button> - )} - {item.type === 'file' && ( - <div className="w-5" /> - )} - </div> + {item.type === 'file' && ( + <div className="w-5" /> + )} + </div> - {item.type === 'folder' ? ( - <Folder className="h-5 w-5 text-blue-500 mr-2" /> - ) : ( - <File className="h-5 w-5 text-gray-500 mr-2" /> - )} - - <span className="flex-1">{item.name}</span> - - <Badge variant="outline" className="mr-2"> - <CategoryIcon className={cn("h-3 w-3 mr-1", categoryColor)} /> - {categoryLabel} - </Badge> - - <span className="text-sm text-muted-foreground mr-4"> - {formatFileSize(item.size)} - </span> - <span className="text-sm text-muted-foreground mr-2"> - {new Date(item.updatedAt).toLocaleDateString()} - </span> - - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" size="sm"> - <MoreVertical className="h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent> - {item.type === 'file' && ( - <> - <DropdownMenuItem onClick={() => onView(item)}> - <Eye className="h-4 w-4 mr-2" /> - View - </DropdownMenuItem> - {item.permissions?.canDownload && ( - <DropdownMenuItem onClick={() => onDownload(item)}> + {item.type === 'folder' ? ( + <Folder className="h-5 w-5 text-blue-500 mr-2" /> + ) : ( + <File className="h-5 w-5 text-gray-500 mr-2" /> + )} + + <span className="flex-1">{item.name}</span> + + <Badge variant="outline" className="mr-2"> + <CategoryIcon className={cn("h-3 w-3 mr-1", categoryColor)} /> + {categoryLabel} + </Badge> + + <span className="text-sm text-muted-foreground mr-4"> + {formatFileSize(item.size)} + </span> + <span className="text-sm text-muted-foreground mr-2"> + {new Date(item.updatedAt).toLocaleDateString()} + </span> + + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="sm"> + <MoreVertical className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent> + {item.type === 'file' && ( + <> + <DropdownMenuItem onClick={() => onView(item)}> + <Eye className="h-4 w-4 mr-2" /> + View + </DropdownMenuItem> + {item.permissions?.canDownload && ( + <DropdownMenuItem onClick={() => onDownload(item)}> + <Download className="h-4 w-4 mr-2" /> + Download + </DropdownMenuItem> + )} + </> + )} + + {item.type === 'folder' && ( + <DropdownMenuItem onClick={() => onDownloadFolder(item)}> <Download className="h-4 w-4 mr-2" /> - Download + Download Folder </DropdownMenuItem> )} - </> - )} - {item.type === 'folder' && ( - <DropdownMenuItem onClick={() => onDownloadFolder(item)}> + {isInternalUser && ( + <> + <DropdownMenuSeparator /> + {item.permissions?.canEdit && ( + <DropdownMenuItem onClick={() => onRename(item)}> + <Edit2 className="h-4 w-4 mr-2" /> + Rename + </DropdownMenuItem> + )} + <DropdownMenuItem onClick={() => onCategoryChange(item)}> + <Shield className="h-4 w-4 mr-2" /> + Change Category + </DropdownMenuItem> + </> + )} + + {item.permissions?.canDelete && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem + className="text-destructive" + onClick={() => onDelete([item.id])} + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> + </DropdownMenu> + </div> + </ContextMenuTrigger> + + <ContextMenuContent> + {item.type === 'file' && ( + <> + <ContextMenuItem onClick={() => onView(item)}> + <Eye className="h-4 w-4 mr-2" /> + View + </ContextMenuItem> + {item.permissions?.canDownload && ( + <ContextMenuItem onClick={() => onDownload(item)}> + <Download className="h-4 w-4 mr-2" /> + Download + </ContextMenuItem> + )} + </> + )} + + {item.type === 'folder' && ( + <> + <ContextMenuItem onClick={() => onDoubleClick(item)}> + <Folder className="h-4 w-4 mr-2" /> + {isExpanded ? 'Collapse' : 'Expand'} + </ContextMenuItem> + <ContextMenuItem onClick={() => onDownloadFolder(item)}> <Download className="h-4 w-4 mr-2" /> Download Folder - </DropdownMenuItem> - )} - - {isInternalUser && ( - <> + </ContextMenuItem> + </> + )} - {item.permissions?.canEdit && ( - <DropdownMenuItem onClick={() => onRename(item)}> - <Edit2 className="h-4 w-4 mr-2" /> - Rename - </DropdownMenuItem> - )} - </> - )} + {isInternalUser && ( + <> + <ContextMenuSeparator /> + {item.permissions?.canEdit && ( + <ContextMenuItem onClick={() => onRename(item)}> + <Edit2 className="h-4 w-4 mr-2" /> + Rename + </ContextMenuItem> + )} + <ContextMenuItem onClick={() => onCategoryChange(item)}> + <Shield className="h-4 w-4 mr-2" /> + Change Category + </ContextMenuItem> + </> + )} - {item.permissions?.canDelete && ( - <> - <DropdownMenuSeparator /> - <DropdownMenuItem - className="text-destructive" - onClick={() => onDelete([item.id])} - > - <Trash2 className="h-4 w-4 mr-2" /> - Delete - </DropdownMenuItem> - </> - )} - </DropdownMenuContent> - </DropdownMenu> - </div> + {item.permissions?.canDelete && ( + <> + <ContextMenuSeparator /> + <ContextMenuItem + className="text-destructive" + onClick={() => onDelete([item.id])} + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete + </ContextMenuItem> + </> + )} + </ContextMenuContent> + </ContextMenu> {item.type === 'folder' && isExpanded && item.children && ( <div> @@ -310,6 +380,7 @@ const TreeItem: React.FC<{ onDelete={onDelete} onShare={onShare} onRename={onRename} + onCategoryChange={onCategoryChange} isInternalUser={isInternalUser} /> ))} @@ -331,12 +402,12 @@ export function FileManager({ projectId }: FileManagerProps) { const [searchQuery, setSearchQuery] = useState(''); const [loading, setLoading] = useState(false); - console.log(items, "items") - // Upload states const [uploadDialogOpen, setUploadDialogOpen] = useState(false); const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]); + const [selectedFilesForUpload, setSelectedFilesForUpload] = useState<File[]>([]); const [uploadCategory, setUploadCategory] = useState<string>('confidential'); + const [preserveFolderStructure, setPreserveFolderStructure] = useState(false); // Dialog states const [folderDialogOpen, setFolderDialogOpen] = useState(false); @@ -345,6 +416,9 @@ export function FileManager({ projectId }: FileManagerProps) { const [renameDialogOpen, setRenameDialogOpen] = useState(false); const [viewerDialogOpen, setViewerDialogOpen] = useState(false); const [viewerFileUrl, setViewerFileUrl] = useState<string | null>(null); + const [categoryDialogOpen, setCategoryDialogOpen] = useState(false); + const [applyToChildren, setApplyToChildren] = useState(false); + const [newCategory, setNewCategory] = useState('confidential'); // Dialog data const [dialogValue, setDialogValue] = useState(''); @@ -473,8 +547,8 @@ export function FileManager({ projectId }: FileManagerProps) { } }; - // Handle file upload - const handleFileUpload = async (files: FileList | File[]) => { + // Handle file upload - 실제 업로드 처리 + const handleFileUpload = async (files: File[]) => { const fileArray = Array.from(files); // Initialize uploading file list @@ -486,63 +560,64 @@ export function FileManager({ projectId }: FileManagerProps) { setUploadingFiles(newUploadingFiles); - // Process each file upload - for (let i = 0; i < fileArray.length; i++) { - const file = fileArray[i]; - - try { - // Update status: uploading - setUploadingFiles(prev => prev.map((f, idx) => - idx === i ? { ...f, status: 'uploading', progress: 20 } : f - )); - - // DRM decryption - setUploadingFiles(prev => prev.map((f, idx) => - idx === i ? { ...f, status: 'processing', progress: 40 } : f - )); - - const decryptedData = await decryptWithServerAction(file); - - // 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()); // Pass file size - if (currentParentId) { - formData.append('parentId', currentParentId); - } - - // Update upload progress - setUploadingFiles(prev => prev.map((f, idx) => - idx === i ? { ...f, progress: 60 } : f - )); - - // API call - const response = await fetch(`/api/data-room/${projectId}/upload`, { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Upload failed'); + // 폴더 구조 보존 옵션이 켜져있고 상대 경로가 있는 경우 + if (preserveFolderStructure && fileArray.some((file: any) => file.webkitRelativePath)) { + // 폴더 구조를 먼저 생성 + const folderMap = new Map<string, string>(); // path -> folderId + + for (let i = 0; i < fileArray.length; i++) { + const file = fileArray[i]; + const relativePath = (file as any).webkitRelativePath; + + if (relativePath) { + const pathParts = relativePath.split('/'); + const folders = pathParts.slice(0, -1); // 파일명 제외 + + let currentFolderPath = ''; + let parentId = currentParentId; + + // 각 폴더를 순차적으로 생성 + for (const folderName of folders) { + currentFolderPath = currentFolderPath ? `${currentFolderPath}/${folderName}` : folderName; + + if (!folderMap.has(currentFolderPath)) { + // 폴더 생성 API 호출 + try { + const response = await fetch(`/api/data-room/${projectId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: folderName, + type: 'folder', + category: uploadCategory, + parentId: parentId, + }), + }); + + if (response.ok) { + const newFolder = await response.json(); + folderMap.set(currentFolderPath, newFolder.id); + parentId = newFolder.id; + } + } catch (error) { + console.error('Failed to create folder:', folderName); + } + } else { + parentId = folderMap.get(currentFolderPath) || null; + } + } + + // 폴더가 생성되었으면 해당 폴더에 파일 업로드 + await uploadSingleFile(file, i, parentId); + } else { + // 상대 경로가 없으면 일반 업로드 + await uploadSingleFile(file, i, currentParentId); } - - // Success - setUploadingFiles(prev => prev.map((f, idx) => - idx === i ? { ...f, status: 'completed', progress: 100 } : f - )); - - } catch (error: any) { - // Failure - setUploadingFiles(prev => prev.map((f, idx) => - idx === i ? { - ...f, - status: 'error', - error: error.message || 'Upload failed' - } : f - )); + } + } else { + // 일반 업로드 (폴더 구조 보존 없음) + for (let i = 0; i < fileArray.length; i++) { + await uploadSingleFile(fileArray[i], i, currentParentId); } } @@ -550,7 +625,7 @@ export function FileManager({ projectId }: FileManagerProps) { await fetchItems(); // Show toast if any files succeeded - const successCount = newUploadingFiles.filter(f => f.status === 'completed').length; + const successCount = uploadingFiles.filter(f => f.status === 'completed').length; if (successCount > 0) { toast({ title: 'Upload Complete', @@ -559,6 +634,72 @@ export function FileManager({ projectId }: FileManagerProps) { } }; + // 단일 파일 업로드 함수 + const uploadSingleFile = async (file: File, index: number, parentId: string | null) => { + try { + // Update status: uploading + setUploadingFiles(prev => prev.map((f, idx) => + idx === index ? { ...f, status: 'uploading', progress: 20 } : f + )); + + // DRM decryption + setUploadingFiles(prev => prev.map((f, idx) => + idx === index ? { ...f, status: 'processing', progress: 40 } : f + )); + + const decryptedData = await decryptWithServerAction(file); + + // 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()); + if (parentId) { + formData.append('parentId', parentId); + } + + // Update upload progress + setUploadingFiles(prev => prev.map((f, idx) => + idx === index ? { ...f, progress: 60 } : f + )); + + // API call + const response = await fetch(`/api/data-room/${projectId}/upload`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Upload failed'); + } + + // Success + setUploadingFiles(prev => prev.map((f, idx) => + idx === index ? { ...f, status: 'completed', progress: 100 } : f + )); + + } catch (error: any) { + // Failure + setUploadingFiles(prev => prev.map((f, idx) => + idx === index ? { + ...f, + status: 'error', + error: error.message || 'Upload failed' + } : f + )); + } + }; + + // 업로드 시작 함수 + const startUpload = async () => { + if (selectedFilesForUpload.length === 0) return; + await handleFileUpload(selectedFilesForUpload); + // 업로드 완료 후 선택된 파일 초기화 + setSelectedFilesForUpload([]); + }; + // Download folder const downloadFolder = async (folder: FileItem) => { if (folder.type !== 'folder') return; @@ -620,14 +761,13 @@ export function FileManager({ projectId }: FileManagerProps) { } }; - // Download multiple files const downloadMultipleFiles = async (itemIds: string[]) => { // 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' + item.permissions?.canDownload ); if (filesToDownload.length === 0) { @@ -712,15 +852,9 @@ export function FileManager({ projectId }: FileManagerProps) { // View file with PDFTron const viewFile = async (file: FileItem) => { try { - - - - setViewerFileUrl(file.filePath); + setViewerFileUrl(file.filePath || ''); setSelectedFile(file); setViewerDialogOpen(true); - - - } catch (error) { toast({ title: 'Error', @@ -858,11 +992,6 @@ export function FileManager({ projectId }: FileManagerProps) { } }; - // 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') { @@ -952,7 +1081,7 @@ export function FileManager({ projectId }: FileManagerProps) { onClick={() => { // 현재 폴더의 카테고리를 기본값으로 설정 if (currentParentId) { - const currentFolder = items.find(item => item.parentId === currentParentId); + const currentFolder = items.find(item => item.id === currentParentId); if (currentFolder) { setUploadCategory(currentFolder.category); } @@ -972,7 +1101,7 @@ export function FileManager({ projectId }: FileManagerProps) { {items.filter(item => selectedItems.has(item.id) && item.type === 'file' && - item.permissions?.canDownload === 'true' + item.permissions?.canDownload ).length > 0 && ( <Button size="sm" @@ -1057,7 +1186,7 @@ export function FileManager({ projectId }: FileManagerProps) { <div className="flex justify-center items-center h-64"> <div className="text-muted-foreground">Loading...</div> </div> - ) : filteredItems.length === 0 ? ( + ) : filteredItems.length === 0 && filteredTreeItems.length === 0 ? ( <div className="flex flex-col items-center justify-center h-64"> <Folder className="h-12 w-12 text-muted-foreground mb-2" /> <p className="text-muted-foreground">Empty</p> @@ -1081,6 +1210,8 @@ export function FileManager({ projectId }: FileManagerProps) { onDoubleClick={() => { if (item.type === 'folder') { handleFolderOpen(item); + } else { + viewFile(item); } }} > @@ -1114,7 +1245,72 @@ export function FileManager({ projectId }: FileManagerProps) { </div> </ContextMenuTrigger> - {/* ... ContextMenuContent는 동일 ... */} + <ContextMenuContent> + {item.type === 'file' && ( + <> + <ContextMenuItem onClick={() => viewFile(item)}> + <Eye className="h-4 w-4 mr-2" /> + View + </ContextMenuItem> + {item.permissions?.canDownload && ( + <ContextMenuItem onClick={() => downloadFile(item)}> + <Download className="h-4 w-4 mr-2" /> + Download + </ContextMenuItem> + )} + </> + )} + + {item.type === 'folder' && ( + <> + <ContextMenuItem onClick={() => handleFolderOpen(item)}> + <Folder className="h-4 w-4 mr-2" /> + Open + </ContextMenuItem> + <ContextMenuItem onClick={() => downloadFolder(item)}> + <Download className="h-4 w-4 mr-2" /> + Download Folder + </ContextMenuItem> + </> + )} + + {isInternalUser && ( + <> + <ContextMenuSeparator /> + {item.permissions?.canEdit && ( + <ContextMenuItem onClick={() => { + setSelectedFile(item); + setDialogValue(item.name); + setRenameDialogOpen(true); + }}> + <Edit2 className="h-4 w-4 mr-2" /> + Rename + </ContextMenuItem> + )} + <ContextMenuItem onClick={() => { + setSelectedFile(item); + setNewCategory(item.category); + setCategoryDialogOpen(true); + }}> + <Shield className="h-4 w-4 mr-2" /> + Change Category + </ContextMenuItem> + </> + )} + + {item.permissions?.canDelete && ( + <> + <ContextMenuSeparator /> + <ContextMenuItem + className="text-destructive" + onClick={() => deleteItems([item.id])} + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete + </ContextMenuItem> + </> + )} + </ContextMenuContent> </ContextMenu> ); })} @@ -1145,6 +1341,11 @@ export function FileManager({ projectId }: FileManagerProps) { setDialogValue(item.name); setRenameDialogOpen(true); }} + onCategoryChange={(item) => { + setSelectedFile(item); + setNewCategory(item.category); + setCategoryDialogOpen(true); + }} isInternalUser={isInternalUser} /> ))} @@ -1154,13 +1355,20 @@ export function FileManager({ projectId }: FileManagerProps) { </ScrollArea> </div> - {/* Upload Dialog */} - <Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}> + {/* Upload Dialog - 수정된 부분 */} + <Dialog open={uploadDialogOpen} onOpenChange={(open) => { + setUploadDialogOpen(open); + if (!open) { + setSelectedFilesForUpload([]); + setUploadingFiles([]); + setPreserveFolderStructure(false); + } + }}> <DialogContent className="max-w-2xl max-h-[90vh] flex flex-col"> <DialogHeader> <DialogTitle>Upload Files</DialogTitle> <DialogDescription> - Drag and drop files or click to select. + Drag and drop files or click to select. You can also select entire folders. </DialogDescription> </DialogHeader> @@ -1176,15 +1384,12 @@ export function FileManager({ projectId }: FileManagerProps) { <SelectContent> {Object.entries(categoryConfig) .filter(([key]) => { - // 현재 폴더가 있는 경우 if (currentParentId) { - const currentFolder = items.find(item => item.parentId === currentParentId); - // 현재 폴더가 public이 아니면 public 옵션 제외 + const currentFolder = items.find(item => item.id === currentParentId); if (currentFolder && currentFolder.category !== 'public') { return key !== 'public'; } } - // 루트 폴더이거나 현재 폴더가 public인 경우 모든 옵션 표시 return true; }) .map(([key, config]) => ( @@ -1197,57 +1402,223 @@ export function FileManager({ projectId }: FileManagerProps) { ))} </SelectContent> </Select> - {/* 현재 폴더 정보 표시 (선택사항) */} - {currentParentId && (() => { - const currentFolder = items.find(item => item.parentId === currentParentId); - if (currentFolder && currentFolder.category !== 'public') { - return ( - <p className="text-xs text-muted-foreground mt-1 flex items-center"> - <AlertCircle className="h-3 w-3 mr-1" /> - Current folder is {categoryConfig[currentFolder.category].label}. - Public uploads are not allowed. - </p> - ); - } - })()} </div> - {/* Dropzone */} - <Dropzone - onDrop={(acceptedFiles: File[]) => { - handleFileUpload(acceptedFiles); - }} - accept={{ - 'application/pdf': ['.pdf'], - 'application/msword': ['.doc'], - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], - 'application/vnd.ms-excel': ['.xls'], - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], - 'application/vnd.ms-powerpoint': ['.ppt'], - 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], - 'text/plain': ['.txt'], - 'text/csv': ['.csv'], - 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'], - 'application/zip': ['.zip'], - 'application/x-rar-compressed': ['.rar'], - 'application/x-7z-compressed': ['.7z'], - 'application/x-dwg': ['.dwg'], - 'application/x-dxf': ['.dxf'], - }} - multiple={true} - disabled={false} - > - <DropzoneZone className="h-48 border-2 border-dashed border-gray-300 rounded-lg"> - <DropzoneInput /> - <div className="flex flex-col items-center justify-center h-full"> - <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" /> - <DropzoneTitle>Drag files or click to upload</DropzoneTitle> - <DropzoneDescription>Multiple files can be uploaded simultaneously</DropzoneDescription> + {/* Preserve Folder Structure Option - 폴더가 선택된 경우만 표시 */} + {selectedFilesForUpload.some((file: any) => file.webkitRelativePath) && ( + <div className="flex items-center space-x-2 p-3 bg-muted rounded-lg"> + <Switch + id="preserve-folder" + checked={preserveFolderStructure} + onCheckedChange={setPreserveFolderStructure} + /> + <Label htmlFor="preserve-folder" className="cursor-pointer"> + <div className="font-medium">Preserve folder structure</div> + <div className="text-xs text-muted-foreground"> + Create folders and maintain the original directory structure + </div> + </Label> + </div> + )} + + {/* Dropzone - 파일이 선택되지 않았을 때만 표시 */} + {selectedFilesForUpload.length === 0 && uploadingFiles.length === 0 && ( + <div + className="h-48 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center relative" + onDragOver={(e) => { + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.classList.add('border-primary', 'bg-accent'); + }} + onDragLeave={(e) => { + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.classList.remove('border-primary', 'bg-accent'); + }} + onDrop={async (e) => { + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.classList.remove('border-primary', 'bg-accent'); + + const items = e.dataTransfer.items; + const files: File[] = []; + const filePromises: Promise<void>[] = []; + + // 폴더 구조 감지를 위한 플래그 + let hasFolderStructure = false; + + // DataTransferItem을 통한 폴더 처리 + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + if (item.kind === 'file') { + const entry = item.webkitGetAsEntry(); + + if (entry) { + filePromises.push( + new Promise<void>(async (resolve) => { + const traverseFileTree = async (item: any, path: string = '') => { + if (item.isFile) { + await new Promise<void>((fileResolve) => { + item.file((file: File) => { + // 파일에 상대 경로 추가 + Object.defineProperty(file, 'webkitRelativePath', { + value: path + file.name, + writable: false + }); + files.push(file); + fileResolve(); + }); + }); + } else if (item.isDirectory) { + hasFolderStructure = true; + const dirReader = item.createReader(); + await new Promise<void>((dirResolve) => { + dirReader.readEntries(async (entries: any[]) => { + for (const entry of entries) { + await traverseFileTree(entry, path + item.name + '/'); + } + dirResolve(); + }); + }); + } + }; + + await traverseFileTree(entry); + resolve(); + }) + ); + } + } + } + + // 모든 파일 처리 완료 대기 + await Promise.all(filePromises); + + // 파일이 없으면 일반 파일 처리 (폴더가 아닌 경우) + if (files.length === 0) { + const droppedFiles = Array.from(e.dataTransfer.files); + if (droppedFiles.length > 0) { + setSelectedFilesForUpload(droppedFiles); + } + } else { + // 폴더 구조가 있으면 자동으로 옵션 활성화 + if (hasFolderStructure) { + setPreserveFolderStructure(true); + } + setSelectedFilesForUpload(files); + } + }} + > + <input + type="file" + multiple + className="hidden" + id="file-input" + onChange={(e) => { + const files = Array.from(e.target.files || []); + setSelectedFilesForUpload(files); + }} + /> + <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" /> + <div className="text-center"> + <p className="font-medium text-lg">Drag folders or files here</p> + <p className="text-sm text-muted-foreground mt-1"> + Folders will maintain their structure + </p> </div> - </DropzoneZone> - </Dropzone> + {/* <div className="mt-4 flex gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => document.getElementById('file-input')?.click()} + > + <File className="h-4 w-4 mr-2" /> + Browse Files + </Button> + <label htmlFor="folder-upload-btn" className="cursor-pointer"> + <input + id="folder-upload-btn" + type="file" + // @ts-ignore + webkitdirectory="" + directory="" + multiple + className="hidden" + onChange={(e) => { + const files = Array.from(e.target.files || []); + setSelectedFilesForUpload(files); + if (files.length > 0 && (files[0] as any).webkitRelativePath) { + setPreserveFolderStructure(true); + } + }} + /> + <Button + variant="outline" + size="sm" + type="button" + as="div" + > + <Folder className="h-4 w-4 mr-2" /> + Browse Folder + </Button> + </label> + </div> */} + </div> + )} - {/* Uploading File List */} + {/* Selected Files List - 업로드 전 */} + {selectedFilesForUpload.length > 0 && uploadingFiles.length === 0 && ( + <div className="border rounded-lg p-4"> + <div className="flex items-center justify-between mb-3"> + <h4 className="font-medium text-sm"> + Selected Files ({selectedFilesForUpload.length}) + </h4> + <Button + size="sm" + variant="ghost" + onClick={() => { + setSelectedFilesForUpload([]); + setPreserveFolderStructure(false); + }} + > + <X className="h-4 w-4 mr-1" /> + Clear All + </Button> + </div> + <div className="space-y-2 max-h-[300px] overflow-y-auto"> + {selectedFilesForUpload.map((file, index) => { + const relativePath = (file as any).webkitRelativePath || file.name; + return ( + <div key={index} className="flex items-center gap-3 p-2 bg-muted/50 rounded-md"> + <File className="h-4 w-4 flex-shrink-0 text-muted-foreground" /> + <div className="flex-1 min-w-0"> + <p className="text-sm truncate" title={relativePath}> + {relativePath} + </p> + <span className="text-xs text-muted-foreground"> + {formatFileSize(file.size)} + </span> + </div> + <Button + size="sm" + variant="ghost" + onClick={() => { + setSelectedFilesForUpload(prev => + prev.filter((_, i) => i !== index) + ); + }} + > + <X className="h-3 w-3" /> + </Button> + </div> + ); + })} + </div> + </div> + )} + + {/* Uploading File List - 업로드 중 */} {uploadingFiles.length > 0 && ( <div className="border rounded-lg p-4 bg-muted/50"> <div className="flex items-center justify-between mb-3"> @@ -1290,19 +1661,6 @@ export function FileManager({ projectId }: FileManagerProps) { <Progress value={uploadFile.progress} className="h-1.5 mt-2" /> )} </div> - {uploadFile.status === 'error' && ( - <Button - size="sm" - variant="ghost" - onClick={() => { - setUploadingFiles(prev => - prev.filter((_, i) => i !== index) - ); - }} - > - <X className="h-4 w-4" /> - </Button> - )} </div> ))} </div> @@ -1316,11 +1674,30 @@ export function FileManager({ projectId }: FileManagerProps) { variant="outline" onClick={() => { setUploadDialogOpen(false); + setSelectedFilesForUpload([]); setUploadingFiles([]); + setPreserveFolderStructure(false); }} > - Close + Cancel </Button> + {/* 파일이 선택되었고 업로드 중이 아닐 때만 Upload 버튼 표시 */} + {selectedFilesForUpload.length > 0 && uploadingFiles.length === 0 && ( + <Button onClick={startUpload}> + <Upload className="h-4 w-4 mr-2" /> + Upload {selectedFilesForUpload.length} File{selectedFilesForUpload.length > 1 ? 's' : ''} + </Button> + )} + {/* 업로드 완료 후 Done 버튼 */} + {uploadingFiles.length > 0 && uploadingFiles.every(f => f.status === 'completed' || f.status === 'error') && ( + <Button onClick={() => { + setUploadDialogOpen(false); + setUploadingFiles([]); + setSelectedFilesForUpload([]); + }}> + Done + </Button> + )} </DialogFooter> </DialogContent> </Dialog> @@ -1375,7 +1752,6 @@ export function FileManager({ projectId }: FileManagerProps) { </DialogContent> </Dialog> - {/* Rename Dialog */} <Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}> <DialogContent> @@ -1429,13 +1805,13 @@ export function FileManager({ projectId }: FileManagerProps) { </DialogContent> </Dialog> - {/* Category Change Dialog (for folders) */} + {/* Category Change Dialog */} <Dialog open={categoryDialogOpen} onOpenChange={setCategoryDialogOpen}> <DialogContent> <DialogHeader> <DialogTitle>Change Category</DialogTitle> <DialogDescription> - Changing category for {selectedFile?.name} folder. + Changing category for {selectedFile?.name}. </DialogDescription> </DialogHeader> @@ -1506,7 +1882,9 @@ export function FileManager({ projectId }: FileManagerProps) { <Button onClick={() => { if (selectedFile) { - changeCategory(selectedFile.id, newCategory, applyToChildren); + changeCategory(selectedFile.id, newCategory, + selectedFile.type === 'folder' && newCategory !== 'public' ? true : applyToChildren + ); setCategoryDialogOpen(false); setSelectedFile(null); setApplyToChildren(false); diff --git a/components/layout/HeaderDataroom.tsx b/components/layout/HeaderDataroom.tsx index 333e3768..fa9b89cf 100644 --- a/components/layout/HeaderDataroom.tsx +++ b/components/layout/HeaderDataroom.tsx @@ -101,18 +101,18 @@ export function HeaderDataRoom() { {/* 네비게이션 메뉴 - 간단한 배열 */} <div className="hidden md:block flex-1 min-w-0"> - <nav className="flex items-center space-x-6"> - {simpleMenus.map((menu) => ( - <Link - key={menu.href} - href={`/${lng}${menu.href}`} - className="text-sm font-medium transition-colors hover:text-primary" - > - {menu.title} - </Link> - ))} - </nav> -</div> + <nav className="flex items-center space-x-6"> + {simpleMenus.map((menu) => ( + <Link + key={menu.href} + href={`/${lng}${menu.href}`} + className="text-sm font-medium transition-colors hover:text-primary" + > + {menu.title} + </Link> + ))} + </nav> + </div> {/* 우측 영역 */} <div className="ml-auto flex flex-shrink-0 items-center space-x-2"> diff --git a/components/layout/HeaderSimple.tsx b/components/layout/HeaderSimple.tsx index 989929ae..f6f60342 100644 --- a/components/layout/HeaderSimple.tsx +++ b/components/layout/HeaderSimple.tsx @@ -89,11 +89,11 @@ export function HeaderSimple() { </Button> {/* 로고 영역 - 항상 표시 */} - <div className="mr-4 flex-shrink-0 flex items-center gap-2 lg:mr-6"> + <div className="mr-8 flex-shrink-0 flex items-center gap-2 lg:mr-8"> <Link href={`/`} className="flex items-center gap-2"> <Image className="dark:invert" - src="/images/SHI_logo.svg" + src="/images/dataRoomLogo.png" alt="EVCP Logo" width={140} height={20} diff --git a/components/project/ProjectDashboard.tsx b/components/project/ProjectDashboard.tsx index 581b7b95..12515903 100644 --- a/components/project/ProjectDashboard.tsx +++ b/components/project/ProjectDashboard.tsx @@ -348,10 +348,10 @@ export function ProjectDashboard({ projectId }: ProjectDashboardProps) { <UserPlus className="h-4 w-4 mr-2" /> Add Member </Button> - <Button variant="outline"> + {/* <Button variant="outline"> <Settings className="h-4 w-4 mr-2" /> Settings - </Button> + </Button> */} </div> )} </div> diff --git a/components/project/ProjectNav.tsx b/components/project/ProjectNav.tsx index c62f760e..1654c30d 100644 --- a/components/project/ProjectNav.tsx +++ b/components/project/ProjectNav.tsx @@ -89,13 +89,13 @@ export function ProjectNav({ projectId }: ProjectNavProps) { active: pathname?.includes('stats') , requireRole: ['owner'], }, - { - label: 'Settings', - icon: Settings, - href: `/evcp/data-room/${projectId}/settings`, - active: pathname?.includes('settiings') , - requireRole: ['owner', 'admin'], - }, + // { + // label: 'Settings', + // icon: Settings, + // href: `/evcp/data-room/${projectId}/settings`, + // active: pathname?.includes('settiings') , + // requireRole: ['owner', 'admin'], + // }, ]; const visibleNavItems = navItems.filter(item => diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json index 93cc538f..5fabc5c4 100644 --- a/i18n/locales/en/menu.json +++ b/i18n/locales/en/menu.json @@ -261,7 +261,9 @@ "document_submission": "Document/Drawing Submission", "document_submission_desc": "Submit vendor documents/drawings", "vendor_progress": "Vendor Progress", - "vendor_progress_desc": "View vendor EDP input progress" + "vendor_progress_desc": "View vendor EDP input progress", + "cover": "Vendor Document Cover", + "cover_desc": "Covenr Page Generator" } }, "additional": { diff --git a/i18n/locales/ko/menu.json b/i18n/locales/ko/menu.json index fb3c4e7a..28a97373 100644 --- a/i18n/locales/ko/menu.json +++ b/i18n/locales/ko/menu.json @@ -95,7 +95,9 @@ "tbe": "TBE", "tbe_desc": "Technical Bid Evaluation", "itb": "RFQ 생성", - "itb_desc": "PR 이슈 전 RFQ 생성" + "itb_desc": "PR 이슈 전 RFQ 생성", + "cover": "Vendor Document Cover", + "cover_desc": "Covenr Page Generator" }, "vendor_management": { "title": "협력업체 관리", diff --git a/lib/forms/services.ts b/lib/forms/services.ts index 6310b693..57b7f000 100644 --- a/lib/forms/services.ts +++ b/lib/forms/services.ts @@ -229,7 +229,6 @@ export async function getEditableFieldsByTag( * 그리고 이 로직 전체를 unstable_cache로 감싸 캐싱. */ export async function getFormData(formCode: string, contractItemId: number) { - try { // 기존 로직으로 projectId, columns, data 가져오기 @@ -1534,9 +1533,9 @@ async function transformDataToSEDPFormat( } // Apply the factor if we got one - if (factor !== undefined && typeof value === 'number') { - value = value * factor; - } + // if (factor !== undefined && typeof value === 'number') { + // value = value * factor; + // } } const attribute: SEDPAttribute = { diff --git a/lib/gtc-contract/service.ts b/lib/gtc-contract/service.ts index 61545d95..e1bca3c5 100644 --- a/lib/gtc-contract/service.ts +++ b/lib/gtc-contract/service.ts @@ -320,6 +320,7 @@ export async function getUsersForFilter(): Promise<UserForFilter[]> { name: users.name, email: users.email, domain: users.domain, + ownerCompanyId: users.ownerCompanyId, }) .from(users) .where(eq(users.isActive, true)) // 활성 사용자만 diff --git a/lib/owner-companies/service.ts b/lib/owner-companies/service.ts index 3692abd4..2f3e914f 100644 --- a/lib/owner-companies/service.ts +++ b/lib/owner-companies/service.ts @@ -60,12 +60,12 @@ export async function createOwnerCompanyUser( .values({ ...data, ownerCompanyId: companyId, - domain: "owner", // 발주처 도메인 + domain: "partners", // 발주처 도메인 isActive: true, }) .returning(); - revalidatePath(`/owner-companies/${companyId}/users`); + revalidatePath(`/evcp/data-room/owner-companies/${companyId}/users`); return { success: true, data: user }; } |
