summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/evcp/data-room/[projectId]/members/page.tsx705
-rw-r--r--app/api/data-room/[projectId]/[fileId]/route.ts4
-rw-r--r--app/api/data-room/[projectId]/files/route.ts246
-rw-r--r--app/api/data-room/[projectId]/folders/route.ts282
4 files changed, 970 insertions, 267 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