diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-29 07:46:57 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-29 07:46:57 +0000 |
| commit | bbc3094e932e3d193d3223448c789461f4afc058 (patch) | |
| tree | f28dd034b191ca78d0af15eccbbdf7a952141153 | |
| parent | d28c43b2d33bac51c69ac7417a14f9fe83f2a25f (diff) | |
(대표님) 데이터룸 관련 변경사항
| -rw-r--r-- | app/[lng]/evcp/data-room/[projectId]/members/page.tsx | 705 | ||||
| -rw-r--r-- | app/api/data-room/[projectId]/[fileId]/route.ts | 4 | ||||
| -rw-r--r-- | app/api/data-room/[projectId]/files/route.ts | 246 | ||||
| -rw-r--r-- | app/api/data-room/[projectId]/folders/route.ts | 282 | ||||
| -rw-r--r-- | components/file-manager/CreateSubfolderForm.tsx | 127 | ||||
| -rw-r--r-- | components/file-manager/FileManager.tsx | 156 | ||||
| -rw-r--r-- | components/file-manager/SecurePDFViewer.tsx | 8 | ||||
| -rw-r--r-- | components/form-data/spreadJS-dialog.tsx | 38 | ||||
| -rw-r--r-- | components/project/ProjectNav.tsx | 4 | ||||
| -rw-r--r-- | components/project/dataroom-members.ts | 74 | ||||
| -rw-r--r-- | types/table.d.ts | 2 |
11 files changed, 1329 insertions, 317 deletions
diff --git a/app/[lng]/evcp/data-room/[projectId]/members/page.tsx b/app/[lng]/evcp/data-room/[projectId]/members/page.tsx index dbd5e37d..244d957b 100644 --- a/app/[lng]/evcp/data-room/[projectId]/members/page.tsx +++ b/app/[lng]/evcp/data-room/[projectId]/members/page.tsx @@ -17,7 +17,8 @@ import { Check, ChevronsUpDown, Loader2, - UserCog + UserCog, + Send } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -68,6 +69,8 @@ import { import { Separator } from '@/components/ui/separator'; import { getUsersForFilter } from '@/lib/gtc-contract/service'; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Checkbox } from '@/components/ui/checkbox'; +import { sendDataRoomInvitation, sendBulkDataRoomInvitations } from '@/components/project/dataroom-members'; interface Member { id: string; @@ -105,6 +108,14 @@ export default function ProjectMembersPage({ const [roleFilter, setRoleFilter] = useState<string>('all'); const [addMemberOpen, setAddMemberOpen] = useState(false); const [editingMember, setEditingMember] = useState<Member | null>(null); + + // 프로젝트 정보 상태 추가 + const [projectName, setProjectName] = useState<string>('Data Room'); + + // 이메일 전송 관련 상태 + const [sendEmailOnAdd, setSendEmailOnAdd] = useState(true); + const [selectedMembers, setSelectedMembers] = useState<Set<string>>(new Set()); + const [sendingEmail, setSendingEmail] = useState(false); // 사용자 선택 관련 상태 const [availableUsers, setAvailableUsers] = useState<User[]>([]); @@ -133,8 +144,22 @@ export default function ProjectMembersPage({ useEffect(() => { fetchMembers(); checkUserRole(); + fetchProjectInfo(); }, [projectId]); + // 프로젝트 정보 가져오기 + const fetchProjectInfo = async () => { + try { + const response = await fetch(`/api/projects/${projectId}`); + const data = await response.json(); + if (data.name) { + setProjectName(data.name); + } + } catch (error) { + console.error('프로젝트 정보 로드 실패:', error); + } + }; + // 다이얼로그가 열릴 때 사용자 목록 가져오기 useEffect(() => { if (addMemberOpen) { @@ -145,6 +170,7 @@ export default function ProjectMembersPage({ setUserSearchTerm(''); setNewMemberRole('viewer'); setIsExternalUser(false); + setSendEmailOnAdd(true); } }, [addMemberOpen]); @@ -217,10 +243,34 @@ export default function ProjectMembersPage({ if (!response.ok) throw new Error('멤버 추가 실패'); - toast({ - title: '성공', - description: '새 멤버가 추가되었습니다.', - }); + // 이메일 전송 옵션이 켜져 있으면 이메일 발송 + if (sendEmailOnAdd) { + const emailResult = await sendDataRoomInvitation({ + email: selectedUser.email, + name: selectedUser.name, + dataRoomName: projectName, + role: newMemberRole, + dataRoomUrl: `${window.location.origin}/projects/${projectId}` + }); + + if (emailResult.success) { + toast({ + title: '성공', + description: '멤버가 추가되고 초대 이메일이 발송되었습니다.', + }); + } else { + toast({ + title: '부분 성공', + description: '멤버는 추가되었지만 이메일 발송에 실패했습니다.', + variant: 'default', + }); + } + } else { + toast({ + title: '성공', + description: '새 멤버가 추가되었습니다.', + }); + } setAddMemberOpen(false); fetchMembers(); @@ -233,6 +283,56 @@ export default function ProjectMembersPage({ } }; + // 선택된 멤버들에게 이메일 보내기 + const sendEmailToSelectedMembers = async () => { + if (selectedMembers.size === 0) { + toast({ + title: '오류', + description: '이메일을 보낼 멤버를 선택해주세요.', + variant: 'destructive', + }); + return; + } + + setSendingEmail(true); + + try { + const membersToEmail = members + .filter(m => selectedMembers.has(m.id)) + .map(m => ({ + email: m.user.email, + name: m.user.name, + dataRoomName: projectName, + role: m.role, + dataRoomUrl: `${window.location.origin}/projects/${projectId}` + })); + + const result = await sendBulkDataRoomInvitations(membersToEmail); + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }); + setSelectedMembers(new Set()); + } else { + toast({ + title: '오류', + description: result.message, + variant: 'destructive', + }); + } + } catch (error) { + toast({ + title: '오류', + description: '이메일 발송에 실패했습니다.', + variant: 'destructive', + }); + } finally { + setSendingEmail(false); + } + }; + const updateMemberRole = async (memberId: string, newRole: string) => { try { const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, { @@ -241,24 +341,26 @@ export default function ProjectMembersPage({ body: JSON.stringify({ role: newRole }), }); - if (!response.ok) throw new Error('역할 변경 실패'); + if (!response.ok) throw new Error('역할 수정 실패'); toast({ title: '성공', - description: '멤버 역할이 변경되었습니다.', + description: '멤버 역할이 수정되었습니다.', }); - fetchMembers(); + setEditingMember(null); } catch (error) { toast({ title: '오류', - description: '역할 변경에 실패했습니다.', + description: '역할 수정에 실패했습니다.', variant: 'destructive', }); } }; const removeMember = async (memberId: string) => { + if (!confirm('정말로 이 멤버를 제거하시겠습니까?')) return; + try { const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, { method: 'DELETE', @@ -270,7 +372,6 @@ export default function ProjectMembersPage({ title: '성공', description: '멤버가 제거되었습니다.', }); - fetchMembers(); } catch (error) { toast({ @@ -281,208 +382,230 @@ export default function ProjectMembersPage({ } }; - const handleSelectUser = (user: User) => { - setSelectedUser(user); - setUserPopoverOpen(false); - - // 외부 사용자(partners)인 경우 역할을 viewer로 고정 - if (user.domain === 'partners') { - setIsExternalUser(true); - setNewMemberRole('viewer'); - } else { - setIsExternalUser(false); - // 내부 사용자는 기본값 viewer로 설정하되 변경 가능 - setNewMemberRole('viewer'); + // 개별 멤버에게 이메일 보내기 + const sendEmailToMember = async (member: Member) => { + setSendingEmail(true); + + try { + const result = await sendDataRoomInvitation({ + email: member.user.email, + name: member.user.name, + dataRoomName: projectName, + role: member.role, + dataRoomUrl: `${window.location.origin}/projects/${projectId}` + }); + + if (result.success) { + toast({ + title: '성공', + description: '초대 이메일이 발송되었습니다.', + }); + } else { + toast({ + title: '오류', + description: '이메일 발송에 실패했습니다.', + variant: 'destructive', + }); + } + } catch (error) { + toast({ + title: '오류', + description: '이메일 발송에 실패했습니다.', + variant: 'destructive', + }); + } finally { + setSendingEmail(false); } }; - const formatDateShort = (iso?: string) => - iso ? new Date(iso).toLocaleDateString() : '-'; - - const roleConfig = { - owner: { label: 'Owner', icon: Crown, color: 'text-yellow-500', bg: 'bg-yellow-50' }, - admin: { label: 'Admin', icon: Shield, color: 'text-blue-500', bg: 'bg-blue-50' }, - editor: { label: 'Editor', icon: Edit2, color: 'text-green-500', bg: 'bg-green-50' }, - viewer: { label: 'Viewer', icon: Eye, color: 'text-gray-500', bg: 'bg-gray-50' }, + const getRoleBadge = (role: string) => { + const config = { + owner: { icon: Crown, label: 'Owner', variant: 'default' as const, className: 'bg-purple-100 text-purple-800' }, + admin: { icon: Shield, label: 'Admin', variant: 'secondary' as const, className: 'bg-blue-100 text-blue-800' }, + editor: { icon: Edit2, label: 'Editor', variant: 'outline' as const, className: 'bg-green-100 text-green-800' }, + viewer: { icon: Eye, label: 'Viewer', variant: 'outline' as const, className: '' } + }; + const { icon: Icon, label, variant, className } = config[role] || config.viewer; + return ( + <Badge variant={variant} className={cn('flex items-center gap-1', className)}> + <Icon className="h-3 w-3" /> + {label} + </Badge> + ); }; - const filteredMembers = members.filter(member => { - const matchesSearch = member.user.name.toLowerCase().includes(searchQuery.toLowerCase()) || - member.user.email.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesRole = roleFilter === 'all' || member.role === roleFilter; - return matchesSearch && matchesRole; + const filteredMembers = members + .filter(m => { + if (roleFilter !== 'all' && m.role !== roleFilter) return false; + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + return m.user.name.toLowerCase().includes(query) || + m.user.email.toLowerCase().includes(query); + }); + + const displayedMembers = filteredMembers.slice((page - 1) * pageSize, page * pageSize); + const totalPages = Math.ceil(filteredMembers.length / pageSize); + + const canManageMembers = ['owner', 'admin'].includes(currentUserRole); + + // 사용자 필터링 로직 + const filteredUsers = availableUsers.filter(user => { + const searchLower = userSearchTerm.toLowerCase(); + return user.name.toLowerCase().includes(searchLower) || + user.email.toLowerCase().includes(searchLower); }); - // 사용자 검색 필터링 - const filteredUsers = availableUsers.filter(user => - user.name.toLowerCase().includes(userSearchTerm.toLowerCase()) || - user.email.toLowerCase().includes(userSearchTerm.toLowerCase()) - ); - - - const canManageMembers = currentUserRole === 'owner' || currentUserRole === 'admin'; - - const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize)); - const paginatedMembers = filteredMembers.slice((page - 1) * pageSize, page * pageSize); + // 전체 선택/해제 + const toggleSelectAll = () => { + if (selectedMembers.size === displayedMembers.length) { + setSelectedMembers(new Set()); + } else { + setSelectedMembers(new Set(displayedMembers.map(m => m.id))); + } + }; if (loading) { return ( - <div className="flex items-center justify-center min-h-[400px]"> - <div className="text-center space-y-3"> - <Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" /> - <p className="text-sm text-muted-foreground">멤버 목록을 불러오는 중...</p> - </div> + <div className="flex items-center justify-center h-96"> + <Loader2 className="h-8 w-8 animate-spin" /> </div> ); } return ( - <div className="p-6 space-y-6"> - {/* 헤더 */} - <div className="flex items-center justify-between"> - <div> - <h1 className="text-2xl font-bold">프로젝트 멤버</h1> - <p className="text-muted-foreground mt-1"> - 프로젝트에 참여 중인 멤버를 관리합니다 - </p> - </div> - - {canManageMembers && ( - <Button onClick={() => setAddMemberOpen(true)}> - <UserPlus className="h-4 w-4 mr-2" /> - 멤버 추가 - </Button> - )} - </div> - - {/* 필터 */} - <div className="flex items-center gap-3"> - <div className="relative flex-1 max-w-md"> - <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> - <Input - placeholder="이름 또는 이메일로 검색..." - className="pl-9" - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - /> - </div> - - <Select value={roleFilter} onValueChange={setRoleFilter}> - <SelectTrigger className="w-40"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="all">모든 역할</SelectItem> - <SelectItem value="owner">Owner</SelectItem> - <SelectItem value="admin">Admin</SelectItem> - <SelectItem value="editor">Editor</SelectItem> - <SelectItem value="viewer">Viewer</SelectItem> - </SelectContent> - </Select> - </div> - - {/* 멤버 목록 (Table) */} - <div className="overflow-x-auto"> - <Table className="[&_td]:py-2 [&_th]:py-2 text-sm"> - <TableHeader className="sticky top-0 bg-background z-10"> - <TableRow> - <TableHead className="w-[44px]"></TableHead> - <TableHead className="w-[100px]">이름</TableHead> - <TableHead className="min-w-[150px]">이메일</TableHead> - <TableHead className="w-[90px] text-center">구분</TableHead> - <TableHead className="w-[140px]">역할</TableHead> - <TableHead className="w-[130px]">추가일</TableHead> - <TableHead className="w-[150px]">마지막 접속</TableHead> - <TableHead className="w-[60px] text-right">액션</TableHead> - </TableRow> - </TableHeader> - - <TableBody> - {paginatedMembers.length > 0 ? ( - paginatedMembers.map((member) => { - const config = roleConfig[member.role]; - const Icon = config.icon; - const isInternal = member.user.domain !== 'partners'; - - return ( - <TableRow key={member.id} className="hover:bg-accent/40"> - {/* Avatar */} - <TableCell className="align-middle"> - <Avatar className="h-8 w-8"> - <AvatarImage src={member.user.imageUrl} /> - <AvatarFallback> - {member.user.name?.charAt(0).toUpperCase()} - </AvatarFallback> - </Avatar> - </TableCell> - - {/* Name */} - <TableCell className="align-middle"> - <span className="font-medium">{member.user.name}</span> - </TableCell> - - {/* Email */} - <TableCell className="align-middle"> - <span className="text-muted-foreground">{member.user.email}</span> - </TableCell> - - {/* Domain */} - <TableCell className="align-middle text-center"> - <Badge variant={isInternal ? 'secondary' : 'outline'}> - {isInternal ? 'Internal' : 'Partner'} - </Badge> - </TableCell> - - {/* Role */} - <TableCell className="align-middle"> - {canManageMembers && member.role !== 'owner' && member.user.domain !== 'partners' ? ( - <Select - value={member.role} - onValueChange={(v) => updateMemberRole(member.id, v)} - > - <SelectTrigger className="h-8 w-[120px]"> - <div className={cn('flex items-center gap-1')}> - <Icon className={cn('h-3 w-3', config.color)} /> - <span className={cn('text-xs font-medium')}> - {config.label} - </span> - </div> - </SelectTrigger> - <SelectContent> - <SelectItem value="viewer">Viewer</SelectItem> - <SelectItem value="editor">Editor</SelectItem> - <SelectItem value="admin">Admin</SelectItem> - </SelectContent> - </Select> - ) : ( - <div className="inline-flex items-center gap-2"> - <div className={cn('px-2 py-1 rounded-full inline-flex items-center gap-1', config.bg)}> - <Icon className={cn('h-3 w-3', config.color)} /> - <span className={cn('text-xs font-medium', config.color)}> - {config.label} - </span> + <div className="container mx-auto py-8"> + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle className="flex items-center gap-2"> + <Users className="h-5 w-5" /> + 프로젝트 멤버 + </CardTitle> + <CardDescription> + 프로젝트에 참여 중인 멤버를 관리합니다 ({filteredMembers.length}명) + </CardDescription> + </div> + <div className="flex gap-2"> + {selectedMembers.size > 0 && ( + <Button + variant="outline" + size="sm" + onClick={sendEmailToSelectedMembers} + disabled={sendingEmail} + > + {sendingEmail ? ( + <Loader2 className="h-4 w-4 mr-2 animate-spin" /> + ) : ( + <Send className="h-4 w-4 mr-2" /> + )} + 선택한 멤버에게 이메일 ({selectedMembers.size}) + </Button> + )} + {canManageMembers && ( + <Button + onClick={() => setAddMemberOpen(true)} + size="sm" + > + <UserPlus className="h-4 w-4 mr-2" /> + 멤버 추가 + </Button> + )} + </div> + </div> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {/* 필터 영역 */} + <div className="flex items-center gap-4"> + <div className="flex-1"> + <div className="relative"> + <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="이름 또는 이메일로 검색..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-9" + /> + </div> + </div> + <Select value={roleFilter} onValueChange={setRoleFilter}> + <SelectTrigger className="w-[150px]"> + <Filter className="h-4 w-4 mr-2" /> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">모든 역할</SelectItem> + <SelectItem value="owner">Owner</SelectItem> + <SelectItem value="admin">Admin</SelectItem> + <SelectItem value="editor">Editor</SelectItem> + <SelectItem value="viewer">Viewer</SelectItem> + </SelectContent> + </Select> + </div> + + <Separator /> + + {/* 멤버 테이블 */} + <div className="rounded-md border"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px]"> + <Checkbox + checked={selectedMembers.size === displayedMembers.length && displayedMembers.length > 0} + onCheckedChange={toggleSelectAll} + /> + </TableHead> + <TableHead>멤버</TableHead> + <TableHead>역할</TableHead> + <TableHead>추가일</TableHead> + {/* <TableHead>마지막 접속</TableHead> */} + {canManageMembers && <TableHead className="w-[100px]">작업</TableHead>} + </TableRow> + </TableHeader> + <TableBody> + {displayedMembers.map((member) => ( + <TableRow key={member.id}> + <TableCell> + <Checkbox + checked={selectedMembers.has(member.id)} + onCheckedChange={(checked) => { + const newSelected = new Set(selectedMembers); + if (checked) { + newSelected.add(member.id); + } else { + newSelected.delete(member.id); + } + setSelectedMembers(newSelected); + }} + /> + </TableCell> + <TableCell> + <div className="flex items-center gap-3"> + <Avatar className="h-8 w-8"> + <AvatarImage src={member.user.imageUrl} /> + <AvatarFallback> + {member.user.name.split(' ').map(n => n[0]).join('').toUpperCase()} + </AvatarFallback> + </Avatar> + <div> + <div className="font-medium">{member.user.name}</div> + <div className="text-sm text-muted-foreground">{member.user.email}</div> </div> - {member.user.domain === 'partners' && canManageMembers && member.role !== 'owner' && ( - <span className="text-xs text-muted-foreground">(고정)</span> - )} </div> - )} - </TableCell> - - {/* AddedAt */} - <TableCell className="align-middle"> - {formatDateShort(member.addedAt)} - </TableCell> - - {/* LastAccess */} - <TableCell className="align-middle"> - {formatDateShort(member.lastAccess)} - </TableCell> - - {/* Actions */} - <TableCell className="align-middle"> - <div className="flex justify-end"> - {canManageMembers && member.role !== 'owner' ? ( + </TableCell> + <TableCell>{getRoleBadge(member.role)}</TableCell> + <TableCell className="text-sm text-muted-foreground"> + {new Date(member.addedAt).toLocaleDateString()} + </TableCell> + {/* <TableCell className="text-sm text-muted-foreground"> + {member.lastAccess + ? new Date(member.lastAccess).toLocaleDateString() + : '접속 기록 없음'} + </TableCell> */} + {canManageMembers && ( + <TableCell> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon"> @@ -490,95 +613,131 @@ export default function ProjectMembersPage({ </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> - <DropdownMenuItem> - <Mail className="h-4 w-4 mr-2" /> - 메일 보내기 - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem - className="text-red-600" - onClick={() => removeMember(member.id)} + <DropdownMenuItem + onClick={() => sendEmailToMember(member)} + disabled={sendingEmail} > - <Trash2 className="h-4 w-4 mr-2" /> - 제거 + <Mail className="h-4 w-4 mr-2" /> + 이메일 보내기 </DropdownMenuItem> + {member.role !== 'owner' && ( + <> + <DropdownMenuItem onClick={() => setEditingMember(member)}> + <UserCog className="h-4 w-4 mr-2" /> + 역할 변경 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => removeMember(member.id)} + className="text-red-600" + > + <Trash2 className="h-4 w-4 mr-2" /> + 멤버 제거 + </DropdownMenuItem> + </> + )} </DropdownMenuContent> </DropdownMenu> - ) : ( - <Button variant="ghost" size="icon" disabled> - <MoreVertical className="h-4 w-4" /> - </Button> - )} - </div> - </TableCell> - </TableRow> - ); - }) - ) : ( - <TableRow> - <TableCell colSpan={8} className="h-32 text-center text-muted-foreground"> - <div className="flex flex-col items-center justify-center gap-2"> - <Users className="h-8 w-8 text-muted-foreground/60" /> - <span>검색 결과가 없습니다</span> - </div> - </TableCell> - </TableRow> + </TableCell> + )} + </TableRow> + ))} + {displayedMembers.length === 0 && ( + <TableRow> + <TableCell colSpan={canManageMembers ? 6 : 5} className="text-center py-8"> + <div className="text-muted-foreground"> + {searchQuery || roleFilter !== 'all' + ? '검색 결과가 없습니다.' + : '아직 멤버가 없습니다.'} + </div> + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + + {/* 페이지네이션 */} + {totalPages > 1 && ( + <div className="flex items-center justify-between"> + <p className="text-sm text-muted-foreground"> + 전체 {filteredMembers.length}명 중 {(page - 1) * pageSize + 1}- + {Math.min(page * pageSize, filteredMembers.length)}명 표시 + </p> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => setPage(p => Math.max(1, p - 1))} + disabled={page === 1} + > + 이전 + </Button> + <span className="text-sm"> + {page} / {totalPages} + </span> + <Button + variant="outline" + size="sm" + onClick={() => setPage(p => Math.min(totalPages, p + 1))} + disabled={page === totalPages} + > + 다음 + </Button> + </div> + </div> )} - </TableBody> - </Table> - </div> + </div> + </CardContent> + </Card> - {/* Pagination */} - <div className="flex items-center justify-between px-4 py-3 border-t"> - <div className="text-sm text-muted-foreground"> - 총 {filteredMembers.length}명 · {pageSize}명/페이지 - </div> - <div className="flex items-center gap-2"> - <Button - variant="outline" - size="sm" - onClick={() => setPage((p) => Math.max(1, p - 1))} - disabled={page === 1} - > - 이전 - </Button> - <span className="text-sm"> - {page} / {totalPages} - </span> - <Button - variant="outline" - size="sm" - onClick={() => setPage((p) => Math.min(totalPages, p + 1))} - disabled={page === totalPages} - > - 다음 - </Button> - </div> - </div> + {/* 역할 변경 다이얼로그 */} + <Dialog open={!!editingMember} onOpenChange={(open) => !open && setEditingMember(null)}> + <DialogContent> + <DialogHeader> + <DialogTitle>역할 변경</DialogTitle> + <DialogDescription> + {editingMember?.user.name}님의 역할을 변경합니다. + </DialogDescription> + </DialogHeader> + <div className="space-y-4"> + <Select + value={editingMember?.role} + onValueChange={(value) => editingMember && updateMemberRole(editingMember.id, value)} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="viewer">Viewer - 읽기 전용</SelectItem> + <SelectItem value="editor">Editor - 파일 편집 가능</SelectItem> + <SelectItem value="admin">Admin - 프로젝트 관리</SelectItem> + </SelectContent> + </Select> + </div> + </DialogContent> + </Dialog> {/* 멤버 추가 다이얼로그 */} <Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}> - <DialogContent className="max-w-lg"> + <DialogContent className="sm:max-w-[500px]"> <DialogHeader> - <DialogTitle>멤버 추가</DialogTitle> + <DialogTitle>새 멤버 추가</DialogTitle> <DialogDescription> - 프로젝트에 멤버를 추가합니다 + 프로젝트에 새 멤버를 추가합니다. 사용자 유형에 따라 부여 가능한 권한이 다릅니다. </DialogDescription> </DialogHeader> <Tabs defaultValue="internal" className="w-full"> <TabsList className="grid w-full grid-cols-2"> <TabsTrigger value="internal">내부 사용자</TabsTrigger> - <TabsTrigger value="external" className="flex items-center gap-2"> - 외부 사용자 - <Badge variant="outline" className="ml-1 text-xs">Viewer 전용</Badge> - </TabsTrigger> + <TabsTrigger value="external">외부 사용자 (파트너)</TabsTrigger> </TabsList> <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" /> @@ -595,8 +754,8 @@ export default function ProjectMembersPage({ className="w-full justify-between" > <span className="truncate"> - {selectedUser && selectedUser.domain !== 'partners' ? ( - <div className="text-left"> + {selectedUser && !isExternalUser ? ( + <div className="flex items-center gap-2"> <div className="font-medium">{selectedUser.name}</div> <div className="text-xs text-muted-foreground">{selectedUser.email}</div> </div> @@ -739,7 +898,7 @@ export default function ProjectMembersPage({ <CommandEmpty>파트너를 찾을 수 없습니다.</CommandEmpty> <CommandGroup heading="파트너 목록"> {filteredUsers - .filter(u => u.ownerCompanyId !== null) + .filter(u => u.domain === 'partners') .map((user) => ( <CommandItem key={user.id} @@ -787,6 +946,21 @@ export default function ProjectMembersPage({ </TabsContent> </Tabs> + {/* 이메일 전송 옵션 */} + <div className="flex items-center space-x-2 pt-4 border-t"> + <Checkbox + id="send-email" + checked={sendEmailOnAdd} + onCheckedChange={(checked) => setSendEmailOnAdd(checked as boolean)} + /> + <label + htmlFor="send-email" + className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" + > + 접근 권한 안내 이메일 발송 + </label> + </div> + <DialogFooter> <Button variant="outline" @@ -796,6 +970,7 @@ export default function ProjectMembersPage({ setUserSearchTerm(''); setNewMemberRole('viewer'); setIsExternalUser(false); + setSendEmailOnAdd(true); }} > 취소 diff --git a/app/api/data-room/[projectId]/[fileId]/route.ts b/app/api/data-room/[projectId]/[fileId]/route.ts index 9ee01eb2..5e6d5088 100644 --- a/app/api/data-room/[projectId]/[fileId]/route.ts +++ b/app/api/data-room/[projectId]/[fileId]/route.ts @@ -21,7 +21,7 @@ export async function GET( } const context: FileAccessContext = { - userId: session.user.id, + userId: Number(session.user.id), userDomain: session.user.domain || 'partners', userEmail: session.user.email, ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, @@ -201,7 +201,7 @@ export async function PATCH( projectId, action, actionDetails, - userId: session.user.id, + userId: Number(session.user.id), userEmail: session.user.email, userDomain: session.user.domain, }); diff --git a/app/api/data-room/[projectId]/files/route.ts b/app/api/data-room/[projectId]/files/route.ts new file mode 100644 index 00000000..8c99c77f --- /dev/null +++ b/app/api/data-room/[projectId]/files/route.ts @@ -0,0 +1,246 @@ +// app/api/data-room/[projectId]/files/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { fileItems } from '@/db/schema'; +import { and, eq, isNull, desc, asc, sql } from 'drizzle-orm'; +import db from '@/db/db'; + +// 파일 목록 조회 +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + try { + const { projectId } = await params; + + // 세션 확인 + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + // URL 파라미터 파싱 + const { searchParams } = new URL(request.url); + const parentId = searchParams.get('parentId'); + const category = searchParams.get('category'); + const type = searchParams.get('type'); // 'file' | 'folder' | 'all' + const sortBy = searchParams.get('sortBy') || 'name'; + const sortOrder = searchParams.get('sortOrder') || 'asc'; + + // 기본 조회 조건 설정 + const conditions = []; + + // 프로젝트 ID는 필수 + conditions.push(eq(fileItems.projectId, projectId)); + + // parentId 조건 추가 + if (parentId && parentId !== 'null') { + conditions.push(eq(fileItems.parentId, parentId)); + } else { + // parentId가 없으면 최상위 항목만 조회 + conditions.push(isNull(fileItems.parentId)); + } + + // 카테고리 필터 + if (category) { + conditions.push(eq(fileItems.category, category)); + } + + // 타입 필터 (file, folder, all) + if (type && type !== 'all') { + if (type === 'file' || type === 'folder') { + conditions.push(eq(fileItems.type, type)); + } + } + + // 파일 목록 조회 + const files = await db + .select() + .from(fileItems) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy( + // 폴더를 먼저 표시 + desc(fileItems.type), + // 그 다음 정렬 기준 적용 + sortBy === 'name' + ? (sortOrder === 'asc' ? asc(fileItems.name) : desc(fileItems.name)) + : sortBy === 'updatedAt' + ? (sortOrder === 'asc' ? asc(fileItems.updatedAt) : desc(fileItems.updatedAt)) + : sortBy === 'size' + ? (sortOrder === 'asc' ? asc(fileItems.size) : desc(fileItems.size)) + : asc(fileItems.name) // 기본값 + ); + + // 파트너사 사용자의 경우 접근 가능한 파일만 필터링 + let filteredFiles = files; + if (session.user.domain === 'partners') { + // 현재는 모든 파일을 볼 수 있도록 설정 + // 필요시 추가 필터링 로직 구현 + filteredFiles = files; + } + + // 응답 데이터 구성 + const response = { + files: filteredFiles.map(file => ({ + id: file.id, + projectId: file.projectId, + parentId: file.parentId, + name: file.name, + type: file.type, + path: file.path || '/', + category: file.category || 'uncategorized', + size: file.size || 0, + mimeType: file.mimeType || '', + uploadedBy: file.uploadedBy, + uploadedByDomain: file.uploadedByDomain || 'default', + createdAt: file.createdAt, + updatedAt: file.updatedAt, + // 내부 사용자에게만 추가 정보 제공 + ...(session.user.domain !== 'partners' && { + createdBy: file.createdBy, + updatedBy: file.updatedBy, + }), + })), + count: filteredFiles.length, + parentId: parentId || null, + // 현재 경로 정보 (브레드크럼용) + currentPath: parentId ? await getCurrentPath(parentId, projectId) : [], + }; + + return NextResponse.json(response); + + } catch (error) { + console.error('파일 목록 조회 오류:', error); + return NextResponse.json( + { + error: '파일 목록을 불러오는데 실패했습니다', + details: process.env.NODE_ENV === 'development' ? error.message : undefined + }, + { status: 500 } + ); + } +} + +// 현재 경로 정보 가져오기 (브레드크럼용) +async function getCurrentPath( + folderId: string, + projectId: string +): Promise<Array<{ id: string; name: string }>> { + try { + const path: Array<{ id: string; name: string }> = []; + let currentId: string | null = folderId; + let depth = 0; + const maxDepth = 10; + + while (currentId && depth < maxDepth) { + const result = await db + .select({ + id: fileItems.id, + name: fileItems.name, + parentId: fileItems.parentId, + }) + .from(fileItems) + .where( + and( + eq(fileItems.id, currentId), + eq(fileItems.projectId, projectId), + eq(fileItems.type, 'folder') + ) + ) + .limit(1); + + const folder = result[0]; + if (!folder) break; + + // 경로 앞에 추가 (역순이므로) + path.unshift({ id: folder.id, name: folder.name }); + currentId = folder.parentId; + depth++; + } + + return path; + } catch (error) { + console.error('경로 조회 오류:', error); + return []; + } +} + +// 파일 검색 (POST) +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + try { + const { projectId } = await params; + + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const { searchTerm, filters } = body; + + if (!searchTerm || searchTerm.trim().length < 2) { + return NextResponse.json({ + error: '검색어는 2글자 이상 입력해주세요' + }, { status: 400 }); + } + + // 검색 쿼리 생성 + const searchConditions = [ + eq(fileItems.projectId, projectId), + sql`LOWER(${fileItems.name}) LIKE LOWER(${'%' + searchTerm + '%'})` + ]; + + // 필터 적용 + if (filters?.category) { + searchConditions.push(eq(fileItems.category, filters.category)); + } + if (filters?.type) { + searchConditions.push(eq(fileItems.type, filters.type)); + } + + const searchResults = await db + .select() + .from(fileItems) + .where(and(...searchConditions)) + .orderBy(desc(fileItems.type), asc(fileItems.name)) + .limit(50); // 최대 50개 결과 + + // 파트너사 권한 필터링 + let filteredResults = searchResults; + if (session.user.domain === 'partners') { + // 현재는 모든 결과 반환 + filteredResults = searchResults; + } + + return NextResponse.json({ + results: filteredResults.map(file => ({ + id: file.id, + projectId: file.projectId, + parentId: file.parentId, + name: file.name, + type: file.type, + path: file.path || '/', + category: file.category || 'uncategorized', + size: file.size || 0, + mimeType: file.mimeType || '', + updatedAt: file.updatedAt, + })), + count: filteredResults.length, + searchTerm, + }); + + } catch (error) { + console.error('파일 검색 오류:', error); + return NextResponse.json( + { + error: '파일 검색에 실패했습니다', + details: process.env.NODE_ENV === 'development' ? error.message : undefined + }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/folders/route.ts b/app/api/data-room/[projectId]/folders/route.ts new file mode 100644 index 00000000..0ddf48f5 --- /dev/null +++ b/app/api/data-room/[projectId]/folders/route.ts @@ -0,0 +1,282 @@ +// app/api/data-room/[projectId]/folders/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { fileItems, fileActivityLogs } from '@/db/schema'; +import { and, eq } from 'drizzle-orm'; +import db from '@/db/db'; + +// 폴더 생성 +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + try { + const { projectId } = await params; + + // 세션 확인 + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + // 내부 사용자만 폴더 생성 가능 + if (session.user.domain === 'partners') { + return NextResponse.json({ error: 'Permission denied' }, { status: 403 }); + } + + // 요청 본문 파싱 + const body = await request.json(); + const { name, parentId, category } = body; + + // 필수 필드 검증 + if (!name || typeof name !== 'string' || !name.trim()) { + return NextResponse.json({ error: 'Folder name is required' }, { status: 400 }); + } + + // 폴더 이름 정리 + const folderName = name.trim(); + + // 폴더 이름 유효성 검사 + const invalidChars = /[<>:"|?*]/; + if (invalidChars.test(folderName)) { + return NextResponse.json({ + error: 'Folder name contains invalid characters' + }, { status: 400 }); + } + + // 부모 폴더 정보 조회 및 경로 설정 + let parentFolder = null; + let path = '/'; + let depth = 0; + let inheritedCategory = category || 'confidential'; // 기본값을 스키마에 맞게 변경 + + if (parentId && parentId !== null) { + const parentFolderResult = await db + .select() + .from(fileItems) + .where( + and( + eq(fileItems.id, parentId), + eq(fileItems.projectId, projectId), + eq(fileItems.type, 'folder') + ) + ) + .limit(1); + + if (parentFolderResult.length === 0) { + return NextResponse.json({ error: 'Parent folder not found' }, { status: 404 }); + } + + parentFolder = parentFolderResult[0]; + // 경로 계산 (부모 경로 + 부모 이름 + /) + path = parentFolder.path + parentFolder.name + '/'; + // depth는 부모 depth + 1 + depth = (parentFolder.depth || 0) + 1; + + // 카테고리가 지정되지 않았으면 부모 폴더의 카테고리 상속 + if (!category) { + inheritedCategory = parentFolder.category || 'confidential'; + } + } + + // 같은 위치에 중복 이름 확인 + const existingFolder = await db + .select() + .from(fileItems) + .where( + and( + eq(fileItems.projectId, projectId), + eq(fileItems.parentId, parentId || null), + eq(fileItems.name, folderName), + eq(fileItems.type, 'folder') + ) + ) + .limit(1); + + if (existingFolder.length > 0) { + return NextResponse.json({ + error: 'A folder with this name already exists' + }, { status: 409 }); + } + + // 새 폴더 생성 (UUID는 자동 생성됨) + const newFolder = { + projectId, + parentId: parentId || null, + name: folderName, + type: 'folder' as const, + path, + depth, + category: inheritedCategory, + size: 0, + mimeType: 'folder', + // filePath와 fileUrl은 폴더에서는 null + filePath: null, + fileUrl: null, + // 권한 설정 + externalAccessLevel: 'view_only' as const, + externalAccessExpiry: null, + downloadCount: 0, + viewCount: 0, + // 메타데이터 + metadata: {}, + tags: null, + // 버전 관리 + version: 1, + previousVersionId: null, + // 감사 로그 + createdBy: Number(session.user.id), + updatedBy: Number(session.user.id), + createdAt: new Date(), + updatedAt: new Date(), + }; + + // DB에 폴더 저장 + const [insertedFolder] = await db + .insert(fileItems) + .values(newFolder) + .returning(); + + // 활동 로그 기록 (스키마가 있다면) + if (fileActivityLogs) { + try { + await db.insert(fileActivityLogs).values({ + fileItemId: insertedFolder.id, + projectId, + action: 'create_folder', + actionDetails: { + folderName: folderName, + parentId: parentId || null, + parentName: parentFolder?.name || null, + path, + category: inheritedCategory, + depth, + }, + userId: Number(session.user.id), + userEmail: session.user.email, + userDomain: session.user.domain || 'default', + ipAddress: request.ip || request.headers.get('x-forwarded-for') || null, + userAgent: request.headers.get('user-agent') || null, + createdAt: new Date(), + }); + } catch (logError) { + // 로그 실패는 무시하고 계속 진행 + console.error('Failed to create activity log:', logError); + } + } + + console.log('Folder created successfully:', { + id: insertedFolder.id, + name: folderName, + path, + depth, + parentId: parentId || null, + }); + + // 성공 응답 + return NextResponse.json({ + success: true, + folder: insertedFolder, + message: 'Folder created successfully', + }, { status: 201 }); + + } catch (error) { + console.error('Folder creation error:', error); + + // 에러 타입에 따른 응답 + if (error instanceof Error) { + // PostgreSQL unique constraint violation + if (error.message.includes('unique') || error.message.includes('duplicate')) { + return NextResponse.json( + { error: 'A folder with this path already exists' }, + { status: 409 } + ); + } + + // Foreign key constraint violation + if (error.message.includes('foreign key')) { + return NextResponse.json( + { error: 'Invalid project or parent folder' }, + { status: 400 } + ); + } + + // 데이터베이스 연결 오류 + if (error.message.includes('connect')) { + return NextResponse.json( + { error: 'Database connection failed' }, + { status: 503 } + ); + } + } + + // 일반 오류 + return NextResponse.json( + { + error: 'Failed to create folder', + details: process.env.NODE_ENV === 'development' ? error?.message : undefined + }, + { status: 500 } + ); + } +} + +// 폴더 목록 조회 +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + try { + const { projectId } = await params; + + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + // URL 파라미터에서 parentId 가져오기 + const { searchParams } = new URL(request.url); + const parentId = searchParams.get('parentId'); + + // 폴더만 조회 + const conditions = [ + eq(fileItems.projectId, projectId), + eq(fileItems.type, 'folder'), + ]; + + if (parentId) { + conditions.push(eq(fileItems.parentId, parentId)); + } else { + conditions.push(eq(fileItems.parentId, null)); + } + + const folders = await db + .select() + .from(fileItems) + .where(and(...conditions)) + .orderBy(fileItems.name); + + // 파트너사 사용자의 경우 접근 가능한 폴더만 필터링 + let filteredFolders = folders; + if (session.user.domain === 'partners') { + // category가 'confidential'이 아닌 폴더만 표시 + filteredFolders = folders.filter(folder => + folder.category !== 'confidential' || + folder.externalAccessLevel !== null + ); + } + + return NextResponse.json({ + folders: filteredFolders, + count: filteredFolders.length, + }); + + } catch (error) { + console.error('Folder list error:', error); + return NextResponse.json( + { error: 'Failed to load folders' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/components/file-manager/CreateSubfolderForm.tsx b/components/file-manager/CreateSubfolderForm.tsx new file mode 100644 index 00000000..0afdf755 --- /dev/null +++ b/components/file-manager/CreateSubfolderForm.tsx @@ -0,0 +1,127 @@ +// CreateSubfolderForm component - English version + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Loader2 } from "lucide-react"; + +interface CreateSubfolderFormProps { + parentFolder: any; + onSubmit: (name: string, category: string) => Promise<void>; + onCancel: () => void; +} + +export function CreateSubfolderForm({ + parentFolder, + onSubmit, + onCancel +}: CreateSubfolderFormProps) { + const [folderName, setFolderName] = useState(""); + const [category, setCategory] = useState(parentFolder?.category || "general"); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!folderName.trim()) { + setError("Please enter a folder name"); + return; + } + + // Special character validation + const invalidChars = /[<>:"|?*\/\\]/; + if (invalidChars.test(folderName)) { + setError("Folder name contains invalid characters"); + return; + } + + setIsCreating(true); + setError(""); + + try { + await onSubmit(folderName, category); + setFolderName(""); + setCategory(parentFolder?.category || "general"); + } catch (error: any) { + setError(error.message || "Failed to create folder"); + } finally { + setIsCreating(false); + } + }; + + return ( + <form onSubmit={handleSubmit} className="space-y-4 py-4"> + <div className="space-y-2"> + <Label htmlFor="folder-name">Folder Name</Label> + <Input + id="folder-name" + value={folderName} + onChange={(e) => { + setFolderName(e.target.value); + setError(""); + }} + placeholder="Enter folder name" + disabled={isCreating} + autoFocus + /> + {error && ( + <p className="text-sm text-destructive">{error}</p> + )} + </div> + + <div className="space-y-2"> + <Label htmlFor="folder-category">Security Category</Label> + <Select + value={category} + onValueChange={setCategory} + disabled={isCreating} + > + <SelectTrigger id="folder-category"> + <SelectValue placeholder="Select category" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="general">General</SelectItem> + <SelectItem value="confidential">Confidential</SelectItem> + <SelectItem value="internal">Internal</SelectItem> + </SelectContent> + </Select> + <p className="text-xs text-muted-foreground"> + Sub-folders inherit the security category from their parent folder by default + </p> + </div> + + <div className="flex justify-end gap-2"> + <Button + type="button" + variant="outline" + onClick={onCancel} + disabled={isCreating} + > + Cancel + </Button> + <Button + type="submit" + disabled={isCreating || !folderName.trim()} + > + {isCreating ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Creating... + </> + ) : ( + "Create Folder" + )} + </Button> + </div> + </form> + ); +}
\ No newline at end of file diff --git a/components/file-manager/FileManager.tsx b/components/file-manager/FileManager.tsx index a95d8c06..c56bb16a 100644 --- a/components/file-manager/FileManager.tsx +++ b/components/file-manager/FileManager.tsx @@ -95,6 +95,7 @@ import { decryptWithServerAction } from '@/components/drm/drmUtils'; import { Progress } from '@/components/ui/progress'; // Import the secure viewer component import { SecurePDFViewer } from './SecurePDFViewer'; +import { CreateSubfolderForm } from './CreateSubfolderForm'; interface FileItem { id: string; @@ -153,6 +154,7 @@ const TreeItem: React.FC<{ onShare: (item: FileItem) => void; onRename: (item: FileItem) => void; onCategoryChange: (item: FileItem) => void; + onCreateSubfolder: (item: FileItem) => void; // 추가 isInternalUser: boolean; }> = ({ item, @@ -169,6 +171,7 @@ const TreeItem: React.FC<{ onShare, onRename, onCategoryChange, + onCreateSubfolder, // 추가 isInternalUser }) => { const hasChildren = item.type === 'folder' && item.children && item.children.length > 0; @@ -263,10 +266,19 @@ const TreeItem: React.FC<{ )} {item.type === 'folder' && ( - <DropdownMenuItem onClick={() => onDownloadFolder(item)}> - <Download className="h-4 w-4 mr-2" /> - Download Folder - </DropdownMenuItem> + <> + {/* Create Sub-folder 추가 */} + {isInternalUser && ( + <DropdownMenuItem onClick={() => onCreateSubfolder(item)}> + <FolderPlus className="h-4 w-4 mr-2" /> + Create Sub-folder + </DropdownMenuItem> + )} + <DropdownMenuItem onClick={() => onDownloadFolder(item)}> + <Download className="h-4 w-4 mr-2" /> + Download Folder + </DropdownMenuItem> + </> )} {isInternalUser && ( @@ -320,6 +332,13 @@ const TreeItem: React.FC<{ {item.type === 'folder' && ( <> + {/* Create Sub-folder 추가 (Context Menu) */} + {isInternalUser && ( + <ContextMenuItem onClick={() => onCreateSubfolder(item)}> + <FolderPlus className="h-4 w-4 mr-2" /> + Create Sub-folder + </ContextMenuItem> + )} <ContextMenuItem onClick={() => onDoubleClick(item)}> <Folder className="h-4 w-4 mr-2" /> {isExpanded ? 'Collapse' : 'Expand'} @@ -381,6 +400,7 @@ const TreeItem: React.FC<{ onShare={onShare} onRename={onRename} onCategoryChange={onCategoryChange} + onCreateSubfolder={onCreateSubfolder} // 추가 isInternalUser={isInternalUser} /> ))} @@ -402,6 +422,59 @@ export function FileManager({ projectId }: FileManagerProps) { const [searchQuery, setSearchQuery] = useState(''); const [loading, setLoading] = useState(false); + const [subfolderDialogOpen, setSubfolderDialogOpen] = useState(false); + const [selectedParentFolder, setSelectedParentFolder] = useState(null); + const [newFolderName, setNewFolderName] = useState(''); + const [newFolderCategory, setNewFolderCategory] = useState('general'); + + const handleCreateSubfolder = (parentFolder) => { + setSelectedParentFolder(parentFolder); + setSubfolderDialogOpen(true); + }; + + const createSubfolder = async (name, category) => { + try { + const response = await fetch(`/api/data-room/${projectId}/folders`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name, + parentId: selectedParentFolder?.id || null, + category: category || selectedParentFolder?.category || 'general', + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to create folder'); + } + + const newFolder = await response.json(); + + // 기존 fetchItems 함수를 재사용하여 목록 새로고침 + await fetchItems(); // 기존에 있던 fetchItems 함수 재사용 + + toast({ + title: "Folder Created", + description: `Folder "${name}" has been created successfully.`, + }); + + setSubfolderDialogOpen(false); + setSelectedParentFolder(null); + + return newFolder; + } catch (error) { + toast({ + title: "Failed to Create Folder", + description: error.message || "An error occurred while creating the folder.", + variant: "destructive", + }); + throw error; + } + }; + // Upload states const [uploadDialogOpen, setUploadDialogOpen] = useState(false); const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]); @@ -419,6 +492,10 @@ export function FileManager({ projectId }: FileManagerProps) { const [categoryDialogOpen, setCategoryDialogOpen] = useState(false); const [applyToChildren, setApplyToChildren] = useState(false); const [newCategory, setNewCategory] = useState('confidential'); + const [subfolderDialog, setSubfolderDialog] = useState<{ + open: boolean; + parentFolder: FileItem | null; + }>({ open: false, parentFolder: null }); // Dialog data const [dialogValue, setDialogValue] = useState(''); @@ -564,22 +641,22 @@ export function FileManager({ projectId }: FileManagerProps) { 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 { @@ -593,7 +670,7 @@ export function FileManager({ projectId }: FileManagerProps) { parentId: parentId, }), }); - + if (response.ok) { const newFolder = await response.json(); folderMap.set(currentFolderPath, newFolder.id); @@ -606,7 +683,7 @@ export function FileManager({ projectId }: FileManagerProps) { parentId = folderMap.get(currentFolderPath) || null; } } - + // 폴더가 생성되었으면 해당 폴더에 파일 업로드 await uploadSingleFile(file, i, parentId); } else { @@ -851,7 +928,7 @@ export function FileManager({ projectId }: FileManagerProps) { // View file with PDFTron const viewFile = async (file: FileItem) => { - try { + try { setViewerFileUrl(file.filePath || ''); setSelectedFile(file); setViewerDialogOpen(true); @@ -1051,17 +1128,17 @@ export function FileManager({ projectId }: FileManagerProps) { // 재귀적으로 트리 항목 검색 const searchTreeItems = (items: FileItem[], query: string): FileItem[] => { const result: FileItem[] = []; - + for (const item of items) { // 현재 항목이 검색어와 일치하는지 확인 const matches = item.name.toLowerCase().includes(query.toLowerCase()); - + // 하위 항목 재귀적으로 검색 let childrenMatches: FileItem[] = []; if (item.children && item.children.length > 0) { childrenMatches = searchTreeItems(item.children, query); } - + // 현재 항목이나 하위 항목 중 하나라도 일치하면 결과에 추가 if (matches || childrenMatches.length > 0) { // 하위 항목이 일치하는 경우 현재 항목도 표시하기 위해 확장된 상태로 복제 @@ -1072,7 +1149,7 @@ export function FileManager({ projectId }: FileManagerProps) { result.push(clonedItem); } } - + return result; }; @@ -1362,6 +1439,12 @@ export function FileManager({ projectId }: FileManagerProps) { setSelectedFile(item); setShareDialogOpen(true); }} + onCreateSubfolder={(item) => { + setSelectedParentFolder(item); + setNewFolderName(''); + setNewFolderCategory(item.category || 'general'); + setSubfolderDialogOpen(true); // setCreateSubfolderDialogOpen 대신 setSubfolderDialogOpen 사용 + }} onRename={(item) => { setSelectedFile(item); setDialogValue(item.name); @@ -1465,21 +1548,21 @@ export function FileManager({ projectId }: FileManagerProps) { 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) => { @@ -1509,7 +1592,7 @@ export function FileManager({ projectId }: FileManagerProps) { }); } }; - + await traverseFileTree(entry); resolve(); }) @@ -1517,10 +1600,10 @@ export function FileManager({ projectId }: FileManagerProps) { } } } - + // 모든 파일 처리 완료 대기 await Promise.all(filePromises); - + // 파일이 없으면 일반 파일 처리 (폴더가 아닌 경우) if (files.length === 0) { const droppedFiles = Array.from(e.dataTransfer.files); @@ -1778,6 +1861,29 @@ export function FileManager({ projectId }: FileManagerProps) { </DialogContent> </Dialog> + <Dialog open={subfolderDialogOpen} onOpenChange={setSubfolderDialogOpen}> + <DialogContent className="sm:max-w-[425px]"> + <DialogHeader> + <DialogTitle>Create Sub-folder</DialogTitle> + <DialogDescription> + {selectedParentFolder ? ( + <> + Create a new sub-folder under <span className="font-medium">{selectedParentFolder.name}</span> + </> + ) : ( + "Create a new folder in the current directory" + )} + </DialogDescription> + </DialogHeader> + + <CreateSubfolderForm + parentFolder={selectedParentFolder} + onSubmit={createSubfolder} + onCancel={() => setSubfolderDialogOpen(false)} + /> + </DialogContent> + </Dialog> + {/* Rename Dialog */} <Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}> <DialogContent> @@ -1908,7 +2014,7 @@ export function FileManager({ projectId }: FileManagerProps) { <Button onClick={() => { if (selectedFile) { - changeCategory(selectedFile.id, newCategory, + changeCategory(selectedFile.id, newCategory, selectedFile.type === 'folder' && newCategory !== 'public' ? true : applyToChildren ); setCategoryDialogOpen(false); diff --git a/components/file-manager/SecurePDFViewer.tsx b/components/file-manager/SecurePDFViewer.tsx index 704c4f24..0deb96b6 100644 --- a/components/file-manager/SecurePDFViewer.tsx +++ b/components/file-manager/SecurePDFViewer.tsx @@ -91,10 +91,6 @@ export function SecurePDFViewer({ documentUrl, fileName, category, onClose }: Se 'ribbons', 'toggleNotesButton' ]); - - const { Core } = instance; - Core.Tools.Tool.disableAutoSwitch(); - Core.Tools.Tool.disableTextSelection(); // CSS 적용으로 추가 보안 const iframeWindow = instance.UI.iframeWindow; @@ -241,6 +237,10 @@ export function SecurePDFViewer({ documentUrl, fileName, category, onClose }: Se } annotationManager.drawAnnotations(documentViewer.getCurrentPage()); + + const { Core } = instance; + Core.Tools.Tool.disableAutoSwitch(); + Core.Tools.Tool.disableTextSelection(); } // Pan 모드로 설정 (텍스트 선택 불가) diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx index 1249ebd5..0d1c258e 100644 --- a/components/form-data/spreadJS-dialog.tsx +++ b/components/form-data/spreadJS-dialog.tsx @@ -210,7 +210,7 @@ export function TemplateViewDialog({ length: Array.isArray(templateData) ? templateData.length : 'N/A', data: templateData }); - + // 템플릿 데이터가 없거나 빈 배열인 경우 기본 GRD_LIST 템플릿 생성 if (!templateData || (Array.isArray(templateData) && templateData.length === 0)) { // columnsJSON이 있으면 기본 GRD_LIST 템플릿 생성 @@ -291,7 +291,7 @@ export function TemplateViewDialog({ } setAvailableTemplates(validTemplates); - + // 🔍 최종 availableTemplates 로깅 console.log('📋 availableTemplates set:', validTemplates.map(t => ({ TMPL_ID: t.TMPL_ID, @@ -302,7 +302,7 @@ export function TemplateViewDialog({ if (validTemplates.length > 0) { // 🔍 현재 선택된 템플릿이 availableTemplates에 있는지 확인 const selectedExists = selectedTemplateId && validTemplates.some(t => t.TMPL_ID === selectedTemplateId); - + if (!selectedExists) { // 선택된 템플릿이 없거나 유효하지 않으면 첫 번째 템플릿 선택 const firstTemplate = validTemplates[0]; @@ -325,13 +325,13 @@ export function TemplateViewDialog({ const handleTemplateChange = (templateId: string) => { const template = availableTemplates.find(t => t?.TMPL_ID === templateId); - + // 🔍 템플릿과 TMPL_ID 검증 if (!template || !template.TMPL_ID) { console.error('❌ Template not found or invalid TMPL_ID:', templateId); return; } - + const templateTypeToSet = determineTemplateType(template); setSelectedTemplateId(templateId); setTemplateType(templateTypeToSet); @@ -349,9 +349,9 @@ export function TemplateViewDialog({ availableCount: availableTemplates.length, availableIds: availableTemplates.map(t => t?.TMPL_ID) }); - + const found = availableTemplates.find(t => t?.TMPL_ID === selectedTemplateId); - + if (!found && selectedTemplateId) { console.warn('⚠️ Selected template not found:', { searching: selectedTemplateId, @@ -365,7 +365,7 @@ export function TemplateViewDialog({ TYPE: found.TMPL_TYPE }); } - + return found; }, [availableTemplates, selectedTemplateId]); @@ -1370,7 +1370,7 @@ export function TemplateViewDialog({ // 🔍 안전성 검증: availableTemplates가 있고, selectedTemplateId가 없을 때만 실행 if (!selectedTemplateId && availableTemplates.length > 0) { const only = availableTemplates[0]; - + // 🔍 TMPL_ID 검증 if (!only || !only.TMPL_ID) { console.error('❌ First template has no TMPL_ID:', only); @@ -1378,17 +1378,17 @@ export function TemplateViewDialog({ } const type = determineTemplateType(only); - + // 🔍 type이 null이 아닐 때만 진행 if (!type) { console.warn('⚠️ Could not determine template type for:', only); return; } - + // 선택되어 있지 않다면 자동 선택 setSelectedTemplateId(only.TMPL_ID); setTemplateType(type); - + // 이미 스프레드가 마운트되어 있다면 즉시 초기화 if (currentSpread) { initSpread(currentSpread, only); @@ -1602,7 +1602,7 @@ export function TemplateViewDialog({ {availableTemplates[0]?.NAME || 'Unnamed'} ({availableTemplates[0]?.TMPL_TYPE || 'Unknown'}) </span> </div> - ) : null} + ) : null} {selectedTemplate && ( <div className="flex items-center gap-4 text-sm"> @@ -1666,11 +1666,13 @@ export function TemplateViewDialog({ /> {selectedTemplate && isClient && isDataValid ? ( - <SpreadSheets - key={`${templateType}-${selectedTemplate?.TMPL_ID || 'unknown'}-${selectedTemplateId}`} - workbookInitialized={initSpread} - hostStyle={hostStyle} - /> + <div style={{ height: '100%', width: '100%', position: 'relative' }}> + <SpreadSheets + key={`${templateType}-${selectedTemplate?.TMPL_ID || 'unknown'}-${selectedTemplateId}`} + workbookInitialized={initSpread} + hostStyle={hostStyle} + /> + </div> ) : ( <div className="flex items-center justify-center h-full text-muted-foreground"> {!isClient ? ( diff --git a/components/project/ProjectNav.tsx b/components/project/ProjectNav.tsx index 1654c30d..0486e9b5 100644 --- a/components/project/ProjectNav.tsx +++ b/components/project/ProjectNav.tsx @@ -123,10 +123,10 @@ export function ProjectNav({ projectId }: ProjectNavProps) { <Badge variant="outline"> {projectRole || 'viewer'} </Badge> - <Button variant="outline" size="sm"> + {/* <Button variant="outline" size="sm"> <Share2 className="h-4 w-4 mr-1" /> Share - </Button> + </Button> */} </div> </div> diff --git a/components/project/dataroom-members.ts b/components/project/dataroom-members.ts new file mode 100644 index 00000000..2512a5cd --- /dev/null +++ b/components/project/dataroom-members.ts @@ -0,0 +1,74 @@ +"use server"; + +import { sendEmail } from "@/lib/mail/sendEmail"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + + +interface SendDataRoomInvitationInput { + email: string; + name: string; + dataRoomName: string; + role: string; + dataRoomUrl?: string; +} + +export async function sendDataRoomInvitation(input: SendDataRoomInvitationInput) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + const dataRoomUrl = input.dataRoomUrl || `${process.env.NEXT_PUBLIC_APP_URL}/data-rooms`; + + const subject = `You've been invited to access "${input.dataRoomName}" Data Room`; + + await sendEmail({ + to: input.email, + subject, + template: "data-room-invitation", + context: { + name: input.name, + dataRoomName: input.dataRoomName, + role: input.role, + inviterName: session.user.name || "Admin", + dataRoomUrl, + loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`, + }, + }); + + return { success: true, message: "Invitation email sent successfully" }; + } catch (error) { + console.error("Failed to send data room invitation:", error); + return { success: false, message: "Failed to send invitation email" }; + } +} + +// 여러 멤버에게 동시에 이메일 전송 +export async function sendBulkDataRoomInvitations( + members: SendDataRoomInvitationInput[] +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + const results = await Promise.allSettled( + members.map((member) => sendDataRoomInvitation(member)) + ); + + const successful = results.filter((r) => r.status === "fulfilled").length; + const failed = results.filter((r) => r.status === "rejected").length; + + return { + success: true, + message: `Sent ${successful} invitations successfully. ${failed} failed.`, + details: { successful, failed, total: members.length }, + }; + } catch (error) { + console.error("Failed to send bulk invitations:", error); + return { success: false, message: "Failed to send invitations" }; + } +}
\ No newline at end of file diff --git a/types/table.d.ts b/types/table.d.ts index e9656bbe..d4053cf1 100644 --- a/types/table.d.ts +++ b/types/table.d.ts @@ -54,7 +54,7 @@ export type Filter<TData> = Prettify< export interface DataTableRowAction<TData> { row: Row<TData> - type:"add_stage"|"specification_meeting"|"clone"|"viewVariables"|"variableSettings"|"addSubClause"|"createRevision"|"duplicate"|"dispose"|"restore"|"download_report"|"submit" |"general_evaluation"| "general_evaluation"|"esg_evaluation" |"schedule"| "view"| "upload" | "addInfo"| "view-series"|"log"| "tbeResult" | "requestInfo"| "esign-detail"| "responseDetail"|"signature"|"update" | "delete" | "user" | "pemission" | "invite" | "items" | "attachment" |"comments" | "open" | "select" | "files" + type:"add_stage"|"specification_meeting"|"clone"|"viewVariables"|"variableSettings"|"addSubClause"|"createRevision"|"duplicate"|"dispose"|"restore"|"download_report"|"submit" |"general_evaluation"| "general_evaluation"|"esg_evaluation" |"schedule"| "view"| "upload" | "addInfo"| "view-series"|"log"| "tbeResult" | "requestInfo"| "esign-detail"| "responseDetail"|"signature"|"update" | "delete" | "user" | "pemission" | "invite" | "items" | "attachment" |"comments" | "open" | "select" | "files" | "vendor-submission" } export interface QueryBuilderOpts { |
