diff options
Diffstat (limited to 'app')
32 files changed, 5469 insertions, 2 deletions
diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/files/page.tsx b/app/[lng]/evcp/(evcp)/data-room/[projectId]/files/page.tsx new file mode 100644 index 00000000..985e7fef --- /dev/null +++ b/app/[lng]/evcp/(evcp)/data-room/[projectId]/files/page.tsx @@ -0,0 +1,14 @@ +// app/projects/[projectId]/files/page.tsx +import { FileManager } from '@/components/file-manager/FileManager'; + +export default function ProjectFilesPage({ + params, +}: { + params: { projectId: string }; +}) { + return ( + <div className="h-full flex flex-col"> + <FileManager projectId={params.projectId} /> + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/layout.tsx b/app/[lng]/evcp/(evcp)/data-room/[projectId]/layout.tsx new file mode 100644 index 00000000..d2e74f8e --- /dev/null +++ b/app/[lng]/evcp/(evcp)/data-room/[projectId]/layout.tsx @@ -0,0 +1,19 @@ +// app/projects/[projectId]/layout.tsx +import { ProjectNav } from '@/components/project/ProjectNav'; + +export default function ProjectLayout({ + children, + params, +}: { + children: React.ReactNode; + params: { projectId: string }; +}) { + return ( + <div className="flex flex-col h-full"> + <ProjectNav projectId={params.projectId} /> + <div className="flex-1 overflow-y-auto"> + {children} + </div> + </div> + ); +} diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/members/page.tsx b/app/[lng]/evcp/(evcp)/data-room/[projectId]/members/page.tsx new file mode 100644 index 00000000..18442c0e --- /dev/null +++ b/app/[lng]/evcp/(evcp)/data-room/[projectId]/members/page.tsx @@ -0,0 +1,811 @@ +// app/projects/[projectId]/members/page.tsx +'use client'; + +import { use, useState, useEffect, useRef } from 'react'; +import { + Users, + UserPlus, + Crown, + Shield, + Eye, + Edit2, + Trash2, + Mail, + MoreVertical, + Search, + Filter, + Check, + ChevronsUpDown, + Loader2, + UserCog +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Label } from '@/components/ui/label'; +import { useToast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; +import { + Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow +} from '@/components/ui/table'; +import { Separator } from '@/components/ui/separator'; +import { getUsersForFilter } from '@/lib/gtc-contract/service'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" + +interface Member { + id: string; + userId: number; + user: { + name: string; + email: string; + imageUrl?: string; + domain: string; + }; + role: 'owner' | 'admin' | 'editor' | 'viewer'; + addedAt: string; + lastAccess?: string; +} + +interface User { + id: number; + name: string; + email: string; + domain?: string; // 'partners' | 'internal' 등 +} + +export default function ProjectMembersPage({ + params: promiseParams +}: { + params: Promise<{ projectId: string }> +}) { + // Next.js 15+ params Promise 처리 + const params = use(promiseParams); + const projectId = params.projectId; + + const [members, setMembers] = useState<Member[]>([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [roleFilter, setRoleFilter] = useState<string>('all'); + const [addMemberOpen, setAddMemberOpen] = useState(false); + const [editingMember, setEditingMember] = useState<Member | null>(null); + + // 사용자 선택 관련 상태 + const [availableUsers, setAvailableUsers] = useState<User[]>([]); + const [selectedUser, setSelectedUser] = useState<User | null>(null); + const [userSearchTerm, setUserSearchTerm] = useState(''); + const [userPopoverOpen, setUserPopoverOpen] = useState(false); + const [loadingUsers, setLoadingUsers] = useState(false); + const [isExternalUser, setIsExternalUser] = useState(false); // 외부 사용자 여부 + + const [newMemberRole, setNewMemberRole] = useState<string>('viewer'); + const [currentUserRole, setCurrentUserRole] = useState<string>('viewer'); + const [page, setPage] = useState(1); + const pageSize = 20; + + // Command component key management + const userOptionIdsRef = useRef<Record<number, string>>({}); + const popoverContentId = `popover-content-${Date.now()}`; + const commandId = `command-${Date.now()}`; + + const { toast } = useToast(); + + useEffect(() => { + setPage(1); + }, [searchQuery, roleFilter]); + + useEffect(() => { + fetchMembers(); + checkUserRole(); + }, [projectId]); + + // 다이얼로그가 열릴 때 사용자 목록 가져오기 + useEffect(() => { + if (addMemberOpen) { + fetchAvailableUsers(); + } else { + // 다이얼로그가 닫힐 때 초기화 + setSelectedUser(null); + setUserSearchTerm(''); + setNewMemberRole('viewer'); + setIsExternalUser(false); + } + }, [addMemberOpen]); + + const fetchAvailableUsers = async () => { + try { + setLoadingUsers(true); + const users = await getUsersForFilter(); + // 이미 프로젝트에 있는 멤버는 제외 + const memberUserIds = members.map(m => m.userId); + const filteredUsers = users.filter(u => !memberUserIds.includes(u.id)); + setAvailableUsers(filteredUsers); + } catch (error) { + console.error('사용자 목록 로드 실패:', error); + toast({ + title: '오류', + description: '사용자 목록을 불러올 수 없습니다.', + variant: 'destructive', + }); + } finally { + setLoadingUsers(false); + } + }; + + const fetchMembers = async () => { + try { + setLoading(true); + const response = await fetch(`/api/projects/${projectId}/members`); + const data = await response.json(); + setMembers(data.member); + } catch (error) { + toast({ + title: '오류', + description: '멤버 목록을 불러올 수 없습니다.', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + const checkUserRole = async () => { + try { + const response = await fetch(`/api/projects/${projectId}/access`); + const data = await response.json(); + setCurrentUserRole(data.role); + } catch (error) { + console.error('권한 확인 실패:', error); + } + }; + + const addMember = async () => { + if (!selectedUser) { + toast({ + title: '오류', + description: '사용자를 선택해주세요.', + variant: 'destructive', + }); + return; + } + + try { + const response = await fetch(`/api/projects/${projectId}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: selectedUser.id, + role: newMemberRole, + }), + }); + + if (!response.ok) throw new Error('멤버 추가 실패'); + + toast({ + title: '성공', + description: '새 멤버가 추가되었습니다.', + }); + + setAddMemberOpen(false); + fetchMembers(); + } catch (error) { + toast({ + title: '오류', + description: '멤버 추가에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + const updateMemberRole = async (memberId: string, newRole: string) => { + try { + const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: newRole }), + }); + + if (!response.ok) throw new Error('역할 변경 실패'); + + toast({ + title: '성공', + description: '멤버 역할이 변경되었습니다.', + }); + + fetchMembers(); + } catch (error) { + toast({ + title: '오류', + description: '역할 변경에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + const removeMember = async (memberId: string) => { + try { + const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('멤버 제거 실패'); + + toast({ + title: '성공', + description: '멤버가 제거되었습니다.', + }); + + fetchMembers(); + } catch (error) { + toast({ + title: '오류', + description: '멤버 제거에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + 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 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 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 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); + + 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> + ); + } + + 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> + {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' ? ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon"> + <MoreVertical className="h-4 w-4" /> + </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)} + > + <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> + )} + </TableBody> + </Table> + </div> + + {/* 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={addMemberOpen} onOpenChange={setAddMemberOpen}> + <DialogContent className="max-w-lg"> + <DialogHeader> + <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> + </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" /> + <span className="ml-2 text-sm text-muted-foreground">사용자 목록 불러오는 중...</span> + </div> + ) : ( + <> + <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={userPopoverOpen} + className="w-full justify-between" + > + <span className="truncate"> + {selectedUser && selectedUser.domain !== 'partners' ? ( + <div className="text-left"> + <div className="font-medium">{selectedUser.name}</div> + <div className="text-xs text-muted-foreground">{selectedUser.email}</div> + </div> + ) : ( + "내부 사용자를 선택하세요..." + )} + </span> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[460px] p-0"> + <Command> + <CommandInput + placeholder="이름 또는 이메일로 검색..." + value={userSearchTerm} + onValueChange={setUserSearchTerm} + /> + <CommandList + className="max-h-[300px]" + onWheel={(e) => { + e.stopPropagation(); + const target = e.currentTarget; + target.scrollTop += e.deltaY; + }} + > + <CommandEmpty>사용자를 찾을 수 없습니다.</CommandEmpty> + <CommandGroup heading="내부 사용자 목록"> + {filteredUsers + .filter(u => u.domain !== 'partners') + .map((user) => ( + <CommandItem + key={user.id} + onSelect={() => { + setSelectedUser(user); + setUserPopoverOpen(false); + setIsExternalUser(false); + setNewMemberRole('viewer'); + }} + value={`${user.name} ${user.email}`} + className="truncate" + > + <Users className="mr-2 h-4 w-4 text-blue-500 flex-shrink-0" /> + <div className="flex-1 truncate"> + <div className="font-medium truncate">{user.name}</div> + <div className="text-xs text-muted-foreground truncate">{user.email}</div> + </div> + <Check + className={cn( + "ml-2 h-4 w-4 flex-shrink-0", + selectedUser?.id === user.id && !isExternalUser ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + <p className="text-xs text-muted-foreground"> + 내부 사용자는 모든 역할을 부여할 수 있습니다. + </p> + </> + )} + </div> + + <div className="space-y-2"> + <Label htmlFor="internal-role">역할</Label> + <Select + value={newMemberRole} + onValueChange={setNewMemberRole} + disabled={!selectedUser || isExternalUser} + > + <SelectTrigger id="internal-role"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="viewer">Viewer - 읽기 전용</SelectItem> + <SelectItem value="editor">Editor - 파일 편집 가능</SelectItem> + <SelectItem value="admin">Admin - 프로젝트 관리</SelectItem> + </SelectContent> + </Select> + </div> + </TabsContent> + + <TabsContent value="external" className="space-y-4 mt-4"> + <div className="rounded-lg bg-amber-50 border border-amber-200 p-3 mb-4"> + <p className="text-sm text-amber-800"> + <strong>보안 정책 안내</strong><br/> + 외부 사용자(파트너)는 보안 정책상 Viewer 권한만 부여 가능합니다. + </p> + </div> + + <div className="space-y-2"> + <Label htmlFor="external-user">파트너 선택</Label> + + {loadingUsers ? ( + <div className="flex items-center justify-center py-4"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="ml-2 text-sm text-muted-foreground">사용자 목록 불러오는 중...</span> + </div> + ) : ( + <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={userPopoverOpen} + className="w-full justify-between" + > + <span className="truncate"> + {selectedUser && selectedUser.domain === 'partners' ? ( + <span className="flex items-center gap-2"> + {selectedUser.name} + <Badge variant="outline" className="ml-1 text-xs">외부</Badge> + </span> + ) : ( + "외부 사용자를 선택하세요..." + )} + </span> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[460px] p-0"> + <Command> + <CommandInput + placeholder="이름으로 검색..." + value={userSearchTerm} + onValueChange={setUserSearchTerm} + /> + <CommandList + className="max-h-[300px]" + onWheel={(e) => { + e.stopPropagation(); + const target = e.currentTarget; + target.scrollTop += e.deltaY; + }} + > + <CommandEmpty>파트너를 찾을 수 없습니다.</CommandEmpty> + <CommandGroup heading="파트너 목록"> + {filteredUsers + .filter(u => u.domain === 'partners') + .map((user) => ( + <CommandItem + key={user.id} + onSelect={() => { + setSelectedUser(user); + setUserPopoverOpen(false); + setIsExternalUser(true); + setNewMemberRole('viewer'); + }} + value={user.name} + className="truncate" + > + <Users className="mr-2 h-4 w-4 text-amber-600" /> + <span className="truncate flex-1">{user.name}</span> + <Badge variant="outline" className="text-xs mx-2">파트너</Badge> + <Check + className={cn( + "ml-auto h-4 w-4 flex-shrink-0", + selectedUser?.id === user.id && isExternalUser ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + )} + </div> + + <div className="space-y-2"> + <Label htmlFor="external-role">역할</Label> + <Select value="viewer" disabled> + <SelectTrigger id="external-role" className="opacity-60"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="viewer">Viewer - 읽기 전용 (고정)</SelectItem> + </SelectContent> + </Select> + </div> + </TabsContent> + </Tabs> + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setAddMemberOpen(false); + setSelectedUser(null); + setUserSearchTerm(''); + setNewMemberRole('viewer'); + setIsExternalUser(false); + }} + > + 취소 + </Button> + <Button + onClick={addMember} + disabled={!selectedUser} + > + 추가하기 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/page.tsx b/app/[lng]/evcp/(evcp)/data-room/[projectId]/page.tsx new file mode 100644 index 00000000..d54a8cab --- /dev/null +++ b/app/[lng]/evcp/(evcp)/data-room/[projectId]/page.tsx @@ -0,0 +1,10 @@ +// app/projects/[projectId]/page.tsx +import { ProjectDashboard } from '@/components/project/ProjectDashboard'; + +export default function ProjectPage({ + params, +}: { + params: { projectId: string }; +}) { + return <ProjectDashboard projectId={params.projectId} />; +} diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/settings/page.tsx b/app/[lng]/evcp/(evcp)/data-room/[projectId]/settings/page.tsx new file mode 100644 index 00000000..aa0f3b52 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/data-room/[projectId]/settings/page.tsx @@ -0,0 +1,488 @@ + +// app/projects/[projectId]/settings/page.tsx +'use client'; + +import { useState, useEffect } from 'react'; +import { + Settings, + Shield, + Globe, + Trash2, + AlertCircle, + Save, + Lock, + Unlock, + Archive, + Users, + HardDrive +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useToast } from '@/hooks/use-toast'; +import { useRouter } from 'next/navigation'; + +interface ProjectSettings { + id: string; + name: string; + description: string; + isPublic: boolean; + externalAccessEnabled: boolean; + storageLimit: number; + maxFileSize: number; + allowedFileTypes: string[]; + autoArchiveDays: number; + requireApproval: boolean; + defaultCategory: string; +} + +export default function ProjectSettingsPage({ + params +}: { + params: { projectId: string } +}) { + const [settings, setSettings] = useState<ProjectSettings | null>(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [archiveDialogOpen, setArchiveDialogOpen] = useState(false); + const [currentUserRole, setCurrentUserRole] = useState<string>('viewer'); + + const { toast } = useToast(); + const router = useRouter(); + + useEffect(() => { + fetchSettings(); + checkUserRole(); + }, [params.projectId]); + + const fetchSettings = async () => { + try { + setLoading(true); + const response = await fetch(`/api/projects/${params.projectId}/settings`); + + if (!response.ok) { + throw new Error('설정을 불러올 수 없습니다'); + } + + const data = await response.json(); + setSettings(data); + } catch (error) { + toast({ + title: '오류', + description: '프로젝트 설정을 불러올 수 없습니다.', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + const checkUserRole = async () => { + try { + const response = await fetch(`/api/projects/${params.projectId}/access`); + const data = await response.json(); + setCurrentUserRole(data.role); + } catch (error) { + console.error('권한 확인 실패:', error); + } + }; + + const saveSettings = async () => { + if (!settings) return; + + try { + setSaving(true); + const response = await fetch(`/api/projects/${params.projectId}/settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); + + if (!response.ok) throw new Error('설정 저장 실패'); + + toast({ + title: '성공', + description: '프로젝트 설정이 저장되었습니다.', + }); + } catch (error) { + toast({ + title: '오류', + description: '설정 저장에 실패했습니다.', + variant: 'destructive', + }); + } finally { + setSaving(false); + } + }; + + const deleteProject = async () => { + try { + const response = await fetch(`/api/projects/${params.projectId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('프로젝트 삭제 실패'); + + toast({ + title: '성공', + description: '프로젝트가 삭제되었습니다.', + }); + + router.push('/projects'); + } catch (error) { + toast({ + title: '오류', + description: '프로젝트 삭제에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + const archiveProject = async () => { + try { + const response = await fetch(`/api/projects/${params.projectId}/archive`, { + method: 'POST', + }); + + if (!response.ok) throw new Error('프로젝트 보관 실패'); + + toast({ + title: '성공', + description: '프로젝트가 보관되었습니다.', + }); + + router.push('/projects'); + } catch (error) { + toast({ + title: '오류', + description: '프로젝트 보관에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + const canEdit = currentUserRole === 'owner' || currentUserRole === 'admin'; + + if (loading || !settings) { + return ( + <div className="p-6"> + <div className="animate-pulse space-y-4"> + {[...Array(5)].map((_, i) => ( + <div key={i} className="h-20 bg-gray-200 rounded" /> + ))} + </div> + </div> + ); + } + + return ( + <div className="p-6 space-y-6 max-w-4xl"> + {/* 헤더 */} + <div className="flex items-center justify-between"> + <div> + <h1 className="text-2xl font-bold">프로젝트 설정</h1> + <p className="text-muted-foreground mt-1"> + 프로젝트 설정을 관리합니다 + </p> + </div> + + {canEdit && ( + <Button onClick={saveSettings} disabled={saving}> + <Save className="h-4 w-4 mr-2" /> + {saving ? '저장 중...' : '변경사항 저장'} + </Button> + )} + </div> + + {!canEdit && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + 프로젝트 설정을 변경하려면 Owner 또는 Admin 권한이 필요합니다. + </AlertDescription> + </Alert> + )} + + <Tabs defaultValue="general"> + <TabsList> + <TabsTrigger value="general">일반</TabsTrigger> + <TabsTrigger value="access">접근 관리</TabsTrigger> + <TabsTrigger value="storage">스토리지</TabsTrigger> + {currentUserRole === 'owner' && ( + <TabsTrigger value="danger">위험 영역</TabsTrigger> + )} + </TabsList> + + <TabsContent value="general" className="space-y-4"> + <Card> + <CardHeader> + <CardTitle>기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <Label htmlFor="name">프로젝트 이름</Label> + <Input + id="name" + value={settings.name} + onChange={(e) => setSettings({ ...settings, name: e.target.value })} + disabled={!canEdit} + /> + </div> + + <div> + <Label htmlFor="description">설명</Label> + <Textarea + id="description" + value={settings.description} + onChange={(e) => setSettings({ ...settings, description: e.target.value })} + disabled={!canEdit} + rows={3} + /> + </div> + + <div> + <Label htmlFor="category">기본 파일 카테고리</Label> + <Select + value={settings.defaultCategory} + onValueChange={(value) => setSettings({ ...settings, defaultCategory: value })} + disabled={!canEdit} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="public">Public - 공개</SelectItem> + <SelectItem value="restricted">Restricted - 제한</SelectItem> + <SelectItem value="confidential">Confidential - 기밀</SelectItem> + <SelectItem value="internal">Internal - 내부</SelectItem> + </SelectContent> + </Select> + </div> + </CardContent> + </Card> + </TabsContent> + + <TabsContent value="access" className="space-y-4"> + <Card> + <CardHeader> + <CardTitle>접근 설정</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center justify-between"> + <div> + <Label htmlFor="public">공개 프로젝트</Label> + <p className="text-sm text-muted-foreground"> + 모든 사용자가 이 프로젝트를 볼 수 있습니다 + </p> + </div> + <Switch + id="public" + checked={settings.isPublic} + onCheckedChange={(checked) => setSettings({ ...settings, isPublic: checked })} + disabled={!canEdit} + /> + </div> + + <div className="flex items-center justify-between"> + <div> + <Label htmlFor="external">외부 사용자 접근 허용</Label> + <p className="text-sm text-muted-foreground"> + 파트너사 사용자도 접근할 수 있습니다 + </p> + </div> + <Switch + id="external" + checked={settings.externalAccessEnabled} + onCheckedChange={(checked) => + setSettings({ ...settings, externalAccessEnabled: checked }) + } + disabled={!canEdit} + /> + </div> + + <div className="flex items-center justify-between"> + <div> + <Label htmlFor="approval">멤버 승인 필요</Label> + <p className="text-sm text-muted-foreground"> + 새 멤버 참여 시 관리자 승인이 필요합니다 + </p> + </div> + <Switch + id="approval" + checked={settings.requireApproval} + onCheckedChange={(checked) => + setSettings({ ...settings, requireApproval: checked }) + } + disabled={!canEdit} + /> + </div> + </CardContent> + </Card> + </TabsContent> + + <TabsContent value="storage" className="space-y-4"> + <Card> + <CardHeader> + <CardTitle>스토리지 설정</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <Label htmlFor="storage-limit">스토리지 제한 (GB)</Label> + <Input + id="storage-limit" + type="number" + value={settings.storageLimit} + onChange={(e) => setSettings({ + ...settings, + storageLimit: parseInt(e.target.value) + })} + disabled={!canEdit} + /> + </div> + + <div> + <Label htmlFor="file-size">최대 파일 크기 (MB)</Label> + <Input + id="file-size" + type="number" + value={settings.maxFileSize} + onChange={(e) => setSettings({ + ...settings, + maxFileSize: parseInt(e.target.value) + })} + disabled={!canEdit} + /> + </div> + + <div> + <Label htmlFor="auto-archive">자동 보관 (일)</Label> + <Input + id="auto-archive" + type="number" + value={settings.autoArchiveDays} + onChange={(e) => setSettings({ + ...settings, + autoArchiveDays: parseInt(e.target.value) + })} + disabled={!canEdit} + /> + <p className="text-sm text-muted-foreground mt-1"> + 설정한 기간 동안 접근하지 않은 파일을 자동으로 보관합니다 + </p> + </div> + </CardContent> + </Card> + </TabsContent> + + {currentUserRole === 'owner' && ( + <TabsContent value="danger" className="space-y-4"> + <Card className="border-red-200"> + <CardHeader> + <CardTitle className="text-red-600">위험 영역</CardTitle> + <CardDescription> + 이 작업들은 되돌릴 수 없습니다. 신중하게 진행하세요. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center justify-between p-4 border rounded-lg"> + <div> + <h3 className="font-medium">프로젝트 보관</h3> + <p className="text-sm text-muted-foreground"> + 프로젝트를 보관하면 읽기 전용이 됩니다 + </p> + </div> + <Button + variant="outline" + onClick={() => setArchiveDialogOpen(true)} + > + <Archive className="h-4 w-4 mr-2" /> + 프로젝트 보관 + </Button> + </div> + + <div className="flex items-center justify-between p-4 border rounded-lg border-red-200"> + <div> + <h3 className="font-medium text-red-600">프로젝트 삭제</h3> + <p className="text-sm text-muted-foreground"> + 프로젝트와 모든 파일을 영구적으로 삭제합니다 + </p> + </div> + <Button + variant="destructive" + onClick={() => setDeleteDialogOpen(true)} + > + <Trash2 className="h-4 w-4 mr-2" /> + 프로젝트 삭제 + </Button> + </div> + </CardContent> + </Card> + </TabsContent> + )} + </Tabs> + + {/* 삭제 확인 다이얼로그 */} + <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>프로젝트 삭제</DialogTitle> + <DialogDescription className="text-red-600"> + 정말로 이 프로젝트를 삭제하시겠습니까? + 모든 파일과 데이터가 영구적으로 삭제됩니다. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}> + 취소 + </Button> + <Button variant="destructive" onClick={deleteProject}> + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* 보관 확인 다이얼로그 */} + <Dialog open={archiveDialogOpen} onOpenChange={setArchiveDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>프로젝트 보관</DialogTitle> + <DialogDescription> + 프로젝트를 보관하시겠습니까? + 보관된 프로젝트는 읽기 전용이 되며, 언제든지 복원할 수 있습니다. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button variant="outline" onClick={() => setArchiveDialogOpen(false)}> + 취소 + </Button> + <Button onClick={archiveProject}> + 보관 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + ); +} diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/stats/page.tsx b/app/[lng]/evcp/(evcp)/data-room/[projectId]/stats/page.tsx new file mode 100644 index 00000000..7f652a99 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/data-room/[projectId]/stats/page.tsx @@ -0,0 +1,373 @@ +// app/projects/[projectId]/stats/page.tsx +'use client'; + +import { use, useState, useEffect } from 'react'; +import { + BarChart3, + TrendingUp, + HardDrive, + Users, + Eye, + Download, + Upload, + Calendar, + FileText, + FolderOpen, + Activity +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { useToast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; + +interface ProjectStats { + storage: { + used: number; + limit: number; + fileCount: number; + folderCount: number; + byCategory: { + public: number; + restricted: number; + confidential: number; + internal: number; + }; + }; + activity: { + views: number; + downloads: number; + uploads: number; + shares: number; + trend: number; // 증감률 + }; + users: { + total: number; + active: number; + byRole: { + admin: number; + editor: number; + viewer: number; + }; + }; + recent: { + type: string; + user: string; + action: string; + timestamp: string; + details: string; + }[]; +} + +export default function ProjectStatsPage({ + params +}: { + params: Promise<{ projectId: string }> +}) { + // Next.js 15에서 params를 unwrap + const resolvedParams = use(params); + const projectId = resolvedParams.projectId; + + const [stats, setStats] = useState<ProjectStats | null>(null); + const [loading, setLoading] = useState(true); + const [dateRange, setDateRange] = useState('30d'); + const { toast } = useToast(); + + useEffect(() => { + fetchStats(); + }, [projectId, dateRange]); + + const fetchStats = async () => { + try { + setLoading(true); + const response = await fetch( + `/api/projects/${projectId}/stats?range=${dateRange}` + ); + + if (!response.ok) { + if (response.status === 403) { + throw new Error('통계를 볼 권한이 없습니다'); + } + throw new Error('통계 로드 실패'); + } + + const data = await response.json(); + setStats(data); + } catch (error: any) { + toast({ + title: '오류', + description: error.message || '통계를 불러올 수 없습니다.', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + const formatBytes = (bytes: number) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const formatNumber = (num: number) => { + return new Intl.NumberFormat('ko-KR').format(num); + }; + + if (loading) { + return ( + <div className="p-6"> + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> + {[...Array(8)].map((_, i) => ( + <div key={i} className="h-32 bg-gray-200 animate-pulse rounded-lg" /> + ))} + </div> + </div> + ); + } + + if (!stats) { + return ( + <div className="p-6 text-center"> + <BarChart3 className="h-12 w-12 mx-auto mb-3 text-muted-foreground" /> + <p className="text-muted-foreground">통계를 불러올 수 없습니다</p> + </div> + ); + } + + const storagePercentage = (stats.storage.used / stats.storage.limit) * 100; + + 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> + + <Tabs value={dateRange} onValueChange={setDateRange}> + <TabsList> + <TabsTrigger value="7d">7일</TabsTrigger> + <TabsTrigger value="30d">30일</TabsTrigger> + <TabsTrigger value="90d">90일</TabsTrigger> + </TabsList> + </Tabs> + </div> + + {/* 주요 지표 */} + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">스토리지 사용량</CardTitle> + <HardDrive className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {formatBytes(stats.storage.used)} + </div> + {/* <Progress value={storagePercentage} className="mt-2" /> */} + {/* <p className="text-xs text-muted-foreground mt-1"> + 총 {formatBytes(stats.storage.limit)} 중 {storagePercentage.toFixed(1)}% 사용 + </p> */} + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">파일 수</CardTitle> + <FileText className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {formatNumber(stats.storage.fileCount)} + </div> + <p className="text-xs text-muted-foreground mt-1"> + 폴더 {formatNumber(stats.storage.folderCount)}개 포함 + </p> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">활성 사용자</CardTitle> + <Users className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {stats.users.active} + </div> + <p className="text-xs text-muted-foreground mt-1"> + 전체 {stats.users.total}명 중 + </p> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">총 다운로드</CardTitle> + <Download className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {formatNumber(stats.activity.downloads)} + </div> + <div className="flex items-center gap-1 mt-1"> + {stats.activity.trend > 0 ? ( + <TrendingUp className="h-3 w-3 text-green-500" /> + ) : ( + <TrendingUp className="h-3 w-3 text-red-500 rotate-180" /> + )} + <span className={cn( + "text-xs", + stats.activity.trend > 0 ? "text-green-500" : "text-red-500" + )}> + {Math.abs(stats.activity.trend)}% + </span> + </div> + </CardContent> + </Card> + </div> + + {/* 상세 통계 */} + <div className="grid gap-6 md:grid-cols-2"> + {/* 파일 카테고리 분포 */} + <Card> + <CardHeader> + <CardTitle>파일 카테고리</CardTitle> + <CardDescription>카테고리별 파일 분포</CardDescription> + </CardHeader> + <CardContent className="space-y-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <div className="h-2 w-2 bg-green-500 rounded-full" /> + <span className="text-sm">Public</span> + </div> + <span className="text-sm font-medium"> + {stats.storage.byCategory.public} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <div className="h-2 w-2 bg-yellow-500 rounded-full" /> + <span className="text-sm">Restricted</span> + </div> + <span className="text-sm font-medium"> + {stats.storage.byCategory.restricted} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <div className="h-2 w-2 bg-red-500 rounded-full" /> + <span className="text-sm">Confidential</span> + </div> + <span className="text-sm font-medium"> + {stats.storage.byCategory.confidential} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <div className="h-2 w-2 bg-blue-500 rounded-full" /> + <span className="text-sm">Internal</span> + </div> + <span className="text-sm font-medium"> + {stats.storage.byCategory.internal} + </span> + </div> + </CardContent> + </Card> + + {/* 활동 요약 */} + <Card> + <CardHeader> + <CardTitle>활동 요약</CardTitle> + <CardDescription>기간별 활동 내역</CardDescription> + </CardHeader> + <CardContent className="space-y-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Eye className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">조회수</span> + </div> + <span className="text-sm font-medium"> + {formatNumber(stats.activity.views)} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Download className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">다운로드</span> + </div> + <span className="text-sm font-medium"> + {formatNumber(stats.activity.downloads)} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Upload className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">업로드</span> + </div> + <span className="text-sm font-medium"> + {formatNumber(stats.activity.uploads)} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Users className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">공유</span> + </div> + <span className="text-sm font-medium"> + {formatNumber(stats.activity.shares)} + </span> + </div> + </CardContent> + </Card> + </div> + +{/* 최근 활동 */} +<Card> + <CardHeader> + <CardTitle>최근 활동</CardTitle> + <CardDescription>프로젝트 내 최근 활동 내역</CardDescription> + </CardHeader> + + {/* 패딩이 스크롤에 포함되도록 CardContent p-0 + 내부 래퍼에 패딩 */} + <CardContent className="p-0"> + <div + className="max-h-80 md:max-h-96 xl:max-h-[480px] overflow-y-auto px-6 pb-6" + style={{ scrollbarGutter: "stable" }} // 스크롤바 생겨도 레이아웃 흔들림 방지 + aria-label="최근 활동 스크롤 영역" + tabIndex={0} // 키보드 포커스 가능 + > + <ul role="list" className="divide-y"> + {stats.recent.map((activity, index) => ( + <li key={index} className="flex items-center gap-3 py-3"> + <Activity className="h-4 w-4 text-muted-foreground shrink-0" /> + <div className="min-w-0 flex-1"> + <p className="text-sm"> + <span className="font-medium">{activity.user}</span> + {" "}님이{" "} + <span className="font-medium">{activity.details}</span> + {activity.action === "upload" && "을(를) 업로드했습니다"} + {activity.action === "download" && "을(를) 다운로드했습니다"} + {activity.action === "view" && "을(를) 조회했습니다"} + {activity.action === "share" && "을(를) 공유했습니다"} + </p> + <p className="text-xs text-muted-foreground mt-1"> + {new Date(activity.timestamp).toLocaleString()} + </p> + </div> + </li> + ))} + </ul> + </div> + </CardContent> +</Card> + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/data-room/page.tsx b/app/[lng]/evcp/(evcp)/data-room/page.tsx new file mode 100644 index 00000000..4ff56abc --- /dev/null +++ b/app/[lng]/evcp/(evcp)/data-room/page.tsx @@ -0,0 +1,26 @@ +// app/projects/page.tsx +import { Suspense } from 'react'; +import { ProjectHeader } from '@/components/project/ProjectHeader'; +import { ProjectList } from '@/components/project/ProjectList'; + +export default function ProjectsPage() { + return ( + <div className="min-h-screen"> + <div className="container mx-auto px-4 py-8 max-w-7xl"> + <Suspense fallback={<ProjectListSkeleton />}> + <ProjectList /> + </Suspense> + </div> + </div> + ); +} + +function ProjectListSkeleton() { + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> + {[...Array(6)].map((_, i) => ( + <div key={i} className="h-32 bg-gray-200 animate-pulse rounded-lg" /> + ))} + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/files/page.tsx b/app/[lng]/partners/(partners)/data-room/[projectId]/files/page.tsx new file mode 100644 index 00000000..985e7fef --- /dev/null +++ b/app/[lng]/partners/(partners)/data-room/[projectId]/files/page.tsx @@ -0,0 +1,14 @@ +// app/projects/[projectId]/files/page.tsx +import { FileManager } from '@/components/file-manager/FileManager'; + +export default function ProjectFilesPage({ + params, +}: { + params: { projectId: string }; +}) { + return ( + <div className="h-full flex flex-col"> + <FileManager projectId={params.projectId} /> + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/layout.tsx b/app/[lng]/partners/(partners)/data-room/[projectId]/layout.tsx new file mode 100644 index 00000000..d2e74f8e --- /dev/null +++ b/app/[lng]/partners/(partners)/data-room/[projectId]/layout.tsx @@ -0,0 +1,19 @@ +// app/projects/[projectId]/layout.tsx +import { ProjectNav } from '@/components/project/ProjectNav'; + +export default function ProjectLayout({ + children, + params, +}: { + children: React.ReactNode; + params: { projectId: string }; +}) { + return ( + <div className="flex flex-col h-full"> + <ProjectNav projectId={params.projectId} /> + <div className="flex-1 overflow-y-auto"> + {children} + </div> + </div> + ); +} diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/members/page.tsx b/app/[lng]/partners/(partners)/data-room/[projectId]/members/page.tsx new file mode 100644 index 00000000..18442c0e --- /dev/null +++ b/app/[lng]/partners/(partners)/data-room/[projectId]/members/page.tsx @@ -0,0 +1,811 @@ +// app/projects/[projectId]/members/page.tsx +'use client'; + +import { use, useState, useEffect, useRef } from 'react'; +import { + Users, + UserPlus, + Crown, + Shield, + Eye, + Edit2, + Trash2, + Mail, + MoreVertical, + Search, + Filter, + Check, + ChevronsUpDown, + Loader2, + UserCog +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Label } from '@/components/ui/label'; +import { useToast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; +import { + Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow +} from '@/components/ui/table'; +import { Separator } from '@/components/ui/separator'; +import { getUsersForFilter } from '@/lib/gtc-contract/service'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" + +interface Member { + id: string; + userId: number; + user: { + name: string; + email: string; + imageUrl?: string; + domain: string; + }; + role: 'owner' | 'admin' | 'editor' | 'viewer'; + addedAt: string; + lastAccess?: string; +} + +interface User { + id: number; + name: string; + email: string; + domain?: string; // 'partners' | 'internal' 등 +} + +export default function ProjectMembersPage({ + params: promiseParams +}: { + params: Promise<{ projectId: string }> +}) { + // Next.js 15+ params Promise 처리 + const params = use(promiseParams); + const projectId = params.projectId; + + const [members, setMembers] = useState<Member[]>([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [roleFilter, setRoleFilter] = useState<string>('all'); + const [addMemberOpen, setAddMemberOpen] = useState(false); + const [editingMember, setEditingMember] = useState<Member | null>(null); + + // 사용자 선택 관련 상태 + const [availableUsers, setAvailableUsers] = useState<User[]>([]); + const [selectedUser, setSelectedUser] = useState<User | null>(null); + const [userSearchTerm, setUserSearchTerm] = useState(''); + const [userPopoverOpen, setUserPopoverOpen] = useState(false); + const [loadingUsers, setLoadingUsers] = useState(false); + const [isExternalUser, setIsExternalUser] = useState(false); // 외부 사용자 여부 + + const [newMemberRole, setNewMemberRole] = useState<string>('viewer'); + const [currentUserRole, setCurrentUserRole] = useState<string>('viewer'); + const [page, setPage] = useState(1); + const pageSize = 20; + + // Command component key management + const userOptionIdsRef = useRef<Record<number, string>>({}); + const popoverContentId = `popover-content-${Date.now()}`; + const commandId = `command-${Date.now()}`; + + const { toast } = useToast(); + + useEffect(() => { + setPage(1); + }, [searchQuery, roleFilter]); + + useEffect(() => { + fetchMembers(); + checkUserRole(); + }, [projectId]); + + // 다이얼로그가 열릴 때 사용자 목록 가져오기 + useEffect(() => { + if (addMemberOpen) { + fetchAvailableUsers(); + } else { + // 다이얼로그가 닫힐 때 초기화 + setSelectedUser(null); + setUserSearchTerm(''); + setNewMemberRole('viewer'); + setIsExternalUser(false); + } + }, [addMemberOpen]); + + const fetchAvailableUsers = async () => { + try { + setLoadingUsers(true); + const users = await getUsersForFilter(); + // 이미 프로젝트에 있는 멤버는 제외 + const memberUserIds = members.map(m => m.userId); + const filteredUsers = users.filter(u => !memberUserIds.includes(u.id)); + setAvailableUsers(filteredUsers); + } catch (error) { + console.error('사용자 목록 로드 실패:', error); + toast({ + title: '오류', + description: '사용자 목록을 불러올 수 없습니다.', + variant: 'destructive', + }); + } finally { + setLoadingUsers(false); + } + }; + + const fetchMembers = async () => { + try { + setLoading(true); + const response = await fetch(`/api/projects/${projectId}/members`); + const data = await response.json(); + setMembers(data.member); + } catch (error) { + toast({ + title: '오류', + description: '멤버 목록을 불러올 수 없습니다.', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + const checkUserRole = async () => { + try { + const response = await fetch(`/api/projects/${projectId}/access`); + const data = await response.json(); + setCurrentUserRole(data.role); + } catch (error) { + console.error('권한 확인 실패:', error); + } + }; + + const addMember = async () => { + if (!selectedUser) { + toast({ + title: '오류', + description: '사용자를 선택해주세요.', + variant: 'destructive', + }); + return; + } + + try { + const response = await fetch(`/api/projects/${projectId}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: selectedUser.id, + role: newMemberRole, + }), + }); + + if (!response.ok) throw new Error('멤버 추가 실패'); + + toast({ + title: '성공', + description: '새 멤버가 추가되었습니다.', + }); + + setAddMemberOpen(false); + fetchMembers(); + } catch (error) { + toast({ + title: '오류', + description: '멤버 추가에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + const updateMemberRole = async (memberId: string, newRole: string) => { + try { + const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: newRole }), + }); + + if (!response.ok) throw new Error('역할 변경 실패'); + + toast({ + title: '성공', + description: '멤버 역할이 변경되었습니다.', + }); + + fetchMembers(); + } catch (error) { + toast({ + title: '오류', + description: '역할 변경에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + const removeMember = async (memberId: string) => { + try { + const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('멤버 제거 실패'); + + toast({ + title: '성공', + description: '멤버가 제거되었습니다.', + }); + + fetchMembers(); + } catch (error) { + toast({ + title: '오류', + description: '멤버 제거에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + 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 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 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 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); + + 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> + ); + } + + 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> + {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' ? ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon"> + <MoreVertical className="h-4 w-4" /> + </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)} + > + <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> + )} + </TableBody> + </Table> + </div> + + {/* 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={addMemberOpen} onOpenChange={setAddMemberOpen}> + <DialogContent className="max-w-lg"> + <DialogHeader> + <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> + </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" /> + <span className="ml-2 text-sm text-muted-foreground">사용자 목록 불러오는 중...</span> + </div> + ) : ( + <> + <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={userPopoverOpen} + className="w-full justify-between" + > + <span className="truncate"> + {selectedUser && selectedUser.domain !== 'partners' ? ( + <div className="text-left"> + <div className="font-medium">{selectedUser.name}</div> + <div className="text-xs text-muted-foreground">{selectedUser.email}</div> + </div> + ) : ( + "내부 사용자를 선택하세요..." + )} + </span> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[460px] p-0"> + <Command> + <CommandInput + placeholder="이름 또는 이메일로 검색..." + value={userSearchTerm} + onValueChange={setUserSearchTerm} + /> + <CommandList + className="max-h-[300px]" + onWheel={(e) => { + e.stopPropagation(); + const target = e.currentTarget; + target.scrollTop += e.deltaY; + }} + > + <CommandEmpty>사용자를 찾을 수 없습니다.</CommandEmpty> + <CommandGroup heading="내부 사용자 목록"> + {filteredUsers + .filter(u => u.domain !== 'partners') + .map((user) => ( + <CommandItem + key={user.id} + onSelect={() => { + setSelectedUser(user); + setUserPopoverOpen(false); + setIsExternalUser(false); + setNewMemberRole('viewer'); + }} + value={`${user.name} ${user.email}`} + className="truncate" + > + <Users className="mr-2 h-4 w-4 text-blue-500 flex-shrink-0" /> + <div className="flex-1 truncate"> + <div className="font-medium truncate">{user.name}</div> + <div className="text-xs text-muted-foreground truncate">{user.email}</div> + </div> + <Check + className={cn( + "ml-2 h-4 w-4 flex-shrink-0", + selectedUser?.id === user.id && !isExternalUser ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + <p className="text-xs text-muted-foreground"> + 내부 사용자는 모든 역할을 부여할 수 있습니다. + </p> + </> + )} + </div> + + <div className="space-y-2"> + <Label htmlFor="internal-role">역할</Label> + <Select + value={newMemberRole} + onValueChange={setNewMemberRole} + disabled={!selectedUser || isExternalUser} + > + <SelectTrigger id="internal-role"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="viewer">Viewer - 읽기 전용</SelectItem> + <SelectItem value="editor">Editor - 파일 편집 가능</SelectItem> + <SelectItem value="admin">Admin - 프로젝트 관리</SelectItem> + </SelectContent> + </Select> + </div> + </TabsContent> + + <TabsContent value="external" className="space-y-4 mt-4"> + <div className="rounded-lg bg-amber-50 border border-amber-200 p-3 mb-4"> + <p className="text-sm text-amber-800"> + <strong>보안 정책 안내</strong><br/> + 외부 사용자(파트너)는 보안 정책상 Viewer 권한만 부여 가능합니다. + </p> + </div> + + <div className="space-y-2"> + <Label htmlFor="external-user">파트너 선택</Label> + + {loadingUsers ? ( + <div className="flex items-center justify-center py-4"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="ml-2 text-sm text-muted-foreground">사용자 목록 불러오는 중...</span> + </div> + ) : ( + <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={userPopoverOpen} + className="w-full justify-between" + > + <span className="truncate"> + {selectedUser && selectedUser.domain === 'partners' ? ( + <span className="flex items-center gap-2"> + {selectedUser.name} + <Badge variant="outline" className="ml-1 text-xs">외부</Badge> + </span> + ) : ( + "외부 사용자를 선택하세요..." + )} + </span> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[460px] p-0"> + <Command> + <CommandInput + placeholder="이름으로 검색..." + value={userSearchTerm} + onValueChange={setUserSearchTerm} + /> + <CommandList + className="max-h-[300px]" + onWheel={(e) => { + e.stopPropagation(); + const target = e.currentTarget; + target.scrollTop += e.deltaY; + }} + > + <CommandEmpty>파트너를 찾을 수 없습니다.</CommandEmpty> + <CommandGroup heading="파트너 목록"> + {filteredUsers + .filter(u => u.domain === 'partners') + .map((user) => ( + <CommandItem + key={user.id} + onSelect={() => { + setSelectedUser(user); + setUserPopoverOpen(false); + setIsExternalUser(true); + setNewMemberRole('viewer'); + }} + value={user.name} + className="truncate" + > + <Users className="mr-2 h-4 w-4 text-amber-600" /> + <span className="truncate flex-1">{user.name}</span> + <Badge variant="outline" className="text-xs mx-2">파트너</Badge> + <Check + className={cn( + "ml-auto h-4 w-4 flex-shrink-0", + selectedUser?.id === user.id && isExternalUser ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + )} + </div> + + <div className="space-y-2"> + <Label htmlFor="external-role">역할</Label> + <Select value="viewer" disabled> + <SelectTrigger id="external-role" className="opacity-60"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="viewer">Viewer - 읽기 전용 (고정)</SelectItem> + </SelectContent> + </Select> + </div> + </TabsContent> + </Tabs> + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setAddMemberOpen(false); + setSelectedUser(null); + setUserSearchTerm(''); + setNewMemberRole('viewer'); + setIsExternalUser(false); + }} + > + 취소 + </Button> + <Button + onClick={addMember} + disabled={!selectedUser} + > + 추가하기 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/page.tsx b/app/[lng]/partners/(partners)/data-room/[projectId]/page.tsx new file mode 100644 index 00000000..d54a8cab --- /dev/null +++ b/app/[lng]/partners/(partners)/data-room/[projectId]/page.tsx @@ -0,0 +1,10 @@ +// app/projects/[projectId]/page.tsx +import { ProjectDashboard } from '@/components/project/ProjectDashboard'; + +export default function ProjectPage({ + params, +}: { + params: { projectId: string }; +}) { + return <ProjectDashboard projectId={params.projectId} />; +} diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/settings/page.tsx b/app/[lng]/partners/(partners)/data-room/[projectId]/settings/page.tsx new file mode 100644 index 00000000..aa0f3b52 --- /dev/null +++ b/app/[lng]/partners/(partners)/data-room/[projectId]/settings/page.tsx @@ -0,0 +1,488 @@ + +// app/projects/[projectId]/settings/page.tsx +'use client'; + +import { useState, useEffect } from 'react'; +import { + Settings, + Shield, + Globe, + Trash2, + AlertCircle, + Save, + Lock, + Unlock, + Archive, + Users, + HardDrive +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useToast } from '@/hooks/use-toast'; +import { useRouter } from 'next/navigation'; + +interface ProjectSettings { + id: string; + name: string; + description: string; + isPublic: boolean; + externalAccessEnabled: boolean; + storageLimit: number; + maxFileSize: number; + allowedFileTypes: string[]; + autoArchiveDays: number; + requireApproval: boolean; + defaultCategory: string; +} + +export default function ProjectSettingsPage({ + params +}: { + params: { projectId: string } +}) { + const [settings, setSettings] = useState<ProjectSettings | null>(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [archiveDialogOpen, setArchiveDialogOpen] = useState(false); + const [currentUserRole, setCurrentUserRole] = useState<string>('viewer'); + + const { toast } = useToast(); + const router = useRouter(); + + useEffect(() => { + fetchSettings(); + checkUserRole(); + }, [params.projectId]); + + const fetchSettings = async () => { + try { + setLoading(true); + const response = await fetch(`/api/projects/${params.projectId}/settings`); + + if (!response.ok) { + throw new Error('설정을 불러올 수 없습니다'); + } + + const data = await response.json(); + setSettings(data); + } catch (error) { + toast({ + title: '오류', + description: '프로젝트 설정을 불러올 수 없습니다.', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + const checkUserRole = async () => { + try { + const response = await fetch(`/api/projects/${params.projectId}/access`); + const data = await response.json(); + setCurrentUserRole(data.role); + } catch (error) { + console.error('권한 확인 실패:', error); + } + }; + + const saveSettings = async () => { + if (!settings) return; + + try { + setSaving(true); + const response = await fetch(`/api/projects/${params.projectId}/settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); + + if (!response.ok) throw new Error('설정 저장 실패'); + + toast({ + title: '성공', + description: '프로젝트 설정이 저장되었습니다.', + }); + } catch (error) { + toast({ + title: '오류', + description: '설정 저장에 실패했습니다.', + variant: 'destructive', + }); + } finally { + setSaving(false); + } + }; + + const deleteProject = async () => { + try { + const response = await fetch(`/api/projects/${params.projectId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('프로젝트 삭제 실패'); + + toast({ + title: '성공', + description: '프로젝트가 삭제되었습니다.', + }); + + router.push('/projects'); + } catch (error) { + toast({ + title: '오류', + description: '프로젝트 삭제에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + const archiveProject = async () => { + try { + const response = await fetch(`/api/projects/${params.projectId}/archive`, { + method: 'POST', + }); + + if (!response.ok) throw new Error('프로젝트 보관 실패'); + + toast({ + title: '성공', + description: '프로젝트가 보관되었습니다.', + }); + + router.push('/projects'); + } catch (error) { + toast({ + title: '오류', + description: '프로젝트 보관에 실패했습니다.', + variant: 'destructive', + }); + } + }; + + const canEdit = currentUserRole === 'owner' || currentUserRole === 'admin'; + + if (loading || !settings) { + return ( + <div className="p-6"> + <div className="animate-pulse space-y-4"> + {[...Array(5)].map((_, i) => ( + <div key={i} className="h-20 bg-gray-200 rounded" /> + ))} + </div> + </div> + ); + } + + return ( + <div className="p-6 space-y-6 max-w-4xl"> + {/* 헤더 */} + <div className="flex items-center justify-between"> + <div> + <h1 className="text-2xl font-bold">프로젝트 설정</h1> + <p className="text-muted-foreground mt-1"> + 프로젝트 설정을 관리합니다 + </p> + </div> + + {canEdit && ( + <Button onClick={saveSettings} disabled={saving}> + <Save className="h-4 w-4 mr-2" /> + {saving ? '저장 중...' : '변경사항 저장'} + </Button> + )} + </div> + + {!canEdit && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + 프로젝트 설정을 변경하려면 Owner 또는 Admin 권한이 필요합니다. + </AlertDescription> + </Alert> + )} + + <Tabs defaultValue="general"> + <TabsList> + <TabsTrigger value="general">일반</TabsTrigger> + <TabsTrigger value="access">접근 관리</TabsTrigger> + <TabsTrigger value="storage">스토리지</TabsTrigger> + {currentUserRole === 'owner' && ( + <TabsTrigger value="danger">위험 영역</TabsTrigger> + )} + </TabsList> + + <TabsContent value="general" className="space-y-4"> + <Card> + <CardHeader> + <CardTitle>기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <Label htmlFor="name">프로젝트 이름</Label> + <Input + id="name" + value={settings.name} + onChange={(e) => setSettings({ ...settings, name: e.target.value })} + disabled={!canEdit} + /> + </div> + + <div> + <Label htmlFor="description">설명</Label> + <Textarea + id="description" + value={settings.description} + onChange={(e) => setSettings({ ...settings, description: e.target.value })} + disabled={!canEdit} + rows={3} + /> + </div> + + <div> + <Label htmlFor="category">기본 파일 카테고리</Label> + <Select + value={settings.defaultCategory} + onValueChange={(value) => setSettings({ ...settings, defaultCategory: value })} + disabled={!canEdit} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="public">Public - 공개</SelectItem> + <SelectItem value="restricted">Restricted - 제한</SelectItem> + <SelectItem value="confidential">Confidential - 기밀</SelectItem> + <SelectItem value="internal">Internal - 내부</SelectItem> + </SelectContent> + </Select> + </div> + </CardContent> + </Card> + </TabsContent> + + <TabsContent value="access" className="space-y-4"> + <Card> + <CardHeader> + <CardTitle>접근 설정</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center justify-between"> + <div> + <Label htmlFor="public">공개 프로젝트</Label> + <p className="text-sm text-muted-foreground"> + 모든 사용자가 이 프로젝트를 볼 수 있습니다 + </p> + </div> + <Switch + id="public" + checked={settings.isPublic} + onCheckedChange={(checked) => setSettings({ ...settings, isPublic: checked })} + disabled={!canEdit} + /> + </div> + + <div className="flex items-center justify-between"> + <div> + <Label htmlFor="external">외부 사용자 접근 허용</Label> + <p className="text-sm text-muted-foreground"> + 파트너사 사용자도 접근할 수 있습니다 + </p> + </div> + <Switch + id="external" + checked={settings.externalAccessEnabled} + onCheckedChange={(checked) => + setSettings({ ...settings, externalAccessEnabled: checked }) + } + disabled={!canEdit} + /> + </div> + + <div className="flex items-center justify-between"> + <div> + <Label htmlFor="approval">멤버 승인 필요</Label> + <p className="text-sm text-muted-foreground"> + 새 멤버 참여 시 관리자 승인이 필요합니다 + </p> + </div> + <Switch + id="approval" + checked={settings.requireApproval} + onCheckedChange={(checked) => + setSettings({ ...settings, requireApproval: checked }) + } + disabled={!canEdit} + /> + </div> + </CardContent> + </Card> + </TabsContent> + + <TabsContent value="storage" className="space-y-4"> + <Card> + <CardHeader> + <CardTitle>스토리지 설정</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <Label htmlFor="storage-limit">스토리지 제한 (GB)</Label> + <Input + id="storage-limit" + type="number" + value={settings.storageLimit} + onChange={(e) => setSettings({ + ...settings, + storageLimit: parseInt(e.target.value) + })} + disabled={!canEdit} + /> + </div> + + <div> + <Label htmlFor="file-size">최대 파일 크기 (MB)</Label> + <Input + id="file-size" + type="number" + value={settings.maxFileSize} + onChange={(e) => setSettings({ + ...settings, + maxFileSize: parseInt(e.target.value) + })} + disabled={!canEdit} + /> + </div> + + <div> + <Label htmlFor="auto-archive">자동 보관 (일)</Label> + <Input + id="auto-archive" + type="number" + value={settings.autoArchiveDays} + onChange={(e) => setSettings({ + ...settings, + autoArchiveDays: parseInt(e.target.value) + })} + disabled={!canEdit} + /> + <p className="text-sm text-muted-foreground mt-1"> + 설정한 기간 동안 접근하지 않은 파일을 자동으로 보관합니다 + </p> + </div> + </CardContent> + </Card> + </TabsContent> + + {currentUserRole === 'owner' && ( + <TabsContent value="danger" className="space-y-4"> + <Card className="border-red-200"> + <CardHeader> + <CardTitle className="text-red-600">위험 영역</CardTitle> + <CardDescription> + 이 작업들은 되돌릴 수 없습니다. 신중하게 진행하세요. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center justify-between p-4 border rounded-lg"> + <div> + <h3 className="font-medium">프로젝트 보관</h3> + <p className="text-sm text-muted-foreground"> + 프로젝트를 보관하면 읽기 전용이 됩니다 + </p> + </div> + <Button + variant="outline" + onClick={() => setArchiveDialogOpen(true)} + > + <Archive className="h-4 w-4 mr-2" /> + 프로젝트 보관 + </Button> + </div> + + <div className="flex items-center justify-between p-4 border rounded-lg border-red-200"> + <div> + <h3 className="font-medium text-red-600">프로젝트 삭제</h3> + <p className="text-sm text-muted-foreground"> + 프로젝트와 모든 파일을 영구적으로 삭제합니다 + </p> + </div> + <Button + variant="destructive" + onClick={() => setDeleteDialogOpen(true)} + > + <Trash2 className="h-4 w-4 mr-2" /> + 프로젝트 삭제 + </Button> + </div> + </CardContent> + </Card> + </TabsContent> + )} + </Tabs> + + {/* 삭제 확인 다이얼로그 */} + <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>프로젝트 삭제</DialogTitle> + <DialogDescription className="text-red-600"> + 정말로 이 프로젝트를 삭제하시겠습니까? + 모든 파일과 데이터가 영구적으로 삭제됩니다. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button variant="outline" onClick={() => setDeleteDialogOpen(false)}> + 취소 + </Button> + <Button variant="destructive" onClick={deleteProject}> + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* 보관 확인 다이얼로그 */} + <Dialog open={archiveDialogOpen} onOpenChange={setArchiveDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>프로젝트 보관</DialogTitle> + <DialogDescription> + 프로젝트를 보관하시겠습니까? + 보관된 프로젝트는 읽기 전용이 되며, 언제든지 복원할 수 있습니다. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button variant="outline" onClick={() => setArchiveDialogOpen(false)}> + 취소 + </Button> + <Button onClick={archiveProject}> + 보관 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + ); +} diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/stats/page.tsx b/app/[lng]/partners/(partners)/data-room/[projectId]/stats/page.tsx new file mode 100644 index 00000000..7f652a99 --- /dev/null +++ b/app/[lng]/partners/(partners)/data-room/[projectId]/stats/page.tsx @@ -0,0 +1,373 @@ +// app/projects/[projectId]/stats/page.tsx +'use client'; + +import { use, useState, useEffect } from 'react'; +import { + BarChart3, + TrendingUp, + HardDrive, + Users, + Eye, + Download, + Upload, + Calendar, + FileText, + FolderOpen, + Activity +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { useToast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; + +interface ProjectStats { + storage: { + used: number; + limit: number; + fileCount: number; + folderCount: number; + byCategory: { + public: number; + restricted: number; + confidential: number; + internal: number; + }; + }; + activity: { + views: number; + downloads: number; + uploads: number; + shares: number; + trend: number; // 증감률 + }; + users: { + total: number; + active: number; + byRole: { + admin: number; + editor: number; + viewer: number; + }; + }; + recent: { + type: string; + user: string; + action: string; + timestamp: string; + details: string; + }[]; +} + +export default function ProjectStatsPage({ + params +}: { + params: Promise<{ projectId: string }> +}) { + // Next.js 15에서 params를 unwrap + const resolvedParams = use(params); + const projectId = resolvedParams.projectId; + + const [stats, setStats] = useState<ProjectStats | null>(null); + const [loading, setLoading] = useState(true); + const [dateRange, setDateRange] = useState('30d'); + const { toast } = useToast(); + + useEffect(() => { + fetchStats(); + }, [projectId, dateRange]); + + const fetchStats = async () => { + try { + setLoading(true); + const response = await fetch( + `/api/projects/${projectId}/stats?range=${dateRange}` + ); + + if (!response.ok) { + if (response.status === 403) { + throw new Error('통계를 볼 권한이 없습니다'); + } + throw new Error('통계 로드 실패'); + } + + const data = await response.json(); + setStats(data); + } catch (error: any) { + toast({ + title: '오류', + description: error.message || '통계를 불러올 수 없습니다.', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }; + + const formatBytes = (bytes: number) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const formatNumber = (num: number) => { + return new Intl.NumberFormat('ko-KR').format(num); + }; + + if (loading) { + return ( + <div className="p-6"> + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> + {[...Array(8)].map((_, i) => ( + <div key={i} className="h-32 bg-gray-200 animate-pulse rounded-lg" /> + ))} + </div> + </div> + ); + } + + if (!stats) { + return ( + <div className="p-6 text-center"> + <BarChart3 className="h-12 w-12 mx-auto mb-3 text-muted-foreground" /> + <p className="text-muted-foreground">통계를 불러올 수 없습니다</p> + </div> + ); + } + + const storagePercentage = (stats.storage.used / stats.storage.limit) * 100; + + 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> + + <Tabs value={dateRange} onValueChange={setDateRange}> + <TabsList> + <TabsTrigger value="7d">7일</TabsTrigger> + <TabsTrigger value="30d">30일</TabsTrigger> + <TabsTrigger value="90d">90일</TabsTrigger> + </TabsList> + </Tabs> + </div> + + {/* 주요 지표 */} + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">스토리지 사용량</CardTitle> + <HardDrive className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {formatBytes(stats.storage.used)} + </div> + {/* <Progress value={storagePercentage} className="mt-2" /> */} + {/* <p className="text-xs text-muted-foreground mt-1"> + 총 {formatBytes(stats.storage.limit)} 중 {storagePercentage.toFixed(1)}% 사용 + </p> */} + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">파일 수</CardTitle> + <FileText className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {formatNumber(stats.storage.fileCount)} + </div> + <p className="text-xs text-muted-foreground mt-1"> + 폴더 {formatNumber(stats.storage.folderCount)}개 포함 + </p> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">활성 사용자</CardTitle> + <Users className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {stats.users.active} + </div> + <p className="text-xs text-muted-foreground mt-1"> + 전체 {stats.users.total}명 중 + </p> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">총 다운로드</CardTitle> + <Download className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold"> + {formatNumber(stats.activity.downloads)} + </div> + <div className="flex items-center gap-1 mt-1"> + {stats.activity.trend > 0 ? ( + <TrendingUp className="h-3 w-3 text-green-500" /> + ) : ( + <TrendingUp className="h-3 w-3 text-red-500 rotate-180" /> + )} + <span className={cn( + "text-xs", + stats.activity.trend > 0 ? "text-green-500" : "text-red-500" + )}> + {Math.abs(stats.activity.trend)}% + </span> + </div> + </CardContent> + </Card> + </div> + + {/* 상세 통계 */} + <div className="grid gap-6 md:grid-cols-2"> + {/* 파일 카테고리 분포 */} + <Card> + <CardHeader> + <CardTitle>파일 카테고리</CardTitle> + <CardDescription>카테고리별 파일 분포</CardDescription> + </CardHeader> + <CardContent className="space-y-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <div className="h-2 w-2 bg-green-500 rounded-full" /> + <span className="text-sm">Public</span> + </div> + <span className="text-sm font-medium"> + {stats.storage.byCategory.public} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <div className="h-2 w-2 bg-yellow-500 rounded-full" /> + <span className="text-sm">Restricted</span> + </div> + <span className="text-sm font-medium"> + {stats.storage.byCategory.restricted} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <div className="h-2 w-2 bg-red-500 rounded-full" /> + <span className="text-sm">Confidential</span> + </div> + <span className="text-sm font-medium"> + {stats.storage.byCategory.confidential} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <div className="h-2 w-2 bg-blue-500 rounded-full" /> + <span className="text-sm">Internal</span> + </div> + <span className="text-sm font-medium"> + {stats.storage.byCategory.internal} + </span> + </div> + </CardContent> + </Card> + + {/* 활동 요약 */} + <Card> + <CardHeader> + <CardTitle>활동 요약</CardTitle> + <CardDescription>기간별 활동 내역</CardDescription> + </CardHeader> + <CardContent className="space-y-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Eye className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">조회수</span> + </div> + <span className="text-sm font-medium"> + {formatNumber(stats.activity.views)} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Download className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">다운로드</span> + </div> + <span className="text-sm font-medium"> + {formatNumber(stats.activity.downloads)} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Upload className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">업로드</span> + </div> + <span className="text-sm font-medium"> + {formatNumber(stats.activity.uploads)} + </span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Users className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">공유</span> + </div> + <span className="text-sm font-medium"> + {formatNumber(stats.activity.shares)} + </span> + </div> + </CardContent> + </Card> + </div> + +{/* 최근 활동 */} +<Card> + <CardHeader> + <CardTitle>최근 활동</CardTitle> + <CardDescription>프로젝트 내 최근 활동 내역</CardDescription> + </CardHeader> + + {/* 패딩이 스크롤에 포함되도록 CardContent p-0 + 내부 래퍼에 패딩 */} + <CardContent className="p-0"> + <div + className="max-h-80 md:max-h-96 xl:max-h-[480px] overflow-y-auto px-6 pb-6" + style={{ scrollbarGutter: "stable" }} // 스크롤바 생겨도 레이아웃 흔들림 방지 + aria-label="최근 활동 스크롤 영역" + tabIndex={0} // 키보드 포커스 가능 + > + <ul role="list" className="divide-y"> + {stats.recent.map((activity, index) => ( + <li key={index} className="flex items-center gap-3 py-3"> + <Activity className="h-4 w-4 text-muted-foreground shrink-0" /> + <div className="min-w-0 flex-1"> + <p className="text-sm"> + <span className="font-medium">{activity.user}</span> + {" "}님이{" "} + <span className="font-medium">{activity.details}</span> + {activity.action === "upload" && "을(를) 업로드했습니다"} + {activity.action === "download" && "을(를) 다운로드했습니다"} + {activity.action === "view" && "을(를) 조회했습니다"} + {activity.action === "share" && "을(를) 공유했습니다"} + </p> + <p className="text-xs text-muted-foreground mt-1"> + {new Date(activity.timestamp).toLocaleString()} + </p> + </div> + </li> + ))} + </ul> + </div> + </CardContent> +</Card> + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/data-room/page.tsx b/app/[lng]/partners/(partners)/data-room/page.tsx new file mode 100644 index 00000000..4ff56abc --- /dev/null +++ b/app/[lng]/partners/(partners)/data-room/page.tsx @@ -0,0 +1,26 @@ +// app/projects/page.tsx +import { Suspense } from 'react'; +import { ProjectHeader } from '@/components/project/ProjectHeader'; +import { ProjectList } from '@/components/project/ProjectList'; + +export default function ProjectsPage() { + return ( + <div className="min-h-screen"> + <div className="container mx-auto px-4 py-8 max-w-7xl"> + <Suspense fallback={<ProjectListSkeleton />}> + <ProjectList /> + </Suspense> + </div> + </div> + ); +} + +function ProjectListSkeleton() { + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> + {[...Array(6)].map((_, i) => ( + <div key={i} className="h-32 bg-gray-200 animate-pulse rounded-lg" /> + ))} + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/shared/[token]/page.tsx b/app/[lng]/shared/[token]/page.tsx new file mode 100644 index 00000000..db7f5d7a --- /dev/null +++ b/app/[lng]/shared/[token]/page.tsx @@ -0,0 +1,15 @@ +// app/shared/[token]/page.tsx + +import { SharedFileViewer } from "@/components/file-manager/SharedFileViewer"; + +export default function SharedFilePage({ + params, +}: { + params: { token: string }; +}) { + return ( + <div className="min-h-screen bg-gray-50"> + <SharedFileViewer token={params.token} /> + </div> + ); +} diff --git a/app/api/data-room/[projectId]/[fileId]/download/route.ts b/app/api/data-room/[projectId]/[fileId]/download/route.ts new file mode 100644 index 00000000..3a3a8fdd --- /dev/null +++ b/app/api/data-room/[projectId]/[fileId]/download/route.ts @@ -0,0 +1,246 @@ +// app/api/data-room/[projectId]/[fileId]/download/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { promises as fs } from 'fs'; +import path from 'path'; +import db from "@/db/db"; +import { fileItems } from "@/db/schema/fileSystem"; +import { eq } from "drizzle-orm"; + +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string; fileId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + + // 파일 접근 권한 확인 + const hasAccess = await fileService.checkFileAccess( + params.fileId, + context, + 'download' + ); + + if (!hasAccess) { + return NextResponse.json( + { error: '파일 다운로드 권한이 없습니다' }, + { status: 403 } + ); + } + + // FileService를 통해 파일 정보 가져오기 (다운로드 카운트 증가 및 로그 기록) + const file = await fileService.downloadFile(params.fileId, context); + + if (!file) { + return NextResponse.json( + { error: '파일을 찾을 수 없습니다' }, + { status: 404 } + ); + } + + // 파일 경로 확인 + if (!file.filePath) { + return NextResponse.json( + { error: '파일 경로가 없습니다' }, + { status: 404 } + ); + } + + // 실제 파일 경로 구성 + const nasPath = process.env.NAS_PATH || "/evcp_nas"; + const isProduction = process.env.NODE_ENV === "production"; + + let absolutePath: string; + if (isProduction) { + // 프로덕션: NAS 경로 사용 + const relativePath = file.filePath.replace('/api/files/', ''); + absolutePath = path.join(nasPath, relativePath); + } else { + // 개발: public 폴더 사용 + absolutePath = path.join(process.cwd(), 'public', file.filePath); + } + + // 파일 존재 여부 확인 + try { + await fs.access(absolutePath); + } catch (error) { + console.error('파일을 찾을 수 없습니다:', absolutePath); + return NextResponse.json( + { error: '파일을 찾을 수 없습니다' }, + { status: 404 } + ); + } + + // 파일 읽기 + const fileBuffer = await fs.readFile(absolutePath); + + // MIME 타입 결정 + const mimeType = getMimeType(file.name, file.mimeType); + + // 파일명 인코딩 (한글 등 특수문자 처리) + const encodedFileName = encodeURIComponent(file.name); + + // Response Headers 설정 + const headers = new Headers(); + headers.set('Content-Type', mimeType); + headers.set('Content-Length', fileBuffer.length.toString()); + headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`); + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.set('Pragma', 'no-cache'); + headers.set('Expires', '0'); + + // 보안 헤더 추가 + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('X-Frame-Options', 'DENY'); + headers.set('X-XSS-Protection', '1; mode=block'); + + // 파일 스트림 반환 + return new NextResponse(fileBuffer, { + status: 200, + headers, + }); + + } catch (error) { + console.error('파일 다운로드 오류:', error); + + if (error instanceof Error) { + if (error.message.includes('권한')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + } + + return NextResponse.json( + { error: '파일 다운로드에 실패했습니다' }, + { status: 500 } + ); + } +} + +// HEAD 요청 처리 (파일 정보만 확인) +export async function HEAD( + request: NextRequest, + { params }: { params: { projectId: string; fileId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return new NextResponse(null, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + + // 파일 접근 권한 확인 + const hasAccess = await fileService.checkFileAccess( + params.fileId, + context, + 'view' // HEAD 요청은 view 권한만 확인 + ); + + if (!hasAccess) { + return new NextResponse(null, { status: 403 }); + } + + // 파일 정보 조회 + const file = await db.query.fileItems.findFirst({ + where: eq(fileItems.id, params.fileId), + }); + + if (!file || !file.filePath) { + return new NextResponse(null, { status: 404 }); + } + + const headers = new Headers(); + headers.set('Content-Type', getMimeType(file.name, file.mimeType)); + headers.set('Content-Length', file.size?.toString() || '0'); + headers.set('Last-Modified', new Date(file.updatedAt).toUTCString()); + + return new NextResponse(null, { + status: 200, + headers, + }); + + } catch (error) { + console.error('HEAD 요청 오류:', error); + return new NextResponse(null, { status: 500 }); + } +} + +// MIME 타입 결정 헬퍼 함수 +function getMimeType(fileName: string, storedMimeType?: string | null): string { + // DB에 저장된 MIME 타입이 있으면 우선 사용 + if (storedMimeType) { + return storedMimeType; + } + + // 확장자 기반 MIME 타입 매핑 + const ext = path.extname(fileName).toLowerCase().substring(1); + const mimeTypes: Record<string, string> = { + // Documents + 'pdf': 'application/pdf', + 'doc': 'application/msword', + 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls': 'application/vnd.ms-excel', + 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt': 'application/vnd.ms-powerpoint', + 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'txt': 'text/plain', + 'csv': 'text/csv', + + // Images + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'bmp': 'image/bmp', + 'webp': 'image/webp', + 'svg': 'image/svg+xml', + + // Archives + 'zip': 'application/zip', + 'rar': 'application/x-rar-compressed', + '7z': 'application/x-7z-compressed', + + // CAD + 'dwg': 'application/x-dwg', + 'dxf': 'application/x-dxf', + + // Video + 'mp4': 'video/mp4', + 'avi': 'video/x-msvideo', + 'mov': 'video/quicktime', + 'wmv': 'video/x-ms-wmv', + + // Audio + 'mp3': 'audio/mpeg', + 'wav': 'audio/wav', + 'ogg': 'audio/ogg', + }; + + return mimeTypes[ext] || 'application/octet-stream'; +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/[fileId]/route.ts b/app/api/data-room/[projectId]/[fileId]/route.ts new file mode 100644 index 00000000..176aaf63 --- /dev/null +++ b/app/api/data-room/[projectId]/[fileId]/route.ts @@ -0,0 +1,147 @@ +// app/api/files/[projectId]/[fileId]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; + +// 파일 정보 조회 +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string; fileId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: session.user.id, + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + const hasAccess = await fileService.checkFileAccess( + params.fileId, + context, + 'view' + ); + + if (!hasAccess) { + return NextResponse.json( + { error: '파일 접근 권한이 없습니다' }, + { status: 403 } + ); + } + + // 파일 정보 반환 + const file = await fileService.downloadFile(params.fileId, context); + + if (!file) { + return NextResponse.json( + { error: '파일을 찾을 수 없습니다' }, + { status: 404 } + ); + } + + return NextResponse.json(file); + } catch (error) { + console.error('파일 조회 오류:', error); + return NextResponse.json( + { error: '파일 조회에 실패했습니다' }, + { status: 500 } + ); + } +} + +// 파일 수정 +export async function PATCH( + request: NextRequest, + { params }: { params: { projectId: string; fileId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: session.user.id, + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + const hasAccess = await fileService.checkFileAccess( + params.fileId, + context, + 'edit' + ); + + if (!hasAccess) { + return NextResponse.json( + { error: '파일 수정 권한이 없습니다' }, + { status: 403 } + ); + } + + const body = await request.json(); + + // 파일 이동 처리 + if (body.parentId !== undefined) { + await fileService.moveFile(params.fileId, body.parentId, context); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('파일 수정 오류:', error); + return NextResponse.json( + { error: '파일 수정에 실패했습니다' }, + { status: 500 } + ); + } +} + +// 파일 삭제 +export async function DELETE( + request: NextRequest, + { params }: { params: { projectId: string; fileId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: session.user.id, + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + await fileService.deleteFile(params.fileId, context); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof Error && error.message.includes('권한')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + + console.error('파일 삭제 오류:', error); + return NextResponse.json( + { error: '파일 삭제에 실패했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/download-folder/[folderId]/route.ts b/app/api/data-room/[projectId]/download-folder/[folderId]/route.ts new file mode 100644 index 00000000..bba7066f --- /dev/null +++ b/app/api/data-room/[projectId]/download-folder/[folderId]/route.ts @@ -0,0 +1,289 @@ +// app/api/data-room/[projectId]/download-folder/[folderId]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { promises as fs } from 'fs'; +import path from 'path'; +import archiver from 'archiver'; +import db from "@/db/db"; +import { fileItems } from "@/db/schema/fileSystem"; +import { eq, and } from "drizzle-orm"; + +interface FileWithPath { + file: any; + absolutePath: string; + relativePath: string; +} + +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string; folderId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + // 폴더 정보 가져오기 + const folder = await db.query.fileItems.findFirst({ + where: and( + eq(fileItems.id, params.folderId), + eq(fileItems.projectId, params.projectId) + ), + }); + + if (!folder || folder.type !== 'folder') { + return NextResponse.json( + { error: '폴더를 찾을 수 없습니다' }, + { status: 404 } + ); + } + + const fileService = new FileService(); + const downloadableFiles: FileWithPath[] = []; + const unauthorizedFiles: string[] = []; + + // 재귀적으로 폴더 내 모든 파일 가져오기 및 권한 확인 + const processFolder = async ( + folderId: string, + folderPath: string = '' + ): Promise<void> => { + const items = await db.query.fileItems.findMany({ + where: and( + eq(fileItems.parentId, folderId), + eq(fileItems.projectId, params.projectId) + ), + }); + + for (const item of items) { + if (item.type === 'file') { + // 파일 권한 확인 + const hasAccess = await fileService.checkFileAccess( + item.id, + context, + 'download' + ); + + if (!hasAccess) { + // 권한이 없는 파일 기록 + unauthorizedFiles.push(path.join(folderPath, item.name)); + continue; + } + + if (!item.filePath) continue; + + // 실제 파일 경로 구성 + const nasPath = process.env.NAS_PATH || "/evcp_nas"; + const isProduction = process.env.NODE_ENV === "production"; + + let absolutePath: string; + if (isProduction) { + const relativePath = item.filePath.replace('/api/files/', ''); + absolutePath = path.join(nasPath, relativePath); + } else { + absolutePath = path.join(process.cwd(), 'public', item.filePath); + } + + // 파일 존재 여부 확인 + try { + await fs.access(absolutePath); + downloadableFiles.push({ + file: item, + absolutePath, + relativePath: path.join(folderPath, item.name) + }); + + // 다운로드 카운트 증가 및 로그 기록 + await fileService.downloadFile(item.id, context); + } catch (error) { + console.warn(`파일을 찾을 수 없습니다: ${absolutePath}`); + } + } else if (item.type === 'folder') { + // 하위 폴더 재귀 처리 + await processFolder( + item.id, + path.join(folderPath, item.name) + ); + } + } + }; + + // 폴더 처리 시작 + await processFolder(params.folderId, folder.name); + + // 권한이 없는 파일이 있으면 다운로드 차단 + if (unauthorizedFiles.length > 0) { + return NextResponse.json( + { + error: '일부 파일에 대한 다운로드 권한이 없습니다', + unauthorizedFiles: unauthorizedFiles, + unauthorizedCount: unauthorizedFiles.length, + message: `다음 파일들에 대한 권한이 없어 폴더 다운로드가 취소되었습니다: ${unauthorizedFiles.slice(0, 5).join(', ')}${unauthorizedFiles.length > 5 ? ` 외 ${unauthorizedFiles.length - 5}개` : ''}` + }, + { status: 403 } + ); + } + + // 다운로드할 파일이 없는 경우 + if (downloadableFiles.length === 0) { + return NextResponse.json( + { error: '다운로드 가능한 파일이 없습니다' }, + { status: 404 } + ); + } + + // 파일 크기 합계 체크 (최대 500MB) + const totalSize = downloadableFiles.reduce((sum, item) => + sum + (item.file.size || 0), 0 + ); + + const maxSize = 500 * 1024 * 1024; // 500MB + if (totalSize > maxSize) { + return NextResponse.json( + { + error: `폴더 크기가 너무 큽니다 (${(totalSize / 1024 / 1024).toFixed(2)}MB). 최대 500MB까지 다운로드 가능합니다.`, + totalSize: totalSize, + maxSize: maxSize, + fileCount: downloadableFiles.length + }, + { status: 400 } + ); + } + + console.log(`📦 폴더 다운로드 시작: ${folder.name} (${downloadableFiles.length}개 파일, ${(totalSize / 1024 / 1024).toFixed(2)}MB)`); + + // ZIP 스트림 생성 + const archive = archiver('zip', { + zlib: { level: 5 } // 압축 레벨 + }); + + // 스트림을 Response로 변환 + const stream = new ReadableStream({ + start(controller) { + archive.on('data', (chunk) => controller.enqueue(chunk)); + archive.on('end', () => controller.close()); + archive.on('error', (err) => { + console.error('Archive error:', err); + controller.error(err); + }); + }, + }); + + // 파일들을 ZIP에 추가 (폴더 구조 유지) + for (const { file, absolutePath, relativePath } of downloadableFiles) { + try { + const fileBuffer = await fs.readFile(absolutePath); + archive.append(fileBuffer, { name: relativePath }); + } catch (error) { + console.error(`파일 추가 실패: ${relativePath}`, error); + } + } + + // ZIP 완료 + archive.finalize(); + + // Response Headers 설정 + const headers = new Headers(); + headers.set('Content-Type', 'application/zip'); + headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(folder.name)}.zip"`); + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.set('Pragma', 'no-cache'); + headers.set('Expires', '0'); + headers.set('X-File-Count', downloadableFiles.length.toString()); + headers.set('X-Total-Size', totalSize.toString()); + + return new NextResponse(stream, { + status: 200, + headers, + }); + + } catch (error) { + console.error('폴더 다운로드 오류:', error); + return NextResponse.json( + { error: '폴더 다운로드에 실패했습니다' }, + { status: 500 } + ); + } +} + +// 폴더 다운로드 전 권한 체크 (선택적) +export async function HEAD( + request: NextRequest, + { params }: { params: { projectId: string; folderId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return new NextResponse(null, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + let totalFiles = 0; + let unauthorizedCount = 0; + let totalSize = 0; + + // 재귀적으로 권한 체크 + const checkFolder = async (folderId: string): Promise<void> => { + const items = await db.query.fileItems.findMany({ + where: and( + eq(fileItems.parentId, folderId), + eq(fileItems.projectId, params.projectId) + ), + }); + + for (const item of items) { + if (item.type === 'file') { + totalFiles++; + totalSize += item.size || 0; + + const hasAccess = await fileService.checkFileAccess( + item.id, + context, + 'download' + ); + + if (!hasAccess) { + unauthorizedCount++; + } + } else if (item.type === 'folder') { + await checkFolder(item.id); + } + } + }; + + await checkFolder(params.folderId); + + const headers = new Headers(); + headers.set('X-Total-Files', totalFiles.toString()); + headers.set('X-Unauthorized-Files', unauthorizedCount.toString()); + headers.set('X-Total-Size', totalSize.toString()); + headers.set('X-Can-Download', unauthorizedCount === 0 ? 'true' : 'false'); + + return new NextResponse(null, { + status: unauthorizedCount > 0 ? 403 : 200, + headers, + }); + + } catch (error) { + console.error('권한 체크 오류:', error); + return new NextResponse(null, { status: 500 }); + } +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/download-multiple/route.ts b/app/api/data-room/[projectId]/download-multiple/route.ts new file mode 100644 index 00000000..64c87b55 --- /dev/null +++ b/app/api/data-room/[projectId]/download-multiple/route.ts @@ -0,0 +1,162 @@ +// app/api/data-room/[projectId]/download-multiple/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { promises as fs } from 'fs'; +import path from 'path'; +import archiver from 'archiver'; +import { Readable } from 'stream'; +import db from "@/db/db"; +import { fileItems } from "@/db/schema/fileSystem"; +import { eq, inArray } from "drizzle-orm"; + +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const { fileIds } = body; + + if (!fileIds || !Array.isArray(fileIds) || fileIds.length === 0) { + return NextResponse.json( + { error: '파일 ID가 제공되지 않았습니다' }, + { status: 400 } + ); + } + + // 너무 많은 파일 방지 (최대 100개) + if (fileIds.length > 100) { + return NextResponse.json( + { error: '한 번에 최대 100개의 파일만 다운로드할 수 있습니다' }, + { status: 400 } + ); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + const downloadableFiles: Array<{ + file: any; + absolutePath: string; + }> = []; + + // 각 파일의 접근 권한 확인 및 경로 확인 + for (const fileId of fileIds) { + // 권한 확인 + const hasAccess = await fileService.checkFileAccess( + fileId, + context, + 'download' + ); + + if (!hasAccess) { + console.warn(`파일 ${fileId}에 대한 다운로드 권한이 없습니다`); + continue; + } + + // 파일 정보 가져오기 + const file = await db.query.fileItems.findFirst({ + where: eq(fileItems.id, fileId), + }); + + if (!file || !file.filePath || file.type !== 'file') { + console.warn(`파일 ${fileId}를 찾을 수 없거나 폴더입니다`); + continue; + } + + // 실제 파일 경로 구성 + const nasPath = process.env.NAS_PATH || "/evcp_nas"; + const isProduction = process.env.NODE_ENV === "production"; + + let absolutePath: string; + if (isProduction) { + const relativePath = file.filePath.replace('/api/files/', ''); + absolutePath = path.join(nasPath, relativePath); + } else { + absolutePath = path.join(process.cwd(), 'public', file.filePath); + } + + // 파일 존재 여부 확인 + try { + await fs.access(absolutePath); + downloadableFiles.push({ file, absolutePath }); + + // 다운로드 카운트 증가 및 로그 기록 + await fileService.downloadFile(fileId, context); + } catch (error) { + console.warn(`파일 ${absolutePath}를 찾을 수 없습니다`); + } + } + + if (downloadableFiles.length === 0) { + return NextResponse.json( + { error: '다운로드 가능한 파일이 없습니다' }, + { status: 404 } + ); + } + + // ZIP 스트림 생성 + const archive = archiver('zip', { + zlib: { level: 5 } // 압축 레벨 (1-9, 5가 균형적) + }); + + // 스트림을 Response로 변환 + const stream = new ReadableStream({ + start(controller) { + archive.on('data', (chunk) => controller.enqueue(chunk)); + archive.on('end', () => controller.close()); + archive.on('error', (err) => controller.error(err)); + }, + }); + + // 파일들을 ZIP에 추가 + for (const { file, absolutePath } of downloadableFiles) { + try { + const fileBuffer = await fs.readFile(absolutePath); + + // 파일명 중복 방지를 위한 고유 이름 생성 + const uniqueName = `${path.parse(file.name).name}_${file.id.slice(0, 8)}${path.extname(file.name)}`; + + archive.append(fileBuffer, { name: uniqueName }); + } catch (error) { + console.error(`파일 추가 실패: ${file.name}`, error); + } + } + + // ZIP 완료 + archive.finalize(); + + // Response Headers 설정 + const headers = new Headers(); + headers.set('Content-Type', 'application/zip'); + headers.set('Content-Disposition', 'attachment; filename="files.zip"'); + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + headers.set('Pragma', 'no-cache'); + headers.set('Expires', '0'); + + return new NextResponse(stream, { + status: 200, + headers, + }); + + } catch (error) { + console.error('다중 파일 다운로드 오류:', error); + return NextResponse.json( + { error: '다중 파일 다운로드에 실패했습니다' }, + { status: 500 } + ); + } +} diff --git a/app/api/data-room/[projectId]/permissions/route.ts b/app/api/data-room/[projectId]/permissions/route.ts new file mode 100644 index 00000000..94401826 --- /dev/null +++ b/app/api/data-room/[projectId]/permissions/route.ts @@ -0,0 +1,74 @@ +// app/api/files/[projectId]/permissions/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { z } from 'zod'; + +const grantPermissionSchema = z.object({ + fileId: z.string().uuid(), + targetUserId: z.number().optional().nullable(), + targetDomain: z.string().optional().nullable(), + permissions: z.object({ + canView: z.boolean().optional(), + canDownload: z.boolean().optional(), + canEdit: z.boolean().optional(), + canDelete: z.boolean().optional(), + canShare: z.boolean().optional(), + }), +}); + +// 권한 부여 +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = grantPermissionSchema.parse(body); + + const context: FileAccessContext = { + userId: session.user.id, + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + await fileService.grantPermission( + validatedData.fileId, + validatedData.targetUserId, + validatedData.targetDomain, + validatedData.permissions, + context + ); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: '잘못된 요청 데이터', details: error.errors }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message.includes('권한')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + + console.error('권한 부여 오류:', error); + return NextResponse.json( + { error: '권한 부여에 실패했습니다' }, + { status: 500 } + ); + } +} diff --git a/app/api/data-room/[projectId]/route.ts b/app/api/data-room/[projectId]/route.ts new file mode 100644 index 00000000..643dcf0f --- /dev/null +++ b/app/api/data-room/[projectId]/route.ts @@ -0,0 +1,118 @@ +// app/api/data-room/[projectId]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { z } from 'zod'; + +// 파일 생성 스키마 검증 +const createFileSchema = z.object({ + name: z.string().min(1).max(255), + type: z.enum(['file', 'folder']), + parentId: z.string().uuid().optional().nullable(), + category: z.enum(['public', 'restricted', 'confidential', 'internal']).default('confidential'), + mimeType: z.string().optional(), + size: z.number().optional(), + filePath: z.string().optional(), +}); + +// 파일 목록 조회 +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const searchParams = request.nextUrl.searchParams; + const parentId = searchParams.get('parentId'); + const viewMode = searchParams.get('viewMode'); // 'tree' or 'grid' + const includeAll = searchParams.get('includeAll') === 'true'; // 전체 목록 가져오기 + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + + // viewMode가 tree이거나 includeAll이 true인 경우 전체 목록 가져오기 + const files = await fileService.getFileList( + params.projectId, + parentId, + context, + { + includeAll: viewMode === 'tree' || includeAll + } + ); + + return NextResponse.json(files); + } catch (error) { + console.error('파일 목록 조회 오류:', error); + return NextResponse.json( + { error: '파일 목록을 불러올 수 없습니다' }, + { status: 500 } + ); + } +} + +// 파일/폴더 생성 +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = createFileSchema.parse(body); + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + const newFile = await fileService.createFileItem( + { + ...validatedData, + projectId: params.projectId, + }, + context + ); + + return NextResponse.json(newFile, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: '잘못된 요청 데이터', details: error.errors }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message === '권한이 없습니다') { + return NextResponse.json( + { error: '파일 생성 권한이 없습니다' }, + { status: 403 } + ); + } + + console.error('파일 생성 오류:', error); + return NextResponse.json( + { error: '파일 생성에 실패했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/share/[token]/route.ts b/app/api/data-room/[projectId]/share/[token]/route.ts new file mode 100644 index 00000000..51582bca --- /dev/null +++ b/app/api/data-room/[projectId]/share/[token]/route.ts @@ -0,0 +1,45 @@ +// app/api/shared/[token]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { FileService } from '@/lib/services/fileService'; + +// 공유 링크로 파일 접근 +export async function GET( + request: NextRequest, + { params }: { params: { token: string } } +) { + try { + const searchParams = request.nextUrl.searchParams; + const password = searchParams.get('password'); + + const fileService = new FileService(); + const result = await fileService.accessFileByShareToken( + params.token, + password || undefined + ); + + if (!result) { + return NextResponse.json( + { error: '유효하지 않은 공유 링크입니다' }, + { status: 404 } + ); + } + + return NextResponse.json({ + file: result.file, + accessLevel: result.accessLevel, + }); + } catch (error) { + if (error instanceof Error) { + return NextResponse.json( + { error: error.message }, + { status: 400 } + ); + } + + console.error('공유 파일 접근 오류:', error); + return NextResponse.json( + { error: '파일 접근에 실패했습니다' }, + { status: 500 } + ); + } +} diff --git a/app/api/data-room/[projectId]/share/route.ts b/app/api/data-room/[projectId]/share/route.ts new file mode 100644 index 00000000..9b27d9fc --- /dev/null +++ b/app/api/data-room/[projectId]/share/route.ts @@ -0,0 +1,79 @@ +// app/api/files/[projectId]/share/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { z } from 'zod'; + +const createShareSchema = z.object({ + fileId: z.string().uuid(), + accessLevel: z.enum(['view_only', 'view_download']).optional(), + password: z.string().optional(), + expiresAt: z.string().datetime().optional(), + maxDownloads: z.number().positive().optional(), + sharedWithEmail: z.string().email().optional(), +}); + +// 공유 링크 생성 +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const validatedData = createShareSchema.parse(body); + + const context: FileAccessContext = { + userId: session.user.id, + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + const fileService = new FileService(); + const shareToken = await fileService.createShareLink( + validatedData.fileId, + { + ...validatedData, + expiresAt: validatedData.expiresAt + ? new Date(validatedData.expiresAt) + : undefined, + }, + context + ); + + const shareUrl = `${process.env.NEXT_PUBLIC_APP_URL}/shared/${shareToken}`; + + return NextResponse.json({ + shareToken, + shareUrl, + success: true + }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: '잘못된 요청 데이터', details: error.errors }, + { status: 400 } + ); + } + + if (error instanceof Error && error.message.includes('권한')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + + console.error('공유 링크 생성 오류:', error); + return NextResponse.json( + { error: '공유 링크 생성에 실패했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/data-room/[projectId]/upload/route.ts b/app/api/data-room/[projectId]/upload/route.ts new file mode 100644 index 00000000..60bbc10f --- /dev/null +++ b/app/api/data-room/[projectId]/upload/route.ts @@ -0,0 +1,139 @@ +// app/api/data-room/[projectId]/upload/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { FileService, type FileAccessContext } from '@/lib/services/fileService'; +import { saveDRMFile, saveFileStream } from '@/lib/file-stroage'; + +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const context: FileAccessContext = { + userId: Number(session.user.id), + userDomain: session.user.domain || 'partners', + userEmail: session.user.email, + ipAddress: request.ip || request.headers.get('x-forwarded-for') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }; + + // 내부 사용자만 업로드 가능 + if (session.user.domain === 'partners') { + return NextResponse.json( + { error: '파일 업로드 권한이 없습니다' }, + { status: 403 } + ); + } + + const formData = await request.formData(); + const file = formData.get('file') as File; + const category = formData.get('category') as string; + const parentId = formData.get('parentId') as string | null; + const fileSize = formData.get('fileSize') as string | null; + + if (!file) { + return NextResponse.json( + { error: '파일이 제공되지 않았습니다' }, + { status: 400 } + ); + } + + // 대용량 파일 임계값 (10MB) + const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; + const actualFileSize = fileSize ? parseInt(fileSize) : file.size; + + let result; + + // 파일 크기에 따라 다른 저장 방법 사용 + if (actualFileSize > LARGE_FILE_THRESHOLD) { + console.log(`🚀 대용량 파일 스트리밍 저장: ${file.name} (${(actualFileSize / 1024 / 1024).toFixed(2)}MB)`); + + // 대용량 파일은 스트리밍 저장 사용 + result = await saveFileStream({ + file, + directory: `projects/${params.projectId}`, + originalName: file.name, + userId: session.user.id + }); + } else { + console.log(`📦 일반 파일 저장: ${file.name} (${(actualFileSize / 1024 / 1024).toFixed(2)}MB)`); + + // 작은 파일은 기존 DRM 저장 방식 사용 + result = await saveDRMFile( + file, + async (file) => file.arrayBuffer(), // 이미 복호화된 데이터 + `projects/${params.projectId}`, + session.user.id + ); + } + + if (!result.success) { + return NextResponse.json( + { error: result.error || '파일 저장에 실패했습니다' }, + { status: 500 } + ); + } + + // DB에 파일 정보 저장 + const fileService = new FileService(); + const newFile = await fileService.createFileItem( + { + name: result.originalName || file.name, + type: 'file', + category: category as 'public' | 'restricted' | 'confidential' | 'internal', + parentId, + size: result.fileSize || actualFileSize, + mimeType: file.type, + filePath: result.publicPath, + projectId: params.projectId, + }, + context + ); + + return NextResponse.json({ + ...newFile, + uploadResult: { + ...result, + uploadMethod: actualFileSize > LARGE_FILE_THRESHOLD ? 'stream' : 'buffer', + fileSizeMB: (actualFileSize / 1024 / 1024).toFixed(2) + }, + }, { status: 201 }); + + } catch (error) { + console.error('파일 업로드 오류:', error); + return NextResponse.json( + { + error: '파일 업로드에 실패했습니다', + details: error instanceof Error ? error.message : undefined + }, + { status: 500 } + ); + } +} + +// 업로드 진행률 확인 (선택적) +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + // 업로드 상태 확인 로직 (필요시 구현) + return NextResponse.json({ message: '업로드 상태 확인 엔드포인트' }); + } catch (error) { + return NextResponse.json( + { error: '상태 확인 실패' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts index 88211f5b..89f00a3c 100644 --- a/app/api/files/[...path]/route.ts +++ b/app/api/files/[...path]/route.ts @@ -51,7 +51,8 @@ const isAllowedPath = (requestedPath: string): boolean => { 'vendors', 'pq', 'pq/vendor', - 'information' + 'information', + 'general-contract-templates' ]; return allowedPaths.some(allowed => diff --git a/app/api/partners/rfq-last/[id]/response/route.ts b/app/api/partners/rfq-last/[id]/response/route.ts index 1fc9d5dd..21a4e7a4 100644 --- a/app/api/partners/rfq-last/[id]/response/route.ts +++ b/app/api/partners/rfq-last/[id]/response/route.ts @@ -264,6 +264,8 @@ export async function PUT( vendorDeliveryDate: data.vendorDeliveryDate ? new Date(data.vendorDeliveryDate) : null, submittedAt: data.submittedAt ? new Date(data.submittedAt) : null, responseVersion: existingResponse.responseVersion + 1, + status:"제출완료", + participationStatus: "참여", isLatest: true, createdBy: existingResponse.createdBy, updatedBy: session.user.id, @@ -286,7 +288,9 @@ export async function PUT( // 3. 견적 아이템 업데이트 // 기존 아이템 삭제 await tx.delete(rfqLastVendorQuotationItems) - .where(eq(rfqLastVendorQuotationItems.vendorResponseId, responseId)) + .where(eq(rfqLastVendorQuotationItems.vendorResponseId, existingResponse.id)) + + console.log(data.quotationItems,"data.quotationItems") // 새 아이템 추가 if (data.quotationItems && data.quotationItems.length > 0) { diff --git a/app/api/projects/[projectId]/access/route.ts b/app/api/projects/[projectId]/access/route.ts new file mode 100644 index 00000000..c4b32ca8 --- /dev/null +++ b/app/api/projects/[projectId]/access/route.ts @@ -0,0 +1,36 @@ +// app/api/projects/[projectId]/access/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { ProjectService } from '@/lib/services/projectService'; + +// 프로젝트 접근 권한 확인 +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const projectService = new ProjectService(); + const access = await projectService.checkProjectAccess( + params.projectId, + Number(session.user.id) + ); + + return NextResponse.json({ + hasAccess: access.hasAccess, + role: access.role || 'viewer', + isOwner: access.isOwner, + }); + } catch (error) { + console.error('권한 확인 오류:', error); + return NextResponse.json( + { hasAccess: false, role: 'viewer', isOwner: false }, + { status: 500 } + ); + } +} diff --git a/app/api/projects/[projectId]/members/[memberId]/route.ts b/app/api/projects/[projectId]/members/[memberId]/route.ts new file mode 100644 index 00000000..55816661 --- /dev/null +++ b/app/api/projects/[projectId]/members/[memberId]/route.ts @@ -0,0 +1,89 @@ +// app/api/projects/[projectId]/members/[memberId]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { ProjectService } from '@/lib/services/projectService'; + +// 멤버 역할 수정 +export async function PATCH( + request: NextRequest, + { params }: { params: { projectId: string; memberId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const { role } = await request.json(); + const projectService = new ProjectService(); + + // Owner 또는 Admin만 가능 + const access = await projectService.checkProjectAccess( + params.projectId, + session.user.id, + 'admin' + ); + + if (!access.hasAccess && !access.isOwner) { + return NextResponse.json( + { error: '멤버 역할을 변경할 권한이 없습니다' }, + { status: 403 } + ); + } + + // 멤버 역할 업데이트 + await projectService.updateMemberRole( + params.projectId, + params.memberId, + role + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('멤버 역할 변경 오류:', error); + return NextResponse.json( + { error: '역할 변경에 실패했습니다' }, + { status: 500 } + ); + } +} + +// 멤버 제거 +export async function DELETE( + request: NextRequest, + { params }: { params: { projectId: string; memberId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const projectService = new ProjectService(); + + // Owner만 멤버 제거 가능 + const isOwner = await projectService.isProjectOwner( + params.projectId, + session.user.id + ); + + if (!isOwner) { + return NextResponse.json( + { error: '멤버를 제거할 권한이 없습니다' }, + { status: 403 } + ); + } + + // 멤버 제거 + await projectService.removeMember(params.projectId, params.memberId); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('멤버 제거 오류:', error); + return NextResponse.json( + { error: '멤버 제거에 실패했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/projects/[projectId]/members/route.ts b/app/api/projects/[projectId]/members/route.ts new file mode 100644 index 00000000..d24b61e3 --- /dev/null +++ b/app/api/projects/[projectId]/members/route.ts @@ -0,0 +1,76 @@ +// app/api/projects/[projectId]/members/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { ProjectService } from '@/lib/services/projectService'; + +// 프로젝트 멤버 추가 (Owner만 가능) +export async function POST( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const projectService = new ProjectService(); + + await projectService.addProjectMember( + params.projectId, + body.userId, + body.role, + Number(session.user.id) + ); + + return NextResponse.json({ success: true }); + } catch (error: any) { + if (error.message.includes('소유자')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + + console.error('멤버 추가 오류:', error); + return NextResponse.json( + { error: '멤버 추가에 실패했습니다' }, + { status: 500 } + ); + } +} + +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const projectService = new ProjectService(); + + const member = await projectService.getProjectMembers( + params.projectId, + ); + + return NextResponse.json({member}); + } catch (error: any) { + if (error.message.includes('소유자')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + + console.error('멤버 조회 오류:', error); + return NextResponse.json( + { error: '멤버 조회에 실패했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts new file mode 100644 index 00000000..38c11930 --- /dev/null +++ b/app/api/projects/[projectId]/route.ts @@ -0,0 +1,134 @@ +// app/api/projects/[projectId]/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { ProjectService } from '@/lib/services/projectService'; +import { z } from 'zod'; + +// GET: 프로젝트 정보 조회 +export async function GET( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const projectService = new ProjectService(); + + // 프로젝트 접근 권한 확인 + const access = await projectService.checkProjectAccess( + params.projectId, + Number(session.user.id) + ); + + if (!access.hasAccess) { + return NextResponse.json( + { error: '프로젝트에 접근할 수 없습니다' }, + { status: 403 } + ); + } + + // 프로젝트 정보 가져오기 + const project = await projectService.getProject(params.projectId); + + if (!project) { + return NextResponse.json( + { error: '프로젝트를 찾을 수 없습니다' }, + { status: 404 } + ); + } + + // 사용자의 역할과 함께 프로젝트 정보 반환 + return NextResponse.json({ + ...project, + role: access.role, + isOwner: access.isOwner, + }); + } catch (error) { + console.error('프로젝트 조회 오류:', error); + return NextResponse.json( + { error: '프로젝트 정보를 불러올 수 없습니다' }, + { status: 500 } + ); + } +} + +// PATCH: 프로젝트 정보 수정 +export async function PATCH( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const projectService = new ProjectService(); + + // Admin 이상 권한 확인 + const access = await projectService.checkProjectAccess( + params.projectId, + Number(session.user.id), + 'admin' + ); + + if (!access.hasAccess) { + return NextResponse.json( + { error: '프로젝트를 수정할 권한이 없습니다' }, + { status: 403 } + ); + } + + await projectService.updateProjectSettings( + params.projectId, + Number(session.user.id), + body + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('프로젝트 수정 오류:', error); + return NextResponse.json( + { error: '프로젝트 수정에 실패했습니다' }, + { status: 500 } + ); + } +} + +// DELETE: 프로젝트 삭제 +export async function DELETE( + request: NextRequest, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const projectService = new ProjectService(); + + // Owner만 삭제 가능 + await projectService.deleteProject(params.projectId, session.user.id); + + return NextResponse.json({ success: true }); + } catch (error: any) { + if (error.message.includes('소유자')) { + return NextResponse.json( + { error: error.message }, + { status: 403 } + ); + } + + console.error('프로젝트 삭제 오류:', error); + return NextResponse.json( + { error: '프로젝트 삭제에 실패했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/projects/[projectId]/stats/route.ts b/app/api/projects/[projectId]/stats/route.ts new file mode 100644 index 00000000..dc2397ac --- /dev/null +++ b/app/api/projects/[projectId]/stats/route.ts @@ -0,0 +1,275 @@ +// app/api/fileSystemProjects/[projectId]/stats/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import db from "@/db/db"; +import { fileItems, fileActivityLogs, fileSystemProjects, projectMembers } from "@/db/schema"; +import { eq, and, gte, sql, desc } from "drizzle-orm"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ projectId: string }> } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const params = await context.params; + const projectId = params.projectId; + + // URL 파라미터에서 날짜 범위 가져오기 + const searchParams = request.nextUrl.searchParams; + const range = searchParams.get('range') || '30d'; + + // 날짜 범위 계산 + const now = new Date(); + let startDate = new Date(); + + switch (range) { + case '7d': + startDate.setDate(now.getDate() - 7); + break; + case '30d': + startDate.setDate(now.getDate() - 30); + break; + case '90d': + startDate.setDate(now.getDate() - 90); + break; + default: + startDate.setDate(now.getDate() - 30); + } + + // 이전 기간 (트렌드 계산용) + const previousStartDate = new Date(startDate); + previousStartDate.setDate(previousStartDate.getDate() - (now.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); + + // 프로젝트 접근 권한 확인 + const projectMember = await db.query.projectMembers.findFirst({ + where: and( + eq(projectMembers.projectId, projectId), + eq(projectMembers.userId, Number(session.user.id)) + ), + }); + + const isInternalUser = session.user.domain !== 'partners'; + + // 내부 사용자가 아니고 프로젝트 멤버가 아닌 경우 접근 거부 + if (!isInternalUser && !projectMember) { + return NextResponse.json( + { error: '통계를 볼 권한이 없습니다' }, + { status: 403 } + ); + } + + // 1. 스토리지 통계 + const storageStats = await db + .select({ + totalSize: sql<number>`COALESCE(SUM(${fileItems.size}), 0)`, + fileCount: sql<number>`COUNT(CASE WHEN ${fileItems.type} = 'file' THEN 1 END)`, + folderCount: sql<number>`COUNT(CASE WHEN ${fileItems.type} = 'folder' THEN 1 END)`, + }) + .from(fileItems) + .where(eq(fileItems.projectId, projectId)); + + // 카테고리별 파일 수 + const categoryStats = await db + .select({ + category: fileItems.category, + count: sql<number>`COUNT(*)`, + }) + .from(fileItems) + .where(and( + eq(fileItems.projectId, projectId), + eq(fileItems.type, 'file') + )) + .groupBy(fileItems.category); + + const byCategory = { + public: 0, + restricted: 0, + confidential: 0, + internal: 0, + }; + + categoryStats.forEach(stat => { + if (stat.category && stat.category in byCategory) { + byCategory[stat.category as keyof typeof byCategory] = Number(stat.count); + } + }); + + // 2. 활동 통계 (현재 기간) + const activityStats = await db + .select({ + action: fileActivityLogs.action, + count: sql<number>`COUNT(*)`, + }) + .from(fileActivityLogs) + .where(and( + eq(fileActivityLogs.projectId, projectId), + gte(fileActivityLogs.createdAt, startDate) + )) + .groupBy(fileActivityLogs.action); + + // 이전 기간 통계 (트렌드 계산용) + const previousActivityStats = await db + .select({ + action: fileActivityLogs.action, + count: sql<number>`COUNT(*)`, + }) + .from(fileActivityLogs) + .where(and( + eq(fileActivityLogs.projectId, projectId), + gte(fileActivityLogs.createdAt, previousStartDate), + sql`${fileActivityLogs.createdAt} < ${startDate}` + )) + .groupBy(fileActivityLogs.action); + + const activityCounts = { + views: 0, + downloads: 0, + uploads: 0, + shares: 0, + }; + + const previousCounts = { + downloads: 0, + }; + + activityStats.forEach(stat => { + switch (stat.action) { + case 'view': + activityCounts.views = Number(stat.count); + break; + case 'download': + activityCounts.downloads = Number(stat.count); + break; + case 'upload': + activityCounts.uploads = Number(stat.count); + break; + case 'share': + activityCounts.shares = Number(stat.count); + break; + } + }); + + previousActivityStats.forEach(stat => { + if (stat.action === 'download') { + previousCounts.downloads = Number(stat.count); + } + }); + + // 트렌드 계산 (다운로드 기준) + const trend = previousCounts.downloads > 0 + ? Math.round(((activityCounts.downloads - previousCounts.downloads) / previousCounts.downloads) * 100) + : 0; + + // 3. 사용자 통계 + const userStats = await db + .select({ + total: sql<number>`COUNT(DISTINCT ${projectMembers.userId})`, + }) + .from(projectMembers) + .where(eq(projectMembers.projectId, projectId)); + + // 활성 사용자 (최근 활동이 있는 사용자) + const activeUsers = await db + .select({ + count: sql<number>`COUNT(DISTINCT ${fileActivityLogs.userId})`, + }) + .from(fileActivityLogs) + .where(and( + eq(fileActivityLogs.projectId, projectId), + gte(fileActivityLogs.createdAt, startDate) + )); + + // 역할별 사용자 수 (간단하게 처리) + const roleStats = await db + .select({ + role: projectMembers.role, + count: sql<number>`COUNT(*)`, + }) + .from(projectMembers) + .where(eq(projectMembers.projectId, projectId)) + .groupBy(projectMembers.role); + + const byRole = { + admin: 0, + editor: 0, + viewer: 0, + }; + + roleStats.forEach(stat => { + if (stat.role === 'manager') byRole.admin = Number(stat.count); + else if (stat.role === 'member') byRole.editor = Number(stat.count); + else byRole.viewer = Number(stat.count); + }); + + // 4. 최근 활동 내역 + const recentActivities = await db + .select({ + action: fileActivityLogs.action, + userEmail: fileActivityLogs.userEmail, + createdAt: fileActivityLogs.createdAt, + fileName: fileItems.name, + fileType: fileItems.type, + }) + .from(fileActivityLogs) + .leftJoin(fileItems, eq(fileActivityLogs.fileItemId, fileItems.id)) + .where(and( + eq(fileActivityLogs.projectId, projectId), + gte(fileActivityLogs.createdAt, startDate) + )) + .orderBy(desc(fileActivityLogs.createdAt)) + .limit(10); + + const recent = recentActivities.map(activity => ({ + type: activity.fileType || 'file', + user: activity.userEmail?.split('@')[0] || 'Unknown', + action: activity.action, + timestamp: activity.createdAt.toISOString(), + details: activity.fileName || 'Unknown file', + })); + + // 5. 프로젝트 정보 (스토리지 제한 등) + const project = await db.query.fileSystemProjects.findFirst({ + where: eq(fileSystemProjects.id, projectId), + }); + + const storageLimit = 10 * 1024 * 1024 * 1024; // 기본 10GB + + // 응답 데이터 구성 + const stats = { + storage: { + used: Number(storageStats[0]?.totalSize || 0), + limit: storageLimit, + fileCount: Number(storageStats[0]?.fileCount || 0), + folderCount: Number(storageStats[0]?.folderCount || 0), + byCategory, + }, + activity: { + views: activityCounts.views, + downloads: activityCounts.downloads, + uploads: activityCounts.uploads, + shares: activityCounts.shares, + trend, + }, + users: { + total: Number(userStats[0]?.total || 0), + active: Number(activeUsers[0]?.count || 0), + byRole, + }, + recent, + }; + + return NextResponse.json(stats); + + } catch (error) { + console.error('통계 조회 오류:', error); + return NextResponse.json( + { error: '통계를 불러올 수 없습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 00000000..c64676c6 --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,56 @@ +// app/api/projects/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { ProjectService } from '@/lib/services/projectService'; + +// 프로젝트 생성 +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const body = await request.json(); + const projectService = new ProjectService(); + + const project = await projectService.createProject( + { + name: body.name, + description: body.description, + isPublic: body.isPublic || false, + }, + Number(session.user.id) + ); + + return NextResponse.json(project, { status: 201 }); + } catch (error) { + console.error('프로젝트 생성 오류:', error); + return NextResponse.json( + { error: '프로젝트 생성에 실패했습니다' }, + { status: 500 } + ); + } +} + +// 사용자의 프로젝트 목록 조회 +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 }); + } + + const projectService = new ProjectService(); + const projects = await projectService.getUserProjects(session.user.id); + + return NextResponse.json(projects); + } catch (error) { + console.error('프로젝트 목록 조회 오류:', error); + return NextResponse.json( + { error: '프로젝트 목록을 불러올 수 없습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file |
