From bbc3094e932e3d193d3223448c789461f4afc058 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 29 Oct 2025 07:46:57 +0000 Subject: (대표님) 데이터룸 관련 변경사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../evcp/data-room/[projectId]/members/page.tsx | 705 +++++++++++++-------- app/api/data-room/[projectId]/[fileId]/route.ts | 4 +- app/api/data-room/[projectId]/files/route.ts | 246 +++++++ app/api/data-room/[projectId]/folders/route.ts | 282 +++++++++ 4 files changed, 970 insertions(+), 267 deletions(-) create mode 100644 app/api/data-room/[projectId]/files/route.ts create mode 100644 app/api/data-room/[projectId]/folders/route.ts (limited to 'app') 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('all'); const [addMemberOpen, setAddMemberOpen] = useState(false); const [editingMember, setEditingMember] = useState(null); + + // 프로젝트 정보 상태 추가 + const [projectName, setProjectName] = useState('Data Room'); + + // 이메일 전송 관련 상태 + const [sendEmailOnAdd, setSendEmailOnAdd] = useState(true); + const [selectedMembers, setSelectedMembers] = useState>(new Set()); + const [sendingEmail, setSendingEmail] = useState(false); // 사용자 선택 관련 상태 const [availableUsers, setAvailableUsers] = useState([]); @@ -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 ( + + + {label} + + ); }; - 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 ( -
-
- -

멤버 목록을 불러오는 중...

-
+
+
); } return ( -
- {/* 헤더 */} -
-
-

프로젝트 멤버

-

- 프로젝트에 참여 중인 멤버를 관리합니다 -

-
- - {canManageMembers && ( - - )} -
- - {/* 필터 */} -
-
- - setSearchQuery(e.target.value)} - /> -
- - -
- - {/* 멤버 목록 (Table) */} -
- - - - - 이름 - 이메일 - 구분 - 역할 - 추가일 - 마지막 접속 - 액션 - - - - - {paginatedMembers.length > 0 ? ( - paginatedMembers.map((member) => { - const config = roleConfig[member.role]; - const Icon = config.icon; - const isInternal = member.user.domain !== 'partners'; - - return ( - - {/* Avatar */} - - - - - {member.user.name?.charAt(0).toUpperCase()} - - - - - {/* Name */} - - {member.user.name} - - - {/* Email */} - - {member.user.email} - - - {/* Domain */} - - - {isInternal ? 'Internal' : 'Partner'} - - - - {/* Role */} - - {canManageMembers && member.role !== 'owner' && member.user.domain !== 'partners' ? ( - - ) : ( -
-
- - - {config.label} - +
+ + +
+
+ + + 프로젝트 멤버 + + + 프로젝트에 참여 중인 멤버를 관리합니다 ({filteredMembers.length}명) + +
+
+ {selectedMembers.size > 0 && ( + + )} + {canManageMembers && ( + + )} +
+
+
+ +
+ {/* 필터 영역 */} +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+
+ +
+ + + + {/* 멤버 테이블 */} +
+
+ + + + 0} + onCheckedChange={toggleSelectAll} + /> + + 멤버 + 역할 + 추가일 + {/* 마지막 접속 */} + {canManageMembers && 작업} + + + + {displayedMembers.map((member) => ( + + + { + const newSelected = new Set(selectedMembers); + if (checked) { + newSelected.add(member.id); + } else { + newSelected.delete(member.id); + } + setSelectedMembers(newSelected); + }} + /> + + +
+ + + + {member.user.name.split(' ').map(n => n[0]).join('').toUpperCase()} + + +
+
{member.user.name}
+
{member.user.email}
- {member.user.domain === 'partners' && canManageMembers && member.role !== 'owner' && ( - (고정) - )}
- )} -
- - {/* AddedAt */} - - {formatDateShort(member.addedAt)} - - - {/* LastAccess */} - - {formatDateShort(member.lastAccess)} - - - {/* Actions */} - -
- {canManageMembers && member.role !== 'owner' ? ( + + {getRoleBadge(member.role)} + + {new Date(member.addedAt).toLocaleDateString()} + + {/* + {member.lastAccess + ? new Date(member.lastAccess).toLocaleDateString() + : '접속 기록 없음'} + */} + {canManageMembers && ( + - - - 메일 보내기 - - - removeMember(member.id)} + sendEmailToMember(member)} + disabled={sendingEmail} > - - 제거 + + 이메일 보내기 + {member.role !== 'owner' && ( + <> + setEditingMember(member)}> + + 역할 변경 + + + removeMember(member.id)} + className="text-red-600" + > + + 멤버 제거 + + + )} - ) : ( - - )} -
-
-
- ); - }) - ) : ( - - -
- - 검색 결과가 없습니다 -
-
-
+ + )} + + ))} + {displayedMembers.length === 0 && ( + + +
+ {searchQuery || roleFilter !== 'all' + ? '검색 결과가 없습니다.' + : '아직 멤버가 없습니다.'} +
+
+
+ )} +
+
+
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+

+ 전체 {filteredMembers.length}명 중 {(page - 1) * pageSize + 1}- + {Math.min(page * pageSize, filteredMembers.length)}명 표시 +

+
+ + + {page} / {totalPages} + + +
+
)} - - -
+
+ + - {/* Pagination */} -
-
- 총 {filteredMembers.length}명 · {pageSize}명/페이지 -
-
- - - {page} / {totalPages} - - -
-
+ {/* 역할 변경 다이얼로그 */} + !open && setEditingMember(null)}> + + + 역할 변경 + + {editingMember?.user.name}님의 역할을 변경합니다. + + +
+ +
+
+
{/* 멤버 추가 다이얼로그 */} - + - 멤버 추가 + 새 멤버 추가 - 프로젝트에 멤버를 추가합니다 + 프로젝트에 새 멤버를 추가합니다. 사용자 유형에 따라 부여 가능한 권한이 다릅니다. 내부 사용자 - - 외부 사용자 - Viewer 전용 - + 외부 사용자 (파트너)
- + {loadingUsers ? (
@@ -595,8 +754,8 @@ export default function ProjectMembersPage({ className="w-full justify-between" > - {selectedUser && selectedUser.domain !== 'partners' ? ( -
+ {selectedUser && !isExternalUser ? ( +
{selectedUser.name}
{selectedUser.email}
@@ -739,7 +898,7 @@ export default function ProjectMembersPage({ 파트너를 찾을 수 없습니다. {filteredUsers - .filter(u => u.ownerCompanyId !== null) + .filter(u => u.domain === 'partners') .map((user) => ( + {/* 이메일 전송 옵션 */} +
+ setSendEmailOnAdd(checked as boolean)} + /> + +
+